演算法·理論:Manacher 筆記

godmoo發表於2024-08-05

UPDATE: \(\footnotesize\text{2024-8-6}\),重修文章格式。

\(\text{Manacher}\) 來啦!

\(\text{Manacher}\) 並沒有什麼前置知識,比 \(\text{KMP}\) 簡單多了。

前置處理

\(\text{Manacher}\) 演算法用於解決迴文串相關問題,先看幾個基本概念:迴文中心、迴文半徑,這些看字面意思就能猜到。

還有一個重要問題:對於迴文串,有長度為奇數或長度為偶數之分,即奇迴文串偶迴文串。顯然兩種迴文串需要分開進行處理,因為奇迴文串的迴文中心是一個字元,但偶迴文串的迴文中心是在兩個相鄰字元之間的,那我們看看能不能一致處理。

不難想到,既然偶迴文串的的迴文中心在兩個相鄰的字元之間,那我們不妨往每兩個相鄰字元之間插入一個虛擬的字元,比如 \(\texttt{\#}\)

比如說對於偶迴文串 \(\texttt{abba}\),我們將他成 \(\texttt{\#a\#b\#b\#a\#}\),這樣這個偶迴文串就變成了一個奇迴文串,它的迴文中心就變成 \(\texttt{\#}\) 了!現在所有迴文串都變成奇迴文串了,接下來我們就可以一致處理了。

(至於頭尾為何各放一個,後文再講)

Manacher 演算法

\(\text{Manacher}\) 演算法,可以在 \(O(n)\) 的複雜度下處理出以每個字元(或兩個字元之間)為迴文中心的最大回文半徑 \(rad[]\)

先說明一下回文半徑的定義:如果這個迴文串的迴文中心為 \(o\),右端點為 \(r\),那麼這個迴文串的迴文半徑 \(rad=r-o+1\),也就是說迴文半徑要算上回文中心。

那麼我們開始吧!首先思考樸素做法,顯然我們可以列舉迴文中心,再不斷同時往兩邊擴充套件,擴充套件到不同時就找到了最遠的左、右端點了,這個演算法叫做中心擴充套件演算法,時間複雜度 \(O(n^2)\),程式碼就不放了,很好打。

同樣注意到我們可以在此基礎上二分迴文半徑,接著用子串雜湊 \(O(1)\) 比較,時間複雜度降到 \(O(n\log n)\)

會議我們的 \(\text{KMP}\) 演算法是如何最佳化時間複雜度的:重複利用已知的資訊,我在 \(\text{KMP}\) 的文章中提過,這種思想叫做增量法,同時這也是 dp 思想的體現。

那我們考慮有什麼資訊可以重複利用?那顯然是迴文啊!那回文又有什麼性質呢?對稱啊!所以發現如果我們之前已經擴充套件到這個字元過,那前面就一定有和當前的字元對稱的內容,那該字元顯然也會擁有前面與它對稱的字元的迴文半徑。

比如說字串 \(s=\texttt{babcbab}\),當我們列舉到 \(s[6]\) 時(倒數第二個字元),顯然這裡已經被 \(s[4]\)(中間的 \(\texttt{c}\))擴充套件過。由中點公式,與它對稱的字元是 \(s[2\times 4-6]=s[2]\),顯然我們前面已經處理出 \(rad[2]\) 了,\(rad[2]=2\),所以 \(rad[6]\) 就至少為 \(2\) 了,當然還需要從迴文半徑為 \(3\) 開始繼續擴充。

但注意到我們只是對稱到了前面計算過的點,並不保證能完全對稱到整個迴文子串,比如說對於字串 \(t=\texttt{babcbad}\),在列舉到 \(s[6]\) 時(倒數第二個字元),雖然可以透過之前 \(s[4]\)(中間的 \(\texttt{c}\))對稱到 \(s[2\times 4-6]=s[2]\),但是 \(rad[6]\) 卻不能到 \(rad[2]\)(自己看一下是不是),為什麼呢?

因為雖然迴文中心可以對稱過來,但是 \(s[4]\)\(rad\) 不夠長,\(s[7]\) 無法對稱過去,所以這樣做就無法保證整個迴文串都能對稱過去,解決方法就是只能利用以 \(s[4]\) 為迴文中心的最長迴文串的右端點以內的資訊,也就是說 \(rad[6]\) 不能直接等於 \(rad[2]\),還要跟在 \(s[4]\) 為迴文中心的最長迴文串的右端點以內的可擴充套件的最長長度取 \(\min\)

形式化的,設我們所利用的迴文串的迴文中心為 \(o\),右端點為 \(r\),現在列舉到 \(s[i]\)\(s[i]<r\)(即可以利用是以前的資訊),那麼:

\[rad[i] \leftarrow\min(rad[2o-i],r-i+1) \]

接著繼續中心擴充套件即可。

解釋\(\min\) 的一個引數是對稱過去的字元所對應的 \(rad\),由中點公式得到;而 \(\min\) 的第二個引數是 \(r\) 及以內的可以擴充套件的最長長度,相信經過前面的講解你應該也懂了。

那在列舉的過程中同時不斷更新 \(o\)\(r\) 即可。

看一眼程式碼:

int n;
char a[N],s[N<<1];
void manacher(){
	//  特殊處理
	int cur=0;
	s[0]='@';
	s[++cur]='#';
	for(int i=1;i<=n;i++) s[++cur]=a[i],s[++cur]='#';
	s[++cur]='!';
	n=cur-1;
	// 接下來就可以一致處理了
	for(int i=1,o=0,r=0;i<=n;i++){
		rad[i]=(i>r?1:min(rad[(o<<1)-i],r-i+1)); // 利用之前的資訊
		while(s[i-rad[i]]==s[i+rad[i]]) rad[i]++; // 中心擴充套件
		if(i+rad[i]-1>r) o=i,r=i+rad[i]-1; // 更新 o 和 r
	}
}

a 是原串,s 是處理過後的字串。

先說怎麼算實際原串的以 \(i\) 為迴文中心的最長迴文串的長度,其實就是 \(rad[i]-1\)(因為特殊處理後加了字元 \(\texttt{\#}\)),自己分類討論一下 \(s[i]\) 是或不是 \(\texttt{\#}\),就容易推出這個式子了。

接著我們就可以解答上文的問題了,為什麼頭尾要各加一個 \(\texttt{\#}\)?舉個例子,對於字串 \(\texttt{bac}\),其實應轉換為 \(\texttt{\#b\#a\#c\#}\),那麼在列舉到 \(\texttt{a}\) 時,實際上得到的迴文串是 \(\texttt{\#a\#}\),所以對於頭尾的字元我們也應該做相同處理,於是前後各加一個 \(\texttt{\#}\);或者你想想,如果兩邊不不加,那麼 \(rad=1\),於是以它為迴文中心的最長迴文串的長度就為 \(rad-1=1-1=0\) 了,所以要這樣修正。

那為什麼頭尾還要加 \(\texttt{@}\)\(\texttt{!}\) 呢?是為了防止越界,或者說讓擴充套件整個串的左右端點處停下來,比方說整個串就對稱時,若列舉它的迴文中心,那如果不往兩邊加兩個不同的字元,那就會一直擴充套件下去,那就越界了。

其他的就沒有什麼好說的了,注意當 \(i>r\) 時就直接從 \(1\) 開始暴力中心擴充套件即可。

Manacher 的複雜度

首先答案肯定是 \(O(n)\) 的,依據是字串演算法全是線性的

\(\text{KMP}\) 知道怎麼分析了,那就自己想想吧,答案在下面。

\(\color{white}\text{同樣唯一需要分析的就是這個 while,其他都顯然是 O(n) 的。}\)

\(\color{white}\text{每個字元至多被從它後面暴力擴充套件到它一次,所以只會進行 O(n) 次 while。}\)

\(\color{white}\text{綜上,實際複雜度 O(n)。}\)


累啊!不過如此!

相關文章