經典動態規劃問題:最短編輯距離演算法的原理及實現
編輯距離的定義
編輯距離(Edit Distance)最常用的定義就是Levenstein距離,是由俄國科學家Vladimir Levenshtein於1965年提出的,所以編輯距離一般又稱Levenshtein距離。它主要作用是測量兩個字串的差異化程度,表示字串a至少要經過多少個操作才能轉換為字串b,這裡的操作包括三種:增加、刪除、替換。
舉個例子:
(1)增加:對於字串a:abc 和 字串b:abcde,顯然,只需要在字串a的末尾增加字元'd'和'e'就能變成字串b了,所以a和b的最短編輯距離為2。
(2)刪除:對於字串a:abcd 和字串b:abc,顯然,只需要在字串a的末尾刪除字元'd'就能變成字串b了,所以a和b的最短編輯距離為1。
(3)替換:對於字串a:abcd 和 字串b:abce,顯然,只需要把字串a的'd'替換成'e'就可以了,此時二者的最短編輯距離是1。
一般字串都是需要增加、刪除、替換三者結合起來一起使用,因為字串a到b可能存在多種變化的方法,而我們往往最關心的是最短的編輯距離,這樣才能得出a和b的相似程度, 最短編輯距離越小,表示a到b所需要的操作越少,a和b的相似度也就越高 。因此,Levenstein距離的一個應用場景就是判斷兩個字串的相似度,可以用在字串的 模糊搜尋 上面。
Levenshtein 演算法原理
先從一個問題談起:對於字串"xyz"和"xcz",它們的最短距離是多少?我們從兩個字串的最後一個字元開始比較,它們都是'z',是相同的,我們可以不用做任何操作,此時二者的距離實際上等於"xy"和"xc"的距離,即d(xyz,xcz) = d(xy,xc)。也即是說,如果在比較的過程中,遇到了相同的字元,那麼二者的距離是除了這個相同字元之外剩下字元的距離。即d(i,j) = d(i - 1,j-1)。
接著,我們把問題拓展一下,最後一個字元不相同的情況:字串A("xyzab")和字串B("axyzc"),問至少經過多少步操作可以把A變成B。
我們還是從兩個字串的最後一個字元來考察即'b'和'c'。顯然二者不相同,那麼我們有以下三種處理辦法:
(1)增加:在A末尾增加一個'c',那麼A變成了"xyzabc",B仍然是"axyzc",由於此時末尾字元相同了,那麼就變成了比較"xyzab"和"axyz"的距離,即d(xyzab,axyzc) = d(xyzab,axyz) + 1。可以寫成d(i,j) = d(i,j - 1) + 1。表示下次比較的字串B的長度減少了1,而加1表示當前進行了一次字元的操作。
(2)刪除:刪除A末尾的字元'b',考察A剩下的部分與B的距離。即d(xyzab,axyzc) = d(xyza,axyzc) + 1。可以寫成d(i,j) = d(i - 1,j) + 1。表示下次比較的字串A的長度減少了1。
(3)替換:把A末尾的字元替換成'c',這樣就與B的末尾字元一樣了,那麼接下來就要考察出了末尾'c'部分的字元,即d(xyzab,axyzc) = d(xyza,axyz) + 1。寫成d(i,j) = d(i -1,j-1) + 1表示字串A和B的長度均減少了1。
由於我們要求的是最短的編輯距離,所以我們取以上三個步驟得出的距離的最小值為最短編輯距離。由上面的步驟可得,這是一個遞迴的過程,因為除掉最後一個字元之後,剩下的字串的最後一位仍然是最後一個字元,我們仍然可以按照上面的三種操作來進行,經過這樣的不斷遞迴,直到比較到第一個字元為止,遞迴結束。
按照以上思路,我們很容易寫出下面的方程:

