文章

【算法】动态规划第六篇:状态机 DP——股票家族全集与三例副作用死刑

【算法】动态规划第六篇:状态机 DP——股票家族全集与三例副作用死刑 摘要 DP 系列第六篇,第 5 级状态机 DP 全记录。股票家族五题一网打尽:121(暴力 O(n²) 起步 → hold/cash 双状态机 + 前缀最小版双轨)、122(自创"落袋贪心"碰巧正确但无法论证,死刑于扩展性;状态机改一行)、188(k 次交易 = 状态加一层楼;j=0 地基层漏算导致"股票白送")、309(冷冻期

【算法】动态规划第六篇:状态机 DP——股票家族全集与三例副作用死刑

文章信息

  • 原文链接:https://jiayq.blog.csdn.net/article/details/164458000
  • 发布时间:2026-09-20 20:11:37
  • 标签:#Golang, #算法, ##状态机, ##算法讲解

【算法】动态规划第六篇:状态机 DP——股票家族全集与三例副作用死刑

摘要

DP 系列第六篇,第 5 级状态机 DP 全记录。股票家族五题一网打尽:121 (暴力 O(n²) 起步 → hold/cash 双状态机 + 前缀最小版双轨)、122 (自创”落袋贪心”碰巧正确但无法论证,死刑于扩展性;状态机改一行)、188 (k 次交易 = 状态加一层楼;j=0 地基层漏算导致”股票白送”)、309 (冷冻期 = cash 分裂出一个状态;”贪心跳天”和 i-2 过严两连坑)、714 (手续费 = 一条转移改一个常数)。本篇的核心心法只有一句:状态机的禁令永远写在转移边上,不写在状态定义里 ;外加三个反复现身的幽灵:“不动”分支 (max 的左半边,五次现身)、传钱通道 (买入本金 = 口袋全部现金,0 元买入是照妖镜)、以及三例”用副作用模拟状态”的死刑记录 (落袋贪生、跳天生、sale 标记生)。还有一张拉锯了七轮的手推表——它一个人贡献了本篇一半的教训。

前置阅读:动态规划第五篇:区间DP——从看什么都像背包到最后戳谁。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode

1. LC121:双状态机的诞生

只许交易一次,求最大利润。[7,1,5,3,6,4] → 5。

起点是一张暴力表(所有买卖对的利润矩阵)和 O(n²) 双重循环——自己诊断出”超时 + 没用 dp”。

1.1 第一层:前缀最小(从自己的表里挖)

盯暴力表:固定卖出日 j,最优买入日 = j 之前价格最低的那天 ——内层循环变成一个边扫边维护的变量,O(n²) → O(n)。

这一层挖出了两个坑:初版写成”找全局最低点再往后扫”——[2,10,1,3] 输出 2(正确 8,2 买 10 卖被太晚的全局最低点葬送)。全局最小是绝对概念,前缀最小是相对概念 ——“对每个卖出日,最优买点是它之前的最低”,这个”之前”随 j 移动。第二坑是笔误 max(prices[i]-buy) 单参数——max 的第一个参数就是”不动”分支,两个参数一个都不能少

1.2 第二层:状态机(为什么值得多学一个框架)

一维 dp[i] 装不下这题——同一个”第 i 天”有两种截然不同的处境:手里有股票 / 没有股票 。状态机登场:

1
2
3
4
5
hold[i] = 第 i 天结束仍持有股票的最大现金
cash[i] = 第 i 天结束空仓的最大现金
hold[i] = max(hold[i-1], -prices[i])          ← 继续拿 / 今天买
cash[i] = max(cash[i-1], hold[i-1]+prices[i]) ← 继续空 / 今天卖

手推 [7,1,5,3,6,4] 时翻的坑(本篇第一个重要教训):cash 行的 day3、day5 写错——只算了”今天卖出”,max 的左半边(昨天状态的延续,今天什么都不做)没执行cash[3] = max(4, 2) = 4 的含义是”第 3 天最优策略是第 1 天买第 2 天卖赚 4,第 3 天什么都不做 “——不动是一个合法且常常最优的决策 ,状态机的”不动”转移就是给它建模的。

两个版本的对照实验回答了”为什么要学状态机”:

前缀最小/贪心版状态机版 
121语义坑(全局 vs 前缀)+ 笔误,三轮修复一次过
122推倒重来改一条转移

贪心/前缀技巧是一次性聪明,状态机是可扩展框架。

2. LC122:第一例副作用死刑——落袋贪生

无限次交易。[7,1,5,3,6,4] → 7。

第一版自创了”落袋贪心 “:浮盈为正就落袋(ans += cash)、当天原价重新建仓(cash=0; hold=-prices[i])。它在 122 上碰巧正确 (等价于吃掉所有正差分),但作者自己的不安(“我感觉是不对的……隐含一个条件”)才是真相:无法论证自己为什么对的解,和碰巧对没有区别

死刑判决在扩展性:188(数不了交易次数)、309(”今天落袋今天买回”违反冷冻规则)——用运行时副作用模拟状态,一遇变体就死

2.1 正解:改一行

1
2
hold[i] = max(hold[i-1], cash[i-1] - prices[i])   ← 唯一改动:买入本金从昨天的 cash 带过来

为什么必须 cash[i-1]hold[i] 先算,此刻 cash[i] 还没算——读到的是零值垃圾。

2.2 传钱通道:day3 的 hold 第一次出现正值

1
2
3
4
        7   1   5   3   6   4
hold   -7  -1  -1   1   1   3
cash    0   0   4   4   7   7

hold[3] = 1:day2 卖出攒了 4,day3 花 3 买回——持仓里除了股票还揣着 1 块利润 。无限次交易的本质:没有落袋清零,利润随状态一路流动 。落袋贪心用”清零+重买”绕出来的效果,状态机用”本金流动”直接表达——买入分支的本金永远是”当前手里的全部现金”,不是股价 。这个”传钱通道”在 122/312/309/188 出现四次,是整个家族的血管。

3. LC188:状态加一维——一层一次交易

最多 k 笔交易。[3,2,6,5,0,3], k=2 → 7。

思考题”k 从哪里进状态”的答案:k 不是下标、不是计数器,是状态本身 ——hold[j][i] / cash[j][i],hold 和 cash 各自分裂成 k 份。”做了几笔”和”持不持仓”一样,是”今天结束时我是谁”的一部分。自己悟出的表述最准:一层一次交易

两个工程坑:

  • 不可能状态哨兵hold[j≥1][0] = -MaxInt(第 0 天完不成两步交易)——max 世界的”小而无害”,填 0 等于凭空有本金
  • 答案取 max over j :”最多 k 笔”允许在任意低层躺平,最优可能在任何一层

3.1 地基级 bug:j=0 层整层没算

第一版的循环 for j := 1 把 j=0 层跳过了——hold[0][i≥1] 全是 make 零值 0,而 cash[1] 的卖出转移读它:

1
2
3
cash[1][i] = max(..., hold[0][i-1] + prices[i])   ← 读到零值 0
[7,6,4,3,1](全程下跌)输出 4:cash[1][2] = 0 + 4 = 4——股票白送,没付一分钱买入

零值能白送的格子,前提是它的语义恰好等于 0cash[0] 恒 0 ✓);hold[0] 的语义是负数(负的前缀最小),零值就是毒。j=0 层不是装饰,是活的地基——必须单独跑一轮转移。

