最長公共子序列也稱作最長公共子串,英文縮寫是LCS(Longest Common Subsequence)。其定義是:一個序列S,如果分別是兩個或多個已知序列的子序列,且是符合此條件的子序列中最長的,則稱S爲已知序列的最長公共子序列。
關於子序列的定義通常有兩種方式,一種是對子序列沒有連續的要求,其子序列的定義就是原序列中刪除若干元素後得到的序列。另一種是對子序列有連續的要求,其子序列的定義是原序列中連續出現的若干個元素組成的序列。求解子序列是非連續的最長公共子序列問題是一個十分實用的問題,它可以描述兩段文字之間的“相似度”,即它們的雷同程度,從而能夠用來辨別抄襲。本文將介紹對子序列沒有連續性要求的情況下如何用計算機解決最長公共子序列問題,對子序列有連續性要求的情況下如何用計算機解決最長公共子序列問題將在後續的文章中介紹。
一、 動態規劃法(Dynamic Programming)
最長公共子序列問題應該是屬於多階段決策問題中求最優解一類的問題,凡此類問題在編制計算機程序時應優先考慮動態規劃法,如果不能用動態規劃法,而且也找不到其它解決方法,還可以考慮窮舉法。對於這個問題,只要能找到描述最長公共子序列的最優子結構和最優解的堆疊方式,並且保證最優子結構中的每一次最優決策都滿足“無後效性”,就可以考慮用動態規劃法。使用動態規劃法的關鍵是對問題進行分解,按照一定的規律分解成子問題(分解後的子問題還可以再分解,這是個遞歸的過程),通過對子問題的定義找出最優子結構中最優決策序列(對於子問題就是最有決策序列的子序列)以及最優決策序列子序列的遞推關係(當然還包括遞推關係的邊界值)。
如果一個給定序列的子序列是在該序列中刪去若干元素後得到的序列,也就意味着子序列在原序列中的位置索引(下標)保持嚴格遞增的順序。例如,序列S = <B,C,D,B>是序列K = <A,B,C,B,D,A,B>的一個子序列(非連續),序列S的元素在在K中的位置索引I = [2,3,5,7],I是一個嚴格遞增序列。
1.1 最優子結構定義與邊界值
現在來分析一下本問題的最優子結構。首先定義問題,假設有字符串str1長度爲m,字符串str2長度爲n,可以把子問題描述爲:求字符串str1<1..m>中從第1個到第i(i <= m)個字符組成的子串str1<1…i>和字符串str2<1..n>中從第1個到第j(j <= n)個字符組成的子串str2<1…j>的最長公共序列,子問題的最長公共序列可以描述爲d[i,j] = { Z1,Z2, … Zk },其中Z1-Zk爲當前子問題已經匹配到的最長公共子序列的字符。子問題定義以後,還要找出子問題的最優序列d[i,j]的遞推關係。分析d[i,j]的遞推關係要從str1[i]和str2[j]的關係入手,如果str1[i]和str2[j]相同,則d[i,j]就是d[i-1,j-1]的最長公共序列+1,Zk=str1[i]=str2[j];如果str1[i]和str2[j]不相同,則d[i,j]就是d[i-1,j]的最長公共序列和d[i,j-1]的最長公共序列中較大的那一個。
最後是確定d[i,j]的邊界值,當字符串str1爲空或字符串str2爲空時,其最長公共子串應該是0,也就是說當i=0或j=0時,d[i,j]就是0。d[i,j]的完整遞推關係如下:
1.1 反求最長公共子序列
根據1.1得到的最優解子結構遞推關係,依次計算i從到m,j從1到n的d[i,j]值,最後得到的d[m,n]就是最長公共子序列的長度。d[m,n]只是最長公共子序列的長度值,表示了兩個字符串的相似程度,如果要獲得最長公共子序列,就需要在計算出d[m,n]矩陣的值後分析每一步決策的結果,根據每一個最優決策逆向構造出最長公共子序列。爲此需要在遞推計算d[i,j]的過程中,需要同時記錄下最優決策的過程,最優決策的過程用矩陣r表示,r[i,j]表示最長公共子序列的長度值d[i,j]的“遞推來源”。根據前面整理的遞推關係,如果r[i,j]的值是1,則表示d[i,j]的值由d[i-1,j-1] + 1遞推得到;如果r[i,j]的值是2,則表示d[i,j]的值由d[i-1,j]遞推得到;如果r[i,j]的值是3,則表示d[i,j]的值由d[i,j-1]遞推得到。以字符串“abcdea”和“aebcda”爲例,根據遞推關係得到的d和r合併到一個矩陣中顯示:
圖(1)逆向構造最長公共子序列示意圖
逆向構造最長公共子串的過程從r[m,n]開始,如果r[i,j]=1,則表示兩個字符串中的str1[i]和str2[j]相同,可以將str1[i]或str2[j]插入到當前構造的最長公共子序列中。如果r[i,j]≠1,則不改變當前構造的最長公共子序列,但是要根據r[i,j]的值是2還是3,調整r[i,j]倒推的下一個位置。以上述兩個字符串構造出的d矩陣和r矩陣爲例,逆向構造最長公共子序列的過程如下:r[6,6]=1表示當前公共子串lcs = <str1[6]>(str1[6]和<str2[6]>值一樣),同時前一次最優決策來自於d[5,5]。r[5,5]=2表示前一次決策來自於d[4,5],此時r[4,5]=1,表示當前公共子串lcs = < str1[4],str1[6]>,同時前一次最優決策來自於d[3,4]。r[3,4]=1表示當前公共子串lcs = < str1[3],str1[4],str1[6]>,同時前一次最優決策來自於d[2,3]。r[2,3]=1表示當前公共子串lcs = < str1[2],str1[3],str1[4],str1[6]>,同時前一次最優決策來自於d[1,2]。r[1,2]=3表示前一次決策來自於d[1,1],此時r[1,1]=1,表示當前公共子串lcs = < str1[1],str1[2],str1[3],str1[4],str1[6]>。由於r[0,0]是邊界,因此逆向構造過程結束,得到最長公共子串的最終結果是lcs = < str1[1],str1[2],str1[3],str1[4],str1[6]>,對應的字符串就是<abcda>。
1.1 動態規劃算法實現
在編寫實現代碼時,將每次決策的策略和最優值用一個數據結構描述:
11 typedef struct tagDPLCS 12 { 13 int d; 14 int r; 15 }DPLCS; |
d是最優決策的值,r是決策方向。整個算法分成兩部分,首先根據遞推關係得到最優值(包括決策方向),這部分由InitializeDpLcs()函數完成,然後是逆向構造出最長公共序列,由GetLcs()函數實現。InitializeDpLcs()函數實現了本文1.1小節介紹的d[i,j]的遞推計算算法以及r[i,j]的構造過程:
17 int InitializeDpLcs(const std::string& str1, const std::string& str2, DPLCS dp[MAX_STRING_LEN][MAX_STRING_LEN]) 18 { 19 std::string::size_type i,j; 20 21 for(i = 1; i <= str1.length(); i++) 22 dp[i][0].d = 0; 23 for(j = 1; j <= str2.length(); j++) 24 dp[0][j].d = 0; 25 26 for(i = 1; i <= str1.length(); i++) 27 { 28 for(j = 1; j <= str2.length(); j++) 29 { 30 if((str1[i - 1] == str2[j - 1])) 31 { 32 dp[i][j].d = dp[i - 1][j - 1].d + 1; 33 dp[i][j].r = 1; 34 } 35 else 36 { 37 if( dp[i - 1][j].d >= dp[i][j - 1].d ) 38 { 39 dp[i][j].d = dp[i - 1][j].d; 40 dp[i][j].r = 2; 41 } 42 else 43 { 44 dp[i][j].d = dp[i][j - 1].d; 45 dp[i][j].r = 3; 46 } 47 } 48 } 49 } 50 51 return dp[str1.length()][str2.length()].d; 52 } |
GetLcs()函數則是1.2小節描述的算法實現,巧妙的利用了遞歸算法實現了逆向構造最長公共子串,構造過程基於第一個字符串,lcs就是最終得到的最長公共字串:
54 void GetLcs(DPLCS dp[MAX_STRING_LEN][MAX_STRING_LEN], int i, int j, conststd::string& str1, std::string& lcs) 55 { 56 if((i == 0) || (j == 0)) 57 return; 58 59 if(dp[i][j].r == 1) 60 { 61 GetLcs(dp, i - 1, j - 1, str1, lcs); 62 lcs += str1[i - 1]; 63 } 64 else 65 { 66 if(dp[i][j].r == 2) 67 GetLcs(dp, i - 1, j, str1, lcs); 68 else 69 GetLcs(dp, i, j - 1, str1, lcs); 70 } 71 } |
一、 窮舉的方法
除了動態規劃法,求最長公共子序列問題也可以使用窮舉算法。窮舉算法的實質就是使用各種匹配兩個字符串的方法,對兩個字符串求解最長公共子串,然後找出各種方法得到子串中最長的一個。窮舉法匹配字符串有很多種方法,本文介紹一種模仿人類思維方式,用遞歸方式求解最長公共子串的算法。
2.1 算法分析
人腦解決此類問題的方法就是逐個比較兩個字符串的每個字符,比如str1[i]和str2[j],如果str1[i]==str2[j],則將str1[i]或str2[j]附加到str1[i]和str2[j]之前計算得到的最長公共子串後面,然後繼續計算str1[i+1]開始的子串和和str2[j+1]開始的子串的最長公共子串。如果str1[i]!=str2[j],則採用三種方法窮舉,第一種方法是刪除str1[i],繼續計算str1[i+1]開始的子串和str2[j]開始的子串的最長公共子串;第二種方法是刪除str2[j],繼續計算str1[i]開始的子串和str2[j+1]開始的子串的最長公共子串;第三種方法是刪除str1[j]和str2[j],繼續計算str1[i+1]開始的子串和str2[j+1]開始的子串的最長公共子串。使用三種方法計算完成後比較結果,取最長的子串附加到str1[i]和str2[j]之前計算得到的最長公共子串後面組成新的最長公共子串。以上繼續計算都是遞歸過程,遞歸的終止條件是到達str1字符串結尾或str2字符串結尾。
2.2 算法實現
RecursionLCS()函數就是對上述2.1分析的實現:
108 void RecursionLCS(const std::string& str1, const std::string& str2,std::string& lcs) 109 { 110 if(str1.length() == 0 || str2.length() == 0) 111 return; 112 113 if(str1[0] == str2[0]) 114 { 115 lcs += str1[0]; 116 RecursionLCS(str1.substr(1), str2.substr(1), lcs); 117 } 118 else 119 { 120 std::string strTmp1,strTmp2,strTmp3; 121 122 RecursionLCS(str1.substr(1), str2, strTmp1); //嘗試刪除str1 123 RecursionLCS(str1, str2.substr(1), strTmp2); //嘗試刪除str2 124 RecursionLCS(str1.substr(1), str2.substr(1), strTmp3); //嘗試同時刪除str1和str2 125 lcs += GetLongestString(strTmp1, strTmp2, strTmp3); 126 } 127 } |
GetLongestString()是一個輔助函數,就是比較三個字符串,返回最長的一個字符串。
三、 總結
本文給出的了兩種求解最長公共子串的算法,毫無疑問,動態規劃的算法要優於遞歸實現的窮舉算法。使用遞歸實現的窮舉算法代碼簡潔易懂,但是算法的時間複雜度在最壞的情況下是О(n3n)(最好情況下是О(n)),這樣的算法只能作爲理論存在,基本上不具備實用價值,寫在這裏只是爲了與動態規劃算法做對比。
參考書籍:
【1】算法藝術和信息學競賽 劉汝佳、黃亮 清華大學出版社 2003年
【2】http://en.wikipedia.org/wiki/Longest_common_subsequence_problem