编辑距离

给你两个单词 word1word2

请返回将 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
  • word1word2 由小写英文字母组成

解法一(二维动态规划):

class Solution {
public:
    int minDistance(string word1, string word2) {
        int m = word1.size();
        int n = word2.size();

        vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

        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[i - 1] == word2[j - 1])
                {
                    dp[i][j] = dp[i - 1][j - 1];
                }
                else
                {
                    dp[i][j] = min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]}) + 1;
                }
            }
        }

        return dp[m][n];
    }
};

解法二(一维滚动数组优化):

class Solution {
public:
    int minDistance(string word1, string word2) {
        int m = word1.size();
        int n = word2.size();

        vector<int> dp(n + 1, 0);

        for (int j = 0; j <= n; j++)
        {
            dp[j] = j;
        }

        for (int i = 1; i <= m; i++)
        {
            int prev = dp[0];
            dp[0] = i;

            for (int j = 1; j <= n; j++)
            {
                int temp = dp[j];

                if (word1[i - 1] == word2[j - 1])
                {
                    dp[j] = prev;
                }
                else
                {
                    dp[j] = min({dp[j], dp[j - 1], prev}) + 1;
                }

                prev = temp;
            }
        }

        return dp[n];
    }
};

核心思想

这题要求把一个字符串变成另一个字符串的最少操作数。

直接想法是尝试所有操作序列:

  • 某一步插入一个字符
  • 某一步删除一个字符
  • 某一步替换一个字符

但是操作顺序组合非常多,直接搜索会产生大量重复状态。

更适合的做法是用动态规划比较两个字符串的前缀。

这题最关键的观察是:

word1 的前 i 个字符变成 word2 的前 j 个字符,只依赖更短前缀之间的编辑距离。

因此可以从空前缀开始,逐步计算更长前缀之间的最小编辑距离。

状态定义

定义:

dp[i][j] 表示把 word1 的前 i 个字符,转换成 word2 的前 j 个字符所需要的最少操作数。

也就是:

word1[0...i - 1] -> word2[0...j - 1]

所需的最小编辑距离。

题目要求的答案就是:

dp[m][n]

其中:

  • m = word1.size()
  • n = word2.size()

递推公式推导

考虑 dp[i][j]

此时比较的是:

  • word1 的第 i 个字符:word1[i - 1]
  • word2 的第 j 个字符:word2[j - 1]

1. 两个字符相同

如果:

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

说明这两个字符不需要额外操作。

只需要把它们前面的部分变成一样:

dp[i][j] = dp[i - 1][j - 1]

2. 两个字符不同

如果:

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

最后一步操作有三种可能。

删除

先把 word1 的前 i - 1 个字符变成 word2 的前 j 个字符。

然后删除 word1[i - 1]

对应:

dp[i - 1][j] + 1

插入

先把 word1 的前 i 个字符变成 word2 的前 j - 1 个字符。

然后在末尾插入 word2[j - 1]

对应:

dp[i][j - 1] + 1

替换

先把 word1 的前 i - 1 个字符变成 word2 的前 j - 1 个字符。

然后把 word1[i - 1] 替换成 word2[j - 1]

对应:

dp[i - 1][j - 1] + 1

所以取三者最小值:

dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1

代码中写成:

dp[i][j] = min({dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]}) + 1;

边界情况

如果 word2 是空串,那么要把 word1 的前 i 个字符变成空串,只能全部删除。

所以:

dp[i][0] = i

如果 word1 是空串,那么要把空串变成 word2 的前 j 个字符,只能逐个插入。

所以:

dp[0][j] = j

代码中初始化:

for (int i = 0; i <= m; i++)
{
    dp[i][0] = i;
}

for (int j = 0; j <= n; j++)
{
    dp[0][j] = j;
}

如果两个字符串都为空,dp[0][0] = 0,表示不需要任何操作。

为什么可以按前缀长度递推

计算 dp[i][j] 时,只依赖三个更短或更小的状态:

  • dp[i - 1][j]
  • dp[i][j - 1]
  • dp[i - 1][j - 1]

如果按 i 从小到大、j 从小到大遍历:

  • 上一行状态已经计算完成
  • 当前行左侧状态已经计算完成
  • 左上角状态也已经计算完成

所以每个状态都可以由已经得到的最优状态推出。

解法二:一维滚动数组优化

