72.编辑距离

57 阅读11分钟

给你两个单词 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 <= 500
  • word1 和 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 操作):

      1. 删除 word1 的第 i-1 个字符:f(i-1,j) (删掉 word1 的尾,剩下把 i-1 变成 j
      2. 插入一个字符使 word1 的尾和 word2[j-1] 对齐:f(i,j-1) (在 word1 后插入 word2[j-1],现在要把前 i 变成前 j-1
      3. 替换 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] 表示当前行 idp[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 表)

我们用自底向上 dpm=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] 的枚举:来自 DIAGUPLEFT),最后从 dp[m][n] 反向回溯得到操作序列。实现会额外占用 O(m*n) 的空间来存 choice 信息。若你需要,我可以再写出回溯代码示例。


9. 复杂度总览与实际建议

  • 最佳时间复杂度:O(m*n)(这是该题的最优时间复杂度);
  • 空间:标准 dpO(m*n);空间优化可降到 O(n)
  • mn 很大(如 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()

注意:ij 指的是 长度(不是最后字符下标),因此最后字符分别是 word1[i-1]word2[j-1](当 i>0j>0 时)。


二、基准情形(boundary)

  • dp[0][j] = j:把空串变为 word2 的前 j 个字符,必须插入 j 个字符。
  • dp[i][0] = i:把长度为 i 的串变成空串,必须删除 i 个字符。

这两条来自最直观的必要操作数。


三、“最后一步”法则 —— 为什么只考虑三种情况?

word1[0..i-1]word2[0..j-1],考虑 最优转换序列的最后一步。最后一步要么:

  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]