Manacher演算法詳解

回首不知身是客發表於2019-06-07

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要不要更新。

Manacher演算法詳解

分支2:i <= R,也就是i在R內,此時分三種情況,在討論這三個情況前,我們先構建一個模型

Manacher演算法詳解

L是當前R關於C的對稱點,i'是i關於C的對稱點,可知 i' = 2*C - i,並且我們會發現,i'的迴文區域是我們已經求過的,從這裡我們就可以開始判斷是不是可以進行加速處理了

情況1:i'的迴文區域在L-R的內部,此時i的迴文直徑與 i' 相同,我們可以直接得到i的迴文半徑,下面給出證明

Manacher演算法詳解

紅線部分是 i' 的迴文區域,因為整個L-R就是一個迴文串,迴文中心是C,所以i形成的迴文區域和i'形成的迴文區域是關於C對稱的。

情況2:i'的迴文區域左邊界超過了L,此時i的迴文半徑則是i到R,下面給出證明

Manacher演算法詳解

首先我們設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繼續向外部匹配,下面給出證明

Manacher演算法詳解

因為 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));
    }

}

相關文章