Longest Palindromic Substring (最大回文子字串)
Longest Palindromic Substring (LPS) 問題是一個DP中的經典問題。處理這個問題的第一個關鍵點在於要區分substring和subsequence(我就犯了這樣的錯誤)。substring指的是連續的子字串,比如abc是abcde的substring, 但abe就不是它的substring,但可以算一個subsequence.
下面介紹這道題的若干種解法。
1)brute force方法。遍歷每一對index,取做起點和終點,然後判斷這個substring是不是迴文。取點的過程花費O(n^2),判斷的過程花費O(n),所以一共O(n^3)。
2)經典DP方法。時間O(n^2);空間O(n^2)。
string longestPalindromeDP(string s) { int n = s.length(); int longestBegin = 0; int maxLen = 1; bool table[1000][1000] = {false}; for (int i = 0; i < n; i++) { table[i][i] = true; } for (int i = 0; i < n-1; i++) { if (s[i] == s[i+1]) { table[i][i+1] = true; longestBegin = i; maxLen = 2; } } for (int len = 3; len <= n; len++) { for (int i = 0; i < n-len+1; i++) { int j = i+len-1; if (s[i] == s[j] && table[i+1][j-1]) { table[i][j] = true; longestBegin = i; maxLen = len; } } } return s.substr(longestBegin, maxLen); }
關鍵步的思路是:對於一個string,如果它的首尾兩個char相同,而除去首位以後剩下的是個迴文,則這個string本身也是迴文。這個方法是最經典最常見的DP解法,但卻不是最優解法。在介紹這個解法的升級版之前,先看下面這段程式碼。它是這個方法的另一種寫法:
string longestPalindromeDP(string s) { int len = s.length(); if (len == 0) return string(""); else if (len == 1) return s; else if (len == 2) return (s[0] == s[1] ? s : s.substr(0, 1)); bool table[1000][1000] = {false}; string result = s.substr(0, 1); for (int i = 0; i < len - 1; i ++) { table[i][i] = true; if (s[i] ==s[i+1]) { table[i][i + 1] = true; result = s.substr(i, 2); } } table[len - 1][len - 1] = true; for (int l = 3; l <= len; l ++) { for (int i = 0; i < len - l + 1; i ++) { int j = i + l - 1; if (s[i] == s[j] && table[i+1][j-1]) { table[i][j] = true; result = s.substr(i, l); } } } return result; }
可以看到,這段程式碼跟上面的那一段非常非常相似。唯一細小的差別是,上面的程式碼每一次記錄起始位置和substring的長度,到返回之前再取substring;而這段程式碼每次直接取substring了。這樣看來,上面的程式碼稍稍更好一點(時間復空間雜度都相同)。但就是這麼一點點的區別,在leetcode上的就變成了非常顯著的區別:上方程式碼可以通過大資料測試,但下方的不行,說超時了。事實上,我用time命令和較大的輸入跑了兩段程式碼,發現他們的差別是可以忽略不計的。說了這麼多廢話貌似只表達了leetcode伺服器上的計時非常精準。。。
3)節省空間的DP演算法。我們的方法2)需要使用O(n^2)的空間。而這裡介紹的這種方法同樣使用O(n^2)的時間,卻可以使用O(1)的extra space。這個演算法的思路是,每一個迴文的string都是像中間對稱的,所以可以由中間“展開”所得。這裡的“中間”可以是一個字母(當length為奇數),也可以是兩個字母中間的位置(length為偶數)。所以一共有2N-1個“中間”,其中N是字元的個數。而展開需要O(N)的時間,所以一共是O(n^2)。程式碼如下:
string expandAroundCenter(string s, int c1, int c2) {
int l = c1, r = c2;
int n = s.length();
while (l >= 0 && r <= n-1 && s[l] == s[r]) {
l--;
r++;
}
return s.substr(l+1, r-l-1);
}
string longestPalindromeSimple(string s) {
int n = s.length();
if (n == 0) return "";
string longest = s.substr(0, 1); // a single char itself is a palindrome
for (int i = 0; i < n-1; i++) {
string p1 = expandAroundCenter(s, i, i);
if (p1.length() > longest.length())
longest = p1;
string p2 = expandAroundCenter(s, i, i+1);
if (p2.length() > longest.length())
longest = p2;
}
return longest;
}
4)一種非常容易錯的DP演算法。有些人會想:既然我們要找的是迴文,那麼如果我把這個string s調轉過來,變成s',然後再找s和s'的最長common substring,不就是我們需要的迴文了麼?這個思路犯了一個不易察覺的錯誤。比如:
acbc這個string調過來變成cbca。那麼我們找這兩個string的common subsequence可以找到cbc,正好是我們需要的最長迴文。但下面就有一個反例:
abxyba,調轉以後是abyxba那麼找到的common string是ab (或ba),但他們不是迴文。
從上面的反例我們可以看出,我們不但需要找到最長的common substring,找到以後還要檢查一下index看是否對應。所以說做這樣一個小改動之後,這個演算法還是可以用的。時間和空間複雜度都是O(n^2)。這道題也相應地變成了一個找兩個string最長common substring的問題。這也是一個DP經典問題。具體解法請自行google。。
5)終極演算法:Manacher’s Algorithm。時間複雜度O(n)。
首先拿到一個字串S之後,我們在每個字元之間插入一個特殊字元#。比如:abaaba轉化成#a#b#a#a#b#a#。這樣做的好處是,不論原來的字元長度是奇數還是偶數,轉化以後都可以使用相同的方式來處理。針對處理後的字元(我們稱之為T),我們建立一個與其長度相同的int陣列p。p[i]的含義是以T[i]為中心的迴文字串可以向左右各延伸幾個字元。比如上面這個例子中,對應的p陣列是:
T = # a # b # a # a # b # a # P = 0 1 0 3 0 1 6 1 0 3 0 1 0我們發現,T中最長的迴文substring是以第六個(index從0算起)字元(是一個‘#’)為中心的,左右長度各為6的字元,因為p[6] = 6, 是p中最大的。一個更重要的性質是,以中心為對稱軸來觀察的話,會發現左右兩邊的數值是對稱的。如果這條性質始終滿足的話,我們豈不是可以少了一半的工作量麼。我們來看下面一個稍微複雜一點的例子:
S=babcbabcbaccba,對應地T=#b#a#b#c#b#a#b#c#b#a#c#c#b#a#。我們看下面這張圖:
假設我們現在來到了i=13的位置。我們可以先看13對應的對稱點是多少,發現對稱點是9,而p[9] = 1。顯而易見,p[13]的值也是1。事實上,以C(此處為11)為中心的左右三個點都是對稱的,即p[12] = p[10], p[13] = p[9], p[14] = p[8]。
假設說我們現在來到了i=15的位置:
它的對應點是7,而p[7] = 7。這是否意味著p[15]也等於7呢?不是。因為我們發現以15為中心的最長迴文是a#b#c#b#a,左右兩邊比7要短的多。這是怎麼回事呢?我們看下面這張圖:
我們看到,綠色實線所覆蓋的區域的T是相對C對稱的,L和R是左右邊界。而在7的位置,對應的數值是7,已經超過了左邊界。這種情況下,p的對稱原則就不再滿足了。我們目前只能知道p[15]的最大值是5(到達右邊界為止)。然後我們再繼續擴張就會發現,T[21] != T[1],所以p[15]只能是5了。
所以這個演算法的最關鍵步驟如下:
if P[ i' ] ≤ R – i,then P[ i ] ← P[ i' ]
else P[ i ] ≥ P[ i' ]. (Which we have to expand past the right edge (R) to find P[ i ]. 當我們發現擴張的時候超出了右邊界的話,那麼就是時候更新centre C和右邊界R了:C=i,而R是擴張的盡頭,即:R = i + p[i]。所以這個演算法的完整程式碼如下:
// Transform S into T.
// For example, S = "abba", T = "^#a#b#b#a#$".
// ^ and $ signs are sentinels appended to each end to avoid bounds checking
string preProcess(string s) {
int n = s.length();
if (n == 0) return "^$";
string ret = "^";
for (int i = 0; i < n; i++)
ret += "#" + s.substr(i, 1);
ret += "#$";
return ret;
}
string longestPalindrome(string s) {
string T = preProcess(s);
int n = T.length();
int *P = new int[n];
int C = 0, R = 0;
for (int i = 1; i < n-1; i++) {
int i_mirror = 2*C-i; // equals to i' = C - (i-C)
P[i] = (R > i) ? min(R-i, P[i_mirror]) : 0;
// Attempt to expand palindrome centered at i
while (T[i + 1 + P[i]] == T[i - 1 - P[i]])
P[i]++;
// If palindrome centered at i expand past R,
// adjust center based on expanded palindrome.
if (i + P[i] > R) {
C = i;
R = i + P[i];
}
}
// Find the maximum element in P.
int maxLen = 0;
int centerIndex = 0;
for (int i = 1; i < n-1; i++) {
if (P[i] > maxLen) {
maxLen = P[i];
centerIndex = i;
}
}
delete[] P;
return s.substr((centerIndex - 1 - maxLen)/2, maxLen);
}
注意這段程式碼的開頭處還有一個巧妙的地方在於,在T的首尾各插入了一個不同於'#'的特殊字元,這樣就不用進行boundary check了,很方便。
Ref:
http://leetcode.com/2011/11/longest-palindromic-substring-part-i.html
http://leetcode.com/2011/11/longest-palindromic-substring-part-ii.html