詳解一道經典面試題:編輯距離

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

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

d0d55fddcf07e7e149e00b47058411de.jpg

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

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

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

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

一、思路

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

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

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

bdd6eec64d155ddf8ed6a8e5870e2c2d.jpeg


d9d46826b2749905c62bff006e55635d.jpeg

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

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

f87d50e1db8428167d04eee2a90a5dfb.jpeg

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

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

c79cea5958f9dd859f4d1d680c9e2c28.jpeg

類似的,如果 i 走完 s1 時 j 還沒走完了 s2,那就只能用插入操作把 s2 剩下的字符全部插入 s1。等會會看到,這兩種情況就是算法的 base case

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

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

二、代碼詳解

先梳理一下之前的思路:

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
        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 對比
# 別忘了操作數加一

8332098440f0b455e889ea9dde5d5a67.gif

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

3d3320c46a1f7198e4c47b602e24abbb.jpeg

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

6b554f836f8c8daea8e529c3e2baf3e5.jpeg

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

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

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。一旦發現一條重複路徑,就說明存在巨量重複路徑,也就是重疊子問題。

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 數組是一個二維數組,長這樣:

173ac1f15aa2d28fabeca55c24dfb144.jpeg

有了之前遞歸解法的鋪墊,應該很容易理解。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:

26292acba478510090d20dcb5f647ec3.jpeg

還有一個細節,既然每個 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 存着最後一個操作,比如說是插入操作,那麼就可以左移一格:

dac80f186831da99b2ae8113bba1030e.jpeg

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

74fa30195b0528e147cb1566e19e335f.jpeg


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章