如图所示,将kitten转化为sitting需要编辑3步,包括两次替换操作与一次添加操作;将hello转化为algo需要三步,包括两次替换操作和一次删除操作。

算法学习笔记(8.6)-编辑距离问题-LMLPHP

编辑距离问题可以很自然地利用决策树模型来解释。字符串对应树节点,一轮决策(一次编辑操作)对应树上的一条边。

如图所示,在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从hello转化到algo有许多种可能的途径。

从决策树的角度看,本题的目标是求解节点hello和节点之间的最短距离。

算法学习笔记(8.6)-编辑距离问题-LMLPHP

动态规划思路:

第一步:思考每轮的决策,定义状态,从而得到dp表

每一轮的决策是对字符串s进行一次编辑操作。

我们希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串s和t的长度分别为n和m,我们先考虑两字符串尾部的字符s[n-1]和t[m-1]。

  1. 若s[n-1]和t[m-1]相同,我们可以跳过它们,直接考虑s[n-2]和t[m-2]。
  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],可根据不同编辑操作分为图例的三种情况。

  1. 在s[i-1]之后添加t[j-1],剩余子问题dp[i,j-1]
  2. 删除s[i-1],剩余子问题dp[i-1,j]。
  3. 将s[i-1]替换为t[j-1],剩余子问题dp[i-1,j-1]。

算法学习笔记(8.6)-编辑距离问题-LMLPHP

根据以上分析,可得最优子结构: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] ;
}
图例:

算法学习笔记(8.6)-编辑距离问题-LMLPHP

空间优化:

由于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] ;
}
07-14 20:55