LC72 编辑距离¶
讲解章节:线性 DP
一句话
插入、删除、替换三个来源取最小,二维表滚成一行并用临时变量接住左上角。
解题思路¶
这题考什么¶
双序列 DP 的标准形态:状态用两个前缀的长度描述,转移按「两串末尾字符怎么对齐」 分类,见 线性 DP。
状态定义:f[i][j] 表示把 word1 的前 i 个字符变成 word2 的前 j 个字符的最少操作数。 边界(一方为空时只能一直插或一直删):
转移方程,记 a = word1 的第 i 个字符、b = word2 的第 j 个字符:
三个来源逐一对应题面允许的三种操作,这是全题最该讲透的一点:
f[i-1][j-1] -> **替换**。两个末尾字符都被消费掉,一次操作把 a 改成 b,
剩下的问题是「前 i-1 变成前 j-1」。
f[i-1][j] -> **删除**。只消费 word1 的末尾字符,一次操作把 a 删掉,
剩下的问题是「前 i-1 变成前 j」。
f[i][j-1] -> **插入**。只消费 word2 的末尾字符,一次操作在 word1 末尾补上 b,
剩下的问题是「前 i 变成前 j-1」。
三者穷尽了「最后一步动了什么」的全部可能,且都把问题严格缩小,无后效性成立。 答案是 f[m][n]。
滚动成一行时的陷阱:每一格用到上一行的同列(「上」)、上一行的前一列(「左上」) 与本行的前一列(「左」)。只留一维数组时,扫到第 j 列的瞬间:
所以必须用一个临时变量 diag 在覆盖发生之前把它接住: 进入内层前 diag 取旧的 dp[0],每列算完后把本列的「上」交棒给下一列的「左上」。 这是滚动数组里最典型的一处坑,见 DP 入门。
数据规模与复杂度¶
0 <= word1.length, word2.length <= 500,均为小写英文字母。 注意下界是 0,两串都可能是空串。 时间 O(m * n) 最坏 500 * 500 = 2.5e5 次内层迭代,毫不吃力。 空间 O(min(m, n)):把较短的串放到内层维度上,滚动数组最长只有 501 项。
朴素递归不记忆化时每层分三叉,深度 m+n,调用数是 3 的 1000 次方 量级, 完全不可行;DP 把重复子问题压成 2.5e5 个格子,每个只算一次。
坑在哪¶
- diag 的维护顺序不能颠倒。进入内层前必须先 diag = dp[0](此刻它还是旧的 f[i-1][0],正是本行第一格的左上角),然后才把 dp[0] 改写成 i。 先写 dp[0] = i 会让第一列的左上角变成本行的值,整行连锁出错。
- 每列结束时 diag = up(本列的「上」),因为下一列的「左上」正是本列的「上」。 漏掉这一步,diag 会一直停在行首的旧值,之后所有替换分支都基于错误的对角值。
- up 必须在 dp[j] 被覆盖之前取出。写成先赋值 dp[j] 再读 up, 读到的是刚写进去的新值,整条递推塌掉。
- 字符相同时是直接继承 f[i-1][j-1],而不是在三者取最小之后再加 0。 写成一律 1 + min(...) 再打补丁,会在等值时凭空多算一次操作。
- 交换两串是安全的:插入与删除互为逆操作、替换与自身互逆, 所以编辑距离对称,d(word1, word2) == d(word2, word1)。 交换只为把滚动数组压到 min(m, n),不影响答案。
- n == 0(较短的一方是空串)必须在建表前挡掉。否则 dp 只有 dp[0] 一项, 内层循环一次都不进,返回的是 dp[0] 而不是 m。 由于前面已经交换过,此时 n == 0 意味着两串至少有一个是空的,答案就是较长者的长度。
样例复核¶
word1 = "horse"、word2 = "ros"。word1 本就更长,不触发交换,内层维度是长度 3 的 "ros"。
dp 初值 [0, 1, 2, 3]
i=1 (a='h'):diag=0, dp[0]=1;三格都不匹配 -> dp = [1, 1, 2, 3]
i=2 (a='o'):dp[0]=2;'o' 与 'r' 不匹配得 2,'o'=='o' 继承 diag=1 -> dp = [2, 2, 1, 2]
i=3 (a='r'):dp[0]=3;'r'=='r' 继承 diag=2 -> dp = [3, 2, 2, 2]
i=4 (a='s'):dp[0]=4;'s'=='s' 在第 3 列继承 diag=2 -> dp = [4, 3, 3, 2]
i=5 (a='e'):dp[0]=5 -> dp = [5, 4, 4, 3]
返回 dp[3] = 3,对应「h 换成 r、删掉 r、删掉 e」,与期望输出一致。