一篇文章解释清楚leetcode经典动态规划问题:72.编辑距离
吐槽
题意简单,但是它就是难,让人摸不着头脑,手足无措的难。
思考一下
由于是求最值,第一反应就是往动态规划方向想,但是就算是套用动态规划的思路框架:
1、思考如何使用数组来表达状态,即dp table
2、思考如何写出状态转移方程
3、思考如何定义初始数据,即base case
4、思考如何优化执行效率
大部分人就会发现,连第一步都想不出来;增、删、替换这些个操作,如何体现为数组的操作。
更不用说写出状态转移方程了。
既然直接写出动态规划的写法太难了,那我们就换个思路,先根据下面的思路,层层推导试试:
递归的暴力解法 => 带备忘录的递归解法 => 非递归的动态规划解法
递归解法
就算是递归,也必须先要知道思路才行,也就是如何表现增、删、替换这些操作,并且记录操作数。这也是这题最难最难的地方。
例如,我们有两个字符串,分别为word1:“pro”, word2:“apple”
我们通过两个字符串分别从后往前一步步比较,一步步删减,然后已经处理的字符就不再管他。
如果是删除:
我们看图片可以发现,如果是删除,那么word1的长度就会变短,word2不会变,并且操作总数增加了1
由于算法的函数签名如下:
public int minDistance(String word1, String word2)
模模糊糊之间,我们似乎可以建立一个未必正确的,针对删除操作的递归方法:
minDistance(word1.substring(0,word1.length() - 1), word2) + 1;
如果是插入:
我们看图片可以发现,如果是插入,那么word1剩余需要处理的字符长度没变,还是“pro”,但是word2需要对比处理的字符长度变少了,少了个"e",后面只需要处理"appl"就行,并且操作总数增加了1
模模糊糊之间,我们似乎可以建立一个未必正确的,针对插入操作的递归方法:
minDistance(word1, word2.substring(0,word2.length() - 1)) + 1;
如果是替换:
我们看图片可以发现,如果是替换,那么word1剩余需要处理的字符长度减一,变成“pr”,并且word2需要对比处理的字符长度也变少了,少了个"e",后面只需要处理"appl"就行,并且操作总数增加了1
模模糊糊之间,我们似乎可以建立一个未必正确的,针对替换操作的递归方法:
minDistance(word1.substring(0,word1.length - 1), word2.substring(0,word2.length() - 1)) + 1;
但是转念一想,不对啊,如果word1当前处理的字符,正好与word2当前处理的字符一样呢?那岂不是不用替换了?那操作总数岂不是不用加一了?
这种思考是正确的,如果两个字符相同,替换可以替换,保证递归进行下去,但是操作数不需要加一,所以需要判断一下两个字符是否相等,相等的话操作数就不用加一了。
int d;
if (word1.charAt(word1.length() - 1) == word2.charAt(word2.length() - 1)) {
d = 0;
} else {
d = 1;
}
minDistance(word1.substring(0,word1.length() - 1), word2.substring(0,word2.length() - 1)) + d;
也许有些人会有些疑惑,如果两个字符相同,为啥删除和插入的操作数就无脑加一?
替换就要判断一下?
这其实非常好理解,正如我上文所说:如果两个字符相同,替换还是会进行替换操作,保证递归进行下去,但是操作数不需要加一,需要跳出代码,从实际想一想。在这里,递归方法的本质是穷举。
ok,前文中的疑问:增、删、替换三种操作如何用代码体现?就得到了解答
递归就两个关键点,其一是递归条件,其二是退出条件
退出条件
看到这一步了退出条件也就非常非常容易推导出来了:
要么word1处理完了,word2没处理完,返回word2没处理完的字符串长度就行(插入操作)
要么word2处理完了,word1没处理完,返回word1没处理完的字符串长度就行(删除操作)
要么word1处理完了,word2也正好处理完,返回0(不需要再操作了操作)
代码:
if (word1.length() == 0) {
return word2.length();
} else if (word2.length() == 0) {
return word1.length();
} else if (word1.equals(word2)) {
return 0;
}
那么递归的两个要素就出来了,完整代码也就出来了。只需要在每次递归中,在增、删、替换三种操作方法中,操作数最小的作为选择就行了。
public int minDistance(String word1, String word2) {
//退出条件
if (word1.length() == 0) {
return word2.length();
} else if (word2.length() == 0) {
return word1.length();
} else if (word1.equals(word2)) {
return 0;
}
int d;
if (word1.charAt(word1.length() - 1) == word2.charAt(word2.length() - 1)) {
d = 0;
} else {
d = 1;
}
//插入
int a = minDistance(word1, word2.substring(0,word2.length() - 1)) + 1;
//删除
int b = minDistance(word1.substring(0,word1.length() - 1), word2) + 1;
//替换,如果两个字符相同,都为字符a,则可以思考为a替换为a,做了操作,但是没必要,为了取得最小编辑次数,不加操作数
int c = minDistance(word1.substring(0,word1.length() - 1), word2.substring(0,word2.length() - 1)) + d;
return Math.min(Math.min(a,b),c);
}
递归总结
递归还是记住最基本的两点,一是递归条件,二是退出条件,牢记这两个基本点,题目多做一点,很多题看到题目就会有思路的。
这一题写是写出来了,但是效率非常的低,leetcode中也会超时。所以接下来要考虑动态规划了
动态规划解法
在上面的递归解法中,我们知道了基本思路,包括如何体现增、删、改的操作。而在动态规划中,基本都是使用数组来代替状态的变化,最关键的是如何定义这个数组,使得状态转移更加便利。
我们都知道动态规划可以借助dp table来推到最优值,如何是本题的dp table
先初始化表格(左边为增,上边为删。如果全部是增,或者全部是删,都是多少个字符就多少步,所以如此初始化)
接下来就开始推导了,根据递归方法中提到的思路:如果两个字符相等,则增、删操作数加一,替换操作数不加;如果两个字符不相等,则增、删、替换操作数都加一。以左上角为替换操作,左边和上边为增和删操作,进行推导如下:
最右下角就是最终的结果
举个简单的例子帮助理解推导过程:
推导过程出来了,代码也就可以参照递归方法写出来了:
public int minDistance(String word1, String word2) {
int sourceLen = word1.length();
int targetLen = word2.length();
if(sourceLen == 0){
return targetLen;
}
if(targetLen == 0){
return sourceLen;
}
//定义矩阵(二维数组)
int[][] arr = new int[sourceLen+1][targetLen+1];
//赋予初值
for(int i=0; i < sourceLen+1; i++){
arr[i][0] = i;
}
for(int j=0; j < targetLen+1; j++){
arr[0][j] = j;
}
Character sourceChar = null;
Character targetChar = null;
for(int i=1; i < sourceLen+1 ; i++){
sourceChar = word1.charAt(i-1);
for(int j=1; j < targetLen+1 ; j++){
targetChar = word2.charAt(j-1);
if(sourceChar.equals(targetChar)){
// 如果source[i] 等于target[j],则替换操作就是最小值:d[i, j] = d[i-1, j-1] + 0
arr[i][j] = arr[i-1][j-1];
}else{
//source[i] 不等于target[j],则需要取三个操作中的最小值
arr[i][j] = (Math.min(Math.min(arr[i-1][j], arr[i][j-1]), arr[i-1][j-1])) + 1;
}
}
}
return arr[sourceLen][targetLen];
}
如果文章认真从头看到尾,代码就非常非常好理解了,思路也清晰了,动态规划的题确实只能总结框架,不能每题都照搬照套,都需要自己思考才能够做出来,这也是为啥大厂面试题都喜欢考动态规划的题目了。
以上的算法确实是动态规划,也可以通过leetcode的提交,但是效率不高,我此刻提交的结果是耗时击败20%,内存击败70%。
因为有一个细节,既然每个dp[i][j]只和它附近的三个状态有关,空间复杂度是可以压缩成 O(min(M,N)) 的(M,N 是两个字符串的长度)。不难,但是可解释性大大降低,并且优化后提升也并不算不大,优化后的代码也放出来吧:
public int minDistance(String word1, String word2) {
int sourceLen = word1.length();
int targetLen = word2.length();
if(sourceLen == 0){
return targetLen;
}
if(targetLen == 0){
return sourceLen;
}
//定义矩阵(二维数组)
int[] dp = new int[targetLen + 1];
//赋予初值
for(int j=0; j < targetLen+1; j++){
dp[j] = j;
}
for(int i=1; i < sourceLen+1 ; i++){
int p = dp[0];
dp[0] = i;
for(int j=1; j < targetLen+1 ; j++){
int q = dp[j - 1]; // dp[i][j - 1]
int r = dp[j]; // dp[i - 1][j]
if(word1.charAt(i-1) == word2.charAt(j-1)){
dp[j] = p;
}else{
dp[j] = Math.min(Math.min(p, q), r) + 1;
}
//更新p
p = r;
}
}
return dp[targetLen];
}
以上就是全部内容了,花费我大半天时间才肝出来,太难了,不过写这篇文章也确实让我对这题的理解,以及动态规划的理解更加透彻了,时间花的不亏。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix