馬拉車演算法,其實並不難!!!
要說馬拉車演算法,必須說說這道題,查詢最長迴文子串,馬拉車演算法是其中一種解法,狠人話不多,直接往下看:
題目描述
給你一個字串 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
等,認真寫好每一篇文章,不喜歡標題黨,不喜歡花裡胡哨,大多寫系列文章,不能保證我寫的都完全正確,但是我保證所寫的均經過實踐或者查詢資料。遺漏或者錯誤之處,還望指正。