3.2 一张拉锯七轮的手推表

188 的手推表([3,2,6,5,0,3], k=2 三层)从第一版到最终版走了七轮,每一轮都是一类错误的标本:

  1. 残骸复制 :j=0 层 cash 行的一个 4(来自第一版把 j=1 层的值填错位置),六轮打补丁都没删——在旧表残骸上打补丁,残骸不删,错误永生 。最后重画满格对齐的新表才清零
  2. 串层 :j=1 的值漏进 j=0、j=2 的终值 7 漏进 j=1——每层只写”有值的尾巴”不对齐是温床
  3. 改对迁就错 :发现 hold=0, sold=3 自相矛盾后,把对的 sold 改成 2 去匹配错的 hold——矛盾是 bug 的报警器,拆报警器不灭火 。自洽不是目的,正确才是
  4. 带利润买入 反复:hold[j][4] = -prices[4] = -0 = 0(121 旧转移)vs cash[j][3]-prices[4] = 4-0 = 4——与 309 同款,详见下节
  5. 只推答案行 :sold/cash(答案来源)逐格认真,hold/free(依赖行)随手凑——每一行都是下一行的依赖,答案行的对是无根之木

最终这张表的修复方式也值得记录:跑自己的代码(七用例全绿的 188)打印三层表,对着打印结果誊写,誊写时每格标注 max 的哪边赢 ——互证规定动作这题从来没做过,补上它,七轮拉锯一朝清账。

