horse → ros 只要 3 步?这道 Hard 的背后是二维 DP 的完整方法论:状态设计、转移推导、滚动数组、路径回溯。本文从 1143 LCS 铺垫一路讲透,三语言 + 空间优化 + 操作序列还原。
引子:两个字符串变一样,最少几步?
word1 = "horse"、word2 = "ros",允许三种操作(插入/删除/替换,各代价 1)。把 word1 变成 word2 最少几步?3 步:
horse → rorse(把 h 替换成 r)
rorse → rose(删除 r)
rose → ros (删除 e)
这是 LeetCode 72 编辑距离(Hard) ------Levenshtein 1965 年提出的经典算法,拼写纠正、文本相似度、DNA 比对的基石12。理解它的钥匙,是 DSA 系列第一次完整展开的二维动态规划。
一、先学会"找相同":1143 最长公共子序列
1.1 LCS 定义与 DP
LCS :不要求连续、只要求保持相对顺序的公共子序列。"abcde" 与 "ace" 的 LCS 是 "ace"(长度 3)3。
dp[i][j] = "s1 前 i 个" 与 "s2 前 j 个" 的 LCS 长度
if s1[i-1] == s2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
1.2 为什么先学 LCS
LCS 教给我们:处理双序列,DP 状态用双下标,每步只关心两个序列的"末尾"。先学会"找相同",才能理解"变相同"。
二、状态设计:dpij 到底代表什么
2.1 一句话定义状态
dpij = 将 word1 的前 i 个字符,转换成 word2 的前 j 个字符,所需的最少编辑距离。4
word1="horse"、word2="ros" 时,dp[4][2] = 把 "hors" 变成 "ro" 的最少步数。
状态含义明确,转移就自然------最后一步一定发生在两个序列的末尾字符之间。
2.2 边界条件
-
dp[0][j] = j:空串变成长度 j 的串,插入 j 次 -
dp[i][0] = i:长度 i 的串变空串,删除 i 次
三、转移方程推导:相等与不等两种分支
3.1 字符相等:直接继承
word1[i-1] == word2[j-1] → 末尾已相同,无需操作:
dp[i][j] = dp[i-1][j-1]
3.2 字符不等:三种"最后一步"取最小
| 最后一步 | 含义 | 对应 |
|---|---|---|
| 删除 word1i-1 | 删掉 word1 末尾 | dpi-1j + 1 |
| 插入 word2j-1 | word1 末尾插入 word2 末尾 | dpij-1 + 1 |
| 替换 word1i-1→word2j-1 | 末尾替换 | dpi-1j-1 + 1 |
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
手算验证 :horse → ros,填表后 dp[5][3] = 32。
四、三语言实现 + 空间优化
4.1 C++
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
for (int j = 0; j <= n; ++j) dp[0][j] = j;
for (int i = 0; i <= m; ++i) dp[i][0] = i;
for (int i = 1; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (word1[i-1] == word2[j-1])
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = 1 + min({dp[i-1][j], dp[i][j-1], dp[i-1][j-1]});
}
}
return dp[m][n];
}
};
4.2 Python
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for j in range(n + 1): dp[0][j] = j
for i in range(m + 1): dp[i][0] = i
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])
return dp[m][n]
4.3 Java
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m + 1][n + 1];
for (int j = 0; j <= n; j++) dp[0][j] = j;
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int i = 1; i <= m; i++)
for (int j = 1; j <= n; j++) {
if (word1.charAt(i-1) == word2.charAt(j-1))
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = 1 + Math.min(dp[i-1][j],
Math.min(dp[i][j-1], dp[i-1][j-1]));
}
return dp[m][n];
}
}
4.4 空间优化:滚动数组
dp[i][j] 只依赖上一行与当前行 → 两行即可,空间 O(mn)→O(n) 6。再进一步,单数组 + prev 缓存 dp[i-1][j-1]:
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
if m < n:
word1, word2, m, n = word2, word1, n, m # 让 n 更小
dp = list(range(n + 1)) # 初始 dp[0][j] = j
for i in range(1, m + 1):
prev = dp[0] # dp[i-1][j-1] 初始 = dp[i-1][0]
dp[0] = i # dp[i][0] = i
for j in range(1, n + 1):
temp = dp[j] # 保存旧 dp[j] 供下一轮 prev
if word1[i-1] == word2[j-1]:
dp[j] = prev
else:
dp[j] = 1 + min(dp[j], dp[j-1], prev)
prev = temp
return dp[n]
关键:prev 保存"本列更新前的 dpj",恰好是下一列需要的 dp[i-1][j-1]。
五、进阶:路径回溯还原操作序列
从 dp[m][n] 倒推来源格子7:
若 s1[i-1] == s2[j-1]:无操作,i--, j--
否则比较三种来源:
dp[i-1][j-1] 最小 → 替换,i--, j--
dp[i][j-1] 最小 → 插入 s2[j-1],j--
dp[i-1][j] 最小 → 删除 s1[i-1],i--
horse → ros 回溯结果:
1. 替换 h → r
2. 删除原位置的 r
3. 删除 e
→ 共 3 步
多个来源并列最小时,回溯路径可能不唯一,任选其一。
六、应用、变式与我的观点
6.1 不止是字符串题:DNA 序列比对
Needleman-Wunsch(1970)把编辑距离推广到生物信息:DNA/蛋白质全局比对,匹配/错配/空位分别赋权2。今天写的 dpij,是基因组学序列比对的基础。
6.2 变式一:只允许插入/删除(无替换)
编辑距离与 LCS 的关系8:
dist = m + n - 2 × LCS(word1, word2)
"保留 LCS"之外的所有字符,要么从 word1 删掉,要么插入 word2。
6.3 变式二:自定义操作代价
替换代价可设为 2 等------DP 框架不变,成本模型可插拔。
6.4 我的三条心得
心得一:状态定义是二维 DP 的灵魂。 "前 i 个变前 j 个"写清楚,转移就是水到渠成。
心得二:滚动数组不是炫技,是"依赖分析"。 空间优化的本质是看一个格子依赖哪些格子------只依赖上一行与当前行,就能压缩。一切二维 DP 通用。
心得三:回溯让 DP 从"算答案"变成"给过程"。 能讲出"horse 到 ros 具体怎么变",比报一个 3 更有说服力------那证明你真的懂表格怎么填的。
编辑距离把"状态设计、转移推导、空间优化、路径回溯"四个 DP 核心能力串成一道题。吃透它,二维 DP 的地基就稳了。
参考资料
-
1 Levenshtein 1965《Binary codes capable of correcting deletions, insertions, and reversals》(一级)
-
2 LeetCode 72 官方题解背景(一级)
-
3 LeetCode 1143 官方题解(一级)
-
4 LeetCode 72 官方题解:dpij 状态定义(一级)
-
5 LeetCode 72 官方题解:三种操作转移(一级)
-
6 力扣讨论区:滚动数组优化(二级)
-
7 力扣讨论区:路径回溯(二级)
-
8 编辑距离与 LCS 等价关系(二级)
-
9 Needleman-Wunsch 1970(一级)