leetCode 系列 (5) Longest Palindromic Substring
leetcode.windliang.cc/ 第一時間釋出
題目描述(中等難度)
給定一個字串,輸出最長的迴文子串。迴文串指的是正的讀和反的讀是一樣的字串,例如 "aba","ccbbcc"。
解法一 暴力破解
暴力求解,列舉所有的子串,判斷是否為迴文串,儲存最長的迴文串。
public boolean isPalindromic(String s) {
int len = s.length();
for (int i = 0; i < len / 2; i++) {
if (s.charAt(i) != s.charAt(len - i - 1 )) {
return false;
}
}
return true;
}
// 暴力解法
public String longestPalindrome(String s) {
String ans = "";
int max = 0;
int len = s.length();
for (int i = 0; i < len; i++)
for (int j = i + 1; j <= len; j++) {
String test = s.substring(i, j);
if (isPalindromic(test) && test.length() > max) {
ans = s.substring(i, j);
max = Math.max(max, ans.length());
}
}
return ans;
}
複製程式碼
時間複雜度:兩層 for 迴圈 O(n²),for 迴圈裡邊判斷是否為迴文,O(n),所以時間複雜度為 O(n³)。
空間複雜度:O(1),常數個變數。
解法二 最長公共子串
根據迴文串的定義,正著和反著讀一樣,那我們是不是把原來的字串倒置了,然後找最長的公共子串就可以了。例如,S = " caba",S' = " abac",最長公共子串是 "aba",所以原字串的最長迴文串就是 "aba"。
關於求最長公共子串(不是公共子序列),有很多方法,這裡用動態規劃的方法,可以先閱讀下邊的連結。
整體思想就是,申請一個二維的陣列初始化為 0,然後判斷對應的字元是否相等,相等的話
arr [ i ][ j ] = arr [ i - 1 ][ j - 1] + 1 。
當 i = 0 或者 j = 0 的時候單獨分析,字元相等的話 arr [ i ][ j ] 就賦為 1 。
arr [ i ][ j ] 儲存的就是公共子串的長度。
public String longestPalindrome(String s) {
if (s.equals(""))
return "";
String origin = s;
String reverse = new StringBuffer(s).reverse().toString(); //字串倒置
int length = s.length();
int[][] arr = new int[length][length];
int maxLen = 0;
int maxEnd = 0;
for (int i = 0; i < length; i++)
for (int j = 0; j < length; j++) {
if (origin.charAt(i) == reverse.charAt(j)) {
if (i == 0 || j == 0) {
arr[i][j] = 1;
} else {
arr[i][j] = arr[i - 1][j - 1] + 1;
}
}
if (arr[i][j] > maxLen) {
maxLen = arr[i][j];
maxEnd = i; //以 i 位置結尾的字元
}
}
}
return s.substring(maxEnd - maxLen + 1, maxEnd + 1);
}
複製程式碼
再看一個例子,S = "abc435cba",S’ = "abc534cba" ,最長公共子串是 "abc" 和 "cba" ,但很明顯這兩個字串都不是迴文串。
所以我們求出最長公共子串後,並不一定是迴文串,我們還需要判斷該字串倒置前的下標和當前的字串下標是不是匹配。
比如 S = " caba ",S' = " abac " ,S’ 中 aba 的下標是 0 1 2 ,倒置前是 3 2 1,和 S 中 aba 的下標符合,所以 aba 就是我們需要找的。當然我們不需要每個字元都判斷,我們只需要判斷末尾字元就可以。
首先 i ,j 始終指向子串的末尾字元。所以 j 指向的紅色的 a 倒置前的下標是 beforeRev = length - 1 - j = 4 - 1 - 2 = 1,對應的是字串首位的下標,我們還需要加上字串的長度才是末尾字元的下標,也就是 beforeRev + arr[ i ] [ j ] - 1 = 1 + 3 - 1 = 3,因為 arr[ i ] [ j ] 儲存的就是當前子串的長度,也就是圖中的數字 3 。此時再和它與 i 比較,如果相等,則說明它是我們要找的迴文串。
之前的 S = "abc435cba",S' = "abc534cba" ,可以看一下圖示,為什麼不符合。
當前 j 指向的 c ,倒置前的下標是 beforeRev = length - 1 - j = 9 - 1 - 2 = 6,對應的末尾下標是 beforeRev + arr[ i ] [ j ] - 1 = 6 + 3 - 1 = 8 ,而此時 i = 2 ,所以當前的子串不是迴文串。
程式碼的話,在上邊的基礎上,儲存 maxLen 前判斷一下下標匹不匹配就可以了。
public String longestPalindrome(String s) {
if (s.equals(""))
return "";
String origin = s;
String reverse = new StringBuffer(s).reverse().toString();
int length = s.length();
int[][] arr = new int[length][length];
int maxLen = 0;
int maxEnd = 0;
for (int i = 0; i < length; i++)
for (int j = 0; j < length; j++) {
if (origin.charAt(i) == reverse.charAt(j)) {
if (i == 0 || j == 0) {
arr[i][j] = 1;
} else {
arr[i][j] = arr[i - 1][j - 1] + 1;
}
}
/**********修改的地方*******************/
if (arr[i][j] > maxLen) {
int beforeRev = length - 1 - j;
if (beforeRev + arr[i][j] - 1 == i) { //判斷下標是否對應
maxLen = arr[i][j];
maxEnd = i;
}
/*************************************/
}
}
return s.substring(maxEnd - maxLen + 1, maxEnd + 1);
}
複製程式碼
時間複雜度:兩層迴圈,O(n²)。
空間複雜度:一個二維陣列,O(n²)。
空間複雜度其實可以再優化一下。
我們分析一下迴圈,i = 0 ,j = 0,1,2 ... 8 更新一列,然後 i = 1 ,再更新一列,而更新的時候我們其實只需要上一列的資訊,更新第 3 列的時候,第 1 列的資訊是沒有用的。所以我們只需要一個一維陣列就可以了。但是更新 arr [ i ] 的時候我們需要 arr [ i - 1 ] 的資訊,假設 a [ 3 ] = a [ 2 ] + 1,更新 a [ 4 ] 的時候, 我們需要 a [ 3 ] 的資訊,但是 a [ 3 ] 在之前已經被更新了,所以 j 不能從 0 到 8 ,應該倒過來,a [ 8 ] = a [ 7 ] + 1,a [ 7 ] = a [ 6 ] + 1 , 這樣更新 a [ 8 ] 的時候用 a [ 7 ] ,用完後才去更新 a [ 7 ],保證了不會出錯。
public String longestPalindrome(String s) {
if (s.equals(""))
return "";
String origin = s;
String reverse = new StringBuffer(s).reverse().toString();
int length = s.length();
int[] arr = new int[length];
int maxLen = 0;
int maxEnd = 0;
for (int i = 0; i < length; i++)
/**************修改的地方***************************/
for (int j = length - 1; j >= 0; j--) {
/**************************************************/
if (origin.charAt(i) == reverse.charAt(j)) {
if (i == 0 || j == 0) {
arr[j] = 1;
} else {
arr[j] = arr[j - 1] + 1;
}
/**************修改的地方***************************/
//之前二維陣列,每次用的是不同的列,所以不用置 0 。
} else {
arr[j] = 0;
}
/**************************************************/
if (arr[j] > maxLen) {
int beforeRev = length - 1 - j;
if (beforeRev + arr[j] - 1 == i) {
maxLen = arr[j];
maxEnd = i;
}
}
}
return s.substring(maxEnd - maxLen + 1, maxEnd + 1);
}
複製程式碼
時間複雜度:O(n²)。
空間複雜度:降為 O(n)。
解法三 暴力破解優化
解法一的暴力解法時間複雜度太高,在 leetCode 上並不能 AC 。我們可以考慮,去掉一些暴力解法中重複的判斷。我們可以基於下邊的發現,進行改進。
首先定義 P(i,j)。
接下來
所以如果我們想知道 P(i,j)的情況,不需要呼叫判斷迴文串的函數了,只需要知道 P(i + 1,j - 1)的情況就可以了,這樣時間複雜度就少了 O(n)。因此我們可以用動態規劃的方法,空間換時間,把已經求出的 P(i,j)儲存起來。
如果 $$S[i+1,j-1]$$ 是迴文串,那麼只要 S [ i ] == S [ j ] ,就可以確定 S [ i , j ] 也是迴文串了。
求 長度為 1 和長度為 2 的 P ( i , j ) 時不能用上邊的公式,因為我們代入公式後會遇到 $$P[i][j]$$ 中 i > j 的情況,比如求 $$P[1][2]$$ 的話,我們需要知道 $$P[1+1][2-1]=P[2][1]$$ ,而 $$P[2][1]$$ 代表著 $$S[2,1]$$ 是不是迴文串,顯然是不對的,所以我們需要單獨判斷。
所以我們先初始化長度是 1 的迴文串的 P [ i , j ],這樣利用上邊提出的公式 $$P(i,j)=(P(i+1,j-1)&&S[i]==S[j])$$,然後兩邊向外各擴充一個字元,長度為 3 的,為 5 的,所有奇數長度的就都求出來了。
同理,初始化長度是 2 的迴文串 P [ i , i + 1 ],利用公式,長度為 4 的,6 的所有偶數長度的就都求出來了。
public String longestPalindrome(String s) {
int length = s.length();
boolean[][] P = new boolean[length][length];
int maxLen = 0;
String maxPal = "";
for (int len = 1; len <= length; len++) //遍歷所有的長度
for (int start = 0; start < length; start++) {
int end = start + len - 1;
if (end >= length) //下標已經越界,結束本次迴圈
break;
P[start][end] = (len == 1 || len == 2 || P[start + 1][end - 1]) && s.charAt(start) == s.charAt(end); //長度為 1 和 2 的單獨判斷下
if (P[start][end] && len > maxLen) {
maxPal = s.substring(start, end + 1);
}
}
return maxPal;
}
複製程式碼
時間複雜度:兩層迴圈,O(n²)。
空間複雜度:用二維陣列 P 儲存每個子串的情況,O(n²)。
我們分析下每次迴圈用到的 P(i,j),看一看能不能向解法二一樣優化一下空間複雜度。
當我們求長度為 6 和 5 的子串的情況時,其實只用到了 4 , 3 長度的情況,而長度為 1 和 2 的子串情況其實已經不需要了。但是由於我們並不是用 P 陣列的下標進行的迴圈,暫時沒有想到優化的方法。
之後看到了另一種動態規劃的思路
公式還是這個不變
首先定義 P(i,j)。
接下來
遞推公式中我們可以看到,我們首先知道了 i +1 才會知道 i ,所以我們只需要倒著遍歷就行了。
public String longestPalindrome(String s) {
int n = s.length();
String res = "";
boolean[][] dp = new boolean[n][n];
for (int i = n - 1; i >= 0; i--) {
for (int j = i; j < n; j++) {
dp[i][j] = s.charAt(i) == s.charAt(j) && (j - i < 2 || dp[i + 1][j - 1]); //j - i 代表長度減去 1
if (dp[i][j] && j - i + 1 > res.length()) {
res = s.substring(i, j + 1);
}
}
}
return res;
}
複製程式碼
時間複雜度和空間複雜和之前都沒有變化,我們來看看可不可以優化空間複雜度。
當求第 i 行的時候我們只需要第 i + 1 行的資訊,並且 j 的話需要 j - 1 的資訊,所以和之前一樣 j 也需要倒敘。
public String longestPalindrome7(String s) {
int n = s.length();
String res = "";
boolean[] P = new boolean[n];
for (int i = n - 1; i >= 0; i--) {
for (int j = n - 1; j >= i; j--) {
P[j] = s.charAt(i) == s.charAt(j) && (j - i < 3 || P[j - 1]);
if (P[j] && j - i + 1 > res.length()) {
res = s.substring(i, j + 1);
}
}
}
return res;
}
複製程式碼
時間複雜度:不變,O(n²)。
空間複雜度:降為 O(n ) 。
解法四 擴充套件中心
我們知道迴文串一定是對稱的,所以我們可以每次迴圈選擇一箇中心,進行左右擴充套件,判斷左右字元是否相等即可。
由於存在奇數的字串和偶數的字串,所以我們需要從一個字元開始擴充套件,或者從兩個字元之間開始擴充套件,所以總共有 n + n - 1 箇中心。
public String longestPalindrome(String s) {
if (s == null || s.length() < 1) return "";
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
int len1 = expandAroundCenter(s, i, i);
int len2 = expandAroundCenter(s, i, i + 1);
int len = Math.max(len1, len2);
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
private int expandAroundCenter(String s, int left, int right) {
int L = left, R = right;
while (L >= 0 && R < s.length() && s.charAt(L) == s.charAt(R)) {
L--;
R++;
}
return R - L - 1;
}
複製程式碼
時間複雜度:O(n²)。
空間複雜度:O(1)。
解法五 Manacher's Algorithm 馬拉車演算法。
馬拉車演算法 Manacher‘s Algorithm 是用來查詢一個字串的最長迴文子串的線性方法,由一個叫Manacher的人在1975年發明的,這個方法的最大貢獻是在於將時間複雜度提升到了線性。
主要參考了下邊連結進行講解。
ju.outofmemory.cn/entry/13000…
articles.leetcode.com/longest-pal…
首先我們解決下奇數和偶數的問題,在每個字元間插入"#",並且為了使得擴充套件的過程中,到邊界後自動結束,在兩端分別插入 "^" 和 "$",兩個不可能在字串中出現的字元,這樣向解法四那樣中心擴充套件的時候,判斷兩端字元是否相等的時候,如果到了邊界就一定會不相等,從而出了迴圈。經過處理,字串的長度永遠都是奇數了。
首先我們用一個數組 P 儲存從中心擴充套件的個數,巧合的它也是去掉 "#" 的字串的總長度,可以看下邊的圖。
用 P 的下標 i 減去 P[i],再除以 2 ,就是原字串的開頭下標了。
例如我們找到 P[i] 的最大值為 5 ,也就是迴文串的最大長度是 5 ,對應的下標是 6 ,所以原字串的開頭下標是 (6 - 5 )/ 2 = 0 。所以我們只需要返回原字串的第 0 到 第 (5 - 1)位就可以了。
接下來是演算法的關鍵了,它充分利用了迴文串的對稱性。
我們用 C 表示迴文串的中心,用 R 表示迴文串的右邊半徑。所以 R = C + P[i] 。C 和 R 所對應的迴文串是當前迴圈中 R 最靠右的迴文串。
用 i_mirror 表示當前擴充套件的第 i 個字元關於 C 對應的下標。
我們現在要求 P [ i ] 如果是解法四,那就向兩邊擴充套件就行了。但是我們其實可以利用迴文串 C 的對稱性。i 關於 C 的對稱點是 i_mirror ,P [ mirror ] = 3,所以 P [ i ] 也等於 3 。
有三種情況將會造成直接賦值為 P [ mirror ] 是不正確的。
超出了 R
當我們要求 P[i] 的時候,P [ mirror ] = 7,而此時 P [ i ] 並不等於 7 ,為什麼呢,因為我們從 i 開始往後數 7 個,等於 22 ,已經超過了最右的 R ,此時不能利用對稱性了,但我們一定可以擴充套件到 R 的,所以 P [i] 至少等於 R - i = 20 - 15 = 5,會不會更大呢,我們只需要比較 T[R+1] 和 T[R+1]關於 i 的對稱點就行了,像解法四一樣一個個擴充套件。
P [ mirror ] 遇到了左邊界
此時 P [ i ] 賦值成 1 是不正確的,出現這種情況的原因是 P [ i_mirror ] 在擴充套件的時候首先是 "#" == "#" ,之後遇到了 "^"和另一個字元比較,也就是到了邊界,才終止迴圈的。而 P [ i ] 並沒有遇到邊界,所以我們可以接著擴充套件,就像之前一樣。
i 等於了 R
此時我們先把 P [ i ] 賦值為 0 ,然後一步一步擴充套件就行了。
就這樣一步一步的求出每個 P [ i ],當求出的 P [ i ] 的右邊界大於當前的 R 時,我們就需要更新 C 和 R 為當前的迴文串了。因為我們必須保證 i 在 R 裡面,所以一旦有更右邊的 R 就要更新 R。
此時的 P [ i ] 求出來將會是 3 ,P [ i ] 對應的右邊界將是 10 + 3 = 13,所以大於當前的 R ,我們需要把 C 更新成 i 的值,也就是 10 ,R 更新成 13。繼續下邊的迴圈。
public String preProcess(String s) {
int n = s.length();
if (n == 0) {
return "^$";
}
String ret = "^";
for (int i = 0; i < n; i++)
ret += "#" + s.charAt(i);
ret += "#$";
return ret;
}
// 馬拉車演算法
public String longestPalindrome2(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;
if (R > i) {
P[i] = Math.min(R - i, P[i_mirror]);// 防止超出 R
} else {
P[i] = 0;// 等於 R 的情況
}
// 碰到之前講的三種情況時候,需要繼續擴充套件
while (T.charAt(i + 1 + P[i]) == T.charAt(i - 1 - P[i])) {
P[i]++;
}
// 判斷是否需要更新 R
if (i + P[i] > R) {
C = i;
R = i + P[i];
}
}
// 找出 P 的最大值
int maxLen = 0;
int centerIndex = 0;
for (int i = 1; i < n - 1; i++) {
if (P[i] > maxLen) {
maxLen = P[i];
centerIndex = i;
}
}
int start = (centerIndex - maxLen) / 2; //最開始講的
return s.substring(start, start + maxLen);
}
複製程式碼
時間複雜度:for 迴圈裡邊套了一層 while 迴圈,難道不是 O ( n² ),不!其實是 O(n)。我們想象一下整個過程,首先外層有一個 for 迴圈,所以每個字元會遍歷一次,而當我們擴充套件的時候,每次都是從 R + 1 開始擴充套件,之後又會更新 R 。所以一些字元會遍歷兩次,但此時這些字元變到 R 的左邊,所以不會遍歷第三次了,因為我們每次從 R 的右邊開始擴充套件。綜上,每個字元其實最多遍歷 2 次,所以依舊是線性的,當然如果字串成為 len ,這裡的 n 其實是 2 * len + 3 。所以時間複雜度是 O(n)。
空間複雜度:O(n)。
總結
時間複雜度從三次方降到了一次,美妙!這裡兩次用到了動態規劃去求解,初步認識了動態規劃,就是將之前求的值儲存起來,方便後邊的計算,使得一些多餘的計算消失了。並且在動態規劃中,通過觀察陣列的利用情況,從而降低了空間複雜度。而 Manacher 演算法對迴文串對稱性的充分利用,不得不讓人歎服,自己加油啦!