編輯距離定義與分類
編輯距離的定義,直接引用百科:
編輯距離是針對二個字符串的差異程度的量化量測,量測方式是看至少需要多少次的處理才能將一個字符串變成另一個字符串。
編輯距離可以用在自然語言處理中,例如拼寫檢查可以根據一個拼錯的字和其他正確的字的編輯距離,判斷哪一個或幾個是比較可能的字。
DNA也可以視為用A、C、G和T組成的字符串,因此編輯距離也用在生物信息學中,判斷兩個DNA的類似程度。
Unix下的 diff 和 patch 即是利用編輯距離來進行文本編輯對比的例子。
編輯距離有幾種不同的定義,差異在於可以對字符串進行的處理的不同。其中比較常用的是萊文斯坦距離。
萊文斯坦(Levenshtein)距離,是編輯距離的一種。指兩個字符串之間,只用如下允許的特定規則,由一個轉成另一個所需的最少編輯操作次數。
允許的編輯操作包括:
1. 將一個字符替換成另一個字符
2. 插入一個字符
3. 刪除一個字符
萊文斯坦距離本質上可以理解為一個動態規划問題,其狀態轉移方程為:
解釋一下狀態轉移方程:
1. 方程第一行:如果 min(i,j) = 0(即當前兩個字符串至少有一個長度為0,即是空字符串)時,則此時的萊文斯坦距離就是兩字符串的長度的最大值。這個很好理解,不贅述了
2. 方程第二行:如果min(i,j) != 0,則取如下三個數字的最小值:
2.1. 將a字符串的長度i減小1,計算其與長度為j的b字符串的萊文斯坦距離,並將其結果再加1,這其實等價於將當前a字符串的i位置的字符刪除了;
2.2. 將b字符串的長度j減小1,計算其與長度為i的a字符串的萊文斯坦距離,並將其結果再加1,這其實等價於在當前a字符串的i位置后面插入了一個字符(即插入了b字符串j位置的那個字符);
2.3. 將str_a、str_b字符串的長度同時減小1,計算兩者的剩余字符串的萊文斯坦距離,此時如果str_a的i位置的字符和str_b的j位置的字符不同,則結果再加1,這其實等價於將str_a的i位置的字符改為str_b的j位置的那個字符;
萊文斯坦距離計算過程圖解
以下通過一個例子來繼續說明,假設當前要計算如下兩個字符串的萊文斯坦距離:
str_a = "abcdd"
str_b = "ebcde"
可以通過繪制過程矩陣來直觀地觀察狀態遷移方程是怎么工作的,如下表格的第一行表示str_a,第一列表示str_b,注意行與列的開頭各自添加了一個0,表示空字符串
表格里一共有6*6=36個空白單元格,每個單元格中會記錄下對應的行字符串和對應列字符串之間的萊文斯坦距離,例如下表所示:
單元格中應該記錄:空字符串 與 ebcd 之間的萊文斯坦距離
單元格中應該記錄:a 與 空字符串 之間的萊文斯坦距離
單元格中應該記錄:abc 與 eb 之間的萊文斯坦距離
單元格中應該記錄:abcdd 與 ebcdf 之間的萊文斯坦距離,這正好是題目本身,也就是說表格最右下角的單元格的值就是str_a和str_b的萊文斯坦距離。
好了,解釋完基礎概念后,現在來表格上演示如何推導出str_a和str_b的編輯距離。
按照狀態轉移方程,實際上可以從兩個方向來解決,如果是從后往前推導,就是遞歸的思路;如果是從前往后推導,就是動態規划的思路。
從后往前推導(遞歸)
狀態轉換方程的表現形式就是遞歸方法,從后往前就是從結果單元格往回計算,找到獲取結果所需要的依賴數值,然后再遞歸地計算每個依賴數值的依賴數值……
以表格中最右下角的藍色單元格開始:
1. 其表示的是abcdd與ebcdf的距離,可見這倆字符串都不為空字符串,所以不符合狀態轉移方程的第一行,改走狀態轉移方程的第二行
2. 藍色單元格的“左”、“上”、“左上”方的三個單元格,恰好就是對應狀態轉移方程第二行的三個分支:
2.1. 藍色單元格“左”方的單元格,對應a字符串刪除當前最后一個字符,再與b字符串計算距離;
2.2. 藍色單元格“上”方的單元格,對應b字符串刪除當前最后一個字符,再與a字符串計算距離;
2.3. 藍色單元格“左上”方的單元格,對應a、b字符串各自刪除當前最后一個字符,然后兩個計算距離;
也就是說,如果想計算出藍色單元格的數值,需要先知道3個橙色單元格的值。
而這3個橙色單元格的值,又可以遞歸地從他們對應的左、上、左上3個單元格的值計算得來:
可見出現了遞歸現象,不斷地重復重復,一直推算到表格的左上角,不能再推導了為止(碰到了空字符串)
這種遞歸的解決方案有一個很大的問題就是冗余,重新觀察一下上面3個表格示意圖,如果把他們疊加到一起就會發現,有3個單元格各自被重復計算了2遍:
隨着不斷向左上角方向推導,這種重復計算的情況會越來越多,帶來的問題就是做了大量的重復、冗余的工作,算法的時間復雜度變得非常高!
從前往后推導(動態規划)
總結上面遇到的問題的特點,就是將一個大問題拆解成多個小問題時,這些小問題彼此之間不獨立,而是相互之間存在部分重復的計算。這種場景恰好是動態規划擅長解決的,因為動態規划是先從0開始,逐個計算出每個小的子問題的結果並將其記錄下來,進而當計算到后面的大問題時,發現依賴到了之前的小問題的結果,直接查之前的小問題計算結果即可,因此動態規划是一種利用空間換時間的方法。
具體到上面的表格,就是從表格的左上角開始推導,逐步向右下方移動,當移動到最右下角時,即得到了最終答案。
1. 左上角單元格,表示的是兩個空字符串的萊文斯坦距離,則根據狀態轉移方程,為max(0,0) = 0,所以該單元格的值是0:
2. 向右下方移動,順次的兩個單元格都直接依賴左上角單元格的結果,此時直接利用左上角單元格計算好的結果放到狀態轉移方程里(符合方程第一行的標准,即有一個字符串為空),即可得到這兩個單元格的數值:
3. 順次計算下面斜線方向的三個單元格,這里重點說一下紅色框中空白單元格的計算,其符合狀態轉移方程的第二行即兩個字符串a和e都不為空,所以要比較三個子字符串的最小值,上、左兩個單元格的值都是1,這兩個都需要再加1所以都是2,而左上單元格的值是0,且此時a和e不相同,需要再加1所以是1,最終基於左上角單元格的值計算下來的值(1)最小,所以此處填1——完全遵照狀態轉移方程推導了出來。
4. 以此類推,計算出每一個斜行的內容,最終得到了題目的答案:abcdd與ebcdf的萊文斯坦距離是2(第1個字符a替換為e、第5個字符d替換為f)
動態規划的方向
上面圖示的動態規划的方向是從左上角到右下角,如果按照這種思路來編程,會發現需要對矩陣做斜向遍歷,斜向遍歷是可以做到的,但其比逐行、逐列遍歷矩陣的成本要高。
再回看一下單元格的依賴關系,每個空白單元格依賴的是其左、上、左上 的3個單元格,所以其實不一定非要從左上往右下遍歷,從左向右逐行遍歷,或從上向下逐列遍歷也沒有問題。
即只要計算當前單元格的時候,能夠獲取到左、上、左上單元格的內容,那么這種遍歷方式就是ok的
但逐行、逐列的遍歷方式更容易寫代碼~;而斜向遍歷要考慮很多異常情況,代碼要復雜得多。
算法復雜度分析
時間復雜度
遞歸方法
遞歸方法因為存在大量的重復計算,所以時間復雜度非常高,這里沒有想到好的詳細推導證明的方法,粗略估計應該至少是個指數級的時間復雜度。
動態規划方法
動態規划從左上角依次計算每個單元格,因此是一個O(mn)的時間復雜度,可以看做是O(n2);
空間復雜度
遞歸方法
遞歸方法的空間耗用主要體現在遞歸過程中對堆棧的使用上,雖然隨着遞歸過程的深入需要不斷入棧甚至有大量的冗余操作,但因為每條遞歸子路徑計算完成后就會相應執行出棧動作,所以整體上棧空間的需求並不高
從右下角開始,親自跟蹤一下遞歸算法的入棧動作,會發現所需要的空間最多就是(m+1+n+1),本質上就是矩陣右下角到左上角的曼哈頓距離。所以可以理解為遞歸算法的空間復雜度是O(m+n)。
動態規划方法
動態規划需要存儲中間過程數值,假設使用數據等數據結構,則耗費的也是棧空間。動態規划需要將所有單元格的值存儲下來,所以是O(mn)的空間復雜度,可以看做是O(n2);
這里有一個空間復雜度的優化點:之前提到過:每個單元格都只依賴左、上、左上3個單元格,對其他更遠的左/上方的單元格並不依賴,以從左至右逐行遍歷舉例,如下圖所示:
如果要計算紅色框內的單元格,只需要3個藍色框的單元格,其中左上、上的兩個單元格是上一輪外循環計算的結果,左單元格是上一輪內循環計算的結果,所以我們只需要開辟一個S1 = m*2的矩陣,把上一輪和本輪外循環計算的結果都存儲下來(即亮黃色背景的兩行),循環結束后本輪結果替換上輪結果,然后繼續迭代即可。這樣空間復雜度可以縮減到O(m*2),可以看做是O(n);
代碼實現
遞歸方法
遞歸方法代碼很簡單,完全遵從狀態轉移方程,直接上代碼

