經典動態規劃:編輯距離
讀完本文,你可以去力扣拿下如下題目:
-----------
前幾天看了一份鵝場的面試題,演算法部分大半是動態規劃,最後一題就是寫一個計算編輯距離的函式,今天就專門寫一篇文章來探討一下這個問題。
我個人很喜歡編輯距離這個問題,因為它看起來十分困難,解法卻出奇得簡單漂亮,而且它是少有的比較實用的演算法(是的,我承認很多演算法問題都不太實用)。下面先來看下題目:
為什麼說這個問題難呢,因為顯而易見,它就是難,讓人手足無措,望而生畏。
為什麼說它實用呢,因為前幾天我就在日常生活中用到了這個演算法。之前有一篇公眾號文章由於疏忽,寫錯位了一段內容,我決定修改這部分內容讓邏輯通順。但是公眾號文章最多隻能修改 20 個字,且只支援增、刪、替換操作(跟編輯距離問題一模一樣),於是我就用演算法求出了一個最優方案,只用了 16 步就完成了修改。
再比如高大上一點的應用,DNA 序列是由 A,G,C,T 組成的序列,可以類比成字串。編輯距離可以衡量兩個 DNA 序列的相似度,編輯距離越小,說明這兩段 DNA 越相似,說不定這倆 DNA 的主人是遠古近親啥的。
下面言歸正傳,詳細講解一下編輯距離該怎麼算,相信本文會讓你有收穫。
一、思路
編輯距離問題就是給我們兩個字串 s1
和 s2
,只能用三種操作,讓我們把 s1
變成 s2
,求最少的運算元。需要明確的是,不管是把 s1
變成 s2
還是反過來,結果都是一樣的,所以後文就以 s1
變成 s2
舉例。
前文「最長公共子序列」說過,解決兩個字串的動態規劃問題,一般都是用兩個指標 i,j
分別指向兩個字串的最後,然後一步步往前走,縮小問題的規模
設兩個字串分別為 "rad" 和 "apple",為了把 s1
變成 s2
,演算法會這樣進行:
請記住這個 GIF 過程,這樣就能算出編輯距離。關鍵在於如何做出正確的操作,稍後會講。
根據上面的 GIF,可以發現操作不只有三個,其實還有第四個操作,就是什麼都不要做(skip)。比如這個情況:
因為這兩個字元本來就相同,為了使編輯距離最小,顯然不應該對它們有任何操作,直接往前移動 i,j
即可。
還有一個很容易處理的情況,就是 j
走完 s2
時,如果 i
還沒走完 s1
,那麼只能用刪除操作把 s1
縮短為 s2
。比如這個情況:
類似的,如果 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 對比
# 別忘了運算元加一
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) -> #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 陣列是一個二維陣列,長這樣:
有了之前遞迴解法的鋪墊,應該很容易理解。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,歡迎標星!