题目描述
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符
删除一个字符
替换一个字符
示例 1:
1 | 输入:word1 = "horse", word2 = "ros" |
示例 2:
1 | 输入:word1 = "intention", word2 = "execution" |
题解
动态规划
一、思路
编辑距离问题就是给我们两个字符串s1
和s2
,只能用三种操作,让我们把s1
变成s2
,求最少的操作数。需要明确的是,不管是把s1
变成s2
还是反过来,结果都是一样的,所以后文就以s1
变成s2
举例。
解决两个字符串的动态规划问题,一般都是用两个指针i,j
分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模
设两个字符串分别为 “rad” 和 “apple”,为了把s1
变成s2
,算法会这样进行:
根据上面的 GIF,可以发现操作不只有三个,其实还有第四个操作,就是什么都不要做(skip)
因为这两个字符本来就相同,为了使编辑距离最小,显然不应该对它们有任何操作,直接往前移动i,j
即可。
还有一个很容易处理的情况,就是j
走完s2
时,如果i
还没走完s1
,那么只能用删除操作把s1
缩短为s2
。比如这个情况:
类似的,如果i
走完s1
时j
还没走完了s2
,那就只能用插入操作把s2
剩下的字符全部插入s1
。等会会看到,这两种情况就是算法的 base case。
二、代码详解
base case 是i
走完s1
或j
走完s2
,可以直接返回另一个字符串剩下的长度。
对于每对儿字符s1[i]
和s2[j]
,可以有四种操作:
1 | if s1[i] == s2[j]: |
有这个框架,问题就已经解决了。读者也许会问,这个「三选一」到底该怎么选择呢?很简单,全试一遍,哪个操作最后得到的编辑距离最小,就选谁。
下面来详细解释一下这段递归代码,base case 应该不用解释了,主要解释一下递归部分。
都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能很清楚地理解算法的逻辑。我们这里 dp(i, j) 函数的定义是这样的:
1 | def dp(i, j) -> int |
记住这个定义之后,先来看这段代码:
1 | if s1[i] == s2[j]: |
如果s1[i]!=s2[j]
,就要对三个操作递归了,稍微需要点思考:
1 | dp(i, j - 1) + 1, # 插入 |
1 | dp(i - 1, j) + 1, # 删除 |
1 | dp(i - 1, j - 1) + 1 # 替换 |
现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。
怎么能一眼看出存在重叠子问题呢?前文 动态规划之正则表达式 有提过,这里再简单提一下,需要抽象出本文算法的递归框架:
1 | def dp(i, j): |
对于子问题dp(i-1,j-1)
,如何通过原问题dp(i,j)
得到呢?有不止一条路径,比如dp(i,j)->#1
和dp(i,j)->#2->#3
。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。
三、动态规划优化
首先明确 dp 数组的含义,dp 数组是一个二维数组,长这样:
dp[i][j]
的含义和之前的 dp 函数类似:
1 | def dp(i, j) -> int |
有了之前递归解法的铺垫,应该很容易理解。dp 函数的 base case 是i,j
等于 -1,而数组索引至少是 0,所以 dp 数组会偏移一位,dp[..][0]
和dp[0][..]
对应 base case。。
既然 dp 数组和递归 dp 函数含义一样,也就可以直接套用之前的思路写代码,唯一不同的是,DP table 是自底向上求解,递归解法是自顶向下求解
1 | public class lc72 { |