我們把兩個字串的相似性定義為:將一個字串轉換成另外一個字串的代價(轉換的方法可能不唯一),轉換的代價越高則說明兩個字串的相似性越低。比如兩個字串:“SNOWY”和“SUNNY”,下面給出兩種將“SNOWY”轉換成“SUNNY”的方法:
變換1:
S - N O W Y
S U N N - Y
Cost = 3 (插入U、替換O、刪除W)
變換2:
- S N O W - Y
S U N - - N Y
Cost = 5 (插入S、替換S、刪除O、刪除W、插入N)
分析問題
我們可以把這種相似性理解為:把一個字串(source)通過“插入、刪除和替換”這樣的編輯操作變成另外一個字串(target)所需要的最少編輯次數,也就是兩個字串之間的編輯距離(edit distance)。能否給出一個演算法,求解任意兩個字串之間的編輯距離?
從題目給出的例子可知,將一個字串經由插入、刪除或替換等操作轉換成另外一個字串的方法不止一種,所需要做的編輯次數也不相同,如果有某種方法能夠用最小的修改次數完成轉換,這種方法的編輯次數就是我們要求的編輯距離。很顯然,這是個求最優解的問題。
提到最優解問題,首先可以考慮使用貪婪法,但是本題顯然是一個多階段決策類型的最優解問題,對source字串做最小的修改變換到target字串,需要在處理過程中的每個階段都選擇修改最小的方式,但是本題中每個階段之間都不是孤立的,受到前面已經確定的決策和後面可選的決策共同影響,無法通過對每一次決策的最優決策簡單堆疊出最後的最優結果,因此,排除貪婪法。
動態規劃法(Dynamic Programming)
對於多階段決策類型的問題,應該優先考慮動態規劃法(Dynamic Programming, DP)。動態規劃法是解決多階段決策最佳化問題常用的一種思想方法[1],也是所有解題方法中最抽象的一種方法。使用動態規劃法解決問題的關鍵有兩點,一點是定義子問題的最優子結構【註解1】,另一點是確定子問題最優解的堆疊。定義最優子結構就是分解子問題,可以用遞推的方式,也可以用遞迴的方式,基本原則就是將問題分成M個子問題,同時確定每個子問題的最優解與其它N(N小於M)個子問題之間的關係。子問題最優解的堆疊是指最優決策序列和它的子序列的遞推關係,包括子問題最優解的遞推關係和邊界值兩部分。對於一個問題,如果能夠找最優子結構的定義方式(包括子問題之間的關係)和子問題最優解的堆疊,並且每個子問題最優解都滿足無後效性【註解2】,則該問題就可以嘗試用動態規劃法解決這個問題。
以本題為例,假設source字串有n個字元,target字串有m個字元,如果將問題定義為求解將source的1-n個字元轉換為target的1-m個字元所需要的最少編輯次數(最小編輯距離),則其子問題就可以定義為將source的1-i個字元轉換為target的1-j個字元所需要的最少編輯次數,這就是本問題的最優子結構。我們用d[i, j]表示source[1..i]到target[1..j]之間的最小編輯距離,則計算d[i,
j]的遞推關係可以這樣計算出來:
如果source[i] 等於target[j],則:
d[i, j] = d[i, j] + 0 (遞推式 1)
如果source[i] 不等於target[j],則根據插入、刪除和替換三個策略,分別計算出使用三種策略得到的編輯距離,然後取最小的一個:
d[i, j] = min(d[i, j - 1] + 1,d[i - 1, j] + 1,d[i - 1, j - 1] + 1 ) (遞推式 2)
d[i, j - 1] + 1 表示對source[i]執行插入操作後計算最小編輯距離
d[i - 1, j] + 1 表示對source[i]執行刪除操作後計算最小編輯距離
d[i - 1, j - 1] + 1表示對source[i]替換成target[i]操作後計算最小編輯距離
d[i, j]的邊界值就是當target為空白字串(m = 0)或source為空白字串(n = 0)時所計算出的編輯距離:
m = 0,對於所有 i:d[i, 0] = i
n = 0,對於所有 j:d[0, j] = j
根據前面分析的最優子結構、最優解的遞推關係以及邊界值,寫出用動態規劃法求解最小編輯距離的演算法就很容易了,以下代碼就是計算兩個字串的最小編輯距離的演算法實現:
30 /*注意:source和target字串的長度不能超過d矩陣的限制*/ 31 int EditDistance(const std::string& source, const std::string& target) 32 { 33 std::string::size_type i,j; 34 int d[MAX_STRING_LEN][MAX_STRING_LEN] = { 0 }; 35 36 for(i = 0; i <= source.length(); i++) 37 d[i][0] = i; 38 for(j = 0; j <= target.length(); j++) 39 d[0][j] = j; 40 41 for(i = 1; i <= source.length(); i++) 42 { 43 for(j = 1; j <= target.length(); j++) 44 { 45 if((source[i - 1] == target[j - 1])) 46 { 47 d[i][j] = d[i - 1][j - 1]; //不需要編輯操作 48 } 49 else 50 { 51 int edIns = d[i][j - 1] + 1; //source 插入字元 52 int edDel = d[i - 1][j] + 1; //source 刪除字元 53 int edRep = d[i - 1][j - 1] + 1; //source 替換字元 54 55 d[i][j] = std::min(std::min(edIns, edDel), edRep); 56 } 57 } 58 } 59 60 return d[source.length()][target.length()]; 61 } |
窮舉法(枚舉法)
除了貪婪法和動態規劃法,窮舉法也是求解最優解的常用方法。窮舉法比動態規劃法容易理解,窮舉法的原理就是對問題域的整個解空間進行搜尋,通過比較所有可能的解,從中選出最優解。窮舉法的本意其實是為了求解某個問題的所有合法的解,最優解可以理解為只是搜尋過程中的一個副產品。根據題目的不同,窮舉法的實現也不一樣,如果問題的解空間是線性結構,則可以使用迴圈方法,如果問題的解空間是樹狀結構,則可以使用遞迴方法。本問題的解空間顯然不是線性結構,因此考慮使用遞迴方法對所有解進行窮舉。遞迴演算法需要解決兩個問題,一個是如何將問題遞迴地分解為子問題,另一個問題是如何確定遞迴終止條件。
對於本文的問題,可以這樣對遞迴分解子問題:位置i表示source和target字串中共同的字元位置,對於每一個i位置,計算從i位置開始的子串的編輯距離,計算方法是比較source[i]和target[i]的值,如果相等,則表示這個位置需要的編輯次數是0,i位置開始的子串的編輯距離就等於source和target字串從i + 1位置開始的子串的編輯距離。如果不相等,則對source字串i位置的字元分別嘗試插入、刪除和替換三種編輯方式計算新的子串的編輯距離,然後加上1就是從i位置開始的子串的編輯距離。用插入、刪除和替換三種方式計算新的子串的開始位置需要分別調整,如果是在i位置插入字元,則source字串的i位置不變化,target字串的i位置移到i
+ 1位置繼續。如果是在i位置刪除字元,則source字串的i位置移到i + 1位置,target字串的i位置不變。如果是在i位置替換字元,則source和target字串的i位置都移到i + 1位置。
有了子問題的遞迴分解,還需要確定遞迴計算的終止條件。本問題的終止條件很簡單,由上面的遞迴子問題定義可知,當source和target字串中的某一個為空白時即可終止遞迴。當達到遞迴終止條件時,編輯距離的值就是source和target字串中非空的那一個剩餘子串的長度,這一點比較容易理解,如果source最後剩餘的子串不為空白,則意味著需要刪除這些字元才能和target一樣,如果target最後剩餘的子串不為空白,則意味著需要對source插入這些字元才能和target一樣,所以最後的編輯距離就是剩餘子串的長度。
根據以上對遞迴子問題的分解方法和遞迴終止條件的分析,可以很容易地寫出使用遞迴方法求解本問題的演算法實現。遞迴演算法通常效率不高,但是與人類解決問題的思維方式一致,通常代碼簡潔,容易理解,本文給出的演算法實現只用了9行代碼:
14 int EditDistance(const std::string& source, const std::string& target) 15 { 16 if(source.empty() || target.empty()) 17 return std::abs(source.length() - target.length()); 18 19 if(source[0] == target[0]) 20 return EditDistance(source.substr(1), target.substr(1)); 21 22 int edIns = EditDistance(source, target.substr(1)) + 1; //source 插入字元 23 int edDel = EditDistance(source.substr(1), target) + 1; //source 刪除字元 24 int edRep = EditDistance(source.substr(1), target.substr(1)) + 1; //source 替換字元 25 26 return std::min(std::min(edIns, edDel), edRep); 27 } |
總結
就時間複雜度而言,動態規劃法的時間複雜度是O(n2),窮舉演算法的時間複雜度在最好的情況下是O(n),也就是每次都走(source[0] == target[0])分支的情況,最差的情況當然是O(3n)。這種時間複雜度是指數型的演算法,基本上是停用演算法,只存在理論上的價值,實際工程中是不會適用這種時間複雜度的演算法。
就空間複雜度而言,動態規劃法的空間複雜度是O(mn),但是這個空間複雜度在很大程度上是可以最佳化的,最佳化的程度因演算法而異。以本文的演算法為例,從(遞推式 2)可以看到,d[i, j]的結果只需要知道i,i - 1,以及j,j – 1位置上的結果就可以遞推計算出來,其它位置上的資訊完全可以在計算完成後釋放,並不需要從頭到尾佔用m x n的空間。類似的最佳化只是資料群組織上的一些小技巧,本文就不再贅述,有興趣的讀者可以自己對EditDistance()函數進行改造。
註解:
【1】最優子結構:對於多階段決策問題,如果每一個階段的最優決策序列的子序列也是最優的,且決策序列具有“無後效性”,就可以將此決策方法理解為最優子結構。
【2】無後效性:動態規劃法的最優解通常是由一系列最優決策組成的決策序列,最優子結構就是這些最優決策序列中的一個子序列,對於每個子序列再做最優決策會產生新的最優決策(子)序列,如果某個決策只受當前最優決策子序列的影響,而不受當前決策可能產生的新的最優決策子序列的影響,則可以理解這個最優決策具有無後效性。
參考文獻:
[1].演算法藝術和資訊學競賽.劉汝佳、黃亮.清華大學出版社.2003
[2]. http://en.wikipedia.org/wiki/Edit_distance
[3]. http://en.wikipedia.org/wiki/Levenshtein_distance