最長公共子序列(LCS)問題(連續子序列)的三種解法
最長公共子序列(LCS)問題有兩種方式定義子序列,一種是子序列不要求不連續,一種是子序列必須連續。上一章介紹了用兩種演算法解決子序列不要求連續的最終公共子序列問題,本章將介紹要求子序列必須是連續的情況下如何用演算法解決最長公共子序列問題。
仍以上一章的兩個字串 “abcdea”和“aebcda”為例,如果子序列不要求連續,其最長公共子序列為“abcda”,如果子序列要求是連續,則其最長公共子序列應為“bcd”。在這種情況下,有可能兩個字串出現多個長度相同的公共子串,比如“askdfiryetd”和“trkdffirey”兩個字串就存在兩個長度為3的公共子串,分別是“kdf”和“fir”,因此問題的性質發生了變化,需要找出兩個字串所有可能存在公共子串的情況,然後取最長的一個,如果有多個最長的公共子串,只取其中一個即可。
字串 “abcdea”和“aebcda”如果都以最左端的a字元對齊,則能夠匹配的最長公共子串就是“a”。但是如果用第二個字串的e字元對齊第一個字串的a字元,則能夠匹配的最長公共子串就是“bcd”。可見,從兩個字串的不同位置開始對齊匹配,可以得到不同的結果,因此,本文采用的演算法就是窮舉兩個字串所有可能的對齊方式,對每種對齊方式進行字元的逐個匹配,找出最長的匹配子串。
一、 遞迴方法
首先看看遞迴方法。遞迴的方法比較簡單,就是比較兩個字串的首字元是否相等,如果相等則將其新增到已知的公共子串結尾,然後對兩個字串去掉首字元後剩下的子串繼續遞迴匹配。如果兩個字串的首字元不相等,則用三種對齊策略分別計算可能的最長公共子串,然後取最長的一個與當前已知的最長公共子串比較,如果比當前已知的最長公共子串長就用計算出的最長公共子串代替當前已知的最長公共子串。第一種策略是將第一個字串的首字元刪除,將剩下的子串與第二個字串繼續匹配;第二種策略是將第二個字串的首字元刪除,將剩下的子串與第一個字串繼續匹配;第三種策略是將兩個字串的首字元都刪除,然後繼續匹配兩個字串剩下的子串。刪除首字元相當於字元對齊移位,整個演算法實現如下:
180 void RecursionLCS(const std::string& str1, const std::string& str2,std::string& lcs) 181 { 182 if(str1.length() == 0 || str2.length() == 0) 183 return; 184 185 if(str1[0] == str2[0]) 186 { 187 lcs += str1[0]; 188 RecursionLCS(str1.substr(1), str2.substr( 189 } 190 else 191 { 192 std::string strTmp1,strTmp2,strTmp3; 193 194 RecursionLCS(str1.substr(1), str2, strTmp1); 195 RecursionLCS(str1, str2.substr(1), strTmp2); 196 RecursionLCS(str1.substr(1), str2.substr(1), strTmp3); 197 std::string strLongest = GetLongestString(strTmp1, strTmp2, strTmp3); 198 if(lcs.length() < strLongest.length()) 199 lcs = strLongest; 200 } 201 } |
二、 兩重迴圈方法
使用兩重迴圈進行字串的對齊匹配過程如下圖所示:
圖(1)兩重迴圈字串對齊匹配示意圖
第一重迴圈確定第一個字串的對齊位置,第二重迴圈確定第二個字串的對齊位置,每次迴圈確定一組兩個字串的對齊位置,並從此對齊位置開始匹配兩個字串的最長子串,如果匹配到的最長子串比已知的(由前面的匹配過程找到的)最長子串長,則更新已知最長子串的內容。兩重迴圈的實現演算法如下:
153 void LoopLCS(const std::string& str1, const std::string& str2, std::string&lcs) 154 { 155 std::string::size_type i,j; 156 157 for(i = 0; i < str1.length(); i++) 158 { 159 for(j = 0; j < str2.length(); j++) 160 { 161 std::string lstr = LeftAllignLongestSubString(str1.substr(i),str2.substr(j)); 162 if(lstr.length() > lcs.length()) 163 lcs = lstr; 164 } 165 } 166 } |
其中LeftAllignLongestSubString()函式的作用就是從某個對齊位置開始匹配最長公共子串,其實現過程就是逐個比較字元,並記錄最長子串的位置資訊。
三、 改進後的演算法
使用兩重迴圈的演算法原理簡單,LoopLCS()函式的實現也簡單,時間複雜度為O(n2)(或O(mn)),比前一個遞迴演算法的時間複雜度O(3n)要好很多。但是如果仔細觀察圖(1)所示的匹配示意圖,就會發現這個演算法在m x n次迴圈的過程中對同一位置的字元進行多次重複的比較。比如i=1,j=0的時候,從對齊位置開始第二次比較會比較第一個字串的第三個字元“c”與第二個字串的第二個字元“e”,而在i=1,j=0的時候,這個比較又進行了一次。全部比較的次數可以近似計算為mn(n-1)/2(其中m和n分別為兩個字串的長度),也就是說比較次數是O(n3)數量級的。而理論上兩個字串的不同位置都進行一次比較只需要mn次比較即可,也就是說比較次數的理論值應該是O(n2)數量級。
考慮對上述演算法優化,可以將兩個字串每個位置上的字元的比較結果儲存到一張二維表中,這張表中的[i,j]位置就表示第一個字串的第i個字元與第二個字串的第j個字元的比較結果,1表示字元相同,0表示字元不相同。在匹配最長子串的過程中,不必多次重複判斷兩個字元是否相等,只需從表中的[i,j]位置直接得到結果即可。
改進後的演算法分成兩個步驟:首先逐個比較兩個字串,建立關係二維表,然後用適當的方法搜尋關係二維表,得到最長公共子串。第一個步驟比較簡單,演算法的改進主要集中在從關係二維表中得到最長公共子串的方法上。根據比較的原則,公共子串都是沿著二維表對角線方向出現的,對角線上連續出現1就表示這個位置是某次比較的公共子串。有上面的分析可知,只需要查詢關係二維表中對角線上連續出現的1的個數,找出最長的一串1出現的位置,就可以得到兩個字串的最長公共子串。改進後的演算法實現如下:
105 void RelationLCS(const std::string& str1, const std::string& str2, std::string&lcs) 106 { 107 int d[MAX_STRING_LEN][MAX_STRING_LEN] = { 0 }; 108 int length = 0; 109 110 InitializeRelation(str1, str2, d); 111 int pos = GetLongestSubStringPosition(d, str1.length(), str2.length(),&length); 112 lcs = str1.substr(pos, length); 113 } |
InitializeRelation()函式就是初始化二維關係表,根據字元比較的結果將d[i,j]相應的位置置0或1,本文不再列出。演算法改進的關鍵在GetLongestSubStringPosition()函式中,這個函式負責沿對角線搜尋最長公共子串,並返回位置和長度資訊。仍然以字串 “abcdea”和“aebcda”為例,InitializeRelation()函式計算得到的關係表如圖(2)所示:
圖(2)示例字串的位置關係示意圖
從圖(2)中可以看到,最長子串出現在紅線標註的對角線上,起始位置在第一個字串(縱向)中的位置是2,在第二個字串(橫向)中的位置是3,長度是3。搜尋對角線從兩個方向開始,一個是沿著縱向搜尋左下角方向上的半個關係矩陣,另一個是沿著橫向搜尋右上角方向上的半個關係矩陣。對每個對角線分別查詢連續的1出現的次數和位置,並比較得到連續1最多的位置。GetLongestSubStringPosition()函式的程式碼如下:
63 int GetLongestSubStringPosition(int d[MAX_STRING_LEN][MAX_STRING_LEN], int m,int n, int *length) 64 { 65 int k,longestStart,longs; 66 int longestI = 0; 67 int longi = 0; 68 69 for(k = 0; k < n; k++) 70 { 71 longi = GetLongestPosition(d, m, n, 0, k, &longs); 72 if(longi > longestI) 73 { 74 longestI = longi; 75 longestStart = longs; 76 } 77 } 78 for(k = 1; k < m; k++) 79 { 80 longi = GetLongestPosition(d, m, n, k, 0, &longs); 81 if(longi > longestI) 82 { 83 longestI = longi; 84 longestStart = longs; 85 } 86 } 87 88 *length = longestI; 89 return longestStart; 90 } |
GetLongestPosition()函式就是沿著對角線方向搜尋1出現的位置和連續長度,演算法簡單,本文不再列出。
至此,本文介紹了三種要求子串連續的情況下的求解最長公共子串的方法,都是簡單易懂的方法,沒有使用複雜的數學原理。第一種遞迴方法的時間複雜度是O(3n),這個時間複雜度的演算法在問題規模比較大的情況下基本不具備可用性, 第三種方法是相對最好的方法,但是仍有改進的餘地,比如使用位域陣列,可以減少儲存空間的使用,同時結合巧妙的位運算技巧,可以極大地提高GetLongestPosition()函式的效率。