引用:
labuladong
liweiwei
图片来自weiwei大神

文章目录
72.编辑距离 字符串匹配
1.思路
前文「最长公共子序列」说过,解决两个字符串的动态规划问题,一般都是用两个指针 i,j 分别指向两个字符串的最后,然后一步步往前走,缩小问题的规模。
设两个字符串分别为 “rad” 和 “apple”,为了把 s1 变成 s2,算法会这样进行:
当s1[i]==a2[j]时,可以skip
当i!=j时,可以insert/replace/delete
还有一个很容易处理的情况:
就是 j 走完 s2 时,如果 i 还没走完 s1,那么只能用删除操作把 s1 缩
短为 s2
类似的,如果 i 走完 s1 时 j 还没走完了 s2,那就只能用插入操作把 s2
剩下的字符全部插入 s1。等会会看到,这两种情况就是算法的 base
case(类似于归并排序)
2.代码详解
先梳理一下之前的思路:
base case 是 i 走完 s1 或 j 走完 s2,可以直接返回另一个字符串剩下的长度。
对于每对儿字符 s1[i] 和 s2[j],可以有四种操作:
if s1[i] == s2[j]:
啥都别做(skip)
i, j 同时向前移动
else:
三选一:
插入(insert)
删除(delete)
替换(replace)

有这个框架,问题就已经解决了。读者也许会问,这个「三选一」到底该怎么选择呢?很简单,全试一遍,哪个操作最后得到的编辑距离最小,就选谁。这里需要递归技巧,理解需要点技巧,先看下代码:
def minDistance(s1, s2) -> int:
def dp(i, j):
# base case
if i == -1: return j + 1 # 一开始s1没有长度
if j == -1: return i + 1
if s1[i] == s2[j]:
return dp(i - 1, j - 1) # 啥都不做
else:
return min(
dp(i, j - 1) + 1, # 插入
dp(i - 1, j) + 1, # 删除
dp(i - 1, j - 1) + 1 # 替换
)
# i,j 初始化指向最后一个索引
return dp(len(s1) - 1, len(s2) - 1)
下面来详细解释一下这段递归代码,base case 应该不用解释了,主要解释一下递归部分。
都说递归代码的可解释性很好,这是有道理的,只要理解函数的定义,就能很清楚地理解算法的逻辑。我们这里 dp(i, j) 函数的定义是这样的:
def dp(i, j) -> int
# 返回 s1[0..i] 和 s2[0..j] 的最小编辑距离
关于#替换/插入/删除/什么都不做/的gif图见Labuladong
现在,你应该完全理解这段短小精悍的代码了。还有点小问题就是,这个解法是暴力解法,存在重叠子问题,需要用动态规划技巧来优化。
怎么能一眼看出存在重叠子问题呢?前文「动态规划之正则表达式」有提过,这里再简单提一下,需要抽象出本文算法的递归框架:
def dp(i, j):
dp(i - 1, j - 1) #1
dp(i, j - 1) #2
dp(i - 1, j) #3
对于子问题 dp(i-1, j-1),如何通过原问题 dp(i, j) 得到呢?有不止一条路径,比如 dp(i, j) -> #1 和 dp(i, j) -> #2 -> #3。一旦发现一条重复路径,就说明存在巨量重复路径,也就是重叠子问题。
3.动态规划优化,暴搜+备忘录解法
对于重叠子问题呢,前文「动态规划详解」详细介绍过,优化方法无非是备忘录或者 DP table。
备忘录很好加,原来的代码稍加修改即可:
暴搜+备忘录解法很实用
def minDistance(s1, s2) -> int:
memo = dict() # 备忘录
def dp(i, j):
if (i, j) in memo:
return memo[(i, j)]
...
if s1[i] == s2[j]:
memo[(i, j)] = ...
else:
memo[(i, j)] = ...
return memo[(i, j)]
return dp(len(s1) - 1, len(s2) - 1)
#include <iostream>
#include <string>
#include <vector>
#include <limits.h>
using namespace std;
int dfs(int i, int j, vector<vector<int>>& memo, string s1, string s2){
//有一个已经到头了(此处已经包括处理了两个都到头的情况)
//dp(i, j)的含义:s1[0,i)左闭右开与s2[0,j)左闭右开, 最少操作数
if(i==-1) return j+1; // 当i=-1时即i指向队头监视,返回s2剩余的长度:j-(-1)
if(j==-1) return i+1;
if(memo[i][j]>0) return memo[i][j];
if(s1[i]==s2[j]){
memo[i][j]=dfs(i-1,j-1, memo, s1, s2);
}
else{
int operator1 = dfs(i-1,j, memo, s1, s2)+1;
int operator2 = dfs(i,j-1, memo, s1, s2)+1;
int operator3 = dfs(i-1,j-1, memo, s1, s2)+1;
memo[i][j]=min(operator1, min(operator2, operator3));
}
return memo[i][j];
}
int main(){
string s1;
string s2;
while(cin>>s1>>s2){
// s1->s2
//if(s1.length()==0) return s2.length(); // 加上word2的所有字符
//if(s2.length()==0) return s1.length(); // 删掉word2的所有字符
vector<vector<int>> memo(s1.length(), vector<int>(s2.length()));
cout << dfs(s1.length()-1, s2.length()-1,memo, s1, s2) << endl;
}
return 0;
}
4.DP格子方法
首先明确 dp 数组的含义,dp 数组是一个二维数组,长这样:

有了之前递归解法的铺垫,应该很容易理解。dp[..][0]和 dp[0][..]对应 base case,dp[i][j](数组,动态规划自底向上)的含义和之前的 dp 函数(自顶向上,递归函数)类似:
def dp(i, j) -> int
// 返回 s1[0…i] 和 s2[0…j] 的最小编辑距离
dp[i-1][j-1]
// 存储 s1[0…i] 和 s2[0…j] 的最小编辑距离
dp 函数的 base case 是 i,j 等于 -1,而数组索引至少是 0,所以 dp 数组会偏移一位。
既然 dp 数组和递归 dp 函数含义一样,也就可以直接套用之前的思路写代码,唯一不同的是,DP table 是自底向上求解,递归解法是自顶向下求解:
int minDistance(string s1, string s2){
int m = s1.length(), n = s2.length();
vector<vector<int>> dp(m+1, vector<int>(n+1));
// base case
for(int i =1; i<=m; i++){
dp[i][0]=i;
}
for(int j =1; j<=n; j


最低0.47元/天 解锁文章

被折叠的 条评论
为什么被折叠?



