1. 程式人生 > >Longest Palindromic Substring (最大回文子字串)

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