1. 程式人生 > 其它 >manacher 演算法

manacher 演算法

基本概念

\(manacher\) 演算法是一種 字串演算法,通常用於求解給定的字串 \(S\) 內最長的迴文子串長度。其充分利用了迴文串的 對稱性,可以在 \(O(n)\) 的時間複雜度內求出最長迴文子串。\(manacher\) 演算法的主要思路和 \(KMP\) 演算法類似,都是通過已經求出的子串資訊來加速暴力列舉的過程。

演算法思想

\(manacher\) 演算法通過迴文的對稱性來確定出一部分已經確定的迴文子串,從而減少列舉的長度,達到線性的時間複雜度。當然,在對字串進行 \(manacher\) 演算法之前需要對字串進行處理。

首先考慮最暴力的做法:列舉 \(0 \leq i < n\)

​,將下標 \(i\)​ 作為可能的迴文子串中點。用雙指標向兩端擴充套件,遇到不同的字元退出。這樣做需要分類討論迴文子串長度為 奇數 和迴文子串長度為 偶數 的情況,並且最壞情況下對於每一個 \(i\) 都需要掃描一遍字串。時間複雜度為 \(O(n^2)\)

為了避免分類討論,我們考慮在每個字元的首尾都增加一個沒有在字串 \(S\)​​​​​ 中出現過的字元。例如當字串 \(S = \texttt{texas}\)​​​​​ 時,在每個字元的首位都增加一個字元 \(\texttt{#}\)​​​​​​ 以後,最終的字串 \(S^{\prime} = \texttt{#t#e#x#a#s#}\)

​​​​​ 。這時我們發現字串 \(S\)​​ 中的迴文子串無論長度奇偶,在字串 \(S^{\prime}\)​​ 對應的字串長度一定為奇數。

對於優化過暴力的 \(manacher\) 演算法,為了減少對邊界的判斷,我們再在字串的開頭加上另一個沒有字串中出現過的字元。最終的字串 \(S^{\prime\prime}\) 可能會是 \(\texttt{|#t#e#x#a#s#}\)。具體的原因參見下文。為方便起見,下文出現的字串 \(S\) 全部指代此處的字串 \(S^{\prime\prime}\)​。

定義 \(len_i\)​​​​ 表示字串 \(S\)​​​​ 以下標 \(i\)​​​​ 為迴文子串中點時的最長迴文子串長度半徑。例如 \(S = \texttt{|#a#b#a#b#a#}\)

​​​​ 時 \(len_6 = 6\)​​​​,對應迴文子串 \(\texttt{#a#b#a#b#a#}\)​​​​​。假設字串 \(S\)​​​​ 內中點在 \([0, i - 1]\)​​​ 區間內最靠右且最長的迴文子串為 \(a\)​​​​,設 \(a\)​​​​ 的中點為 \(p\)​​​​,\(a\)​​​​ 的右端點在 \(S\)​​​​ 中的下標為 \(r\)​​​​。此時我們考慮能否直接從 \([len_0, len_{i - 1}]\)​​​​ 中貢獻一部分確定的迴文子串,減少列舉量。此時稍微分類討論。

假如 \(i > r\)​,那麼因為以 \(i\)​ 為中點的最長迴文子串一定會包含 \(S_{0, i}\)​ 以外的部分,所以無法直接從 \([len_0, len_{i - 1}]\)​​​ 轉移,考慮暴力雙指標列舉。反之,若 \(i \leq r\),那麼我們可以先確定一部分迴文子串。因為 \(i\) 被包含在某一個迴文子串內,所以在該回文子串 \(a = [S_l, S_r]\) 內一定存在一個迴文子串,使得這個迴文子串是以 \(i\) 為中點的迴文子串的子串。

結合一個例子來理解。不妨設 \(S = \texttt{|#t#e#x#e#x#}\)​​​​​​​​,假設現在要更新 \(len_8, S_8 = \texttt{e}\)​​​​​​​​。此時 \(p = 6, r = 9\)​​​​​​。因為 \(8 < 9\)​​​​​,所以可以考慮先確定一部分迴文子串。我們發現因為 \(i\)​​​​​ 被包含在迴文子串內,所以在區間 \([i - len_i + 1, i + len_i - 1]\)​​​​ 內一定存在至少兩個 \(S_i\)​​​,並且 \(S_i = S_{2 \times p - i}\)​​​。具體到這個例子也就是在 \(4\)​​​ 的位置一定為 \(\texttt{e}\)​​​。這時 \(len_{2 \times p - i}\)​​​​ 一定已經計算過了,並且由於位置 \(i\)​​ 也在目前最右側的最長迴文子串內,所以位置 \(i\)​​ 的迴文子串一定包含長度為 \(len_{2 \times p - i} \times 2 - 1\)​​ 的與位置 \(2 \times p - i\)​​​ 的迴文子串相同的子串。我們還需要考慮溢位,假如 \(len_{2 \times p - i}\)​​ 過大,延伸到了右側最長迴文子串的右邊,那麼因為我們沒有計算右邊的值,所以不能用這個值來更新。那麼因為 \(i + len_{2 \times p - i} > r, 2 \times p - i - len_{2 \times p - i} < l\)​​,所以迴文子串一定可以包含 \(S_{i, r}\)​​。因此當 \(i \leq r\)​​ 的時候,可以確定的迴文子串長度最大為 \(\min\{len_{2 \times p - i}, r - i + 1\}\)​。

從確定的迴文子串兩側開始延伸雙指標,直到遇到不同的字元為止。因為在迴圈的過程中最右側最長迴文子串的右端點是不降的,如果當前迴文子串的右端點不超過右側最長迴文子串​的右端點,那麼直接轉移的時間複雜度顯然是 \(O(1)\)​。如果超過,則此時右側最長迴文子串的右端點一定會增加。右端點右移的時間複雜度為 \(O(n)\)​​。因此 \(manacher\)​ 演算法的時間複雜度為 \(O(n)\)​。

在程式碼實現的時候,儲存 \(len\) 時不必過多判斷邊界。這裡可以不用 \(- 1\),相應地程式碼中也有一些需要更改的地方。下面的模板字串下標從 \(0\) 開始,因此邊界條件的判斷是對的。

參考程式碼

例題連結

#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;

const int maxn = 1.1e7 + 5;

int n, cnt, ans;
int len[maxn * 3];
char a[maxn], s[maxn * 3];

void get_str()
{
	s[cnt] = '#';
	s[++cnt] = '|';
	for (int i = 0; i < n; i++)
	{
		s[++cnt] = a[i];
		s[++cnt] = '|';
	}
}

int main()
{
	int r = 0, mid = 0;
	scanf("%s", a);
	n = strlen(a);
	get_str();
	for (int i = 1; i <= cnt; i++)
	{
		if (i <= r)
			len[i] = min(len[2 * mid - i], r - i + 1);
		while (s[i - len[i]] == s[i + len[i]])
			len[i]++;
		if (len[i] + i - 1 >= r)
		{
			r = len[i] + i - 1;
			mid = i;
		}
		ans = max(ans, len[i]);
	}
	printf("%d\n", ans - 1);
	return 0;
}