1. 程式人生 > 實用技巧 >經典動態規劃:編輯距離

經典動態規劃:編輯距離

讀完本文,你可以去力扣拿下如下題目:

72.編輯距離

-----------

前幾天看了一份鵝場的面試題,演算法部分大半是動態規劃,最後一題就是寫一個計算編輯距離的函式,今天就專門寫一篇文章來探討一下這個問題。

我個人很喜歡編輯距離這個問題,因為它看起來十分困難,解法卻出奇得簡單漂亮,而且它是少有的比較實用的演算法(是的,我承認很多演算法問題都不太實用)。下面先來看下題目:

為什麼說這個問題難呢,因為顯而易見,它就是難,讓人手足無措,望而生畏。

為什麼說它實用呢,因為前幾天我就在日常生活中用到了這個演算法。之前有一篇公眾號文章由於疏忽,寫錯位了一段內容,我決定修改這部分內容讓邏輯通順。但是公眾號文章最多隻能修改 20 個字,且只支援增、刪、替換操作(跟編輯距離問題一模一樣),於是我就用演算法求出了一個最優方案,只用了 16 步就完成了修改。

再比如高大上一點的應用,DNA 序列是由 A,G,C,T 組成的序列,可以類比成字串。編輯距離可以衡量兩個 DNA 序列的相似度,編輯距離越小,說明這兩段 DNA 越相似,說不定這倆 DNA 的主人是遠古近親啥的。

下面言歸正傳,詳細講解一下編輯距離該怎麼算,相信本文會讓你有收穫。

一、思路

編輯距離問題就是給我們兩個字串 s1s2,只能用三種操作,讓我們把 s1 變成 s2,求最少的運算元。需要明確的是,不管是把 s1 變成 s2 還是反過來,結果都是一樣的,所以後文就以 s1 變成 s2 舉例。

前文「最長公共子序列」說過,解決兩個字串的動態規劃問題,一般都是用兩個指標 i,j 分別指向兩個字串的最後,然後一步步往前走,縮小問題的規模

設兩個字串分別為 "rad" 和 "apple",為了把 s1 變成 s2,演算法會這樣進行:


請記住這個 GIF 過程,這樣就能算出編輯距離。關鍵在於如何做出正確的操作,稍後會講。

根據上面的 GIF,可以發現操作不只有三個,其實還有第四個操作,就是什麼都不要做(skip)。比如這個情況:

因為這兩個字元本來就相同,為了使編輯距離最小,顯然不應該對它們有任何操作,直接往前移動 i,j 即可。

還有一個很容易處理的情況,就是 j 走完 s2 時,如果 i 還沒走完 s1,那麼只能用刪除操作把 s1 縮短為 s2。比如這個情況:

類似的,如果 i 走完 s1j 還沒走完了 s2

,那就只能用插入操作把 s2 剩下的字元全部插入 s1。等會會看到,這兩種情況就是演算法的 base case

下面詳解一下如何將思路轉換成程式碼,坐穩,要發車了。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。

二、程式碼詳解

先梳理一下之前的思路:

base case 是 i 走完 s1j 走完 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
        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] 的最小編輯距離

記住這個定義之後,先來看這段程式碼:

if s1[i] == s2[j]:
    return dp(i - 1, j - 1)  # 啥都不做
# 解釋:
# 本來就相等,不需要任何操作
# s1[0..i] 和 s2[0..j] 的最小編輯距離等於
# s1[0..i-1] 和 s2[0..j-1] 的最小編輯距離
# 也就是說 dp(i, j) 等於 dp(i-1, j-1)

如果 s1[i]!=s2[j],就要對三個操作遞迴了,稍微需要點思考:

dp(i, j - 1) + 1,    # 插入
# 解釋:
# 我直接在 s1[i] 插入一個和 s2[j] 一樣的字元
# 那麼 s2[j] 就被匹配了,前移 j,繼續跟 i 對比
# 別忘了運算元加一

dp(i - 1, j) + 1,    # 刪除
# 解釋:
# 我直接把 s[i] 這個字元刪掉
# 前移 i,繼續跟 j 對比
# 運算元加一

dp(i - 1, j - 1) + 1 # 替換
# 解釋:
# 我直接把 s1[i] 替換成 s2[j],這樣它倆就匹配了
# 同時前移 i,j 繼續對比
# 運算元加一

現在,你應該完全理解這段短小精悍的程式碼了。還有點小問題就是,這個解法是暴力解法,存在重疊子問題,需要用動態規劃技巧來優化。

怎麼能一眼看出存在重疊子問題呢?前文「動態規劃之正則表示式」有提過,這裡再簡單提一下,需要抽象出本文演算法的遞迴框架:

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) -> #1dp(i, j) -> #2 -> #3。一旦發現一條重複路徑,就說明存在巨量重複路徑,也就是重疊子問題。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。

三、動態規劃優化

對於重疊子問題呢,前文「動態規劃詳解」詳細介紹過,優化方法無非是備忘錄或者 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)

主要說下 DP table 的解法

首先明確 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();
    int[][] dp = new int[m + 1][n + 1];
    // base case 
    for (int i = 1; i <= m; i++)
        dp[i][0] = i;
    for (int j = 1; j <= n; j++)
        dp[0][j] = j;
    // 自底向上求解
    for (int i = 1; i <= m; i++)
        for (int j = 1; j <= n; j++)
            if (s1.charAt(i-1) == s2.charAt(j-1))
                dp[i][j] = dp[i - 1][j - 1];
            else               
                dp[i][j] = min(
                    dp[i - 1][j] + 1,
                    dp[i][j - 1] + 1,
                    dp[i-1][j-1] + 1
                );
    // 儲存著整個 s1 和 s2 的最小編輯距離
    return dp[m][n];
}

int min(int a, int b, int c) {
    return Math.min(a, Math.min(b, c));
}

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。

四、擴充套件延伸

一般來說,處理兩個字串的動態規劃問題,都是按本文的思路處理,建立 DP table。為什麼呢,因為易於找出狀態轉移的關係,比如編輯距離的 DP table:

還有一個細節,既然每個 dp[i][j] 只和它附近的三個狀態有關,空間複雜度是可以壓縮成 O(min(M, N)) 的(M,N 是兩個字串的長度)。不難,但是可解釋性大大降低,讀者可以自己嘗試優化一下。

你可能還會問,這裡只求出了最小的編輯距離,那具體的操作是什麼?你之前舉的修改公眾號文章的例子,只有一個最小編輯距離肯定不夠,還得知道具體怎麼修改才行。

這個其實很簡單,程式碼稍加修改,給 dp 陣列增加額外的資訊即可:

// int[][] dp;
Node[][] dp;

class Node {
    int val;
    int choice;
    // 0 代表啥都不做
    // 1 代表插入
    // 2 代表刪除
    // 3 代表替換
}

val 屬性就是之前的 dp 陣列的數值,choice 屬性代表操作。在做最優選擇時,順便把操作記錄下來,然後就從結果反推具體操作。

我們的最終結果不是 dp[m][n] 嗎,這裡的 val 存著最小編輯距離,choice 存著最後一個操作,比如說是插入操作,那麼就可以左移一格:

重複此過程,可以一步步回到起點 dp[0][0],形成一條路徑,按這條路徑上的操作進行編輯,就是最佳方案。

_____________

我的 線上電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 演算法倉庫 已經獲得了 70k star,歡迎標星!