给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
示例 1:
输入: word1 = "horse", word2 = "ros"
输出: 3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')
示例 2:
输入: word1 = "intention", word2 = "execution"
输出: 5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')
提示:
0 <= word1.length, word2.length <= 500word1和word2由小写英文字母组成
下面我把 “编辑距离(LeetCode 72)” 的解题思路做成一步步渐进的讲解(Java 实现),从最直观的递归,到带记忆化的自顶向下,再到标准的自底向上动态规划,以及常见的空间优化版本。每一步都会说明思路、转移方程、复杂度,并对典型例子做详细演示(包括 horse -> ros 的 DP 表)。
2. 直观的递归(暴力):把问题想成处理首字符或尾字符
先用最直观的想法:考虑将 word1[0..i-1] 变为 word2[0..j-1](我们常把问题缩小到前缀)。设函数 f(i,j) 表示把 word1 的前 i 个字符(下标 0..i-1)变成 word2 的前 j 个字符所需的最少操作数。目标 f(m,n)。
-
如果
i==0(word1 为空),要得到长度为j的字符串,唯一办法是插入j次:f(0,j)=j。 -
如果
j==0(word2 为空),要把长度i的字符串变成空,必须删除i次:f(i,0)=i。 -
对于一般
i,j:-
若
word1[i-1] == word2[j-1],尾字符相等,不用操作:f(i,j) = f(i-1,j-1)。 -
若不等,则考虑三种操作(都要 +1 操作):
- 删除
word1的第i-1个字符:f(i-1,j)(删掉word1的尾,剩下把i-1变成j) - 插入一个字符使
word1的尾和word2[j-1]对齐:f(i,j-1)(在word1后插入word2[j-1],现在要把前i变成前j-1) - 替换
word1[i-1]为word2[j-1]:f(i-1,j-1)
所以f(i,j) = 1 + min( f(i-1,j), f(i,j-1), f(i-1,j-1) )。
- 删除
-
这是一个清晰的定义,但直接递归会重复子问题,复杂度指数级(大约 O(3^(m+n))),对 m,n <= 500 肯定不可行。
3. 记忆化递归(Top-down DP)——把重复计算去掉
把上面的 f(i,j) 用二维数组 memo[i][j] 存储。若某个 f(i,j) 已计算过就直接返回。时间复杂度降为 O(m*n)(每个 i,j 只算一次),空间复杂度 O(m*n)(递归栈最坏 O(m+n))。
下面给出 Java 的自顶向下带记忆化的实现,代码里加了详细注释:
// Top-down (recursion + memoization)
class SolutionTopDown {
private String a, b;
private int[][] memo; // memo[i][j] 表示 f(i,j),-1 表示未计算
public int minDistance(String word1, String word2) {
a = word1;
b = word2;
int m = a.length();
int n = b.length();
memo = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) {
for (int j = 0; j <= n; j++) memo[i][j] = -1;
}
return dp(m, n);
}
// 把 a[0..i-1] 转换为 b[0..j-1] 的最少操作数
private int dp(int i, int j) {
if (i == 0) return j; // 空串 -> 长度为 j 的串: 需要插入 j 次
if (j == 0) return i; // 长度为 i 的串 -> 空串: 需要删除 i 次
if (memo[i][j] != -1) return memo[i][j];
if (a.charAt(i - 1) == b.charAt(j - 1)) {
memo[i][j] = dp(i - 1, j - 1); // 尾字符相等,无操作
} else {
int delete = dp(i - 1, j); // 删除 a 的尾字符
int insert = dp(i, j - 1); // 在 a 末尾插入 b[j-1]
int replace = dp(i - 1, j - 1);// 替换 a 的尾字符为 b 的尾字符
memo[i][j] = 1 + Math.min(delete, Math.min(insert, replace));
}
return memo[i][j];
}
}
复杂度:时间 O(m*n),空间 O(m*n)(memo)+ 递归栈 O(m+n)。
4. 自底向上 DP(Tabulation)——标准且易于证明正确
定义 dp[i][j] 的含义同上:把 word1 的前 i 个字符变为 word2 的前 j 个字符的最少操作。
初始化:
dp[0][j] = j(空 -> 长度 j: 插入 j)dp[i][0] = i(长度 i -> 空: 删除 i)
转移:
- 若
word1[i-1] == word2[j-1]:dp[i][j] = dp[i-1][j-1] - 否则:
dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])(对应 delete/insert/replace)
下面是自底向上的 Java 实现(注释详细):
// Bottom-up DP (tabulation)
class SolutionBottomUp {
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
int[][] dp = new int[m + 1][n + 1];
// 初始化边界
for (int i = 0; i <= m; i++) dp[i][0] = i;
for (int j = 0; j <= n; j++) dp[0][j] = j;
// 填表
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 {
int delete = dp[i - 1][j]; // 删除 word1 的第 i-1 个字符
int insert = dp[i][j - 1]; // 在 word1 的末尾插入 word2[j-1]
int replace = dp[i - 1][j - 1];// 替换 word1[i-1] 为 word2[j-1]
dp[i][j] = 1 + Math.min(delete, Math.min(insert, replace));
}
}
}
return dp[m][n];
}
}
时间复杂度:O(m * n)。
空间复杂度:O(m * n)(可优化为 O(n),下面讲)。
5. 空间优化(只保留一维数组)
观察转移 dp[i][j] 只依赖上一行 dp[i-1][*] 和当前行左侧 dp[i][j-1],我们可以用一维数组 cur[j] 表示当前行 i 的 dp[i][j],并用变量 prevDiagonal 保存 dp[i-1][j-1](即上一次迭代中 cur[j-1] 被覆盖前的值)。
伪思想:
-
初始化
cur[j] = dp[0][j] = j -
对每个
i(1..m):-
prevDiagonal = cur[0](即 dp[i-1][0]) -
cur[0] = i(dp[i][0] = i) -
对每个
j(1..n):- 先保存
temp = cur[j](那是 dp[i-1][j],下次需要) - 如果相等:
cur[j] = prevDiagonal - 否则:
cur[j] = 1 + min(cur[j] (原 dp[i-1][j] = temp), cur[j-1] (dp[i][j-1]), prevDiagonal (dp[i-1][j-1])) - 然后
prevDiagonal = temp
- 先保存
-
下面是 Java 空间优化版本(清晰注释):
// Space-optimized DP: O(n) space
class SolutionSpaceOptimized {
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
// 确保用较短的字符串作为列以减少空间(可选)
// 这里我们仍以 word2 长度 n 来分配数组 cur[n+1]。
int[] cur = new int[n + 1];
// 初始:把空串变成 word2 前 j 个字符需要 j 次插入
for (int j = 0; j <= n; j++) cur[j] = j;
for (int i = 1; i <= m; i++) {
int prevDiagonal = cur[0]; // 之前的 dp[i-1][0]
cur[0] = i; // dp[i][0] = i (把长度为 i 的 word1 前缀 变成空串)
for (int j = 1; j <= n; j++) {
int temp = cur[j]; // 暂存 dp[i-1][j],下次循环时这是 prevDiagonal
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
cur[j] = prevDiagonal; // dp[i][j] = dp[i-1][j-1]
} else {
int delete = temp; // dp[i-1][j] (删除)
int insert = cur[j - 1]; // dp[i][j-1] (插入)
int replace = prevDiagonal; // dp[i-1][j-1] (替换)
cur[j] = 1 + Math.min(delete, Math.min(insert, replace));
}
prevDiagonal = temp; // 更新 prevDiagonal 为下一列的 dp[i-1][j]
}
}
return cur[n];
}
}
时间复杂度:O(m*n);空间复杂度:O(n)(n = word2.length())。
6. 用例演示:word1 = "horse", word2 = "ros"(详解 DP 表)
我们用自底向上 dp,m=5, n=3。表格 dp[i][j](行 i 表示 word1 前 i 个字符,列 j 表示 word2 前 j 个字符):
初始化:
dp[0][0..3] = [0,1,2,3](空 -> "r","ro","ros")dp[0..5][0] = [0,1,2,3,4,5]("h","ho","hor","hors","horse" -> 空)
填表(结果最终为 dp[5][3] = 3):
最终完整表格如下(行 i 从 0 到 5,列 j 从 0 到 3):
"" r o s (word2 前缀)
"" 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
(word1 前缀)
解释(几个关键格子):
dp[1][1]("h"->"r"):h!=r,三种操作都要考虑,最少为 1(替换)。dp[2][2]("ho"->"ro"):最后字符o==o,所以dp[2][2] = dp[1][1] = 1(即把h替换为r)。- 最终
dp[5][3] = 3,对应示例给出的三步操作(替换h->r,删除r,删除e)。
7. 如何理解 insert/delete 的对应关系(常见迷惑)
当在 word1 末尾 插入 word2[j-1] 时,原来需要把 word1[0..i-1] 变为 word2[0..j-2],因此对应状态是 dp[i][j-1](然后 +1 的插入操作)。
删除 word1 的尾字符则对应 dp[i-1][j](删除 1 个字符后变为 i-1 的前缀)。
替换对应 dp[i-1][j-1]。
8. 如何重建具体操作序列(可选思路)
若需要知道具体做了哪些操作(不是只要最少步数),可以在计算 dp 时同时记录从哪一个子状态转移过来的(例如保存一个 choice[i][j] 的枚举:来自 DIAG、UP、LEFT),最后从 dp[m][n] 反向回溯得到操作序列。实现会额外占用 O(m*n) 的空间来存 choice 信息。若你需要,我可以再写出回溯代码示例。
9. 复杂度总览与实际建议
- 最佳时间复杂度:
O(m*n)(这是该题的最优时间复杂度); - 空间:标准
dp用O(m*n);空间优化可降到O(n)。 - 若
m或n很大(如 500),m*n上限为 250k,完全可行。 - 开发建议:一般直接写自底向上的
dp,然后如有内存要求再切换到O(n)的空间优化版本。
状态转移条件的解读:
一、回到定义(先把索引意义固定住)
我们定义:
dp[i][j] = 把 word1 的 前 i 个字符(即 word1[0..i-1])转换为 word2 的 前 j 个字符(即 word2[0..j-1])所需的最少操作数。
目标是 dp[m][n],其中 m = word1.length(), n = word2.length()。
注意:i、j 指的是 长度(不是最后字符下标),因此最后字符分别是 word1[i-1] 和 word2[j-1](当 i>0、j>0 时)。
二、基准情形(boundary)
dp[0][j] = j:把空串变为word2的前 j 个字符,必须插入 j 个字符。dp[i][0] = i:把长度为 i 的串变成空串,必须删除 i 个字符。
这两条来自最直观的必要操作数。
三、“最后一步”法则 —— 为什么只考虑三种情况?
把 word1[0..i-1] → word2[0..j-1],考虑 最优转换序列的最后一步。最后一步要么:
- 最后两个字符已经相等(
word1[i-1] == word2[j-1]),那最后一步“什么都不做”——问题就降为把前i-1转成前j-1。
→dp[i][j] = dp[i-1][j-1].
否则(尾字符不相同),最后一步的操作一定是:删除 / 插入 / 替换 其中一种(因为题目只允许这三种操作),我们把每一种对应到之前的子问题:
四、三种情况逐个说明(并给出为什么对应某个 dp[...])
1) 删除(Delete)
操作:删除 word1 的最后一个字符 word1[i-1]。
- 如果我们在最优序列的最后一步把
word1[i-1]删掉,那么在删除它之前,我们已经将word1[0..i-2](长度i-1)变成word2[0..j-1](长度j)。 - 所以在删除前需要
dp[i-1][j]步,最后再删除一次:总是dp[i-1][j] + 1。 - 因此 删除对应
dp[i-1][j] + 1。
直观想象:把 abcX 变为 def,如果最后一步是删掉 X,那之前我们已把 abc 变成 def。
2) 插入(Insert)
操作:在 word1 后面插入一个字符,使其匹配 word2 的最后字符 word2[j-1]。
这个是最容易混淆的地方:插入对应的是 dp[i][j-1] + 1,不是 dp[i-1][j] + 1。为什么?
想象流程:我们要把 word1[0..i-1] 变为 word2[0..j-1]。如果最后一步是“在 word1 的末尾插入 word2[j-1]”,那在插入之前,我们需要把 word1[0..i-1] 变为 word2[0..j-2](少了最后一个字符),然后插入 word2[j-1]。
- 因此前面需要
dp[i][j-1]步,再 +1 插入:dp[i][j-1] + 1。
举个非常直观的例子:
word1 = "ab",word2 = "abc",i=2, j=3。把"ab"->"abc"最后一步是插入'c'。在插入之前把ab变成ab(即dp[2][2]),然后插一个字符 →dp[2][2] + 1 = dp[2][3]。这就是dp[i][j-1] + 1的意义。
另一种对称理解(把插入看成对方的删除) :插入到 word1 相当于在 word2 端删除它的最后字符(在比较两个串时,插入与对方删除是对称的),但在我们的 dp 定义里更直观的解释是 dp[i][j-1] + 1。
3) 替换(Replace)
操作:把 word1[i-1] 替换为 word2[j-1]。
- 如果最优序列最后一步是替换,那么在替换之前,需要把
word1[0..i-2]变成word2[0..j-2],即dp[i-1][j-1],然后替换一次:dp[i-1][j-1] + 1。 - 所以替换对应
dp[i-1][j-1] + 1。
五、把三种情况合起来,得出转移
当 word1[i-1] != word2[j-1] 时,最后一步必然是三者之一,取最优(最少步):
dp[i][j] = 1 + min(
dp[i-1][j], // delete
dp[i][j-1], // insert
dp[i-1][j-1] // replace
)
当 word1[i-1] == word2[j-1] 时,不需要最后一步(尾字符匹配):
dp[i][j] = dp[i-1][j-1]