1 #-*- encoding:utf-8 -*- 2 import sys 3 4 5 def levenshtein_distance(str_a, str_b): 6 """萊文斯坦距離(遞歸實現) 7 Args: 8 str_a: 第一個字符串 9 str_b: 第二個字符串 10 return: 兩個字符串的萊文斯坦距離 11 exception: 12 TypeError: 參數類型錯誤異常 13 """ 14 # 異常類型判斷 15 if not isinstance(str_a, basestring) or not isinstance(str_b, basestring): 16 raise TypeError("input must be string") 17 18 # 判斷空字符串 19 if min(len(str_a), len(str_b)) == 0: 20 return max(len(str_a), len(str_b)) 21 22 # 遞歸判斷子串的距離 23 ret_sub_a = levenshtein_distance(str_a[:-1], str_b) + 1 24 ret_sub_b = levenshtein_distance(str_a, str_b[:-1]) + 1 25 ret_sub_a_b = levenshtein_distance(str_a[:-1], str_b[:-1]) \ 26 + (1 if str_a[-1] != str_b[-1] else 0) 27 return min(ret_sub_a, ret_sub_b, ret_sub_a_b) 28 29 30 def main(str_a, str_b): 31 """主函數 32 """ 33 print("distance=%d" % levenshtein_distance(str_a, str_b)) 34 35 36 if __name__ == '__main__': 37 main(sys.argv[1], sys.argv[2])
執行結果:
$ python levenshtein_distance_recursion.py abc 1
distance=3
動態規划方法
此處給出的是從左向右逐行遍歷的代碼實現

