要說馬拉車算法,必須說說這道題,查找最長回文子串,馬拉車算法是其中一種解法,狠人話不多,直接往下看:
題目描述
給你一個字符串 s,找到 s 中最長的回文子串。
例子
示例 1:
輸入:s = "babad"
輸出:"bab"
解釋:"aba" 同樣是符合題意的答案。
示例 2:
輸入:s = "cbbd"
輸出:"bb"
示例 3:
輸入:s = "a"
輸出:"a"
示例 4:
輸入:s = "ac"
輸出:"a"
馬拉車算法
這是一個奇妙的算法,是1957年一個叫Manacher的人發明的,所以叫Manacher‘s Algorithm
,主要是用來查找一個字符串的最長回文子串,這個算法最大的貢獻是將時間復雜度提升到線性,前面我們說的動態規划的時間復雜度為 O(n2)。
前面說的中心拓展法,中心可能是字符也可能是字符的間隙,這樣如果有 n 個字符,就有 n+n+1
個中心:
為了解決上面說的中心可能是間隙的問題,我們往每個字符間隙插入”#
“,為了讓拓展結束邊界更加清晰,左邊的邊界插入”^
“,右邊的邊界插入 "$
":
S
表示插入"#
","^
","$
"等符號之后的字符串,我們用一個數組P
表示S
中每一個字符能夠往兩邊拓展的長度:
比如 P[8] = 3
,表示可以往兩邊分別拓展3個字符,也就是回文串的長度為 3,去掉 #
之后的字符串為aca
:
P[11]= 4
,表示可以往兩邊分別拓展4個字符,也就是回文串的長度為 4,去掉 #
之后的字符串為caac
:
假設我們已經得知數組P,那么我們怎么得到回文串?
用 P
的下標 index
,減去 P[i]
(也就是回文串的長度),可以得到回文串開頭字符在拓展后的字符串 S
中的下標,除以2,就可以得到在原字符串中的下標了。
那么現在的問題是:如何求解數組P[i]
其實,馬拉車算法的關鍵是:它充分利用了回文串的對稱性,用已有的結果來幫助計算后續的結果。
假設已經計算出字符索引位置 P 的最大回文串,左邊界是PL,右邊界是PR:
那么當我們求因為一個位置 i
的時候,i
小於等於 PR,其實我們可以找到 i
關於 P
的對稱點 j
:
那么假設 j 為中心的最長回文串長度為 len,並且在 PL 到 P 的范圍內,則 i 為中心的最長回文串也是如此:
以 i 為中心的最長回文子串長度等於以 j 為中心的最長回文子串的長度
但是這里有兩個問題:
- 前一個回文字符串P,是哪一個?
- 有哪些特殊情況?特殊情況怎么處理?
(1) 前一個回文字符串 P
,是指的前面計算出來的右邊界最靠右的回文串,因為這樣它最可能覆蓋我們現在要計算的 i 為中心的索引,可以盡量重用之前的結果的對稱性。
也正因為如此,我們在計算的時候,需要不斷保存更新 P 的中心和右邊界,用於每一次計算。
(2) 特殊情況其實就是當前 i 的最長回文字符串計算不能再利用 P 點的對稱,例如:
- 以
i
的回文串的右邊界超出了P
的右邊界 PR:
這種情況的解決方案是:超過的部分,需要按照中心拓展法來一一拓展。
i
不在 以P
為中心的回文串里面,只能按照中心拓展法來處理。
具體的代碼實現如下:
// 構造字符串
public String preProcess(String s) {
int n = s.length();
if (n == 0) {
return "^$";
}
String ret = "^";
for (int i = 0; i < n; i++)
ret = ret + "#" + s.charAt(i);
ret = ret + "#$";
return ret;
}
// 馬拉車算法
public String longestPalindrome(String str) {
String S = preProcess(str);
int n = S.length();
// 保存回文串的長度
int[] P = new int[n];
// 保存邊界最右的回文中心以及右邊界
int center = 0, right = 0;
// 從第 1 個字符開始
for (int i = 1; i < n - 1; i++) {
// 找出i關於前面中心的對稱
int mirror = 2 * center - i;
if (right > i) {
// i 在右邊界的范圍內,看看i的對稱點的回文串長度,以及i到右邊界的長度,取兩個較小的那個
// 不能溢出之前的邊界,否則就得中心拓展
P[i] = Math.min(right - i, P[mirror]);
} else {
// 超過范圍了,中心拓展
P[i] = 0;
}
// 中心拓展
while (S.charAt(i + 1 + P[i]) == S.charAt(i - 1 - P[i])) {
P[i]++;
}
// 看看新的索引是不是比之前保存的最右邊界的回文串還要靠右
if (i + P[i] > right) {
// 更新中心
center = i;
// 更新右邊界
right = i + P[i];
}
}
// 通過回文長度數組找出最長的回文串
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 str.substring(start, start + maxLen);
}
至於算法的復雜度,空間復雜度借助了大小為n的數組,為O(n),而時間復雜度,看似是用了兩層循環,實則不是 O(n2),而是 O(n)
,因為絕大多數索引位置會直接利用前面的結果以及對稱性獲得結果,常數次就可以得到結果,而那些需要中心拓展的,是因為超出前面結果覆蓋的范圍,才需要拓展,拓展所得的結果,有利於下一個索引位置的計算,因此拓展實際上較少。
【作者簡介】:
秦懷,公眾號【秦懷雜貨店】作者,技術之路不在一時,山高水長,縱使緩慢,馳而不息。個人寫作方向:Java源碼解析
,JDBC
,Mybatis
,Spring
,redis
,分布式
,劍指Offer
,LeetCode
等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花里胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查找資料。遺漏或者錯誤之處,還望指正。