【算法】动态规划入门:从打家劫舍到状态设计的三堂课

【算法】动态规划入门:从打家劫舍到状态设计的三堂课

摘要

回溯学完之后,我进了动态规划(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 演化链闭环:三版放在一起)
    • [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 == 00 < 2 为 true → nums[0] 越界 panic
  • len >= 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 结束的子数组,最大值

跑出来几乎全错。三层问题:

  1. 定义自相矛盾 :从 x 到 y 的子数组是唯一确定的,它的和只有一个,没有"最大"可言。这句话本身不成立
  2. 方程的延伸分支用了错误状态nums[i] + dp[j][i-1] 加的是"区间 [j, i-1] 的最优值",但延伸到 i 要求前面整段 [j..i-1] 都在子数组里(连续性)------该加整段和,不是最优值
  3. 实现错误 :对角线 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 < inums[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 版权协议,转载请附上原文出处链接和本声明。

相关推荐
淡海水11 小时前
07-04-并发-ConcurrentBag-T-工作窃取WorkStealing算法
开发语言·算法·c#·bag·concurrent·workstealing
leobertlan11 小时前
好玩系列:训练一个神经网络模型指导小孩玩游戏2-大局观教练
算法
Tisfy12 小时前
LeetCode 3876.构造奇偶一致的数组 II:三种情况分类讨论(其实还是脑筋急转弯)
算法·leetcode·题解·脑筋急转弯
lvwangshu12 小时前
P7668 [JOI 2018 Final] 团子制作 / Dango Maker 题解
动态规划·题解·贪心·性质题·思维好题
乐迪信息12 小时前
智慧港口船舶AI算法实现在线状态监测
大数据·人工智能·深度学习·算法·计算机视觉
木井巳14 小时前
【BFS/DFS 解决 FloodFill 算法】太平洋大西洋水流问题
java·算法·leetcode·深度优先·广度优先·宽度优先·推荐算法
心抵鹊14 小时前
归并排序之翻转对(hard)
数据结构·算法
白山编程大哥14 小时前
Java 集合算法:从排序、查找到底层原理的实战指南
java·python·算法
shehuiyuelaiyuehao15 小时前
算法34,位运算符操作,总结
算法