1 #-*- encoding:utf-8 -*- 2 import numpy as np 3 import sys 4 5 def levenshtein_distance(str_a, str_b): 6 """萊文斯坦距離(動態規划實現) 7 Args: 8 str_a: 第一個字符串 9 str_b: 第二個字符串 10 return: 兩個字符串的萊文斯坦距離 11 exception: 12 TypeError: 參數類型錯誤異常 13 """ 14 if not isinstance(str_a, basestring) or not isinstance(str_b, basestring): 15 raise TypeError("input must be string") 16 17 len_a = len(str_a) 18 len_b = len(str_b) 19 20 # define a matrix variable 21 matrix = np.zeros((len_a + 1, len_b + 1), dtype=int) 22 23 # calculate distance between two strings recursively 24 for i in range(0, len_a + 1): 25 for j in range(0, len_b + 1): 26 if min(i, j) == 0: 27 matrix[i][j] = max(i, j) 28 continue 29 len_sub_a = matrix[i - 1][j] + 1 30 len_sub_b = matrix[i][j - 1] + 1 31 len_sub_a_b = matrix[i - 1][j - 1] + (1 if str_a[i - 1] != str_b[j - 1] else 0) 32 matrix[i][j] = min(len_sub_a, len_sub_b, len_sub_a_b) 33 34 return matrix[-1][-1] 35 36 37 def main(str_a, str_b): 38 distance = levenshtein_distance(str_a, str_b) 39 print("distance=%d" % distance) 40 41 42 if __name__ == '__main__': 43 main(sys.argv[1], sys.argv[2])
執行結果:
$ python levenshtein_distance_dp.py abcdd ebcdf
distance=2
進一步考慮優化空間復雜度的動態規划方法的代碼實現(思路是申請一個兩行n列的matrix,然后每輪內循環時往第1行里寫入,內循環結束后把第1行的內容整體copy到第0行)

