最長公共子串(Longest Common Substring)是一個非常經典的面試題目,在實際的程序中也有很高的實用價值,所以把該問題的解法總結在本文重。不過不單單只是寫出該問題的基本解決代碼而已,關鍵還是享受把學習算法一步步的優化,讓時間和空間復雜度一步步的減少的驚喜。
概覽
最長公共子串問題的基本表述為:
給定兩個字符串,求出它們之間最長的相同子字符串的長度。
最直接的解法自然是找出兩個字符串的所有子字符串進行比較看他們是否相同,然后取得相同最長的那個。對於一個長度為n
的字符串,它有n(n+1)/2
個非空子串。所以假如兩個字符串的長度同為n,通過比較各個子串其算法復雜度大致為O(n4)
。這還沒有考慮字符串比較所需的時間。簡單想想其實並不需要取出所有的子串,而只要考慮每個子串的開始位置就可以,這樣可以把復雜度減到O(n3)
。
但這個問題最好的解決辦法是動態規划法,在后邊會更加詳細介紹這個問題使用動態規划法的契機:有重疊的子問題。進而可以通過空間換時間,讓復雜度優化到O(n2)
,代價是空間復雜度從O(1)
一下子提到了O(n2)
。
從時間復雜度的角度講,對於最長公共子串問題,O(n2)
已經是目前我所知最優的了,也是面試時所期望達到的。但是對於空間復雜度O(n2)
並不算什么,畢竟算法上時間比空間更重要,但是如果可以省下一些空間那這個算法就會變得更加美好。所以進一步的可以把空間復雜度減少到O(n)
,這是相當美好了。但有一天無意間讓我發現了一個算法可以讓該問題的空間復雜度減少回原來的O(1)
,而時間上如果幸運還可以等於O(n)
。
暴力解法 – 所得即所求
對於該問題,直觀的思路就是問題要求什么就找出什么。要子串,就找子串;要相同,就比較每個字符;要最長就記錄最長。所以很容易就可以想到如下的解法。
1 int longestCommonSubstring_n3(const string& str1, const string& str2) 2 { 3 size_t size1 = str1.size(); 4 size_t size2 = str2.size(); 5 if (size1 == 0 || size2 == 0) return 0; 6 7 // the start position of substring in original string 8 int start1 = -1; 9 int start2 = -1; 10 // the longest length of common substring 11 int longest = 0; 12 13 // record how many comparisons the solution did; 14 // it can be used to know which algorithm is better 15 int comparisons = 0; 16 17 for (int i = 0; i < size1; ++i) 18 { 19 for (int j = 0; j < size2; ++j) 20 { 21 // find longest length of prefix 22 int length = 0; 23 int m = i; 24 int n = j; 25 while(m < size1 && n < size2) 26 { 27 ++comparisons; 28 if (str1[m] != str2[n]) break; 29 30 ++length; 31 ++m; 32 ++n; 33 } 34 35 if (longest < length) 36 { 37 longest = length; 38 start1 = i; 39 start2 = j; 40 } 41 } 42 } 43 #ifdef IDER_DEBUG 44 cout<< "(first, second, comparisions) = (" 45 << start1 << ", " << start2 << ", " << comparisons 46 << ")" << endl; 47 #endif 48 return longest; 49 }
該解法的思路就如前所說,以字符串中的每個字符作為子串的端點,判定以此為開始的子串的相同字符最長能達到的長度。其實從表層上想,這個算法的復雜度應該只有O(n2)
因為該算法把每個字符都成對相互比較一遍,但關鍵問題在於比較兩個字符串的效率並非是O(1)
,這也導致了實際的時間復雜度應該是滿足Ω(n2)
和O(n3)
。
動態規划法 – 空間換時間
有了一個解決問題的方法是一件很不錯的事情了,但是拿着上邊的解法回答面試題肯定不會得到許可,面試官還是會問有沒有更好的解法呢?不過上述解法雖然不是最優的,但是依然可以從中找到一個改進的線索。不難發現在子串比較中有很多次重復的比較。
比如再比較以i
和j
分別為起始點字符串時,有可能會進行i+1
和j+1
以及i+2
和j+2
位置的字符的比較;而在比較i+1
和j+1
分別為起始點字符串時,這些字符又會被比較一次了。也就是說該問題有非常相似的子問題,而子問題之間又有重疊,這就給動態規划法的應該提供了契機。
暴力解法是從字符串開端開始找尋,現在換個思維考慮以字符結尾的子串來利用動態規划法。
假設兩個字符串分別為s和t,s[i]
和t[j]
分別表示其第i
和第j
個字符(字符順序從0
開始),再令L[i, j]
表示以s[i]
和s[j]
為結尾的相同子串的最大長度。應該不難遞推出L[i, j]
和L[i+1,j+1]
之間的關系,因為兩者其實只差s[i+1]
和t[j+1]
這一對字符。若s[i+1]
和t[j+1]
不同,那么L[i+1, j+1]
自然應該是0
,因為任何以它們為結尾的子串都不可能完全相同;而如果s[i+1]
和t[j+1]
相同,那么就只要在以s[i]
和t[j]
結尾的最長相同子串之后分別添上這兩個字符即可,這樣就可以讓長度增加一位。合並上述兩種情況,也就得到L[i+1,j+1]=(s[i]==t[j]?L[i,j]+1:0)
這樣的關系。
最后就是要小心的就是臨界位置:如若兩個字符串中任何一個是空串,那么最長公共子串的長度只能是0
;當i
為0
時,L[0,j]
應該是等於L[-1,j-1]
再加上s[0]
和t[j]
提供的值,但L[-1,j-1]
本是無效,但可以視s[-1]
是空字符也就變成了前面一種臨界情況,這樣就可知L[-1,j-1]==0
,所以L[0,j]=(s[0]==t[j]?1:0)
。對於j
為0
也是一樣的,同樣可得L[i,0]=(s[i]==t[0]?1:0)
。
最后的算法代碼如下:
1 int longestCommonSubstring_n2_n2(const string& str1, const string& str2) 2 { 3 size_t size1 = str1.size(); 4 size_t size2 = str2.size(); 5 if (size1 == 0 || size2 == 0) return 0; 6 7 vector<vector<int> > table(size1, vector<int>(size2, 0)); 8 // the start position of substring in original string 9 int start1 = -1; 10 int start2 = -1; 11 // the longest length of common substring 12 int longest = 0; 13 14 // record how many comparisons the solution did; 15 // it can be used to know which algorithm is better 16 int comparisons = 0; 17 for (int j = 0; j < size2; ++j) 18 { 19 ++comparisons; 20 table[0][j] = (str1[0] == str2[j] ? 1 :0); 21 } 22 23 for (int i = 1; i < size1; ++i) 24 { 25 ++comparisons; 26 table[i][0] = (str1[i] == str2[0] ? 1 :0); 27 28 for (int j = 1; j < size2; ++j) 29 { 30 ++comparisons; 31 if (str1[i] == str2[j]) 32 { 33 table[i][j] = table[i-1][j-1]+1; 34 } 35 } 36 } 37 38 for (int i = 0; i < size1; ++i) 39 { 40 for (int j = 0; j < size2; ++j) 41 { 42 if (longest < table[i][j]) 43 { 44 longest = table[i][j]; 45 start1 = i-longest+1; 46 start2 = j-longest+1; 47 } 48 } 49 } 50 #ifdef IDER_DEBUG 51 cout<< "(first, second, comparisions) = (" 52 << start1 << ", " << start2 << ", " << comparisons 53 << ")" << endl; 54 #endif 55 56 return longest; 57 }
算法開辟了一個矩陣內存來存儲值來保留計算值,從而避免了重復計算,於是運算的時間復雜度也就降至了O(n2)
。
動態規划法優化 – 能省一點是一點
仔細回顧之前的代碼,其實可以做一些合並讓代碼變得更加簡潔,比如最后一個求最長的嵌套for循環其實可以合並到之前計算整個表的for
循環之中,每計算完L[i,j]
就檢查它是的值是不是更長。當合並代碼之后,就會發現內部循環的過程重其實只用到了整個表的相鄰兩行而已,對於其它已經計算好的行之后就再也不會用到,而未計算的行曽之前也不會用到,因此考慮只用兩行來存儲計算值可能就足夠。
於是新的經過再次優化的算法就有了:
int longestCommonSubstring_n2_2n(const string& str1, const string& str2) { size_t size1 = str1.size(); size_t size2 = str2.size(); if (size1 == 0 || size2 == 0) return 0; vector<vector<int> > table(2, vector<int>(size2, 0)); // the start position of substring in original string int start1 = -1; int start2 = -1; // the longest length of common substring int longest = 0; // record how many comparisons the solution did; // it can be used to know which algorithm is better int comparisons = 0; for (int j = 0; j < size2; ++j) { ++comparisons; if (str1[0] == str2[j]) { table[0][j] = 1; if (longest == 0) { longest = 1; start1 = 0; start2 = j; } } } for (int i = 1; i < size1; ++i) { ++comparisons; // with odd/even to swith working row int cur = ((i&1) == 1); //index for current working row int pre = ((i&1) == 0); //index for previous working row table[cur][0] = 0; if (str1[i] == str2[0]) { table[cur][0] = 1; if (longest == 0) { longest = 1; start1 = i; start2 = 0; } } for (int j = 1; j < size2; ++j) { ++comparisons; if (str1[i] == str2[j]) { table[cur][j] = table[pre][j-1]+1; if (longest < table[cur][j]) { longest = table[cur][j]; start1 = i-longest+1; start2 = j-longest+1; } } else { table[cur][j] = 0; } } } #ifdef IDER_DEBUG cout<< "(first, second, comparisions) = (" << start1 << ", " << start2 << ", " << comparisons << ")" << endl; #endif return longest; }
跟之前的動態規划算法代碼相比,兩種解法並沒有實質的區別,完全相同的嵌套for
循環,只是將檢查最長的代碼也並入其中,然后table
中所擁有的行也只剩下2
個。
此解法的一些技巧在於如何交換兩個行數組作為工作數組。可以交換數組中的每個元素,異或交換一對指針。上邊代碼中所用的方法類似於后者,根據奇偶性來決定那行數組可以被覆蓋,哪行數組有需要的緩存數據。不管怎么說,該算法都讓空間復雜度從O(n2)
減少到了O(n)
,相當有效。
動態規划法再優化 – 能用一點就只用一點
最長公共子串問題的解法優化到之前的模樣,基本是差不多了,Wikipedia上對於這個問題給出的解法也就到上述而已。但思考角度不同,還是有意外的驚喜的。不過要保持算法的時間復雜度不增加,算法的基本思路方針還是不能變的。
下圖是上述動態規划的計算過程的示例:
在填充這張表的過程中,算法是從上往下一行一行計算,然后每行是從左往右。對於每一格,要知道它左上格是什么值,這就導致需要保留一整行的數據信息。但如果只針對一格看,它需要知道的只是左上格,而它的左上格又只要知道左上格的左上格就足夠了,於是就是一個對角線的路徑。
而如若按對角線為行,一行行計算的話,其實就只需要緩存下一個數據就可以將對角線上的格子填充完畢。從字符串上講,就是偏移一個字符串的頭,然后跟另一個字符串比較看在如此固定的位置下能找到最長的公共子串是多長。
解釋可能有點不清,程序員可能還是從代碼更能看懂算法的意思:
1 int longestCommonSubstring_n2_1(const string& str1, const string& str2) 2 { 3 size_t size1 = str1.size(); 4 size_t size2 = str2.size(); 5 if (size1 == 0 || size2 == 0) return 0; 6 7 // the start position of substring in original string 8 int start1 = -1; 9 int start2 = -1; 10 // the longest length of common substring 11 int longest = 0; 12 13 // record how many comparisons the solution did; 14 // it can be used to know which algorithm is better 15 int comparisons = 0; 16 17 for (int i = 0; i < size1; ++i) 18 { 19 int m = i; 20 int n = 0; 21 int length = 0; 22 while(m < size1 && n < size2) 23 { 24 ++comparisons; 25 if (str1[m] != str2[n]) 26 { 27 length = 0; 28 } 29 else 30 { 31 ++length; 32 if (longest < length) 33 { 34 longest = length; 35 start1 = m-longest+1; 36 start2 = n-longest+1; 37 } 38 } 39 40 ++m; 41 ++n; 42 } 43 } 44 45 // shift string2 to find the longest common substring 46 for (int j = 1; j < size2; ++j) 47 { 48 int m = 0; 49 int n = j; 50 int length = 0; 51 while(m < size1 && n < size2) 52 { 53 ++comparisons; 54 if (str1[m] != str2[n]) 55 { 56 length = 0; 57 } 58 else 59 { 60 ++length; 61 if (longest < length) 62 { 63 longest = length; 64 start1 = m-longest+1; 65 start2 = n-longest+1; 66 } 67 } 68 69 ++m; 70 ++n; 71 } 72 } 73 74 #ifdef IDER_DEBUG 75 cout<< "(first, second, comparisions) = (" 76 << start1 << ", " << start2 << ", " << comparisons 77 << ")" << endl; 78 #endif 79 80 return longest; 81 }
算法中兩個for
循環都嵌套着一個while
循環,但實際時間復雜度是跟原來一致依然是O(n2)
而不是翻倍(當然翻倍了O
還是一樣的),因為每個for
其實都只遍歷原表的一半區域而已。
看看這兩個for
實在是不歡喜,循環內的代碼除了頭兩行對m和n的初始化值不同以外,其它代碼全都一模一樣。對於這種冗余的代碼是程序員極為不滿的,所以我們應該合並它們,一種方法就是把代碼封裝到方法中,在兩個for
循環里調用方法即可。不過我用來一些非常規的技巧和C++
的引用類型特性來合並兩個for
循環:
1 int longestCommonSubstring_n2_1(const string& str1, const string& str2) 2 { 3 size_t size1 = str1.size(); 4 size_t size2 = str2.size(); 5 if (size1 == 0 || size2 == 0) return 0; 6 7 // the start position of substring in original string 8 int start1 = -1; 9 int start2 = -1; 10 // the longest length of common substring 11 int longest = 0; 12 13 // record how many comparisons the solution did; 14 // it can be used to know which algorithm is better 15 int comparisons = 0; 16 17 int indices[] = {0, 0}; 18 int sizes[] = {size1, size2}; 19 20 // shift strings to find the longest common substring 21 for (int index = 0; index < 2; ++index) 22 { 23 indices[0] = 0; 24 indices[1] = 0; 25 26 // i is reference to the value in array 27 int &i = indices[index]; 28 int size = sizes[index]; 29 30 // this is tricky to skip comparing strings both start with 0 for second loop 31 i = index; 32 for (; i < size; ++i) 33 { 34 int m = indices[0]; 35 int n = indices[1]; 36 int length = 0; 37 38 // with following check to reduce some more comparisons 39 if (size1-m <= longest || size2-n <= longest) 40 break; 41 42 while(m < size1 && n < size2) 43 { 44 ++comparisons; 45 if (str1[m] != str2[n]) 46 { 47 length = 0; 48 } 49 else 50 { 51 ++length; 52 if (longest < length) 53 { 54 longest = length; 55 start1 = m-longest+1; 56 start2 = n-longest+1; 57 } 58 } 59 60 ++m; 61 ++n; 62 } 63 } 64 } 65 66 #ifdef IDER_DEBUG 67 cout<< "(first, second, comparisions) = (" 68 << start1 << ", " << start2 << ", " << comparisons 69 << ")" << endl; 70 #endif 71 72 return longest; 73 }
在上述代表中,有一個語句if (size1-m <= longest || size2-n <= longest)
會break
循環,其中的條件其實是檢查剩下子串長度是否小於或等於跟最長公共子串的長度,如果是那么剩下的可定不足以構建出更長的子串。這對於當一個字符串是另一個字符串的子串是可以減少很多的比較,這也是之前在提到:在幸運的時候運算時間復雜度可以有O(n)
。對於這樣的微小優化也可以在之前的幾個算法中使用。
測試案例及結果
最后貼上一些測試隨機生成的測試案例已經調用每個算法所得到的結果和運算需要的“量”。打包的所有代碼都可以在這里下載,歡迎測試並給出一些建議和優化方案。
YXXXXXY (7) YXYXXYYYYXXYYYYXYYXXYYXXYXYYYYYYXYXYYXYXYYYXXXXXX (49) (first, second, comparisions) = (0, 42, 537) 6 (first, second, comparisions) = (0, 42, 343) 6 (first, second, comparisions) = (0, 42, 343) 6 (first, second, comparisions) = (0, 42, 316) 6 XXYXYYYXXYXYYYYXYXYYYXYYYYYXYX (30) XYY (3) (first, second, comparisions) = (3, 0, 127) 3 (first, second, comparisions) = (3, 0, 90) 3 (first, second, comparisions) = (3, 0, 90) 3 (first, second, comparisions) = (3, 0, 12) 3 XXYXXYYYXYXYYXXYYYYYXXYXXXYXXYXYXXXXYXXYYYXYYXYXYXXXYYXXXYYXYYXYXYXYXXXXXXXXXYXXXX (82) YYYYYXYYYXYYXXXYYYXXYYXXYXXXYYYYYYYYXXYXYYYYXYXYYXYX (52) (first, second, comparisions) = (5, 41, 7911) 9 (first, second, comparisions) = (5, 41, 4264) 9 (first, second, comparisions) = (5, 41, 4264) 9 (first, second, comparisions) = (18, 20, 4183) 9 X (1) XXYYYYYYXYYXYXXXYYXXXYYYYYYXYYYXYYXXYYYYXXXYXXXXXXXYXYXYXYYYYYYYYXYXYXXX (72) (first, second, comparisions) = (0, 0, 72) 1 (first, second, comparisions) = (0, 0, 72) 1 (first, second, comparisions) = (0, 0, 72) 1 (first, second, comparisions) = (0, 0, 1) 1 (0) XYXXYYYXXXYYXXYYYYXXYYYXYYYXXXXXYYXXYXYXXXYY (44) 0 0 0 0
從見過可以看出所有方法的計算應該都是正確並且一致的,也不難看出對於經典的動態規划的兩個方法需要的比較時間正式兩個字符串長度的乘積。另一個問題如果不旦旦只有長度而要找出最長子串來,算法給出的答案就有可能不同,因為兩個字符串中可能存在多個不同的最長公共子串。