如图所示,将kitten转化为sitting需要编辑3步,包括两次替换操作与一次添加操作;将hello转化为algo需要三步,包括两次替换操作和一次删除操作。
编辑距离问题可以很自然地利用决策树模型来解释。字符串对应树节点,一轮决策(一次编辑操作)对应树上的一条边。
如图所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从hello转化到algo有许多种可能的途径。
从决策树的角度看,本题的目标是求解节点hello和节点之间的最短距离。
动态规划思路:
第一步:思考每轮的决策,定义状态,从而得到dp表
每一轮的决策是对字符串s进行一次编辑操作。
我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串s和t的长度分别为n和m,我们先考虑两字符串尾部的字符s[n-1]和t[m-1]。
- 若s[n-1]和t[m-1]相同,我们可以跳过它们,直接考虑s[n-2]和t[m-2]。
- 若s[n-1]和t[m-1]不同,我们需要对s进行一次编辑(删除、插入、替换),使得两字符串尾部的字符相同,从而可以跳过它们,考虑规模更小的问题。
也就是说,我们在字符串s中进行的每一轮决策(编辑操作),都会使得s和t中剩余的待匹配字符发生变化。因此,状态为当前在s和t中考虑的第i和第j个字符,记为[i,j]。
状态[i,j]对应的子问题:将s的前i个字符更改为t的前j个字符所需的最少编辑步数。
至此,得到一个尺寸为(i+1)*(j+1)的二维dp表。
第二步:找出最优子结构,进而推导出状态转移方程
考虑子问题dp[i,j],对应的两个字符串的尾部字符为s[i-1]和t[j-1],可根据不同编辑操作分为图例的三种情况。
- 在s[i-1]之后添加t[j-1],剩余子问题dp[i,j-1]
- 删除s[i-1],剩余子问题dp[i-1,j]。
- 将s[i-1]替换为t[j-1],剩余子问题dp[i-1,j-1]。
根据以上分析,可得最优子结构:dp[i,j]的最少编辑步数等于dp[i,j-1]、dp[i-1,j]、dp[i-1,j-1]三者中的最少编辑步数,再加上本次编辑步数为1.对应的状态转移方程为:
dp[i,j] = min(dp[i,j-1],dp[i-1,j],dp[i-1,j-1]) + 1
请注意当s[i-1]和t[j-1]相同时,无须编辑当前字符,这种情况下的状态转移方程为:
dp[i,j] = dp[i-1,j-1]
第三步:确定边界条件和状态转移顺序
当两个字符串都为空时,编辑步数为0,即dp[0,0] = 0。当s为空但t不为空时,最少编辑步数等于t的长度,即首行的dp[0,j] = j。当s不为空但t为空时,最少编辑步数等于s的长度,即首列dp[i,0] = i。
代码实现:
# python 代码示例
def edit_distance_dp(s, t) :
n, m = len(s), len(t)
dp = [ [0] * (m + 1) for _ in range(n + 1)]
for j in range(1, m + 1) :
dp[0][j] = j ;
for i in range(1, n + 1) :
dp[i][0] = i ;
for i in range(1, n + 1) :
for j in range(1, m + 1) :
if s[i - 1] == t[j - 1] :
dp[i][j] = dp[i - 1][j - 1]
else :
dp[i][j] = min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1
return dp[n][m]
// c++ 代码示例
int editDistanceDP(string s, string t)
{
int n = s.length(), m = t.length() ;
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0)) ;
for (int i = 1 ; i <= n ; i++)
{
dp[i][0] = i ;
}
for (int j = 1 ; j <= m ; j++)
{
dp[0][j] = j ;
}
for (int i = 1 ; i <= n ; i++)
{
for (int j = 1 ; j <= m ; j++)
{
if (s[i - 1] == t[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[n][m] ;
}
图例:
空间优化:
由于dp[i,j]是由上方的dp[i-1,j]、左方dp[i,j-1]、左上方dp[i-1,j-1]转移而来的,而正序遍历会丢失左上方dp[i-1,j-1],倒叙遍历无法提前构建dp[i,j-1],因此两种遍历顺序都不可取。
为此,我们可以使用一个变量leftup来暂存左上方的解dp[i-1,j-1],从而只需要考虑作坊和上方的解。此时的情况与完全背包问题相同,可以使用正序遍历。
代码如下:
# python 代码示例
def edit_distance_dp_comp(s, t) :
n, m = len(s), len(t)
dp = [0] * (m + 1)
for j in range(1, m + 1) :
dp[j] = j
for i in range(1, n + 1) :
leftup = dp[0] # 暂存dp[i - 1][j - 1]
dp[0] += 1
for j in range(1, m + 1) :
temp = dp[j]
if s[i - 1] == t[j - 1] :
dp[j] = leftup
else :
dp[j] = min(dp[j - 1], dp[j], leftup) + 1
leftup = temp
return dp[m]
// c++ 代码示例
int editDistanceDPComp(string s, string t)
{
int n = s.length(), m = t.length() ;
vector<int> dp(m + 1, 0) ;
for (int j = 1 ; j <= m ; j++)
{
dp[j] = j ;
}
for (int i = 1 ; i <= n ; i++)
{
leftup = dp[0] ;
dp[0]++ ;
for (int j = 1 ; j <= m ; j++)
{
int temp = dp[j] ;
if (s[i - 1] == t[j - 1])
{
dp[j] =leftup ;
}
else
{
dp[j] = min(dp[j], dp[j - 1], leftup) + 1 ;
}
leftup = temp ;
}
}
return dp[m] ;
}