【算法】动态规划第二篇:双序列 DP 三课------继承、计步与断链
摘要
DP 第一篇(打家劫舍三版演化 → 最大子数组 → LIS)收官在一维。这一篇进二维双序列,一天三题:最长公共子序列(LC1143)→ 编辑距离(LC72)→ 最长重复子数组(LC718) 。三题骨架相同(dp[i][j] 看两处末尾),灵魂各异:LCS 是继承型 (不等时 max 搬运)、编辑距离是计步型 (三操作各消一个末尾)、子数组是断链型 (不等归零)。踩的坑包括:两个 bug 互相抵消的测试陷阱、dp[0][0] 的语义漂移、"结尾限定型"的惯性误用、手推表"结果对过程错"、以及 go1.21 内建 min 在旧版本上的编译翻车。最后是那道收官之问的答案:什么时候答案是 dp[m][n],什么时候必须 max 扫全表。
前置阅读:动态规划第一篇:从打家劫舍到状态设计的三堂课。环境:macOS + g 管理 Go 多版本(默认 1.20.14,需要内建 min/max 时切 1.24.13,跑完切回)。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode
【算法】动态规划第二篇:双序列 DP 三课------继承、计步与断链
- [【算法】动态规划第二篇:双序列 DP 三课------继承、计步与断链](#【算法】动态规划第二篇:双序列 DP 三课——继承、计步与断链)
-
- 摘要
- [1. 第一课:LCS------两个 bug 互相抵消的那晚](#1. 第一课:LCS——两个 bug 互相抵消的那晚)
-
- [1.1 先看正确的骨架](#1.1 先看正确的骨架)
- [1.2 bug 一:返回了"砍掉末尾"的格子](#1.2 bug 一:返回了"砍掉末尾"的格子)
- [1.3 bug 二:`dp00 = 1` 的语义漂移](#1.3 bug 二:
dp[0][0] = 1的语义漂移) - [1.4 双 bug 抵消:测试陷阱的加强版](#1.4 双 bug 抵消:测试陷阱的加强版)
- [1.5 空行列是语义锚点,不是浪费](#1.5 空行列是语义锚点,不是浪费)
- [1.6 定义表述:结尾限定型的惯性](#1.6 定义表述:结尾限定型的惯性)
- [2. 第二课:编辑距离------从"完全不懂"到"设计满分"](#2. 第二课:编辑距离——从"完全不懂"到"设计满分")
-
- [2.1 定义复用,方程看两处末尾](#2.1 定义复用,方程看两处末尾)
- [2.2 边界不再是零:锚点公式](#2.2 边界不再是零:锚点公式)
- [2.3 "倒序比较":用自己的话重新发明方法论](#2.3 "倒序比较":用自己的话重新发明方法论)
- [2.4 设计满分,手推翻车:白捡的一步没捡](#2.4 设计满分,手推翻车:白捡的一步没捡)
- [2.5 顺手撞上的编译门:内建 min 与 Go 版本](#2.5 顺手撞上的编译门:内建 min 与 Go 版本)
- [3. 第三课:最长重复子数组------一个字之差,方程换血](#3. 第三课:最长重复子数组——一个字之差,方程换血)
-
- [3.1 你在解另一道题](#3.1 你在解另一道题)
- [3.2 自己摸到病灶](#3.2 自己摸到病灶)
- [3.3 定义也要换血:双结尾限定](#3.3 定义也要换血:双结尾限定)
- [3.4 手推表:结果对,过程错](#3.4 手推表:结果对,过程错)
- [4. 双序列三课总览](#4. 双序列三课总览)
-
- [4.1 三种方程对照](#4.1 三种方程对照)
- [4.2 收官之问:dpmn 还是 max(表)?](#4.2 收官之问:dp[m][n] 还是 max(表)?)
- [4.3 方法论增补(接第一篇的判据表)](#4.3 方法论增补(接第一篇的判据表))
- [5. 踩坑清单(本篇全集)](#5. 踩坑清单(本篇全集))
- [6. 下一步:第 3 级背包](#6. 下一步:第 3 级背包)
- 总结
- 参考资料
1. 第一课:LCS------两个 bug 互相抵消的那晚
LC1143 最长公共子序列:求两个字符串公共子序列的最大长度。"abcde" 和 "ace" → 3。
1.1 先看正确的骨架
双序列 DP 的标准开场,第一篇学的"自由度分配"直接落地------两个序列各贡献一个自由度,都进下标:
dp[i][j] = text1 前 i 个字符 与 text2 前 j 个字符 的 LCS 长度
dp[i][j] = dp[i-1][j-1] + 1 (末尾相等:接上)
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) (末尾不等:跳过某一边)
返回 dp[m][n] (右下角)
第一版实现,方程、循环、返回值......看起来都对。跑测试:
ace/abcde: 3 ✓(期望 3 ------ 碰巧对,下面解释)
hofubmnylkra/pqhgx...: 4 ✗(期望 5)
ab/ba: 0 ✗(期望 1)
1.2 bug 一:返回了"砍掉末尾"的格子
go
return dp[len(text1)-1][len(text2)-1] // ← dp[m-1][n-1]
dp 开的是 (m+1)×(n+1),最后一行一列填完了却没读 。dp[m-1][n-1] 的语义是"两边各砍掉末字符的 LCS"------第二个用例里 r 的匹配恰好落在最后一行一列,砍掉就丢了。
1.3 bug 二:dp[0][0] = 1 的语义漂移
go
if text1[0] == text2[0] {
dp[0][0] = 1 // ← 想给 dp[1][1] 的语义,写在了 dp[0][0] 的位置
}
dp[0][0] 的语义是"空前缀 vs 空前缀的 LCS" = 0,永远 。这个初始化填的是"首字符相等"------那是 dp[1][1] 的事,索引和语义错位一格。后果是整个表从左上角开始膨胀:
代码实际算出的表: 手推的表(正确):
"" a b c d e a b c d e
"" 1 0 0 0 0 0 a 1 1 1 1 1
a 0 2 2 2 2 2 c 1 1 2 2 2
c 0 2 2 3 3 3 e 1 1 2 2 3
e 0 2 2 3 3 4
dp[1][1] = 2,手推同一个格子是 1------代码算出的表和自己的手推对不上,而我从没对过。
1.4 双 bug 抵消:测试陷阱的加强版
第一个用例为什么碰巧输出 3?整表膨胀(每个格子 +1)× 返回格子偏左上(少一格),负负得正 。第二个用例首字符 h/p 不等,bug 二不触发,bug 二的"补偿"消失,bug 一原形毕露。
用例1(首字符相等):bug2 让表膨胀 +1,bug1 让格子偏 -1 格 → 抵消 → 输出碰巧对
用例2(首字符不等):bug2 不触发,表是正确的,bug1 单独暴露 → 输出 4 ≠ 5
打家劫舍那篇记录过"1,2,3,1 掩盖 bug",这次是升级版:两个 bug、两个用例,一个互相抵消装没事,一个原形毕露。教训不变:测试要针对约束设计,而且要能区分"碰巧对"和"对"。
1.5 空行列是语义锚点,不是浪费
(m+1)×(n+1) 多出来的那圈,我在手推时直接略过了(推的是 3×5,作业要求的是 6×4)------而两个 bug 恰好都藏在那圈里。
dp[i][0] = dp[0][j] = 0(空串和谁都没有公共子序列)是语义锚点 。有这圈零值兜底,循环才能从i=1, j=1起步,方程里的dp[i-1][j-1]在边界处永远合法、不用特判------和打家劫舍开(n+1)表让dp[i-2]不越界是同一个设计。
1.6 定义表述:结尾限定型的惯性
注释里写的定义是"以 x 结尾、以 y 结尾的最长公共序列"------这是第一篇 53/300 学的"以 i 结尾"惯性。但看自由度分配:
- 53 最大子数组:一个自由度进下标,另一个被定义吸收("结尾"限定词)
- LCS:两个自由度(两串的长度 i、j)都进了下标,值里不需要吸收任何东西
自证:若真是结尾限定,text1[i-1] != text2[j-1] 时 dp 应该 = 0(不存在以两边不同末尾结尾的非空公共子序列)------但转移写的是 max(...),那是前缀型的方程。代码是前缀型,嘴上说的是结尾型。 正确表述:dp[i][j] = text1 前 i 个字符与 text2 前 j 个字符的 LCS 长度。
顺手:注释里的 max(dp[i][j-1], dp[i-1][j], dp[i-1][j-1]+1) 三项混一起也是错的------相等/不等是互斥两情形,不能混一个 max("ab" vs "ba" 会推出 2,正确 1)。
2. 第二课:编辑距离------从"完全不懂"到"设计满分"
LC72 编辑距离:插入/删除/替换三种操作,求 word1 变成 word2 的最少步数。"horse" → "ros" = 3。
拿到题的反应是"不懂、不会手推、dp 数组想不明白"------编辑距离确实是双序列 DP 公认的坎。拆开看,新东西只有两个:三操作映射 和非零边界。
2.1 定义复用,方程看两处末尾
定义直接抄 LCS(前缀型二维),最后一步分类法盯住两串末尾 X = word1[i-1]、Y = word2[j-1]:
X == Y(白捡) :末尾已对齐,不用动 → dp[i][j] = dp[i-1][j-1]。
X != Y(三选一) :用 "horse" → "ros"(X=e, Y=s)具体演示:
| 操作 | 动作 | 消掉谁的末尾 | 剩下的子问题 | 转移 |
|---|---|---|---|---|
| 替换 | e 改成 s |
两边同时 | "hors"→"ro" |
dp[i-1][j-1] + 1 |
| 删除 | 删掉 e |
word1 的(i 减) | "hors"→"ros" |
dp[i-1][j] + 1 |
| 插入 | 末尾补个 s |
word2 的(j 减) | "horse"→"ro" |
dp[i][j-1] + 1 |
记忆锚点:下标谁减一,就是谁的末尾被消掉了 。三种都合法 → 取 min,每个 +1 是那次操作的花费。
几何图景(手推全靠它):
word2: r o s
┌──────
word1: '' │ 0
h │ 1 ←─ 左 = dp[i][j-1] 插入
│ ╲
o │ 2 ↖ 左上 = dp[i-1][j-1] 替换(相等时免费看它)
│ ↑
... │ 上 = dp[i-1][j] 删除
手推口诀:看末尾------相等抄左上;不等三个邻居各 +1 取 min。
2.2 边界不再是零:锚点公式
dp[i][0] = i (word1 前 i 个 → 空串:只能全删,i 次)
dp[0][j] = j (空串 → word2 前 j 个:只能全插,j 次)
LCS 里空串的答案是 0(没有公共子序列),这里空串的答案是"长度本身"。锚点还在,只是锚的值换了------公式:问自己"边界这个退化子问题,肉眼答案是多少",填多少。
2.3 "倒序比较":用自己的话重新发明方法论
理解过程的转折点是自己冒出的一句:"ros 和 horse 倒序比较,删除 e 和替换 e 再删前一个 s 相比,直接删除 e 步数最少"------这就是"最后一步分类法"的白话版。把账本摊开:
| 路线 | 操作序列 | 步数 |
|---|---|---|
| 删除 | 删 e → "hors"→"ros"(2 步) |
1+2 = 3 ✓ 胜出 |
| 替换(绕路版) | e→s 得 horss → 删前面的 s → "hors"→"ros" |
1+1+2 = 4 |
| 替换(正规) | e→s,末尾 s 配对白带走 → "hors"→"ro"(3 步) |
1+3 = 4 |
| 插入 | 末尾补 s 配对 → "horse"→"ro"(4 步) |
1+4 = 5 |
两个精修点:① 替换出来的 s 不用删 ------和 word2 末尾配对后免费跟走,同一个步数可以对应不同操作序列,dp 只管计数不管走法;② 只比了两条路不够------min 要比三条,这次赢家恰好在你比的两条里,换个格子就不一定了。
2.4 设计满分,手推翻车:白捡的一步没捡
交作业:设计五要素全对(前缀定义、非零边界、相等抄左上、三邻居 +1 取 min、右下角返回)------但手推表最后一行错两格:
代码实测表: 手推表:
s | 3 3 2 2 2 3 3 3 2 2 3 4 ← 最后两格错
病灶:s/s 格("hors" ↔ "ros")末尾 s == s,该走"相等抄左上" (左上 = 2),却套了不等公式算出 3------白捡的一步没捡 。错误传播到右下角:本该 min(4, 2+1, 5) = 3,用自己的错值算出 min(4, 4, 5) = 4。
讽刺的是这个右下角在讨论阶段刚逐格走过一遍------嘴上 get 了,手上没走通。这就是为什么作业要求"每格标注来源":标注"抄左上"会逼你看末尾字符。
2.5 顺手撞上的编译门:内建 min 与 Go 版本
代码里 min(a, b, c) 三参数是 go1.21+ 的内建,本地 1.20 直接编译失败:
./main.go:32:16: undefined: min
这暴露了一个事实:"本地跑通 + 打印 dp 表互证"的作业规定动作被跳过了------跑了就会撞编译错误,也必然发现手推表和实测表对不上。一次规定动作,两颗雷全排。
顺便立了本地编译的规矩(g 多版本管理):
bash
g use 1.24.13 # 切到新版编译执行(内建 min/max 可用)
g use 1.20.14 # 跑完必须切回,不可遗留
3. 第三课:最长重复子数组------一个字之差,方程换血
LC718 最长重复子数组:两个数组的公共、连续 子数组的最大长度。[1,2,3,2,1] × [3,2,1,4,7] → 3。
出题时埋了个考点:"这题和 LCS 只差一个字------子序列换成子数组 "。第一版交上来:LCS 整个搬来,一个字没改 ------前缀定义、不等 max 继承、返回右下角。
3.1 你在解另一道题
实测:
用例1 [1,2,3,2,1] x [3,2,1,4,7] 输出 3 ✓ ← 碰巧:这组数据的 LCS [3,2,1] 恰好连续
用例2 [0,1,1,1,1] x [1,0,1,0,1] 输出 3 ✗ ← 暴露:LCS = [1,1,1] = 3,
但 nums2 里没有连续两个 1,真实答案 2
max 继承的逻辑属于"允许断开"的子序列世界------用子序列的方程解子数组的题,等于在解另一道题,第一用例碰巧两个答案重合,第二用例当场分家。
3.2 自己摸到病灶
第一版注释里留了一句话:"这里有个问题,不连续的怎么处理? "------问的就是本题灵魂,答案四个字:不等归零。
| LCS(子序列) | 本题(子数组) | |
|---|---|---|
| 末尾不等时 | 前面攒的长度还能用(跳过不匹配字符) | 连续性当场死亡,从零重数 |
| 转移 | dp[i][j] = max(邻居) |
dp[i][j] = 0 |
3.3 定义也要换血:双结尾限定
前缀型定义("前 x 个 vs 前 y 个的最长子数组")推不出方程 ------53 二维撞墙的同款:dp[i-1][j-1] 的最优子数组不保证以两边末尾结尾,相等时 +1 接不上。53 那天救场的限定词,这次要双份:
dp[i][j] = 以 nums1[i-1] 结尾、同时以 nums2[j-1] 结尾的最长公共子数组长度
"以 i 结尾"的二维版。连带答案位置也变了:结尾限定型的最优可以结束在任何格子 → 返回 max(整表),不是右下角。
修正版三要素:相等 dp[i-1][j-1]+1、不等归零、扫全表取 max------实测两用例全中。
3.4 手推表:结果对,过程错
修正版手推两张表,max 都"碰巧"对,过程错一片:
实测表(用例2,行=nums2): 手推表:
1 | 0 0 2 1 1 1 0 1 2 0 0 0
0 | 0 1 0 0 0 0 0 1 2 0 0 0 ← 抄了上一行
1 | 0 0 2 1 1 1 0 1 2 0 0 0
错误模式很有代表性:"断链归零"被过度泛化成了"链断了这行后面全零" 。看实测表 0 0 2 1 1 1------2 是一条链的终点,但它后面的 1 1 1 不是链的延续也不是零,是三条新短链各自从 1 起头:
每个格子只看自己的末尾:相等就
左上+1(哪怕左上是 0,也从 1 重新数),不等才是 0。链是"格与格之间"的事------断链清的是跨格继承,不清格子自己的起头资格。
4. 双序列三课总览
4.1 三种方程对照
| LCS 1143 | 编辑距离 72 | 子数组 718 | |
|---|---|---|---|
| 定义类型 | 前缀型 | 前缀型 | 双结尾限定型 |
| 末尾相等 | dp[i-1][j-1] + 1 |
dp[i-1][j-1](免费) |
dp[i-1][j-1] + 1 |
| 末尾不等 | max(两个邻居) 继承 |
min(三个邻居 + 1) 计步 |
归零 |
| 边界 | 全 0 | i 和 j(非零!) |
全 0 |
| 答案 | dp[m][n] |
dp[m][n] |
max(全表) |
| 灵魂 | 不匹配可跳过,信息搬运 | 三操作各消一个末尾 | 连续性断即死亡 |
4.2 收官之问:dpmn 还是 max(表)?
学完三课自己问出了这个问题。判据一条:看 dp 定义的类型。
| 定义类型 | 语义 | 答案位置 |
|---|---|---|
| 前缀型 | "前 i 个 vs 前 j 个的最优" | dp[m][n]------"全部 vs 全部"天然就是题目 |
| 结尾限定型 | "以 i 结尾的最优" | max(全表)------最优可能结束在任何位置 |
为什么限定型必须扫全表:"以 i 结尾"是为方程接得上而付出的代价------它让子状态可用,但 dp 值只代表"被迫以 i 结尾"条件下的最优,题目并没要求最优解以哪里结尾。
更深的统一视角:LCS 的右下角之所以是答案,是因为不等分支的 max 继承把"结束在别处"的信息一路搬运到了终点;718 断链归零,信息只能留在原地------所以一个终点收网,一个遍地开花。
实战判据:拿到题先问"最优解一定以最后一个位置结尾吗?"一定 → 右下角;不一定 → max 扫全表。
4.3 方法论增补(接第一篇的判据表)
| 问题 | 判据 | 出处 |
|---|---|---|
| 双序列怎么定义 | 两个自由度都进下标(前缀型),除非方程接不上才加限定词 | LCS vs 718 |
| 三操作映射 | 下标谁减一 = 谁的末尾被消掉(替换双消/删除消 i/插入消 j) | 72 |
| 边界填什么 | 退化子问题肉眼看答案(空串的 LCS 是 0,变空串的操作数是长度) | 1143/72 |
| 连续 vs 子序列 | 连续:不等归零 + 结尾限定 + max 全表;子序列:继承 + 前缀 + 右下角 | 718 |
| 答案位置 | 定义带限定词 → max 全表;纯前缀 → 右下角 | 718 |
| 手推闭环 | 真推 + 打印 dp 表与代码互证,缺一环 = 没做 | 1143/72/718 |
5. 踩坑清单(本篇全集)
- 双 bug 抵消(1143):表膨胀 +1 × 格子偏移 -1,负负得正------用例碰巧全对。两个 bug 两个用例,一个抵消一个暴露
- 语义漂移 (1143):
dp[0][0]填了dp[1][1]的语义------脑内的表是 3×5,代码的表是 4×6,空行列的语义从没建立过 - 定义表述惯性(1143):把 53 的"以 i 结尾"套到 LCS 上------代码是前缀型,嘴上说的是结尾型
- 凑注释(1143/718):三项混 max、漏下标------代码对了注释是错的,凑了三次
- 白捡没捡(72):末尾相等该抄左上,套了不等公式------上一轮刚讨论过的格子照样错,嘴上 get 手上没通
- 跳过规定动作(72):本地没跑 → 编译错误没撞上 + 手推 diff 没发现。一次互证排两雷
- 整套照搬(718):子数组的题搬子序列的方程------在解另一道题,第一用例碰巧 LCS = 最长公共子数组
- 过度泛化(718):"断链归零"理解成"链断了后面全零"------断链清的是跨格继承,不清格子自己起头的资格
- 结果对过程错(718):手推表 max 碰巧对,中间格子错一片------结果对不等于理解对
6. 下一步:第 3 级背包
双序列三课收官。下一级进背包 DP(416 分割等和子集、322 零钱兑换、518 零钱兑换 II)------那里会遇到"维度切换"的新问题:dp 的一个维度从"序列位置"变成"容量",选与不选的老朋友会以新面貌回归。
总结
一天三题,从照抄 LCS 到自己问出"dpmn 还是 max",这个弧线值得记下来。几个感受:
双序列的骨架比想象中窄 。dp[i][j] + 看两处末尾 + 两情形------三题共用这一个骨架,换的只是"不等时干什么"(继承 / 计步 / 归零)。识别出骨架之后,每题的新知识其实只有一小块。
错误在收敛。第一篇的 bug 是"方程写错、死代码、panic";这一篇的 bug 全在"表述、边界语义、手推纪律"------设计层面一次比一次干净。代价是 bug 越来越隐蔽:双 bug 抵消、结果对过程错,靠"跑对了"再也发现不了,只能靠互证纪律。
提问的质量在涨。"倒序比较"是自己重新发明了最后一步分类法,"不连续的怎么处理"是自己摸到了本题灵魂,"dpmn 还是 max"是收官级的总结之问。方法论从"被教"走向"自产",这大概就是内化的样子。
手推和代码必须互证。这一篇九个坑里至少五个能被"打印 dp 表逐格对照"当场抓住------包括那个编译错误。手推的完整闭环 = 真推(不凑)+ 与代码互证(不跳),这两个括号里的字,每一条我都在 2023 年那份"完全正确但完全忘了"的旧代码里见过反面教材。
下一篇:背包见。
参考资料
- LeetCode 1143. 最长公共子序列
- LeetCode 72. 编辑距离
- LeetCode 718. 最长重复子数组
- Go 1.21 Release Notes(内建 min/max)
- 动态规划第一篇:从打家劫舍到状态设计的三堂课
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。