1 #-*- encoding:utf-8 -*- 2 import numpy as np 3 import sys 4 5 def levenshtein_distance(str_a, str_b): 6 """萊文斯坦距離(動態規划實現+優化空間復雜度) 7 Args: 8 str_a: 第一個字符串 9 str_b: 第二個字符串 10 return: 兩個字符串的萊文斯坦距離 11 exception: 12 TypeError: 參數類型錯誤異常 13 """ 14 if not isinstance(str_a, basestring) or not isinstance(str_b, basestring): 15 raise TypeError("input must be string") 16 17 len_a = len(str_a) 18 len_b = len(str_b) 19 20 # define a matrix variable 21 matrix = np.zeros((2, len_b + 1), dtype=int) 22 23 # calculate distance between two strings recursively 24 for i in range(0, len_a + 1): 25 for j in range(0, len_b + 1): 26 if min(i, j) == 0: 27 matrix[1][j] = max(i, j) 28 continue 29 len_sub_a = matrix[0][j] + 1 30 len_sub_b = matrix[1][j - 1] + 1 31 len_sub_a_b = matrix[0][j - 1] + (1 if str_a[i - 1] != str_b[j - 1] else 0) 32 matrix[1][j] = min(len_sub_a, len_sub_b, len_sub_a_b) 33 matrix[0] = matrix[1].copy() 34 35 return matrix[-1][-1] 36 37 38 def main(str_a, str_b): 39 distance = levenshtein_distance(str_a, str_b) 40 print("distance=%d" % distance) 41 42 43 if __name__ == '__main__': 44 main(sys.argv[1], sys.argv[2])
當然,還可以進一步優化,省去copy的操作,用一個指示變量curr_i來控制當前寫入的行:

1 #-*- encoding:utf-8 -*- 2 import numpy as np 3 import sys 4 5 def levenshtein_distance(str_a, str_b): 6 """萊文斯坦距離(動態規划實現+優化空間復雜度) 7 Args: 8 str_a: 第一個字符串 9 str_b: 第二個字符串 10 return: 兩個字符串的萊文斯坦距離 11 exception: 12 TypeError: 參數類型錯誤異常 13 """ 14 if not isinstance(str_a, basestring) or not isinstance(str_b, basestring): 15 raise TypeError("input must be string") 16 17 len_a = len(str_a) 18 len_b = len(str_b) 19 20 # define a matrix variable 21 matrix = np.zeros((2, len_b + 1), dtype=int) 22 curr_i = 0 # only 0 or 1 23 24 # calculate distance between two strings recursively 25 for i in range(0, len_a + 1): 26 for j in range(0, len_b + 1): 27 if min(i, j) == 0: 28 matrix[curr_i][j] = max(i, j) 29 continue 30 len_sub_a = matrix[1 - curr_i][j] + 1 31 len_sub_b = matrix[curr_i][j - 1] + 1 32 len_sub_a_b = matrix[1 - curr_i][j - 1] + (1 if str_a[i - 1] != str_b[j - 1] else 0) 33 matrix[curr_i][j] = min(len_sub_a, len_sub_b, len_sub_a_b) 34 curr_i = 1 - curr_i 35 36 return matrix[1 - curr_i][-1] 37 38 39 def main(str_a, str_b): 40 distance = levenshtein_distance(str_a, str_b) 41 print("distance=%d" % distance) 42 43 44 if __name__ == '__main__': 45 main(sys.argv[1], sys.argv[2])
執行結果
$ python levenshtein_distance_dp_opt.py abcdd dd
distance=3