编辑距离(Edit Distance,学术名字叫 Levenshtein 距离)可能是面试史上出场率最高的 Hard 题:它悬在「字符串 + 动态规划」两大高频考点的交点上,又是 diff 工具、拼写检查、DNA 序列比对背后的真实算法。它和本站之前讲过的最长递增子序列是序列 DP 的双子星——LIS 是一维序列的巅峰,编辑距离是二维序列的巅峰。这道题最好的地方在于:它的暴力解法一眼能写出但必然超时,它的正确解法一眼看不懂但一旦看懂就会发现「Hard 不过如此」。
题面与约束
给你两个单词
word1和word2,请返回将word1转换成word2所使用的最少操作数。你可以对一个单词进行如下三种操作:插入一个字符;删除一个字符;替换一个字符。
约束条件:0 <= word1.length, word2.length <= 500,两个单词都由小写英文字母组成。官方示例:horse 到 ros 需要 3 步(horse → rorse 替换 r → rose 删 e → ros 删 e);intention 到 execution 需要 5 步。长度 500 的上限决定了 O(m·n) 时间复杂度完全可行(501 × 501 = 25 万个状态),但这题真正的教学价值在于理解「状态从哪来」。
为什么贪心在这里必错
很多人的第一直觉是模拟:两个指针从头往后扫,字符相同就跳过,不同就「做一步最优操作」。问题在于局部的最优操作会污染全局。
看一个反例:horse 到 ros。如果只盯着第一位,h 对 r 不同,替换它似乎合理(rorse),运气不错;但如果输入是 HALT 到 HAT 呢?第一位相同跳过,第二位 A 对 A 相同跳过,第三位 L 对 T 不同——替换成 HATT 再删一个,3 步;而最优解是直接删除 L,2 步。替换这一步看似推进了匹配,实际把后面逼进了死胡同。再比如插入和删除在效果上可以互相模拟(对 word1 插入等价于对 word2 删除),局部怎么选根本没有依据。
结论:三个操作的选择相互影响、一步选错满盘皆输——这正是动态规划的场景特征。我们需要一个能把「所有可能都试过」变成「每个子问题只算一次」的框架。
状态定义:前缀才是好的切割线
编辑距离的状态定义是所有 DP 里最优雅的之一:
dp[i][j] = word1 的前 i 个字符 变成 word2 的前 j 个字符 所需的最少操作数
为什么切前缀而不是任意子串?因为三种操作(插入、删除、替换)都只动末尾的字符——这是这道题能做线性切割的关键。定义好状态,转移方程只需要回答一个问题:dp[i][j] 能从哪些更小的状态走过来?
比较两个前缀的最后一个字符 word1[i-1] 和 word2[j-1](下标从 0 开始),分两种情况:
- 相等:这对字符不用管,问题直接缩小成去掉它们之后的子问题,
dp[i][j] = dp[i-1][j-1]。注意这一步操作数为 0,永远不会更差——保留匹配字符总是不亏的。 - 不等:必须做一次操作,三种选择对应三个来源:
- 替换:把
word1[i-1]改成word2[j-1],两个末字符同时解决,来自dp[i-1][j-1],花 1 步; - 删除:删掉
word1[i-1],word1缩短、word2不动,来自dp[i-1][j],花 1 步; - 插入:在
word1末尾插入word2[j-1],word2的末字符被匹配掉,来自dp[i][j-1],花 1 步。
- 替换:把
三者取最小加一:
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
# dp[i][j]: word1 前 i 个字符 -> word2 前 j 个字符的最少操作数
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i # 变成空串:删 i 次
for j in range(n + 1):
dp[0][j] = j # 空串变过来:插 j 次
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 - 1], # 替换
dp[i - 1][j], # 删除
dp[i][j - 1], # 插入
)
return dp[m][n]
边界不是背出来的:dp[i][0] = i 的语义是「把任意前缀变成空串只能逐个删除」,dp[0][j] = j 是「从空串变出目标只能逐个插入」。状态定义对了,边界是自动推导的。
手工演算一遍:horse 到 ros
DP 的理解必须落一张表。word1 = horse(行)、word2 = ros(列),加粗的格子是从左上到右下的一条最优路径:
| ∅ | r | o | s | |
|---|---|---|---|---|
| ∅ | 0 | 1 | 2 | 3 |
| h | 1 | 1 | 2 | 3 |
| o | 2 | 2 | 1 | 2 |
| r | 3 | 2 | 2 | 2 |
| s | 4 | 3 | 3 | 2 |
| e | 5 | 4 | 4 | 3 |
第一行和第一列就是边界。然后逐行填:h 对 r 不等,1 + min(左上 0, 上 1, 左 1) = 1;o 对 o 相等,直接抄左上角的 1;r 对 r 相等,抄左上角 dp[2][0] = 2……右下角的 3 就是答案。沿着加粗路径回看,每一步恰好对应官方示例的操作序列:horse →(h 替换成 r)→ rorse →(删 r)→ rose →(删 e)→ ros。
这张表还藏着一个重要的性质:每个格子只依赖左、上、左上三个邻居。把格子看成网格上的点,这等于说整个问题是一条从左上角走到右下角的寻路——替换是斜着走一步(两个前缀同时缩短)、删除是往下一步(word1 缩短)、插入是往右走一步(word2 缩短),每个不相等的格子进价比都是 1,相等格子斜穿免费。编辑距离就是这张网格上最便宜的一条路。这也意味着整张表其实不需要存——只保留当前行和上一行就够了,空间从 O(m·n) 降到 O(n):
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
# 滚动数组:让较短串当列,空间 O(min(m, n))
if len(word1) < len(word2):
word1, word2 = word2, word1
m, n = len(word1), len(word2)
prev = list(range(n + 1)) # 第 0 行:dp[0][j] = j
for i in range(1, m + 1):
cur = [i] + [0] * n # 每行开头是 dp[i][0] = i
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
cur[j] = prev[j - 1]
else:
cur[j] = 1 + min(prev[j - 1], prev[j], cur[j - 1])
prev = cur
return prev[n]
习惯自顶向下思考的同学也可以写记忆化递归,状态与转移一一对应,只是方向相反:
from functools import lru_cache
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
@lru_cache(maxsize=None)
def solve(i: int, j: int) -> int: # word1 前 i 个 -> word2 前 j 个
if i == 0:
return j
if j == 0:
return i
if word1[i - 1] == word2[j - 1]:
return solve(i - 1, j - 1)
return 1 + min(solve(i - 1, j - 1), solve(i - 1, j), solve(i, j - 1))
return solve(len(word1), len(word2))
Python 的递归深度默认限制在 1000,本题 500 × 500 的规模刚好贴着边界,因此正式提交建议用递推版;但这两种写法的等价性值得亲手体验一遍——递归是把大问题拆到边界,递推是从边界填回大问题,填表顺序恰恰是递归调用树逆过来。
两个版本在本地实测完全一致(对拍 300 组随机用例),500 × 500 的最大规模跑完约 20 毫秒——Hard 题的「大」从来在思维难度,不在计算量。
复杂度与常见的四个疑问
时间复杂度 O(m·n):25 万个状态、每个状态 O(1) 转移;空间二维版 O(m·n),滚动版 O(min(m, n))。
四个高频疑问,一次说清。问:为什么「相等」时不用考虑删除/插入? 可以严格一点地论证:保留这对相等字符的操作序列,永远不劣于任何先拆散它们的序列——假如最优解删除了 word1[i-1] 又在后面某处插入了同一个字符,这两步可以直接约掉;替换一个本就相等的字符更是白花一步。所以 dp[i-1][j-1] 在相等时必然是最优来源,这个「局部安全」的贪心可以证明是对的。问:三个操作会不会有更隐蔽的组合,比如连续两次替换不如一次插入加一次删除? 不会漏——插入和删除已经在转移方程里,任何操作序列的最优值都会被这张表捕捉到,这正是「定义覆盖全部子问题」的含义。问:求具体改了哪几步怎么办? 从右下角沿依赖关系回溯:看当前值来自左上(替换或匹配)、上方(删除)还是左方(插入),一路走到 (0, 0),把沿途动作倒序输出就是完整编辑脚本,git diff 输出的对齐行就是这么算出来的。问:记忆化递归写法行不行? 行,复杂度相同;但本题两重循环的递推写法更短、更不容易写错,也方便做滚动优化。
它在真实世界长什么样
编辑距离不是面试造物,它的历史比 LeetCode 老得多。1965 年,苏联数学家 Vladimir Levenshtein 在研究纠错码时提出了这个度量(所以它又叫 Levenshtein 距离);1974 年 Wagner 和 Fischer 发表的动态规划算法就是本文的写法,至今仍是教科书标准实现。拼写检查器拿用户输入和词典做距离运算,距离 1 的候选(少敲一个字母、敲错一个字母)排最前;Linux 的 git diff 计算两版文件的最短编辑脚本,走的是 Myers 在 1986 年提出的专攻「编辑脚本」的算法谱系,后来他 1999 年的位并行版本(bit-parallel,把状态压缩进机器字一次算 64 格)至今仍在各语言的 diff 库里服役;生物信息学里比较两条 DNA 序列的突变数,用的是换了打分矩阵的加权版编辑距离。连模糊搜索(fuzzy finder)里「为什么输入 edt 能匹配到 edit」的排序逻辑,背后都是距离函数。
还有一个工程细节值得一提:O(m·n) 对单词级别毫无压力,但对「两个大文件」这种长文本,25 万格会膨胀到亿格级别。所以真实的 diff 工具都会先做公共前缀与后缀裁剪(horse 和 horseman 的公共头尾先砍掉,只对中间差异部分跑 DP),再对大块内容按行分块——工程实现和教科书算法的差距,全在这些「不聪明但必要」的预处理上。
顺带一提,这题还有一个面试官爱追问的扩展:如果三种操作代价不同(替换 2 分、插入删除各 1 分)怎么办?答案是转移方程里把 1 + 换成对应的代价即可——DP 的骨架纹丝不动,这正是它比「背套路」值钱的地方。
举一反三
同一副骨架的近亲题,建议按此顺序巩固:
| 题目 | 与本题的关系 |
|---|---|
| LC 1143 最长公共子序列 | 「相等抄左上、不等取两边最大」的同构 DP,编辑距离的孪生兄弟 |
| LC 583 两个字符串的删除操作 | 只许删除的编辑距离,答案恒等于 m + n - 2 × LCS |
| LC 712 ASCII 删除和最小 | 把「步数」换成「ASCII 代价」,转移方程形状不变 |
| LC 10 正则表达式匹配 | 同为二维双串 DP,状态定义更绕,适合本题熟练后挑战 |
双串 DP 的通用心法可以压缩成一句话:定义「两个前缀」的状态,比较两个末字符,穷举操作作为转移来源。记住这句,这一族题就都有了起点。
其中 583 的那个恒等式 m + n - 2 × LCS 值得单独看一眼:只允许删除时,最终留下的字符必须同时在两个串里且顺序一致——这恰好是公共子序列的定义。留住的每个字符都省下「左边删一次、右边删一次」共两次操作,于是答案就是两个串长度之和减去两倍的最长公共子序列。一个 DP 问题的答案变成另一个 DP 问题答案的函数,这种「问题之间的汇率」在动态规划里出现时,通常说明两个状态定义其实是同一个东西的两个投影。
参考资料
- LeetCode 72. Edit Distance:原题与官方约束。
- Wagner & Fischer, The String-to-String Correction Problem(1974):本文
O(m·n)动态规划解法的原始论文。 - Myers, An O(ND) Difference Algorithm and Its Variations(1986):
git diff使用的最短编辑脚本算法。 - Myers, A Fast Bit-Vector Algorithm for Approximate String Matching(1999):位并行加速编辑距离计算的经典工程实现。
- doocs/leetcode:编辑距离多语言题解:社区维护的对照实现。
- AlgoMonster: Edit Distance:Levenshtein 距离的问题框架分析。
读者留言
COMMENTS 暂无还没有留言,来说第一句?