Manacher 算法详解
Manacher算法,又名“马拉车”算法。
引入
给出一个只由小写英文字符 \(\texttt a,\texttt b,\texttt c,\ldots\texttt y,\texttt z\) 组成的字符串 \(s\) ,求 \(s\) 中最长回文串的长度 。
对于这种问题,一眼便有两种朴素算法。
-
枚举最长回文串的左右边界 \(l,r\),然后 \(\mathcal O(r-l+1)\) 判断回文。
时间复杂度:\(\mathcal O(n^3)\)。
-
枚举回文串的中心 \(s_i\),然后从 \(s_i\) 开始向两边扩展。即:判断 \(s_{i-1},s_{i+1}\) 是否一样,\(s_{i-2},s_{i+2}\) 是否一样,\(s_{i-3},s_{i+3}\) 是否一样,直到 \(s_{i-k},s_{i+k}\) 不一样时便可以得出以 \(s_i\) 为中心的最长回文串长度。
时间复杂度:\(\mathcal O(n^2)\)。
令 \(s\) 长度为 \(n\)。
当 \(n\) 超过 \(2\times10^4\) 时,\(\mathcal O(n^2)\) 的算法便很难在 \(1s\) 内得出答案,考虑使用其他方法。
- 字符串哈希,\(\mathcal O(n\log_2n)\)。
- 后缀数组+快速LCA,\(\mathcal O(n)\)。(使用加减 1RMQ求解欧拉序+ST表的LCA,可以做到 \(\mathcal O(n)\) 预处理,\(\mathcal O(1)\) 查询)
然而,这些算法都不是那么的简单,相比起来这些,Manacher算法 是 压倒性 的简单。
但是这里描述的算法 压倒性 的简单,并且在时间和空间复杂度上具有更小的常数。该算法由 Glenn K. Manacher 在 1975 年提出。——OI Wiki
Manacher算法实现
回文转化
首先,回文分为奇回文和偶回文。对于这两种情况的回文中心,明显需要分类讨论。
考虑转化,那么我们在每两个相邻字符之间插入一个特殊字符即可。
- 对于奇回文,回文中心不变。
- 对于偶回文,回文中心便是我们插入的特殊字符。
比如:abcba 变为 a#b#c#b#a,原先回文,现在仍然回文;abccba 变为 a#b#c#c#b#a,原先回文现在仍然回文,但是从偶回文转变为了奇回文,回文中心为插入的 #。
回文中心与回文半径
-
首先,回文串是关于回文中心呈轴对称的。
-
在一个回文串 \(s\) 内选一子串 \(t\),那么在 \(s\) 内一定还存在一个子串 \(t'=rev(t)\),\(rev(t)\) 表示 \(t\) 的反转字符串。
-
如果回文串 \(s\) 的左半部分有子串 \(s_1\) 回文,那么右半部分也一定存在子串 \(s_2\) 回文,且满足 \(s_1=rev(s_2)\)。
考虑记录以 \(s_i\) 为回文中心的最长回文半径(包括 \(s_i\)) \(p_i\)。
例如对于 a#b#a,b 的回文半径就是 \(3\)。
状态转移
令插入后的字符串 \(s\) 长度为 \(m=2n+1\)。
令 \(r\) 为当前已确定的、最远的回文串右边界。即:\(r=\max \limits_{i=1}^m(i+p_i-1)\)。
令 \(mid\) 为当前已确定的、最远的回文串回文中心。
令字符串 \(a\) 是 \(s\) 插入后的字符串,从下标为 \(1\) 开始存储。
对于 \(i\) 遍历 \(1\sim m\),若 \(i<r\),都可以确定:
首先,取最小值是为了防止越出 \(r\) 的范围,很好理解。
对于 \(p_{2\times mid+1}\),如图:

其中 \(j\) 为 \(i\) 关于 \(mid\) 的对称点,也就是 \(j=2\times mid-i\)。
那么以 \(j\) 为中心的最长回文串(\(\color{#FF7F27}\colorbox{#FF7F27}{1}\) 橙色部分)也是关于 \(mid\) 对称,则 \(\color{#ED1C24}\colorbox{#ED1C24}{1}\) 红色部分同样回文且其为 \(\color{#FF7F27}\colorbox{#FF7F27}{1}\) 橙色部分的反转字符串(事实上,一个回文字符串无论如何反转,都是不变的,也就是说,两部分其实是全等的)。
那么 \(p_i\) 就可以直接赋值为 \(\min\left(p_{2\times mid+1},r-i+1\right)\),然后再进行暴力向两边扩展的操作即可。
统计答案
令 \(ans=\max_{i=1}^mp_i\),则最长回文串的长度为 \(ans-1\),理由如下:
首先, \(ans\) 中是记录了 插入字符(#)的个数。
-
\(ans\) 以
#为中心的时候。明显长这样:
#a#a#a...a#a...a#a#a#;a可以替换为任意字符,但要求回文;...表示省略。那么半径上的有效字符(非
#)个数为:\(\frac{ans-1}{2}\)。整个回文字符串长度为:\(2\times\frac{ans-1}2=ans-1\)。
-
\(ans\) 不以
#为中心的时候。明显长这样:
#a#a#a...a#a#a...a#a#a#;a可以替换为任意字符,但要求回文;...表示省略。那么半径上的有效字符(非
#)个数为:\(\frac{ans}{2}\)。整个回文字符串长度为:\(2\times\frac{ans}2=ans\)。考虑到回文中心计算了两次,则最终答案为 \(ans-1\)。
故答案为 \(ans-1=\left(\max\limits_{i=1}^mp_i\right)-1\)。
时间复杂度分析
虽然套了两层循环,但是仍为 \(\mathcal O(n)\)。
首先最外层循环遍历 \([1,m]\) 肯定是 \(\mathcal O(m)=\mathcal O(2n+1)=\mathcal O(n)\)。
那么考虑 while() 的总数到底是多少:由于匹配过的位置不再匹配,且最坏情况下 \(r\) 每次自增 \(1\),那么每个字符至多匹配一次,\(\mathcal O(m)=\mathcal O(2n+1)=\mathcal O(n)\)。
故,总时间复杂度为 \(\mathcal O(n)\)。
边界条件
我们可以令 \(a_0\) 为 ~(不同于插入的 # 和其他有效字符)。
这样是为了防止越界。
注意到,只有 while(a[i-p[i]]==a[i+p[i]])p[i]++; 可能会产生越界,那么我们令 \(a_0\) 为一个不同于 \(a_{2i}\) 的值即可。
例题AC代码
//#include<bits/stdc++.h>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<iomanip>
#include<cstdio>
#include<string>
#include<vector>
#include<cmath>
#include<ctime>
#include<deque>
#include<queue>
#include<stack>
#include<list>
using namespace std;
const int N=1.1*1e7;
int n,m,p[2*N+1];
char s[N+1],a[2*N+1];
int main(){
/*freopen("test.in","r",stdin);
freopen("test.out","w",stdout);*/
scanf("%s",s);
n=strlen(s);
a[m]='~';
for(int i=0;i<n;i++){
a[++m]='#';
a[++m]=s[i];
}a[++m]='#';
int ans=0;
for(int i=1,r=0,mid=0;i<=m;i++){
if(i<=r)p[i]=min(p[mid*2-i],r-i+1);
while(a[i-p[i]]==a[i+p[i]])p[i]++;
if(p[i]+i>r)r=p[i]+i-1,mid=i;
if(p[i]>ans)ans=p[i];
}printf("%d\n",ans-1);
/*fclose(stdin);
fclose(stdout);*/
return 0;
}

浙公网安备 33010602011771号