bigywater.tech
算法与数据结构 · LeetCode 72

LeetCode 72. 编辑距离

给定两个字符串 word1 和 word2,求把 word1 变成 word2 所需的最少操作数,操作只有三种:插入一个字符、删除一个字符、替换一个字符。这是经典的二维动态规划:dp[i][j] 表示 word1 的前 i 个字符变成 word2 的前 j 个字符的最少操作数。如果两个末尾字符相同,不需要操作,等于 dp[i-1][j-1];否则 1 加上三种操作里最小的:替换(dp[i-1][j-1])、删除(dp[i-1][j])、插入(dp[i][j-1])。本文用真实数据展示"只比较对应位置字符"的直觉写法会错多少,展示不带记忆化的递归调用次数,实测动态规划的耗时,给出五种语言的实现,并把填表做成分步演示。

10,000
组随机对拍,与暴力解的结果全部一致
17 步
分步演示,每一帧都来自解法的真实执行,另附 5 种语言实现

题意与示例

完整题目见官方页面(链接在文末)。基准解是带记忆化的递归,方向反过来:go(i, j) 表示 word1[i:] 变成 word2[j:] 的最少操作数,从头往后处理,与从前往后填 dp 表的方向不同。一共 14 组用例,覆盖:官方两个示例、两个空串、一个空串、完全相同、完全不同、以及几对经典的例子(kitten → sitting、sunday → saturday):

真实实测 · 本机执行输出
输入 ["horse","ros"]
输出 3
输入 ["intention","execution"]
输出 5
输入 ["",""]
输出 0
输入 ["","abc"]
输出 3
输入 ["abc",""]
输出 3
输入 ["a","a"]
输出 0

陷阱:只比较对应位置,不是编辑距离

一个直觉的想法是:把两个字符串对齐,数有多少个位置字符不同,再加上长度差。但插入或删除一个字符之后,后面所有字符的位置都错开了,可能只用一次删除就能让后面全部对齐。下面是这种写法与 solve 的真实结果:

真实实测 · 本机执行输出
# 「对应位置不同的个数 + 长度差」的写法
'horse' -> 'ros': 该写法 4    solve 3
'intention' -> 'execution': 该写法 5    solve 5
'kitten' -> 'sitting': 该写法 3    solve 3
'abc' -> 'abc': 该写法 0    solve 0
'abcd' -> 'bcd': 该写法 4    solve 1
随机 10000 组:该写法与正确答案不同的有 5028 组(50.3%)

horse → ros 的正确答案是 3,这种写法给出 4;abcd → bcd 只需要删除开头的 a(1 次),而对应位置比较每一位都不同,加上长度差得到 4。随机的 10000 组两个字符串(各长 0 到 9,字母取自 a、b、c)里,这种写法与正确答案不同的有 5028 组,占 50.3%。

不带记忆化的递归

最直接的递归是:看两个字符串的末尾,相同就去掉,不同就尝试三种操作,取最小。同一个(word1 的前 i 个,word2 的前 j 个)状态会被重复算很多次。下面是两个长度都为 n、字符完全不同的字符串(这是最坏情形)上真实统计的递归调用次数:

真实实测 · 本机执行输出
不带记忆化的递归:两个长度都为 n、字符全不同(最坏情形)的字符串
n=4: 结果 4,递归调用       481 次,     0.11 ms;动态规划只有 25 个状态
n=6: 结果 6,递归调用     13483 次,     1.54 ms;动态规划只有 49 个状态
n=8: 结果 8,递归调用    398593 次,    44.42 ms;动态规划只有 81 个状态
n=9: 结果 9,递归调用   2193844 次,   246.48 ms;动态规划只有 100 个状态

n 从 4 增加到 9,递归调用次数从 481 次增加到 2193844 次,耗时从 0.11ms 涨到 246.5ms;而动态规划只有 (n+1)² 个状态(n = 9 时 100 个)。递归调用次数每增加一个 n 大约乘 5(n = 8 到 9 是 5.5 倍),是指数增长。