最短編輯距離方程
註釋:該方程的第一個條件min(i,j) = 0,表示若某一字串為空,轉換成另一個字串所需的操作次數,顯然,就是另一個字串的長度(新增length個字元就能轉換)。這個條件可以看成是遞迴的出口條件,此時i或j減到了0。
根據以上方程,我們能快速寫出遞迴程式碼,但由於遞迴包含了大量的重複計算,並且如果初始字串過長,會造成遞迴層次過深,容易造成棧溢位的問題,所以我們這裡可以用 動態規劃 來實現。 如果說遞迴是自頂向下的運算過程,那麼動態規劃就是自底向上的過程。 它從i和j的最小值開始,不斷地增大i和j,同時對於一個i和j都會算出當前地最短距離,因為下一個i和j的距離會與當前的有關,所以通過一個數組來儲存每一步的運算結果來避免重複的計算過程,當i和j增加到最大值length時,結果也就出來了,即d[length][length]為A、B的最短編輯距離。
動態規劃中,i和j的增加需要兩層迴圈來完成,外層迴圈遍歷i,內層迴圈遍歷j,也即是,對於每一行,會掃描行內的每一列的元素進行運算。因此,時間複雜度為o(n²),空間複雜度為o(n²)。
圖解動態規劃求最短編輯距離過程
在寫程式碼之前,為了讓讀者對動態規劃有一個直觀的感受,筆者以表格的形式,列出動態規劃是如何一步步地工作的。
下面以字串"xyzab"和"axyzc"為例來講解。


