Edit Distance編輯距離
插入、刪除、取代三種操作的最小次數。
用在:拼字校正、模糊搜尋、語音辨識評分
01為什麼需要它
使用者在電商搜尋框打了「recieve」,站內沒有這個字。系統要在十萬個商品關鍵字裡,挑出最可能是他想打的那幾個。
為什麼用它把兩個字的差異量化成「最少要幾次插入、刪除或取代」:recieve 到 receive 是 2、到 receipt 是 4。先用長度差剔除明顯不可能的候選,再只算距離 ≤ 2 的斜帶,一次查詢只要幾毫秒。但 recieve 到 relieve 只有 1,比 receive 還近,所以實際的拼字建議會再搭配詞頻,或改用把「相鄰兩字對調」算成一步的 Damerau 距離,recieve 到 receive 就只要 1 步。
語音辨識模型把一段 20 個詞的語音轉成文字,和人工聽打的參考稿比較:有的詞聽錯、有的漏掉、有的多出來。團隊要一個能跨版本比較的準確度指標。
為什麼用它把每個「詞」當成一個字元算編輯距離:取代是聽錯、刪除是漏聽、插入是多聽。距離除以參考稿的詞數就是業界通用的詞錯誤率(WER),例如距離 3、參考稿 20 個詞,WER 就是 15%。表格回溯還能列出到底錯在哪幾個詞。
發票掃描後辨識出「鮮奶茶大杯(去冰)」,但 OCR 偶爾會把「杯」認成「林」、漏掉括號。系統要把它對應到商品主檔裡最接近的品項才能自動記帳。
為什麼用它逐字計算和每個候選品名的編輯距離,距離最小而且低於門檻的就自動配對,太遠的才丟給人工確認。編輯距離容忍少數字元的錯漏,比「完全相同才算」實用得多,而且三種操作的成本可以依 OCR 常見的錯誤類型調整。
看到這些關鍵字就想到它:兩個字串有多像、最少幾步把 A 變成 B、插入刪除取代、拼字校正、模糊比對、容錯搜尋、詞錯誤率、dp[i][j] 看兩個前綴。
02核心概念
編輯距離(Levenshtein 距離)是把字串 A 變成 B 所需的最少操作次數,允許三種操作:插入一個字、刪除一個字、把一個字取代成另一個字。狀態和 LCS 一樣看兩個前綴:dp[i][j] 是「A 的前 i 個字變成 B 的前 j 個字」的最少步數。邊界很直觀:dp[i][0] = i(刪光),dp[0][j] = j(全部插入),答案在 dp[m][n]。
轉移同樣只看最後一個字。若 A[i−1] = B[j−1],這兩個字直接對上,dp[i][j] = dp[i−1][j−1],不用花任何一步。若不同,在任何一組最佳操作裡,A 的最後一個字只有三種下場:被取代成 B 的最後一個字(左上 dp[i−1][j−1] + 1)、被刪除(上方 dp[i−1][j] + 1),或者它留著、B 的最後一個字是插入的(左方 dp[i][j−1] + 1)。三種情況涵蓋了所有可能,取最小值就是答案。填表順序和 LCS 一樣逐列由左到右。
複雜度 O(mn) 時間;要還原操作序列需要整張表 O(mn),從右下角看每一格是由哪一項算出來的往回走即可。只要距離時,每一列只依賴上一列,但轉移用到「左上角」,覆寫前要先用一個變數存起來,空間降到 O(min(m, n))。實務上常只關心「距離是否 ≤ k」,例如拼字建議只看 1、2 步內的字:距離 ≤ k 的路徑一定待在 |i − j| ≤ k 的斜帶裡,只算這條帶子就是 O(k·n),長度差超過 k 更可以直接跳過。
常見的坑:字相同時還加 1;邊界的第 0 列、第 0 行忘了初始化成 0..n、0..m;把「兩個相鄰字對調」當成一步,Levenshtein 距離裡 teh → the 其實要兩次取代,想算一步要改用多了「交換」操作的 Damerau 距離。三種操作的成本不一定要相同,OCR 或語音辨識常把某些取代設得比較便宜,轉移式照樣成立,只是把 +1 換成各自的成本。和上一篇的關係:拿掉「取代」、只准插入與刪除,答案就變成 m + n − 2·LCS;同樣「兩個前綴」的表格也出現在 Interleaving String、Distinct Subsequences 和萬用字元比對。
03演算法步驟
- 1開
(m+1) × (n+1)的表,dp[i][0] = i、dp[0][j] = j。 - 2逐列由左到右填:
A[i−1] == B[j−1]就dp[i][j] = dp[i−1][j−1];否則1 + min(左上, 上, 左),分別對應取代、刪除、插入。 - 3
dp[m][n]就是編輯距離。 - 4要操作序列:從
(m, n)往回走,看這一格等於哪一個來源(加上該步的成本),往那裡移動並記下操作,走到(0, 0)後反轉。 - 5只要距離時滾動一列並暫存左上角;只問「是否 ≤ k」就只算
|i − j| ≤ k的斜帶。
04互動示範
把 horse 變成 ros,這是 LeetCode 72 的範例。先填第 0 列與第 0 行,接著逐格填:藍色是正在填的格子,黃色是這一格最後選用的來源,格子左上角的小箭頭記下來源方向(↖ 相同或取代、↑ 刪除、← 插入;幾個來源一樣小時,依取代、刪除、插入的順序挑)。填完後從右下角回溯,綠色是回溯路徑,右側會依序列出操作:取代 h → r、刪除 r、刪除 e,共 3 步,正好等於右下角的值。
dp[i][j] = A[:i] → B[:j] 的最少步數
回溯時才會出現
05程式碼
Python 放完整表格、還原操作序列,以及滾動一列的省空間版,最後用 teh 的拼字建議示範「對調兩個字」在 Levenshtein 距離裡算兩步。C++ 放只用一列加一個左上角變數的寫法,以及只算斜帶、判斷距離是否 ≤ k 的版本。
def edit_table(a, b):
"""dp[i][j]:把 a 的前 i 個字變成 b 的前 j 個字,最少要幾次插入、刪除或取代"""
m, n = len(a), len(b)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i # 變成空字串:刪 i 次
for j in range(n + 1):
dp[0][j] = j # 從空字串變出來:插入 j 次
for i in range(1, m + 1):
for j in range(1, n + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # 最後一個字相同,不用動
else:
dp[i][j] = 1 + min(dp[i - 1][j - 1], # 取代 a[i-1] → b[j-1]
dp[i - 1][j], # 刪除 a[i-1]
dp[i][j - 1]) # 插入 b[j-1]
return dp
def edit_script(a, b):
"""從右下角往回走,還原實際的操作(相同時優先取代,其次刪除,最後插入)"""
dp = edit_table(a, b)
i, j, ops = len(a), len(b), []
while i > 0 or j > 0:
if i > 0 and j > 0 and a[i - 1] == b[j - 1]:
i, j = i - 1, j - 1 # 相同,不算一步
elif i > 0 and j > 0 and dp[i][j] == dp[i - 1][j - 1] + 1:
ops.append(f"取代 {a[i - 1]}→{b[j - 1]}")
i, j = i - 1, j - 1
elif i > 0 and dp[i][j] == dp[i - 1][j] + 1:
ops.append(f"刪除 {a[i - 1]}")
i -= 1
else:
ops.append(f"插入 {b[j - 1]}")
j -= 1
return ops[::-1]
def edit_distance(a, b):
"""只要距離:滾動一列。cur[j] 用到的左上角就是 prev[j-1]"""
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i] + [0] * len(b)
for j, cb in enumerate(b, 1):
cur[j] = prev[j - 1] if ca == cb else 1 + min(prev[j - 1], prev[j], cur[j - 1])
prev = cur
return prev[-1]
if __name__ == "__main__":
print(edit_table("horse", "ros")[-1][-1], edit_script("horse", "ros"))
# 3 ['取代 h→r', '刪除 r', '刪除 e'](和互動示範相同)
print(edit_distance("intention", "execution"), edit_distance("recieve", "receive")) # 5 2
words = ["the", "ten", "tea", "eh", "tech", "then"]
print(sorted(words, key=lambda w: (edit_distance("teh", w), w)))
# ['eh', 'tea', 'tech', 'ten', 'the', 'then']:the 要 2 步,因為「兩個字對調」算兩次取代06練習題
- LeetCode 72Edit DistanceMedium
- LeetCode 97Interleaving String(同樣是兩個前綴的表格)Medium
- LeetCode 115Distinct Subsequences(把「最少步數」換成「方法數」)Hard
- LeetCode 44Wildcard Matching(* 可以對上任意長度)Hard
- LeetCode 1312Minimum Insertion Steps to Make a String Palindrome(和自己的反轉比對)Hard