动态规划:五种语言的实现

用两个一维数组滚动:prev 是上一行(word1 的前 i-1 个字符),cur 是当前行。初始时 prev[j] = j(空串变成 word2 前 j 个字符,插入 j 次),每一行开头 cur[0] = i(word1 前 i 个字符变成空串,删除 i 次)。然后对每个 j:末尾字符相同就取 prev[j-1],否则取 1 + min(prev[j-1](替换), prev[j](删除), cur[j-1](插入))。最后返回 prev[n]。Python、Java、Go、JavaScript、C++ 都是真实编译运行的。

Python · 解法代码def solve(word1, word2): m, n = len(word1), len(word2) prev = list(range(n + 1)) # prev[j]:word1 的前 i-1 个字符变成 word2 的前 j 个字符的最少操作数(i=0 时就是插入 j 个字符) for i in range(1, m + 1): cur = [i] + [0] * n # word1 的前 i 个字符变成空串:删除 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], # 删除 word1 的这个字符 cur[j - 1]) # 在 word1 里插入 word2 的这个字符 prev = cur return prev[n]
真实实测 · Python · 前几组用例的输出
$ python3 solution.py   # 统一入口:逐行读取 cases.jsonl,调用上面的 solve,打印 JSON
["horse","ros"]  →  3
["intention","execution"]  →  5
["",""]  →  0
["","abc"]  →  3
["abc",""]  →  3
["a","a"]  →  0
Java · 解法代码class Solution { public int minDistance(String word1, String word2) { int m = word1.length(), n = word2.length(); int[] prev = new int[n + 1]; // prev[j]:word1 的前 i-1 个字符变成 word2 的前 j 个字符的最少操作数 for (int j = 0; j <= n; j++) prev[j] = j; // i = 0:插入 j 个字符 for (int i = 1; i <= m; i++) { int[] cur = new int[n + 1]; cur[0] = i; // word1 的前 i 个字符变成空串:删除 i 个字符 for (int j = 1; j <= n; j++) { if (word1.charAt(i - 1) == word2.charAt(j - 1)) { cur[j] = prev[j - 1]; // 末尾字符相同:不需要操作 } else { cur[j] = 1 + Math.min(prev[j - 1], Math.min(prev[j], cur[j - 1])); // 替换、删除、插入 } } prev = cur; } return prev[n]; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java Solution.java && java -cp build Main < cases.jsonl
["horse","ros"]  →  3
["intention","execution"]  →  5
["",""]  →  0
["","abc"]  →  3
["abc",""]  →  3
["a","a"]  →  0
Go · 解法代码func minDistance(word1 string, word2 string) int { m, n := len(word1), len(word2) prev := make([]int, n+1) // prev[j]:word1 的前 i-1 个字符变成 word2 的前 j 个字符的最少操作数 for j := range prev { prev[j] = j // i = 0:插入 j 个字符 } for i := 1; i <= m; i++ { cur := make([]int, n+1) cur[0] = i // word1 的前 i 个字符变成空串:删除 i 个字符 for j := 1; j <= n; j++ { if word1[i-1] == word2[j-1] { cur[j] = prev[j-1] // 末尾字符相同:不需要操作 } else { best := prev[j-1] // 替换 if prev[j] < best { best = prev[j] // 删除 } if cur[j-1] < best { best = cur[j-1] // 插入 } cur[j] = 1 + best } } prev = cur } return prev[n] }
真实实测 · Go · 前几组用例的输出
$ go build -o bin . && ./bin < cases.jsonl
["horse","ros"]  →  3
["intention","execution"]  →  5
["",""]  →  0
["","abc"]  →  3
["abc",""]  →  3
["a","a"]  →  0
JavaScript · 解法代码function minDistance(word1, word2) { const m = word1.length, n = word2.length; let prev = Array.from({ length: n + 1 }, (_, j) => j); // prev[j]:word1 的前 i-1 个字符变成 word2 的前 j 个字符的最少操作数 for (let i = 1; i <= m; i++) { const cur = new Array(n + 1).fill(0); cur[0] = i; // word1 的前 i 个字符变成空串:删除 i 个字符 for (let j = 1; j <= n; j++) { if (word1[i - 1] === word2[j - 1]) { cur[j] = prev[j - 1]; // 末尾字符相同:不需要操作 } else { cur[j] = 1 + Math.min(prev[j - 1], prev[j], cur[j - 1]); // 替换、删除、插入 } } prev = cur; } return prev[n]; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js < cases.jsonl
["horse","ros"]  →  3
["intention","execution"]  →  5
["",""]  →  0
["","abc"]  →  3
["abc",""]  →  3
["a","a"]  →  0
C++ · 解法代码#include <algorithm> #include <string> #include <vector> using namespace std; class Solution { public: int minDistance(string word1, string word2) { int m = word1.size(), n = word2.size(); vector<int> prev(n + 1), cur(n + 1); for (int j = 0; j <= n; j++) prev[j] = j; // i = 0:插入 j 个字符 for (int i = 1; i <= m; i++) { cur[0] = i; // word1 的前 i 个字符变成空串:删除 i 个字符 for (int j = 1; j <= n; j++) { 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]}); // 替换、删除、插入 } swap(prev, cur); } return prev[n]; } };
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin < cases.jsonl
["horse","ros"]  →  3
["intention","execution"]  →  5
["",""]  →  0
["","abc"]  →  3
["abc",""]  →  3
["a","a"]  →  0
真实实测 · 本机执行输出
共 14 组用例(含边界),以暴力解的结果为基准逐组比对:
  Python      通过 14/14
  Java        通过 14/14
  Go          通过 14/14
  JavaScript  通过 14/14
  C++         通过 14/14

耗时实测

两个字符串的长度 n 分别为 50、100、300、1000,字母取自 a、b、c 的规律序列,每个规模取三次中最快的一次。基准是带记忆化的递归(同样是 O(n²) 个状态):

真实实测 · 本机执行输出
两个字符串的长度 n | 暴力解 | 本文解法
        50 |     0.493 ms | 0.166 ms
       100 |     2.169 ms | 0.642 ms
       300 |    22.437 ms | 5.899 ms
      1000 |      (未测,太慢) | 83.560 ms

两者都是平方增长:动态规划从 100 到 300(3 倍)耗时增长约 9.2 倍,从 300 到 1000(3.3 倍)增长约 14.2 倍,比理论的 11 倍略高,我没有细究。基准(记忆化递归)在 n = 300 时 22.4ms,动态规划 5.9ms,约 3.8 倍,差距来自递归调用和缓存字典的开销。基准在 n = 1000 时没有再测(递归深度也会很大)。

随机对拍

Python 版本另外和记忆化递归做了随机对拍,两个字符串各长 0 到 9,字母取自 a、b、c:

真实实测 · 本机执行输出
随机对拍 10000 组(两个各长 0 到 9、字母取自 a、b、c 的随机串),solve 与 brute 的结果全部一致。

另外四种语言只在上面 14 组固定用例上验证过,没有做大规模随机对拍。

分步演示:horse → ros

下面的演示用官方示例 horse → ros,答案是 3(horse → rorse(把 h 替换成 r)→ rose(删除一个 r)→ ros(删除 e))。演示里每一步显示一个格子是怎么由左、上、左上三个格子得到的:末尾相同时直接取左上角,不同时取三者最小值再加 1。每一帧都是把上面的解法在这组输入上真实执行记录下来的,运行器还检查了演示的最终结果与 solve 的返回值一致。

编辑距离:"horse" → "ros"
正在计算的格子已算好

    参考与说明

    • 官方题目页:LeetCode 72. 编辑距离。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚;耗时对比只用 Python 测量。
    • 没有覆盖:还原出具体的编辑序列(需要保留完整的 dp 表再回溯);操作代价不同的加权编辑距离。
    京ICP备2026067502号-1京公网安备11010502063487号