【算法】动态规划第二篇:双序列 DP 三课——继承、计步与断链

【算法】动态规划第二篇:双序列 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→shorss → 删前面的 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 ij(非零!) 全 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. 踩坑清单(本篇全集)

  1. 双 bug 抵消(1143):表膨胀 +1 × 格子偏移 -1,负负得正------用例碰巧全对。两个 bug 两个用例,一个抵消一个暴露
  2. 语义漂移 (1143):dp[0][0] 填了 dp[1][1] 的语义------脑内的表是 3×5,代码的表是 4×6,空行列的语义从没建立过
  3. 定义表述惯性(1143):把 53 的"以 i 结尾"套到 LCS 上------代码是前缀型,嘴上说的是结尾型
  4. 凑注释(1143/718):三项混 max、漏下标------代码对了注释是错的,凑了三次
  5. 白捡没捡(72):末尾相等该抄左上,套了不等公式------上一轮刚讨论过的格子照样错,嘴上 get 手上没通
  6. 跳过规定动作(72):本地没跑 → 编译错误没撞上 + 手推 diff 没发现。一次互证排两雷
  7. 整套照搬(718):子数组的题搬子序列的方程------在解另一道题,第一用例碰巧 LCS = 最长公共子数组
  8. 过度泛化(718):"断链归零"理解成"链断了后面全零"------断链清的是跨格继承,不清格子自己起头的资格
  9. 结果对过程错(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 年那份"完全正确但完全忘了"的旧代码里见过反面教材。

下一篇:背包见。

参考资料


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

相关推荐
江畔柳前堤2 小时前
前台·中台·后台:2026年AI原生时代的架构全景图
开发语言·人工智能·算法·机器学习·架构·scala·ai-native
文心快码BaiduComate2 小时前
2026 OPC x AI Coding 趋势案例交流会圆满落幕:一个人就一支研发团队
算法
ACM-moran2 小时前
条件显化(二分单峰函数+构造数列)
算法·推公式·整数三分
闻缺陷则喜何志丹3 小时前
【栈 运算系统】P3719 [AHOI2017初中组] rexp|普及+
c++·算法··洛谷·运算系统
疯狂打码的少年3 小时前
【数据库技术】多值依赖与第四范式(4NF)
数据库·笔记·算法
子文雨3 小时前
基于 uC3845 与 ATtiny13 双芯片架构的 8.4V/2.5Ah 锂电池智能充电器设计
单片机·嵌入式硬件·算法
青山木3 小时前
Hot 100 --- 数组中的第K个最大元素
java·数据结构·算法·排序算法
玛卡巴卡ldf3 小时前
【AICoding】笔试提示词设计思路
java·算法·ai编程
明月_清风4 小时前
位图与布隆过滤器:海量数据下的"存在性判断"艺术
前端·后端·算法