【算法】动态规划入门:从打家劫舍到状态设计的三堂课
摘要
回溯学完之后,我进了动态规划(DP)的坑。这篇文章记录 DP 第 1 级(一维 DP)的完整学习过程:从「打家劫舍」的三版演化链(回溯暴力 → 记忆化递归 → DP 数组)开始,看 DP 如何从回溯里一步步"长"出来;再到「最大子数组和」学习状态定义的限定词技巧,到「最长递增子序列」补上前驱枚举。每个坑都有错误代码、实测数据(4831 万次调用 vs 71 次、全 0 数组上 435 万次 vs 61 次)和修正方案。适合刚学完回溯、准备进 DP 的人对照着少走弯路。
说明,以下环境基于 go1.21+,题目均来自 LeetCode。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode
【算法】动态规划入门:从打家劫舍到状态设计的三堂课
- 【算法】动态规划入门:从打家劫舍到状态设计的三堂课
-
- 摘要
- [1. 起点:2023 年的旧代码与"背题解式遗忘"](#1. 起点:2023 年的旧代码与"背题解式遗忘")
- [2. 原理课:DP 是从回溯长出来的](#2. 原理课:DP 是从回溯长出来的)
-
- [2.1 第一步:回溯暴力------每间房"偷/不偷"](#2.1 第一步:回溯暴力——每间房"偷/不偷")
- [2.2 第二步:记忆化------缓存结论](#2.2 第二步:记忆化——缓存结论)
- [2.3 第三步:翻方向------递归变循环](#2.3 第三步:翻方向——递归变循环)
- [2.4 我对原理的第一个误解:"倒推"](#2.4 我对原理的第一个误解:"倒推")
- [2.5 转移方程从哪来:最后一步分类法](#2.5 转移方程从哪来:最后一步分类法)
- [3. 实战课一:打家劫舍的四个 bug](#3. 实战课一:打家劫舍的四个 bug)
-
- [3.1 数组版:`< 2` 的死代码与空数组炸弹](#3.1 数组版:
< 2的死代码与空数组炸弹) - [3.2 回溯版:`idx > 1` 的漏网之鱼与用例的巧合性](#3.2 回溯版:
idx > 1的漏网之鱼与用例的巧合性) - [3.3 记忆化版:只写不读的装饰品](#3.3 记忆化版:只写不读的装饰品)
- [3.4 哨兵配对:`> 0` 与 `>= 0`](#3.4 哨兵配对:
> 0与>= 0) - [3.5 演化链闭环:三版放在一起](#3.5 演化链闭环:三版放在一起)
- [3.1 数组版:`< 2` 的死代码与空数组炸弹](#3.1 数组版:
- [4. 实战课二:最大子数组和------状态定义的升级](#4. 实战课二:最大子数组和——状态定义的升级)
-
- [4.1 二维的诱惑与陷阱](#4.1 二维的诱惑与陷阱)
- [4.2 限定词的魔法:"以 i 结尾"](#4.2 限定词的魔法:"以 i 结尾")
- [4.3 "耗时很高"?先本地基准](#4.3 "耗时很高"?先本地基准)
- [4.4 滚动变量:Kadane 与"依赖只看常数步"](#4.4 滚动变量:Kadane 与"依赖只看常数步")
- [5. 实战课三:最长递增子序列------贪心摘要的失明](#5. 实战课三:最长递增子序列——贪心摘要的失明)
-
- [5.1 maxV 贪心为什么错](#5.1 maxV 贪心为什么错)
- [5.2 前驱枚举:max over j](#5.2 前驱枚举:max over j)
- [5.3 凑出来的手推比没有更糟](#5.3 凑出来的手推比没有更糟)
- [6. 阶段总结:状态定义三板斧](#6. 阶段总结:状态定义三板斧)
- 总结
- 参考资料
1. 起点:2023 年的旧代码与"背题解式遗忘"
学 DP 的第一题选了经典的 LC198 打家劫舍。翻提交记录,发现我 2023 年做过:
java
class Solution {
public int rob(int[] nums) {
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < nums.length; i++) {
dp[i] = Math.max(nums[i] + dp[i - 2], dp[i - 1]);
}
return dp[nums.length - 1];
}
}
代码完全正确。问题是:我完全不记得它为什么对 。dp[i] 表示什么?方程怎么推的?一概想不起来。
这就是"背题解式学习"的下场------三个月后归零。对比同一时期的回溯学习(翻车、修复、内化),现在还能默写骨架。所以这次换学法:不直接写 DP,从我已经熟练的回溯出发,看 DP 怎么一步步"长"出来。
2. 原理课:DP 是从回溯长出来的
2.1 第一步:回溯暴力------每间房"偷/不偷"
打家劫舍:一排房屋,每间有现金 nums[i],相邻两间不能同时偷,求最大金额。这是回溯"要/不要"模型的老本行:
rob(i) = 从第 i 间房开始,能偷到的最大值
rob(i) = max(
偷第 i 间: nums[i] + rob(i+2) ← 偷了 i,i+1 不能碰,跳到 i+2
不偷第 i 间:rob(i+1) ← 不偷,考虑下一间
)
画一下递归树,熟悉的配方出现了:
rob(0)
├─ 偷 0:nums[0] + rob(2)
│ ├─ 偷 2:nums[2] + rob(4)
│ └─ 不偷 2:rob(3)
│ └─ ... rob(4) 第二次出现!
└─ 不偷 0:rob(1)
└─ 不偷 1:rob(2) ← rob(2) 第二次出现!
同一个 rob(i) 被多条路径重复到达------和单词拆分(LC139)当年 417 万次调用是同一个病。而且答案与到达路径无关(无后效性),记忆化的靶子,老朋友了。
2.2 第二步:记忆化------缓存结论
go
memo[i] = 从第 i 间开始能偷到的最大值 // 缓存结论,不是选择
到这里没有任何新知识,全是回溯下半场的老本。DP 的最后一步跳跃是:把递归翻过来。
2.3 第三步:翻方向------递归变循环
#mermaid-svg-W6IaZgfAffJf323S{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-W6IaZgfAffJf323S .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-W6IaZgfAffJf323S .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-W6IaZgfAffJf323S .error-icon{fill:#552222;}#mermaid-svg-W6IaZgfAffJf323S .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-W6IaZgfAffJf323S .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-W6IaZgfAffJf323S .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-W6IaZgfAffJf323S .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-W6IaZgfAffJf323S .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-W6IaZgfAffJf323S .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-W6IaZgfAffJf323S .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-W6IaZgfAffJf323S .marker{fill:#333333;stroke:#333333;}#mermaid-svg-W6IaZgfAffJf323S .marker.cross{stroke:#333333;}#mermaid-svg-W6IaZgfAffJf323S svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-W6IaZgfAffJf323S p{margin:0;}#mermaid-svg-W6IaZgfAffJf323S .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-W6IaZgfAffJf323S .cluster-label text{fill:#333;}#mermaid-svg-W6IaZgfAffJf323S .cluster-label span{color:#333;}#mermaid-svg-W6IaZgfAffJf323S .cluster-label span p{background-color:transparent;}#mermaid-svg-W6IaZgfAffJf323S .label text,#mermaid-svg-W6IaZgfAffJf323S span{fill:#333;color:#333;}#mermaid-svg-W6IaZgfAffJf323S .node rect,#mermaid-svg-W6IaZgfAffJf323S .node circle,#mermaid-svg-W6IaZgfAffJf323S .node ellipse,#mermaid-svg-W6IaZgfAffJf323S .node polygon,#mermaid-svg-W6IaZgfAffJf323S .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-W6IaZgfAffJf323S .rough-node .label text,#mermaid-svg-W6IaZgfAffJf323S .node .label text,#mermaid-svg-W6IaZgfAffJf323S .image-shape .label,#mermaid-svg-W6IaZgfAffJf323S .icon-shape .label{text-anchor:middle;}#mermaid-svg-W6IaZgfAffJf323S .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-W6IaZgfAffJf323S .rough-node .label,#mermaid-svg-W6IaZgfAffJf323S .node .label,#mermaid-svg-W6IaZgfAffJf323S .image-shape .label,#mermaid-svg-W6IaZgfAffJf323S .icon-shape .label{text-align:center;}#mermaid-svg-W6IaZgfAffJf323S .node.clickable{cursor:pointer;}#mermaid-svg-W6IaZgfAffJf323S .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-W6IaZgfAffJf323S .arrowheadPath{fill:#333333;}#mermaid-svg-W6IaZgfAffJf323S .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-W6IaZgfAffJf323S .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-W6IaZgfAffJf323S .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-W6IaZgfAffJf323S .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-W6IaZgfAffJf323S .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-W6IaZgfAffJf323S .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-W6IaZgfAffJf323S .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-W6IaZgfAffJf323S .cluster text{fill:#333;}#mermaid-svg-W6IaZgfAffJf323S .cluster span{color:#333;}#mermaid-svg-W6IaZgfAffJf323S div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-W6IaZgfAffJf323S .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-W6IaZgfAffJf323S rect.text{fill:none;stroke-width:0;}#mermaid-svg-W6IaZgfAffJf323S .icon-shape,#mermaid-svg-W6IaZgfAffJf323S .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-W6IaZgfAffJf323S .icon-shape p,#mermaid-svg-W6IaZgfAffJf323S .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-W6IaZgfAffJf323S .icon-shape .label rect,#mermaid-svg-W6IaZgfAffJf323S .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-W6IaZgfAffJf323S .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-W6IaZgfAffJf323S .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-W6IaZgfAffJf323S :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 发现重叠子问题
加 memo
把递归翻过来
从边界往前填
回溯暴力
O(2^n)
每间房偷/不偷
记忆化递归
O(n)
自顶向下问
DP 数组
O(n)
自底向上推
go
// 自顶向下(记忆化):rob(0) 问 rob(2)、rob(1),一路问到底
// 自底向上(DP 数组):dp[2] 由 dp[1]、dp[0] 算出,一路填到 dp[n-1]
两者算的是完全相同的东西,只是方向相反------DP 表就是记忆化的显式化,去掉递归换成一层 for。
2.4 我对原理的第一个误解:"倒推"
我一开始的理解是"DP 的原理是倒推------要知道 dpi,一直倒推直到 0 和 1"。
对的部分:依赖链(dpi 依赖更小的状态)和边界锚点(0 和 1 无法再倒推,递推的锚)。
混掉的部分:依赖方向和计算顺序是两回事------
依赖方向(谁需要谁):dp[i] → 需要 dp[i-1]、dp[i-2] (大 → 小)
计算顺序(先算谁): dp[0], dp[1] → dp[2] → ... → dp[i] (小 → 大)
线性依赖里两者是硬币两面,混淆无害;但区间 DP(dp[i][j] 依赖 dp[i+1][j-1])里这个区分就是生死线------计算顺序必须保证被依赖的先算好。
2.5 转移方程从哪来:最后一步分类法
原理课最重要的一块。我一开始只回答了"怎么算出来"(递推机制,for 循环谁都会写),没回答"方程本身怎么来的"。方法论:
盯着最后一步的决策,按它分类。
打家劫舍推一遍。问 dp[i](前 i+1 间房的最大金额)怎么算------盯住最后一间房:
情形A:偷第 i 间
→ 第 i-1 间被锁死(相邻不能偷)
→ 剩下的问题 = "前 i-1 间的最优" = dp[i-2]
→ 此情形答案:nums[i] + dp[i-2]
情形B:不偷第 i 间
→ 剩下的问题 = "前 i 间的最优" = dp[i-1]
→ 此情形答案:dp[i-1]
两种情形都合法 → 取 max:
dp[i] = max(nums[i] + dp[i-2], dp[i-1])
每个转移方程都是这样现推的,没有模板可背。顺手发现一个惊人事实:转移方程的两个分支,就是回溯的"要/不要"------
回溯在每一层做"要/不要"往下递归展开整棵树;DP 只看最后一个位置的"要/不要"做一次分类,因为它确信子树答案早就算好了(就在 dp 表里)。回溯用树的深度换理解,DP 用一张表把整棵树压扁成一层分类讨论。
3. 实战课一:打家劫舍的四个 bug
3.1 数组版:< 2 的死代码与空数组炸弹
第一版 Go 实现的边界:
go
if len(nums) == 1 {
return nums[0]
}
if len(nums) < 2 { // ← len==1 已被上面拦截,走到这里 len 要么是 0 要么 >= 2
if nums[0] > nums[1] { // ← len=0 时直接越界 panic!
...
len == 0:0 < 2为 true →nums[0]越界 paniclen >= 2:< 2为 false → 整段死代码 (本想特判 len2,写成了< 2;而且 len2 根本不用特判,dp 路径天然覆盖)
测试结果:五个常规用例全对,空数组直接炸。LeetCode 约束 1 <= len 所以提交能过,但"能过"和"对"是两回事。用例清单里永远要有"最小输入"和"空输入"。
3.2 回溯版:idx > 1 的漏网之鱼与用例的巧合性
回溯版用 used 数组管"相邻不能偷",剪枝写成:
go
if idx > 1 && used[idx-1] { // ← 应该是 idx > 0
backtrack(sum, idx+1) // 前一间偷了,本间只能不偷
return
}
idx > 1 排除了 idx == 1 的检查------唯一漏网的非法组合是"偷第 0 间 + 偷第 1 间"。三个测试用例的戏剧性:
[1,2,3,1] 输出 4 ✓(非法路径偷0偷1偷3 = 1+2+1 = 4,恰好等于合法最优 4------数值巧合)
[2,7,9,3,1] 输出 12 ✓(非法增益偷0偷1 = 9 < 合法最优 12------被 max 淹没)
[10,10,1] 输出 20 ✗(非法偷0偷1 = 20 > 合法最优 11------终于露馅)
两个"看起来对"的用例都在掩盖同一个 bug,这不是运气差,是必然 。教训:测试要针对约束设计反例------约束是"相邻不能偷",就要构造"偷相邻两间收益超过合法最优"的输入(前两间肥、后面瘦)。
3.3 记忆化版:只写不读的装饰品
按演化链写记忆化版,方程、边界、返回值全对------但跑 35 间房:
我的版本(memo 只写不读): 48,315,633 次调用
真记忆化(入口查表): 71 次调用
差距: 68 万倍
bug:
go
memo[idx] = max(backtrack(idx+1), nums[idx]+backtrack(idx+2)) // 写了
return memo[idx] // 从来没有人读它
没有查表语句,每次递归照样完整展开子树------memo 只是把结果抄进数组然后扔掉。修复三行,加在递归入口:
go
if memo[idx] >= 0 {
return memo[idx] // 算过了,直接拿结论,跳过子树展开
}
这是我在"记忆化"上翻的第四个跟头,做个全集:
LC139: 不知道要 memo → 超时,417 万次调用
LC140 v1:memo 存"一个选择" → 锁死分支,答案错
LC140 v2:memo 存"候选词列表" → 正确但不防爆,调用次数和没有 memo 一模一样
LC198: memo 只写不读 → 4831 万次,68 万倍差距
四个形态,一个判据:缓存的东西,必须让后来者跳过子树的展开。
3.4 哨兵配对:> 0 与 >= 0
补上查表后还有最后一颗雷:查表条件写成了 memo[idx] > 0,正确是 >= 0。
后果在正常输入下不可见(正数金额下两者恰好等价),但全 0 数组 (LeetCode 合法输入,约束 0 <= nums[i])下每个状态算出来都是 0,0 > 0 恒 false,记忆化整体失效:
正常输入 [1,2,3,1]: 查表>0 = 9 次, 查表>=0 = 9 次 ← 无感
全 0 数组(30间): 查表>0 = 4,356,617 次, >=0 = 61 次 ← 退化回纯暴力
原则:哨兵值与判断条件必须严格配对------初值 -1 就配 >= 0 。写 > 0 是在赌"答案永远是正的",又一个隐性依赖约束(这题答案的合法值域包含 0)。
3.5 演化链闭环:三版放在一起
回溯暴力: 偷/不偷两分支,used 管合法性,全局 maxV 收集 O(2^n)
记忆化递归: max(rob(i+1), nums[i]+rob(i+2)) + 入口查表 O(n)
DP 数组: dp[i] = max(nums[i]+dp[i-2], dp[i-1]) O(n)
盯着后两版的方程------同一个式子照镜子 :rob(i+1), rob(i+2)(往后问)和 dp[i-1], dp[i-2](往前查)。这就是"DP = 回溯 + 记忆化 + 去掉递归"的全部含义。
另一个值得记录的观察:回溯版的 used 数组记录了"每间房偷没偷",但代码里只读过 used[idx-1] 一个位置------
回溯背着完整的历史走(整个 used 数组),DP 只带"往后看时真正有用的信息"。 更极端的是 DP 连那个 bool 都不要:偷第 i 间的分支直接写
nums[i] + rob(i+2),跳两格的转移结构天然保证不相邻,合法性问题在方程里自动消失。
4. 实战课二:最大子数组和------状态定义的升级
LC53:找和最大的连续子数组。这题我撞的墙很有代表性。
4.1 二维的诱惑与陷阱
我的第一反应:连续子数组有起点和终点两个自由度,一维数组装不下,上二维:
dp[x][y] 表示从 x 开始到 y 结束的子数组,最大值
跑出来几乎全错。三层问题:
- 定义自相矛盾 :从 x 到 y 的子数组是唯一确定的,它的和只有一个,没有"最大"可言。这句话本身不成立
- 方程的延伸分支用了错误状态 :
nums[i] + dp[j][i-1]加的是"区间[j, i-1]的最优值",但延伸到 i 要求前面整段[j..i-1]都在子数组里(连续性)------该加整段和,不是最优值 - 实现错误 :对角线
dp[i][i]没初始化(全是零值 0);返回dp[n-1][n-1]取错格子
DP 的第一块骨牌是那一句定义,它倒了后面全倒。
4.2 限定词的魔法:"以 i 结尾"
我的感知其实是对的------子数组确实有两个自由度 。错的不是感知,是应对方式。我的应对是"两个自由度都进下标"(二维);标准解法的应对是分工:
一个自由度进下标(终点 = i),另一个自由度被定义吸收(dpi 的值天然聚合了所有起点里最优的那个)。
dp[i] = 以第 i 个元素【结尾】的最大子数组和
└────┬────┘
多这一个限定词,就少一个维度
"结尾"限定词为什么是魔法------盯最后一步(nums[i] 前面接不接):
情形A:接上 → 连续性要求前面那段必须"以 i-1 结尾" → 恰好是 dp[i-1] 的定义!直接可用
→ nums[i] + dp[i-1]
情形B:另起 → nums[i] 自己单开一段
→ nums[i]
dp[i] = max(nums[i] + dp[i-1], nums[i])
对比为什么"前缀定义"推不出来 :如果 dp[i] = 前 i+1 个元素的最大子数组和,"接上"时需要"以 i-1 结尾"的最优------但前缀定义的 dp[i-1] 不保证它的最优子数组以 i-1 结尾(可能中途就结束了),接不上。限定词"结尾"就是为了让子状态恰好可用而生的。
答案是 max(dp[0..n-1]) 不是 dp[n-1]:每个子数组都有个结尾,枚举所有结尾覆盖所有子数组;"以 i 结尾"只是过渡状态,全局最优藏在其中某一处。
4.3 "耗时很高"?先本地基准
一维版跑对了,但 LeetCode 显示耗时偏高。本地基准测试的结论:
我的版本(dp 数组): n=10^5 → 307µs(0.3 毫秒)
0.3 毫秒跑完 LeetCode 最大规模------算法已经是理论最优 O(n)(总得读一遍数组),LeetCode 显示的毫秒数大头是 Go 评测机的平台开销(运行时启动、GC、计时波动,同一份代码 Beats 能从 20% 跳到 80%)。
和回溯期组合总和那次对照着记,形成方法论:
组合总和那次:本地复现 → 42% 调用是死分支 → 真有病 → 修
最大子数组这次:本地基准 → 0.3ms 跑完 10^5 → 没病 → LeetCode 的数字是噪声
看复杂度排名,不看毫秒数。
4.4 滚动变量:Kadane 与"依赖只看常数步"
基准测试顺手暴露了一个真东西:dp 数组版 vs 滚动变量版有近 10 倍 差距(307µs vs 33µs)------数组版每轮写 dp[i](内存写 + 下标边界检查),滚动版的 cur 编译后直接进 CPU 寄存器:
go
func maxSubArray(nums []int) int {
cur := nums[0] // cur = 以当前元素结尾的最大和(就是 dp[i],不再存历史)
ans := nums[0]
for i := 1; i < len(nums); i++ {
cur = max(cur+nums[i], nums[i]) // 方程一字没变
ans = max(ans, cur)
}
return ans
}
方程完全没变 ,只是"历史全部保留的 dp 数组"换成"只留最近一格"。什么时候能滚动?------依赖只往回看常数步(这题一步,打家劫舍两步,同样能滚)。什么时候不能?------要回读历史的时候(马上见下一题)。
5. 实战课三:最长递增子序列------贪心摘要的失明
LC300:最长严格递增子序列。我的第一版翻在一个熟悉的老病上。
5.1 maxV 贪心为什么错
我维护了"当前最大元素 maxV",只和它比较判断递增。case2 [0,1,0,3,2,3](期望 4)直接输出 3。看 2(下标 4)的处境:
2 不能接在 maxV=3 后面(2 < 3)→ 判定"不递增",只能继承
但 2 能接在 1(下标 1)后面! [0,1] + 2 = [0,1,2],长度 3
之后 3 接在 [0,1,2] 后 → [0,1,2,3],长度 4 ← 正确答案藏在这条路上
nums[i] 的前驱是"所有 j < i 且 nums[j] < nums[i]"的完整集合------这个集合因 i 而异,无法用单一的最大值摘要。贪心只记住一条"最粗的链",而最优子序列可能从任何位置分叉。
这是我第三次犯"发明局部规则回避枚举"(博弈题的"选最小"贪心、139 的漫游)------这次穿着 DP 的衣服:dp 数组建了、循环也写了,但方程里塞的是贪心摘要。
5.2 前驱枚举:max over j
go
func lengthOfLIS(nums []int) int {
// dp[i] = 以 nums[i] 结尾的最长严格递增子序列的长度
dp := make([]int, len(nums))
ans := 0
for i := 0; i < len(nums); i++ {
dp[i] = 1 // 基线:自己单独成序列
for j := 0; j < i; j++ {
if nums[j] < nums[i] {
dp[i] = max(dp[j]+1, dp[i]) // 接在 j 后面,如果更长
}
}
ans = max(ans, dp[i]) // 全局最优可能以任何位置结尾
}
return ans
}
dp[i] 的信息来源不是"上一个格子"(dp[i-1]),而是前面所有格子的最大值 ------这就是必须两层循环的准确含义。手推验证([10,9,2,5,3,7,101,18]):
nums: 10 9 2 5 3 7 101 18
dp: 1 1 1 2 2 3 4 4
↑ ↑
7 接 5 或 3 后面 18 不能接 101(18<101),接 7 后面
dp[7]=4 的来源正好是贪心看不见的那条分叉。
滚动判据在这里兑现 :dp[i] 要读所有 dp[0..i-1]------依赖往回看任意步,滚动优化失效,dp 数组必须完整保留。上一题的判据一用就灵。
5.3 凑出来的手推比没有更糟
重写版的代码对了,但注释里手推的 dp 数组写成了 1,1,1,2,2,2,3,4,4------9 个数,输入只有 8 个元素 ,且 dp[5] 应是 3 写成了 2。代码对、手推错,说明手推是照着代码结果凑的。
手推的全部意义在于独立于代码验证理解:先手推、再写码、两边对上,才叫闭环。凑出来的手推比没有更糟------它会给你"我验证过了"的错觉。
6. 阶段总结:状态定义三板斧
DP 第 1 级(一维)收官,三道题沉淀出三种状态定义模式:
198 打家劫舍:前缀型 dp[i] = 前 i+1 项的最优 + 跳格依赖(可滚动)
53 最大子数组:限定型 dp[i] = 以 i 结尾的最优 + 一步依赖(可滚动 → Kadane)
300 LIS: 限定型 + 前驱枚举 max over j + 任意步回读(不可滚动)
一张判据总表:
| 问题 | 判据 | 出处 |
|---|---|---|
| 这题能不能 DP | 无后效性:状态的结论与到达路径无关 | 139 老判据 |
| 方程怎么推 | 最后一步分类:盯住最后一个位置的选择,按它分类 | 198 |
| 自由度怎么处理 | 每个自由度要么进下标,要么被定义吸收(限定词) | 53 |
| 能否滚动优化 | 依赖只往回看常数步 → 能;要回读历史 → 不能 | 53/300 |
| 记忆化缓存什么 | 缓存结论(能让后来者跳过子树展开的东西) | 198 四连翻 |
| 哨兵怎么配 | 初值 -1 配 >= 0,0 可能是答案就不能用 0 当哨兵 |
198 |
| "耗时高"怎么判 | 先本地基准,区分代码病 vs 平台噪声 | 53 |
学习轨迹:
198 打家劫舍(原理 + 演化链 + 四个 bug 修复)
↓
53 最大子数组(二维陷阱 → 限定词 → 滚动变量)
↓
300 LIS(贪心摘要失明 → 前驱枚举 → 滚动判据验证)
↓
下一篇:1143 LCS(二维的正当登场)
总结
三道题、三堂课,几个感受:
DP 的入口比想象中低。从回溯出发的演化链(暴力 → 记忆化 → 翻方向)让 DP 不是"新算法",而是旧知识的压缩形态。转移方程的两个分支就是回溯的"要/不要",dp 表就是记忆化的显式化------写出第一版正确的记忆化,剩下的只是机械的翻转。
但 DP 的难点也真的不一样 。回溯的灵魂是决策设计,DP 的灵魂是状态定义------dp[i] 那一句话。我在 53 上把定义写自相矛盾("从 x 到 y 的最大值"),方程跟着全错;换一个限定词("以 i 结尾")整题就通了。一句话的定义,就是 DP 的全部灵魂。
老病会换新皮复发。贪心摘要代替枚举(300)、哨兵不配对(198)、边界不测(198×2)------这些在回溯期都翻过的车,穿上 DP 的衣服又来了一遍。好在识别速度快了:300 那次自己就诊断出了"只有一层循环不够"。
实测数据是最好的老师。4831 万 vs 71、435 万 vs 61、307µs vs 33µs------每个 bug 都有数字拍在脸上,比"感觉慢"精确一万倍。本地基准 + 调用计数,这两招会一直用下去。
下一篇进双序列 DP(LCS、编辑距离),二维数组这次会是正当的------两个序列各贡献一个自由度。撞了坑再来更新。
参考资料
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。