4. LC309:加一个状态——冷冻期

卖出后冷冻一天。[1,2,3,0,2] → 3。

手推时的自问自答一针见血:“难点:冷静期怎么处理?直接下标跳 1? ”——问题极准,答案就是本节全部:不跳下标,加状态

4.1 第二例副作用死刑:跳天生

第一版用 sale 布尔标记 + continue 跳天模拟冷冻。死因两个:

  1. 贪心判定丢路径if 卖出比不卖好 { sale = true }——[1,3,5] 在 i=1(价格 3)就卖(2 > 0),”拿到 5”的路径当场死亡。输出 2,正确 4。“今天卖还是继续拿”是需要 max 权衡的决策,贪心一口咬定就丢分支
  2. 跳天把世界饿死 :冷冻期不是”跳过一天”,是一种状态 ——世界在里面继续运转,只是”买入”这一个动作被禁。continue 把这天所有其他路径抹掉

4.2 第三例副作用死刑:i-2 过严——禁令写错了地方

状态分裂对了(hold/justSold/free),但把 free 的转移写成 justSold[i-2]——直觉”冷冻几天解禁差几格”,账算错了:

1
2
3
4
规则:卖出 d → d+1 冷冻 → d+2 可买           (冷冻 1 天)
i-1 填法:justSold[d] → free[d+1] → hold[d+2]   ✓ 恰好
i-2 填法:justSold[d] → free[d+2] → hold[d+3]   ✗ 多冻一天,路径丢失

病根是把禁令写进了状态定义(free = “空仓且可买 ”)。正确的语义:free = 空仓(冷冻日也是空仓——你确实一股没有,只是今天不能买) ;”不能买”的禁令只写在一条转移边上——hold 的买入只能从 free[i-1] 来,不能从 justSold[i-1] 直接来。两个”差一格”的转移拼出恰好一天的冷冻:

1
2
3
4
justSold[i-1] → free[i] → hold[i+1]
 第 i-1 天卖出    第 i 天空仓    第 i+1 天买入
                  (冷冻日,在 free 里躺着)

状态机的禁令永远写在转移边上,不写在状态定义里 ——状态只描述”我是谁”,禁令描述”我能去哪”。

4.3 0 元照妖镜与”不动”分支的收官

hold[3] 这格连错三轮:写 0,正确 1。0 的来源是 -prices[3] = -0 = 0(121 旧转移),正确是 free[2]-prices[3] = 1-0 = 10 元买入是照妖镜 :买入价非 0 时两种写法只差一个常数,错误能藏很久;买入价 0 时,一个等于 0、一个等于全部本金,藏无可藏。物理画面一句话钉死:口袋里的钱不会因为买了 0 元的股票就蒸发

最后一格 hold[4] = max(1, 0) = 1——只算”今天买入”(0)没和”继续持有”(1)取 max。“不动”分支在 121/122/309/188/714 五道题里现身五次 ,从 121 的 cash 行一路打到 309 的最后一格——它是这个家族真正的 BOSS,也是所有 max 转移的左半边。自查口诀的最终版:写 max 之前先问:左半边(不动/继承)你带了吗

5. LC714:改一个常数——一分钟的题

每笔交易收 fee 手续费。[1,3,2,8,4,9], fee=2 → 8。

122 状态机原样保留,卖出边减 fee:

1
2
cash[i] = max(cash[i-1], hold[i]+prices[i]-fee)

