深入解析编辑距离:动态规划解决 LeetCode 72 题

155 阅读6分钟

前言

大家好,最近作者在狂刷动态规划,这篇文章准备给大家带来一道我认为比较难,很考验动态规划水平的题目:LeetCode 72 题--编辑距离,接下来本文将详细解析编辑距离的解法思路和实现。

问题描述

给定两个字符串 word1 和 word2,计算将 word1 转换成 word2 所需的最少操作数。允许的操作包括:

  1. 插入一个字符
  2. 删除一个字符
  3. 替换一个字符

示例:
输入: word1 = "horse", word2 = "ros"
输出: 3
解释:
horse -> rorse (替换 'h' 为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

这道题乍一看非常难解决,暴力的话都不知道要怎么暴力,但是用动态规划的思路就比较好想一点!


怎么看出是动态规划的?

1. 最优子结构(Optimal Substructure)

问题的最优解包含其子问题的最优解。具体来说:

  • 要计算将整个字符串word1[0..m-1]转换为word2[0..n-1]的最小操作数(即dp[m][n]),我们可以通过考虑最后一步操作,将其分解为子问题:

  • 插入操作:如果我们在word1的末尾插入一个字符(与word2的最后一个字符相同),那么问题就转化为将word1[0..m-1]转换为word2[0..n-2],即dp[m][n-1]

  • 删除操作:如果我们删除word1的最后一个字符,那么问题就转化为将word1[0..m-2]转换为word2[0..n-1],即dp[m-1][n]

  • 替换操作:如果我们将word1的最后一个字符替换为word2的最后一个字符(当这两个字符不同时),那么问题就转化为将word1[0..m-2]转换为word2[0..n-2],即dp[m-1][n-1]

  • 无操作:如果word1word2的最后一个字符相同,那么我们不需要任何操作,问题转化为dp[m-1][n-1]

因此,大问题的最优解可以由子问题的最优解推导出来。

2. 重叠子问题(Overlapping Subproblems)

在递归求解过程中,同一个子问题会被多次计算。例如,计算dp[i][j]时,可能需要dp[i-1][j-1]dp[i][j-1]dp[i-1][j]。而在计算其他状态时,这些子问题又会被重复使用。动态规划通过存储子问题的解(即填表)避免了重复计算。


动态规划思路&&解法

1. 定义状态我们使用二维数组dp[i][j]来表示子问题的解:

dp[i][j] = 将word1的前i个字符(即word1[0..i-1])转换为word2的前j个字符(即word2[0..j-1])所需的最小操作数。

注意:这里ij可以取值为0,表示空字符串。

2. 状态转移方程

考虑如何从已知的子问题推导出dp[i][j]。我们关注两个字符串的最后一个字符:word1[i-1]word2[j-1]。根据这两个字符是否相等,我们有两种情况:

情况1:两个字符相等

  • word1[i-1] == word2[j-1]

  • 此时,我们不需要对最后一个字符进行操作,因此操作次数等于将前i-1个字符转换为前j-1个字符的操作次数。

  • 状态转移:dp[i][j] = dp[i-1][j-1]

情况2:两个字符不相等

  • word1[i-1] != word2[j-1]

  • 此时,我们需要对最后一个字符进行操作,有三种选择:

a. 插入:在word1的末尾插入一个与word2[j-1]相同的字符。这样,word1的最后一个字符就和word2的最后一个字符匹配了,然后我们只需要将word1的前i个字符转换为word2的前j-1个字符。操作数等于dp[i][j-1] + 1(加1表示插入操作)。

b. 删除:删除word1的最后一个字符。然后我们只需要将word1的前i-1个字符转换为word2的前j个字符。操作数等于dp[i-1][j] + 1(加1表示删除操作)。

c. 替换:将word1的最后一个字符替换为word2的最后一个字符。这样,两个字符串的最后一个字符就匹配了,然后我们只需要将word1的前i-1个字符转换为word2的前j-1个字符。操作数等于dp[i-1][j-1] + 1(加1表示替换操作)。

得到状态转移dp[i][j] = min(dp[i][j-1] + 1, dp[i-1][j] + 1, dp[i-1][j-1] + 1)

3. 边界条件(初始状态)

  • i=0时,表示word1是空字符串,那么转换为word2的前j个字符需要插入j个字符,所以dp[0][j] = j

  • j=0时,表示word2是空字符串,那么将word1的前i个字符转换为空字符串需要删除i个字符,所以dp[i][0] = i。- 特别地,dp[0][0]=0,因为两个空字符串不需要任何操作。

4. 计算顺序

我们需要从小到大计算ij。通常,我们使用两层循环:

  • 外层循环i从0到len(word1)

  • 内层循环j从0到len(word2)

这样,在计算dp[i][j]时,它所依赖的子问题(dp[i-1][j-1], dp[i][j-1], dp[i-1][j])都已经被计算过了。

5. 最终结果

最终结果存储在dp[m][n]中,其中m是word1的长度,n是word2的长度。.


完整代码

/**
 * 计算两个字符串之间的编辑距离
 * @param {string} word1 源字符串
 * @param {string} word2 目标字符串
 * @return {number} 最小操作次数
 */
function minDistance(word1, word2) {
    const m = word1.length;
    const n = word2.length;
    
    // 创建 DP 表 (m+1) x (n+1)
    const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
    
    // 初始化边界条件:空字符串转换
    // 1. word1 为空时:需要插入所有 word2 的字符
    for (let j = 0; j <= n; j++) {
        dp[0][j] = j;
    }
    
    // 2. word2 为空时:需要删除所有 word1 的字符
    for (let i = 0; i <= m; i++) {
        dp[i][0] = i;
    }
    
    // 按照从左到右 从上到下递推
    for (let i = 1; i <= m; i++) {
        for (let j = 1; j <= n; j++) {
            if (word1[i - 1] === word2[j - 1]) {
                // 字符相同:直接继承左上角的值(无操作)
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                // 字符不同:取三种操作的最小值 + 1
                dp[i][j] = Math.min(
                    dp[i][j - 1] + 1,   // 插入操作:在 word1 中插入 word2[j-1]
                    dp[i - 1][j] + 1,   // 删除操作:删除 word1[i-1]
                    dp[i - 1][j - 1] + 1 // 替换操作:将 word1[i-1] 替换为 word2[j-1]
                );
            }
        }
    }
    
    // DP 表右下角的值即为最终答案
    return dp[m][n];
}

总结

这道LeetCode 72题:编辑距离是动态规划中的经典题目,它还是非常有挑战性的,需要我们仔细分析,也建议大家亲自动手画一画DP表格,体验下"horse"变"ros"的完整过程。