圖解
由上面可以看出,動態規劃就是逐行逐列地運算,逐漸填滿整個陣列,最後得到結果恰好儲存在陣列的最後一行和最後一列的元素上。
程式碼實現
一、基本實現
public class LevenshteinDistance { private static int minimum(int a,int b,int c){ return Math.min(Math.min(a,b),c); } public static int computeLevenshteinDistance(CharSequence src,CharSequence dst){ int[][] distance = new int[src.length() + 1][dst.length() + 1]; for (int i = 0;i <= src.length();i++) distance[i][0] = i; for (int j = 0;j <= dst.length();j++) distance[0][j] = j; for (int i = 1;i <= src.length();i++){ for (int j = 1;j <= dst.length();j++){ int flag = (src.charAt(i - 1) == dst.charAt(j - 1)) ? 0 : 1; distance[i][j] = minimum( distance[i - 1][j] + 1, distance[i][j - 1] + 1, distance[i - 1][j - 1] + flag); } } return distance[src.length()][dst.length()]; } //測試方法 public static void main(String args[]){ String s1 = "xyzab"; String s2 = "axyzc"; String s3 = "等啊高原"; String s4 = "阿登高原"; String s5 = "xyz阿登高原"; String s6 = "1y3等啊高原x"; System.out.println("字串(\"" + s1 + "\")和字串(\"" + s2 + "\")的最小編輯距離為:"+ computeLevenshteinDistance(s1,s2)); System.out.println("字串(\"" + s3 + "\")和字串(\"" + s4 + "\")的最小編輯距離為:"+ computeLevenshteinDistance(s3,s4)); System.out.println("字串(\"" + s5 + "\")和字串(\"" + s6 + "\")的最小編輯距離為:"+ computeLevenshteinDistance(s5,s6)); } }
上面的程式碼是利用了動態規劃的思想來實現的最短編輯距離演算法,它的實現與原理方程基本上是一致的,都是先對第一行和第一列的資料進行初始化,然後開始逐行逐列進行計算,填充滿整個陣列,即自底向上的思想,通過這樣減少了大量的遞迴重複計算,實現了運算速度的提升。上面提到,這種實現的時間複雜度和空間複雜度都是n²級別的(實際上是m×n,兩個字串長度的乘積)。實際上,我們可以對程式碼進行優化,降低空間複雜度。
二、利用滾動陣列進行空間複雜度的優化
滾動陣列是動態規劃中一種常見的優化思想。為了理解滾動陣列的思想,我們先來看看如何進行空間複雜度的優化。回到原理方程,我們可以觀察到d(i,j)只與上一行的元素d(i-1,j)、d(i,j-1)和d(i-1,j-1)有關,而上一行之前的元素沒有關係,也就是說,對於某一行的d(i,j),我們只需要知道上一行的資料就行,別的資料都是無效資料。實際上,我們只需要兩行的陣列就可以了。
舉個例子:還是上面的"xyzab"和"axyzc",當我們計算完第一行和第二行的資料後,到達第三行時,我們以第二行為上一行結果來計算,並把計算結果放到第一行內;到達第四行時,由於第三行的資料實際上儲存在第一行,所以我們根據第一行來計算,把結果儲存在第二行……以此類推,直到計算到最後一行,即 不斷交替使用兩行陣列的空間 ,“滾動陣列”也因此得名。通過使用滾動陣列的形式,我們不需要n×m的空間,只需要2×min(n,m)的空間,這樣便能把空間複雜度降到線性範圍內,節省了大量的空間。
利用滾動陣列後的空間複雜度為o(2×n)或者o(2×m),這取決於程式碼的實現,即取字串A還是B的長度為陣列的列數。(因為無論把哪一個字串作為src或dst,都是等價的,結果都是一樣的。)其實我們可以通過判斷A、B的長度,來選取一個最小值作為列數,此時空間複雜度變為o(2×min(n,m))。下面給出基於滾動陣列的最小編輯距離的優化版本,由Java實現。
/** *利用滾動陣列優化過的最小編輯距離演算法。空間複雜度為O(2×min(lenSrc,lenDst)) * @param src 動態規劃陣列的行元素 * @param dst 動態規劃陣列的列元素 * @return */ public static int computeLevenshteinDistance_Optimized(CharSequence src,CharSequence dst){ int lenSrc = src.length() + 1; int lenDst = dst.length() + 1; CharSequence newSrc = src; CharSequence newDst = dst; //如果src長度比dst的短,表示陣列的列數更多,此時我們 //交換二者的位置,使得陣列的列數變為較小的值。 if (lenSrc < lenDst){ newSrc = dst; newDst = src; int temp = lenDst; lenDst = lenSrc; lenSrc = temp; } //建立滾動陣列,此時列數為lenDst,是最小的 int[] cost = new int[lenDst];//當前行依賴的上一行資料 int[] newCost = new int[lenDst];//當前行正在修改的資料 //對第一行進行初始化 for(int i = 0;i < lenDst;i++) cost[i] = i; for(int i = 1;i < lenSrc;i++){ //對第一列進行初始化 newCost[0] = i; for(int j = 1;j < lenDst;j++){ int flag = (newDst.charAt(j - 1) == newSrc.charAt(i - 1)) ? 0 : 1; int cost_insert = cost[j] + 1;//表示“上面”的資料,即對應d(i - 1,j) int cost_replace = cost[j - 1] + flag;//表示“左上方的資料”,即對應d(i - 1,j - 1) int cost_delete = newCost[j - 1] + 1; //表示“左邊的資料”,對應d(i,j - 1) newCost[j] = minimum(cost_insert,cost_replace,cost_delete); //對應d(i,j) } //把當前行的資料交換到上一行內 int[] temp = cost; cost = newCost; newCost = temp; } return cost[lenDst - 1]; }
把main()方法的方法呼叫改為上述方法,比較前後兩個方法的輸出結果,結果一致,符合預期。

輸出結果
三、對空間複雜度的進一步優化
實際上,我們還能對這個進行進一步的優化,把空間複雜度減少為o(min(n,m)),即我們只需要一行的陣列d加一個額外的臨時變數就可以實現。比如說我們要修改d[i]的值時,只需知道它的左邊、上邊和左上方的元素的值,而左邊的值就是d[i-1],上邊的值是修改之前的d[i],左上方的值是d[i-1]修改之前的值。每一次需要修改d[i-1]的時候,都用臨時變數把他儲存起來,這樣i位置就能直接獲取這三個值進行比較,得到結果之後,先用這個臨時變數把d[i]儲存起來,然後再寫入d[i]內,……以此類推,直到遍歷完一行。
其核心思想是:把求得的資料,再次寫回這一行資料對應下標元素的位置,而臨時變數temp則儲存當前位置左上方元素的值,以提供給下一個位置的計算。總的來說,資料的操作只集中在一行之內,所以空間複雜度就是o(n)。
下面以圖解的形式表達這一過程,方便讀者理解。

單行陣列
程式碼實現也不復雜,有興趣的同學可以根據上圖或者思路來實現,這裡就不再實現了。
好了,這篇文章寫到這裡就結束了,希望能對各位同學有所裨益,謝謝你們的耐心閱讀~