这题的价值全在两个思考题:

  • 等价性 :fee 扣在买入边和卖出边等价 ——fee 是一笔交易的过路费,一进一出只收一次,收在入口还是出口总账相同;区别只是持仓中的 hold 平移一个 fee,而答案取自 cash(空仓),平移不外泄。钱在完整交易的两端守恒,中间的记账口径不影响终点
  • 手续费的语义[1,3,7,5,10,3], fee=3 一笔 1→10 拿 6,拆两笔只有 5——fee 抑制交易次数[9,8,7,1,2] 赚 1 付 3,不交易——max 的”不动”分支兜底

手推表最后一格 hold[5] 又凑了一次(写 10 应 1,且 cash[5]=8 暴露实际按 1 算)——”只推答案行”的第四次现身。

6. 股票家族总表:五个限制,五种改动

限制状态机改动新增教训
121只许一次hold/cash 双态基础“不动”分支首现
122无限次改一条转移 (本金从 cash 流动)传钱通道;落袋贪死刑
188最多 k 次加一维 (j = 层数,一层一次交易)不可能状态哨兵;j=0 地基要活算
309冷冻期加一个状态 (cash 分裂出 justSold)禁令写在边上;跳天生死刑
714手续费改一个常数 (卖出边 -fee)等价性 = 钱在交易两端守恒

三例副作用死刑(同一个元模式的三个马甲):

1
2
3
4
落袋贪生(122):浮盈就清零 + 原价重建仓 —— 数不了次数,违反冷冻
跳天生(309):sale 标记 + continue 跳天   —— 贪心丢路径,世界被饿死
改对迁就错(188 手推):把正确的 sold 改坏去匹配错误的 hold —— 拆报警器

共同死因:用运行时副作用/表面自洽模拟状态与正确性,而不是把状态和禁令写进结构里

7. 方法论增补

问题判据出处
一维装不下时问”同一天结束时有几种处境”——处境数 = 状态数121
限制怎么进模型加一维(次数)/ 加一状态(冷冻)/ 改一常数(费率)/ 改一下标(解禁延迟)家族总表
禁令写在哪转移边上,永远不在状态定义里309
买入分支的本金口袋全部现金(cash[i-1]-prices[i]),0 元买入是照妖镜122/309/188
不可能状态max 世界用 -MaxInt(小而无害),填 0 = 凭空本金188
答案位置多状态时取相关状态的 max(max over j、max(free, justSold))188/309
手推纪律每层写满对齐;每格标注 max 哪边赢;残骸必须删干净188 七轮表
矛盾怎么办矛盾是报警器——改错的一边去对齐对的,绝不反过来188

8. 终章预告:LC140

第 6 级(最后一站):LC140 单词拆分 II ——回溯期亲手数过 417 万次调用、缓存候选词列表”一个数字都没差”、以及”缓存结论不缓存选择”的判据,全部在此合龙。它是 DP 和回溯的合体:DP 负责判断哪些路活着(可行性折叠),回溯负责把活路走完(方案枚举) ——两个系列在此闭环。

总结

五道题,一条主线,三个感想:

状态机是”我是谁”的建模学 。每道题的转移大同小异,真正的设计全在”今天结束时我处于什么状态”——持仓与否(121)、完成几笔(188)、是否刚卖(309)。限制从来不是障碍,是状态的提示:每种限制都精确对应一种结构改动 (一维/一状态/一常数),这是本篇唯一需要背的表。

“不动”是这个家族的 BOSS 。五道题、五次现身、三种形态(继承/继续持有/不交易)——它永远是 max 的左半边,永远最容易被忘掉,永远在 0 元买入、全程下跌这种边界格子里现形。口诀贴显示器:写 max 之前先问,左半边带了吗

188 那张七轮表比五道题都值钱 。残骸复制、串层、改对迁就错、只推答案行——四种错误模式在一张表里集齐,每种都配了完整的一生。它最终教会的是一件事:打补丁修复不了系统性混乱,重画(对着机器打印誊写 + 逐格标注)才能清零 。手推表和代码是同一张图纸的两份拷贝,图纸乱了,找机器要一份新的。

终章 140 见——从 IP 题到单词拆分 II,从决策树到状态机,正好一个圆。

参考资料


版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

本文由作者按照 CC BY 4.0 进行授权