編輯距離詳解


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

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

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

為什么說它實用呢,因為前幾天我就在日常生活中用到了這個算法。之前有一篇公眾號文章由於疏忽,寫錯位了一段內容,我決定修改這部分內容讓邏輯通順。但是公眾號文章最多只能修改 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

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

二、代碼詳解

先梳理一下之前的思路:

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

三、動態規划優化

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

三、擴展延伸

一般來說,處理兩個字符串的動態規划問題,都是按本文的思路處理,建立 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],形成一條路徑,按這條路徑上的操作進行編輯,就是最佳方案。

我最近精心制作了一份電子書《labuladong的算法小抄》,分為【動態規划】【數據結構】【算法思維】【高頻面試】四個章節,共 60 多篇原創文章,絕對精品!限時開放下載,在我的公眾號 labuladong 后台回復關鍵詞【pdf】即可免費下載!

目錄

歡迎關注我的公眾號 labuladong,技術公眾號的清流,堅持原創,致力於把問題講清楚!

labuladong


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM