最長公共子序列(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(1), lcs);

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(= 0; i < str1.length(); i++)

158     {

159         for(= 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(= 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(= 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()函數的效率。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章