二维动态规划中,计算当前行时,只需要用到:

  • 上一行同一列:dp[i - 1][j]
  • 当前行左一列:dp[i][j - 1]
  • 上一行左上角:dp[i - 1][j - 1]

所以可以用一维数组 dp[j] 保存当前行的状态。

更新时:

  • 更新前的 dp[j] 表示二维里的 dp[i - 1][j]
  • 更新后的 dp[j - 1] 表示二维里的 dp[i][j - 1]
  • 变量 prev 表示二维里的 dp[i - 1][j - 1]

因此字符相同时:

dp[j] = prev

字符不同时:

dp[j] = min(dp[j], dp[j - 1], prev) + 1

每次更新前用:

int temp = dp[j];

保存旧的上一行同一列。

循环末尾再令:

prev = temp;

这样下一列就能继续使用正确的左上角旧值。

正确性证明

我们证明:动态规划算法返回的结果等于把 word1 转换成 word2 的最少操作数。

结论 1:边界初始化正确

把任意长度为 i 的字符串变成空串,最少需要删除 i 次。

所以 dp[i][0] = i

把空串变成任意长度为 j 的字符串,最少需要插入 j 次。

所以 dp[0][j] = j

当两个字符串都是空串时,不需要任何操作,dp[0][0] = 0

因此边界初始化正确。

结论 2:当最后字符相同时,递推公式正确

如果 word1[i - 1] == word2[j - 1],最后一个字符已经相同,不需要对它额外操作。

此时只需要把前面的部分:

word1[0...i - 2]

转换成:

word2[0...j - 2]

所以:

dp[i][j] = dp[i - 1][j - 1]

正确。

结论 3:当最后字符不同时,递推公式覆盖了所有最优操作

如果 word1[i - 1] != word2[j - 1],最后一步操作只可能是三种之一。

如果最后一步是删除,那么之前必须已经完成:

word1[0...i - 2] -> word2[0...j - 1]

代价是 dp[i - 1][j] + 1

如果最后一步是插入,那么之前必须已经完成:

word1[0...i - 1] -> word2[0...j - 2]

代价是 dp[i][j - 1] + 1

如果最后一步是替换,那么之前必须已经完成:

word1[0...i - 2] -> word2[0...j - 2]

代价是 dp[i - 1][j - 1] + 1

任意合法转换序列的最后一步都属于这三种情况之一。

取三者最小值,就不会漏掉最优解,也不会加入非法操作。

因此最后字符不同时的递推正确。

结论 4:计算顺序保证依赖状态已经完成

算法按前缀长度从小到大计算。

当计算 dp[i][j] 时,dp[i - 1][j]dp[i][j - 1]dp[i - 1][j - 1] 都已经计算完成。

所以每个状态都能由正确的旧状态推出。

结论 5:一维滚动数组与二维动态规划等价

一维数组更新时,dp[j] 在更新前表示上一行同一列,更新后的 dp[j - 1] 表示当前行左一列,prev 表示上一行左上角。

这三个值正好对应二维转移中的删除、插入和替换来源。

因此一维滚动数组只是复用了空间,不会改变计算结果。

得出结论

由结论 1 可知,边界初始化正确。

由结论 2 和结论 3 可知,状态转移正确。

由结论 4 可知,计算顺序正确。

由结论 5 可知,一维优化与二维动态规划等价。

因此算法返回的 dp[m][n]dp[n] 就是最少操作数。

举例理解

以:

word1 = "horse"
word2 = "ros"

为例。

一种最优转换方式是:

horse -> rorse

'h' 替换成 'r'

rorse -> rose

删除第二个字符 'r'

rose -> ros

删除最后一个字符 'e'

总共用了 3 次操作。

动态规划会在比较两个字符串前缀时,自动在插入、删除、替换三种操作中选择代价最小的方案。

所以最终:

dp[5][3] = 3

再看:

word1 = ""
word2 = "abc"

只能插入三个字符,所以答案是:

3

这正对应边界状态:

dp[0][3] = 3

复杂度分析

m = word1.lengthn = word2.length

解法一

需要计算 (m + 1) * (n + 1) 个状态。

  • 时间复杂度:O(mn)
  • 空间复杂度:O(mn)

解法二

同样需要计算每个状态一次。

一维数组长度为 n + 1

  • 时间复杂度:O(mn)
  • 空间复杂度:O(n)

解法二空间更优,是更推荐的写法。