线性状态机 DP
受限选取·股票买卖
本课摘要
线性状态机 DP课程回答“带持有或选择限制的问题怎样画成有限状态机”。内容以受限选取·股票买卖为主线,配合逐步推导、可编辑演示、例题与练习形成可复查的学习闭环。
- 判断线性状态机 DP的适用条件与状态边界
- 围绕“受限选取·股票买卖”推导转移与计算顺序
- 用演示、复杂度分析和配套题目校验实现
本课目录 · 0 节
正在整理目录…
给每个位置装上一个「状态」
到目前为止,线性 DP 的每个位置 只记一个数:。但很多问题里,「站在位置 」并不是一个笼统的局面—— 它还分几种互斥的处境。比如手上有没有股票、第 个数选了没选、机器此刻停在哪一档。 于是我们给每个位置引入若干离散状态 ,用 分别记录,让转移在状态之间流动——这就是线性状态机 DP。
先看一个最朴素的「受限选取」:给一排数 ,要挑出若干个使总和最大,但有一条硬约束——相邻的两个不能同时选(选了 就不能选 和 )。这正是「打家劫舍」式的模型。
为什么不能贪心地「从大到小挑,冲突就跳过」?看最短的反例 :贪心先拿最大的 (在中间),它左右两个数就都被禁了,只得 ; 可最优是拿两端 。贪心为了眼前那个大数,堵死了两侧更划算的组合。此刻选或不选,牵动前后两侧——又是需要 DP 的信号。而枚举「每个数选或不选」的所有组合是 种, 就已无从枚举。
状态与转移:选,还是不选
定状态。光记「前 个的最大和」不够——因为下一步能不能选 ,取决于 到底选没选。 于是把这个「选没选」显式记进状态:设 = 考虑前 个、且不选 时的最大和; = 前 个、且选 时的最大和。
不选 ():既然本位不取,前一个 选或不选都行,所以从上一位置的两个状态里取较大者:
选 ():本位既然要取,前一个 就必须不选(相邻互斥),只能接上一位置的「不选」状态,再加上 自己:
边界:(哨兵起点,什么都没考虑)。答案在末列取两状态较大者——因为最后一个数选不选都可以:
本质:状态,就是「决策的记忆」
普通线性 DP 的 只背一个总量;状态机 DP 多出的那一维,背的是「上一步做了什么决定」——正因为把「 选没选」记进了状态,下一步才知道自己能不能选。 它把「后面的决策依赖前面怎么选」这层耦合,拆成了每个状态各自独立、可按位置递推的小问题, 种组合被 ( 为状态数)装下。
跟着算一遍
用开头的例子 走几步,把两个状态并行推进(下标从 1 记):
看两个状态一格一格长出来
- 来源参与当前转移的依赖状态
- 当前正在计算或观察的状态
- 确定 / 最优已进入当前最优解或确定结果
- 已处理已经扫描、当前不再活跃的状态
- 非法越界、冲突或不可达状态
深化:股票买卖——把「持有 / 未持有」做成状态机
「选 / 不选」只是最简单的两状态。真正让状态机大显身手的,是股票买卖一族:给出每天的价格 ,可以在某天买入、某天卖出(手上最多持一股),求最大利润。 关键抓手同样是——站在第 天,你此刻「手里有没有股票」是两种截然不同的处境,能做的动作也不同。
于是设两个状态: = 第 天结束时持有一股的最优现金; = 第 天结束时空仓的最优现金(现金相对初始 0 计,买入是垫钱、卖出是回款)。
逐日在两状态间转移(无限次交易版):
前者:空仓 = 昨天就空仓(不动)、或昨天持有今天卖出();后者:持有 = 昨天就持有(不动)、或昨天空仓今天买入()。 初始 、(还没买过)。答案取末日的 ——手里不留股才算落袋。
再加一条冷却期(卖出后次日不能买入)会怎样?只需把买入时的现金基准从「昨日空仓」改成「前天空仓」——这样刚卖出的那天就买不回来了。 换句话说,加一条规则,只是给状态机换一条边,主干丝毫不动。这正是状态机模型的威力:现实约束越复杂,越能靠「多设一个状态 / 改一条转移边」优雅地容纳。
常见陷阱 · 答案取「空仓」态,别取 max
股票问题最终答案是 (末日空仓),不是 ——手里还攥着一股不叫利润。 另一处易错: 的初值必须是 而非 0,否则「还没买就先算持有」会凭空多出一股。状态机 DP 的边界,往往就藏在这些「哪个状态一开始根本不可能」的细节里。
例题
#include <iostream>
#include <algorithm>
using namespace std;
int n;
int a[25]; // 每个地窖的地雷数
bool g[25][25]; // g[i][j]:i 能否走到 j(题目保证 j>i)
int f[25], nxt[25]; // f[i]:从 i 出发最多能挖的地雷;nxt[i]:路径上 i 的下一个
int main()
{
cin >> n;
for (int i = 1; i <= n; i++)
cin >> a[i];
for (int i = 1; i < n; i++) // 上三角连接矩阵:i 到 i+1..n
for (int j = i + 1; j <= n; j++)
cin >> g[i][j];
int start = 1;
for (int i = n; i >= 1; i--) // ★逆序:转移只依赖编号更大的地窖
{
f[i] = a[i]; // 至少挖自己这一窖
nxt[i] = 0; // 0 表示到此为止
for (int j = i + 1; j <= n; j++)
if (g[i][j] && a[i] + f[j] > f[i])
{
f[i] = a[i] + f[j]; // 接到 j 那条最优链后面
nxt[i] = j; // 记下一步,供回溯路径
}
if (f[i] > f[start]) // 起点可以是任意地窖,取全局最优
start = i;
}
for (int i = start; i; i = nxt[i]) // 顺着 nxt 链把路径打印出来
cout << i << (nxt[i] ? " " : "\n");
cout << f[start] << endl;
return 0;
}
// TAG: 线性DP DAG路径 状态机 方案回溯#include <iostream>
#include <algorithm>
using namespace std;
int n, ans;
int f[35]; // f[b]:以「第 b 位为 1 的数」结尾的最长合法子序列长度
int main()
{
cin >> n;
for (int i = 1; i <= n; i++)
{
int x;
cin >> x;
// 决策的记忆压进 31 个「按位状态」里:
// b_i & b_{i-1} != 0 ⇔ 两数至少共享一个为 1 的位。
int best = 0; // 能接在谁后面:所有与 x 共位的状态取最大
for (int b = 0; b < 31; b++)
if (x >> b & 1)
best = max(best, f[b]);
int cur = best + 1; // x 自己接上去,长度 +1
for (int b = 0; b < 31; b++) // x 的每个为 1 的位都被刷新为 cur
if (x >> b & 1)
f[b] = max(f[b], cur);
ans = max(ans, cur);
}
cout << ans << endl;
return 0;
}
// TAG: 线性DP 按位状态机 f[bit]转移 O(30n)
