Manacher算法詳解


Manacher

Manacher算法是一個用來查找一個字符串中的最長回文子串(不是最長回文序列)的線性算法。它的優點就是把時間復雜度為O(\({n}^{2}\))的暴力算法優化到了O(n)。首先先讓我們來看看最原始的暴力擴展,分析其存在的弊端,以此來更好的理解Manacher算法。

暴力匹配

暴力匹配算法的原理很簡單,就是從原字符串的首部開始,依次向尾部進行遍歷,每訪問一個字符,就以此字符為中心向兩邊擴展,記錄該點的最長回文長度。那么我們可以想想,這樣做存在什么弊端,是不是可以求出真正的最長回文子串?

答案是顯然不行的,我們從兩個角度來分析這個算法

1.不適用於偶數回文串

我們舉兩個字符串做例子,它們分別是 "aba","abba",我們通過肉眼可以觀察出,它們對應的最長回文子串長度分別是3和4,然而我們要是用暴力匹配的方法去對這兩個字符串進行操作就會發現,"aba" 對應的最長回文長是 "131","abba" 對應的最長回文長度是 "1111",我們對奇數回文串求出了正確答案,但是在偶數回文串上並沒有得到我們想要的結果,通過多次測試我們發現,這種暴力匹配的方法不適用於偶數回文串

2.時間復雜度O(\({n}^{2}\))

這里的時間復雜度是一個平均時間復雜度,並不代表每一個字符串都是這個復雜度,但因為每到一個新位置就需要向兩邊擴展比對,所以平均下來時間復雜度達到了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;
	}
	//記錄下manacher字符串的長度,方便后面使用
	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));
	}

}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM