Manacher
**Manacher演算法是一個用來查詢一個字串中的最長迴文子串(不是最長迴文序列)的線性演算法。它的優點就是把時間複雜度為O(n*n)的暴力演算法優化到了O(n)。首先先讓我們來看看最原始的暴力擴充套件,分析其存在的弊端,以此來更好的理解Manacher演算法。**
暴力匹配
暴力匹配演算法的原理很簡單,就是從原字串的首部開始,依次向尾部進行遍歷,每訪問一個字元,就以此字元為中心向兩邊擴充套件,記錄該點的最長迴文長度。那麼我們可以想想,這樣做存在什麼弊端,是不是可以求出真正的最長迴文子串?
答案是顯然不行的,我們從兩個角度來分析這個演算法
1.不適用於偶數迴文串
我們舉兩個字串做例子,它們分別是 "aba","abba",我們通過肉眼可以觀察出,它們對應的最長迴文子串長度分別是3和4,然而我們要是用暴力匹配的方法去對這兩個字串進行操作就會發現,"aba" 對應的最長迴文長是 "131","abba" 對應的最長迴文長度是 "1111",我們對奇數迴文串求出了正確答案,但是在偶數迴文串上並沒有得到我們想要的結果,通過多次測試我們發現,這種暴力匹配的方法不適用於偶數迴文串
2.時間複雜度O(n*n)
這裡的時間複雜度是一個平均時間複雜度,並不代表每一個字串都是這個複雜度,但因為每到一個新位置就需要向兩邊擴充套件比對,所以平均下來時間複雜度達到了O(n*n)。
Manacher演算法本質上也是基於暴力匹配的方法,只不過做了一點簡單的預處理,且在擴充套件時提供了加速
Manacher對字串的預處理
我們知道暴力匹配是無法解決偶數迴文串的,可Manacher演算法也是一種基於暴力匹配的演算法,那它是怎麼來實現暴力匹配且又不出錯的呢?它用來應對偶數字符串的方法就是——做出預處理,這個預處理可以巧妙的讓所有字串都變為奇數迴文串,不論它原本是什麼。操作實現也很簡單,就是將原字串的首部和尾部以及每兩個字元之間插入一個特殊字元,這個字元是什麼不重要,不會影響最終的結果(具體原因會在後面說),這一步預處理操作後的效果就是原字串的長度從n改變成了2*n+1,也就得到了我們需要的可以去做暴力擴充套件的字串,並且從預處理後的字串得到的最長迴文字串的長度除以2就是原字串的最長迴文子串長度,也就是我們想要得到的結果。
這裡解釋一下為什麼預處理後不會影響對字串的擴充套件匹配
比如我們的原字串是 "aa",假設預處理後的字串是 "#a#a#",我們在任意一個點,比如字元 '#',向兩端匹配只會出現 'a' 匹配 'a','#' 匹配 '#' 的情況,不會出現原字串字元與特殊字元匹配的情況,這樣就能保證我們不會改變原字串的匹配規則。通過這個例子,你也可以發現實際得到的結果與上述符合。
Manacher演算法核心
Manacher演算法的核心部分在於它巧妙的令人驚歎的加速,這個加速一下把時間複雜度提升到了線性,讓我們從暴力的演算法中解脫出來,我們先引入概念,再說流程,最後提供實現程式碼。
概念:
ManacherString:經過Manacher預處理的字串,以下的概念都是基於ManasherString產生的。
迴文半徑和迴文直徑:因為處理後迴文字串的長度一定是奇數,所以迴文半徑是包括迴文中心在內的迴文子串的一半的長度,迴文直徑則是迴文半徑的2倍減1。比如對於字串 "aba",在字元 'b' 處的迴文半徑就是2,迴文直徑就是3。
最右迴文邊界R:在遍歷字串時,每個字元遍歷出的最長迴文子串都會有個右邊界,而R則是所有已知右邊界中最靠右的位置,也就是說R的值是隻增不減的。
迴文中心C:取得當前R的第一次更新時的迴文中心。由此可見R和C時伴生的。
半徑陣列:這個陣列記錄了原字串中每一個字元對應的最長迴文半徑。
流程:
步驟1:預處理原字串
先對原字串進行預處理,預處理後得到一個新的字串,這裡我們稱為S,為了更直觀明瞭的讓大家理解Manacher的流程操作,我們在下文的S中不顯示特殊字元(這樣並不影響結果)。
步驟2:R和C的初始值為-1,建立半徑陣列pArr
這裡有點與概念相差的小偏差,就是R實際是最右邊界位置的右一位。
步驟3:開始從下標 i = 0去遍歷字串S
分支1:i > R ,也就是i在R外,此時沒有什麼花裡胡哨的方法,直接暴力匹配,此時記得看看C和R要不要更新。
分支2:i <= R,也就是i在R內,此時分三種情況,在討論這三個情況前,我們先構建一個模型
L是當前R關於C的對稱點,i'是i關於C的對稱點,可知 i' = 2*C - i,並且我們會發現,i'的迴文區域是我們已經求過的,從這裡我們就可以開始判斷是不是可以進行加速處理了
情況1:i'的迴文區域在L-R的內部,此時i的迴文直徑與 i' 相同,我們可以直接得到i的迴文半徑,下面給出證明
紅線部分是 i' 的迴文區域,因為整個L-R就是一個迴文串,迴文中心是C,所以i形成的迴文區域和i'形成的迴文區域是關於C對稱的。
情況2:i'的迴文區域左邊界超過了L,此時i的迴文半徑則是i到R,下面給出證明
首先我們設L點關於i'對稱的點為L',R點關於i點對稱的點為R',L的前一個字元為x,L’的後一個字元為y,k和z同理,此時我們知道L - L'是i'迴文區域內的一段迴文串,故可知R’ - R也是迴文串,因為L - R是一個大回文串。所以我們得到了一系列關係,x = y,y = k,x != z,所以 k != z。這樣就可以驗證出i點的迴文半徑是i - R。
情況3:i' 的迴文區域左邊界恰好和L重合,此時i的迴文半徑最少是i到R,迴文區域從R繼續向外部匹配,下面給出證明
因為 i' 的迴文左邊界和L重合,所以已知的i的迴文半徑就和i'的一樣了,我們設i的迴文區域有邊界的下一個字元是y,i的迴文區域左邊界的上一個字元是x,現在我們只需要從x和y的位置開始暴力匹配,看是否能把i的迴文區域擴大即可。
總結一下,Manacher演算法的具體流程就是先匹配 -> 通過判斷i與R的關係進行不同的分支操作 -> 繼續遍歷直到遍歷完整個字串
時間複雜度:
我們可以計算出時間複雜度為何是線性的,分支一的情況下時間時間複雜度是O(n),分支二的前兩種情況都是O(1),分支二的第三種情況,我們可能會出現O(1)——無法從R繼續向後匹配,也可能出現O(n)——可以從R繼續匹配,即使可以繼續匹配,R的值也會增大,這樣會影響到後續的遍歷匹配複雜度,所以綜合起來整個演算法的時間複雜度就是線性的,也就是O(n)。
實現程式碼:
整個程式碼並不是對上述流程的生搬硬套(那樣會顯得程式碼冗長),程式碼進行了精簡優化,具體如何我會在程式碼中進行註釋
#include<iostream>
#include<string>
#include<cstring>
#include<algorithm>
#include<vector>
#include<cmath>
using namespace std;
//演算法主體
int maxLcsplength(string str) {
//空字串直接返回0
if (str.length() == 0) {
return 0;
}
//記錄下原始字串的長度,方便後面使用
int len = (int)(str.length() * 2 + 1);
//開闢動態陣列chaArr記錄manacher化的字串
//開闢動態陣列pArr記錄每個位置的迴文半徑
char *chaArr = new char[len];
int* pArr = new int[len];
int index = 0;
for (int i = 0; i < len;i++) {
chaArr[i] = (i & 1) == 0 ? '#' : str[index++];
}
//到此完成對原字串的manacher化
//R是最右迴文邊界,C是R對應的最左迴文中心,maxn是記錄的最大回文半徑
int R = -1;
int C = -1;
int maxn = 0;
//開始從左到右遍歷
for (int i = 0; i < len; i++) {
//第一步直接取得可能的最短的迴文半徑,當i>R時,最短的迴文半徑是1,反之,最短的迴文半徑可能是i對應的i'的迴文半徑或者i到R的距離
pArr[i] = R > i ? min(R - i, pArr[2 * C - i]) : 1;
//取最小值後開始從邊界暴力匹配,匹配失敗就直接退出
while (i + pArr[i]<len && i + pArr[i]>-1) {
if (chaArr[i + pArr[i]] == chaArr[i - pArr[i]]) {
pArr[i]++;
}
else {
break;
}
}
//觀察此時R和C是否能夠更新
if (i + pArr[i] > R) {
R = i + pArr[i];
C = i;
}
//更新最大回文半徑的值
maxn = max(maxn, pArr[i]);
}
//記得清空動態陣列哦
delete[] chaArr;
delete[] pArr;
//這裡解釋一下為什麼返回值是maxn-1,因為manacherstring的長度和原字串不同,所以這裡得到的最大回文半徑其實是原字串的最大回文子串長度加1,有興趣的可以自己驗證試試
return maxn - 1;
}
int main()
{
string s1 = "";
cout << maxLcsplength(s1) << endl;
string s2 = "abbbca";
cout << maxLcsplength(s2) << endl;
return 0;
}
下面附上java程式碼
public class Manacher {
public static char[] manacherString(String str) {
char[] charArr = str.toCharArray();
char[] res = new char[str.length() * 2 + 1];
int index = 0;
for (int i = 0; i != res.length; i++) {
res[i] = (i & 1) == 0 ? '#' : charArr[index++];
}
return res;
}
public static int maxLcpsLength(String str) {
if (str == null || str.length() == 0) {
return 0;
}
char[] charArr = manacherString(str);
int[] pArr = new int[charArr.length];
int C = -1;
int R = -1;
int max = Integer.MIN_VALUE;
for (int i = 0; i != charArr.length; i++) {
pArr[i] = R > i ? Math.min(pArr[2 * C - i], R - i) : 1;
while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
if (charArr[i + pArr[i]] == charArr[i - pArr[i]])
pArr[i]++;
else {
break;
}
}
if (i + pArr[i] > R) {
R = i + pArr[i];
C = i;
}
max = Math.max(max, pArr[i]);
}
return max - 1;
}
public static void main(String[] args) {
String str1 = "abc123321cba";
System.out.println(maxLcpsLength(str1));
}
}