马拉车(manacher) & 回文自动机(PAM)

发布时间 2023-04-22 20:08:19作者: 0htoAi

读了徐安矣2023年集训队论文写的,对于差分性质和习题,我会在理解清楚之后再补充。本篇博客仅讨论前两种算法。

首先,马拉车和回文自动机都是处理回文串问题的。但在此之前,学习一些更加简单的回文算法。

小 trick:把给定串的两头和缝隙插入相同字符,且在边界处用不同字符标记,使得长度为偶数的回文串和长度为奇数的回文串可以用同一种方式算出来。

例子:

ABBABAB \(\to\) @#A#B#B#A#B#A#B#%

其中的 ABBA \(\to\) #A#B#B#A#,回文中心为中间的 #。ABA \(\to\) #B#A#B#,回文中心为 A。

下文的 \(N\) 表示字符串长度。除了回文自动机使用原串外,其它算法都需要使用在缝隙添加相同字符的新字符串。

  • 暴力

对于一个回文中心,枚举回文半径。时间复杂度为 \(O(n^2)\)

  • 哈希

预处理前缀哈希和后缀哈希,可以对于一个回文中心,二分回文半径。具体原理就是如果二分到 Mid,i 左右的长度为 Mid 的段的哈希值相等,那就是回文的,反之则不回文,有二分性。时间复杂度,预处理 \(O(n)\),求单个点的回文半径 \(O(\log n)\)

  • 马拉车

记录当前右端点最大的回文串的回文中心的右端点。初始都是 \(0\)。枚举回文中心,从 \(1\)\(N\)

如果当前回文中心在右端点之后,那根据每个回文串右端点至少为其本身的性质,当前最右右端点一定为 \(i-1\),则直接暴力更新这个点为回文中心的回文串的右端点,顺带可以算出这个点的回文半径。

否则,根据回文的性质,设当前最右端点为 \(MaxR\),其回文中心为 \(MaxI\),则 \(i\) 可以通过 \(MaxI\) 映射到 \(2MaxI-i\) 这个点,因为这是回文的。之前已经计算过 \(2MaxI-i\) 的回文半径了,但由于在 \(MaxI\) 为回文半径的回文串左端点之前的值与 \(MaxR\) 之后的值不能映射(因为不回文了),若 \(R_i\) 表示 \(i\) 的回文半径,则 \(i\) 点的初始回文半径为 \(\min(R_{2MaxI-i},MaxR-i+1)\)

定义一下回文半径,若 \(i\) 的回文半径为 \(R_i\),则以 \(i\) 为回文中心的极长回文串为 \([i-R_i+1,r+R_i-1]\),极长的意思就是再长就不行了。

上文的初始回文半径可能并不是最终回文半径,所以暴力拓展,在拓展的同时,顺带更新 \(MaxR\) 即可。因为想要暴力拓展,当且仅当 \(i\) 点的初始回文半径为 \(MaxR-i+1\),此时当前回文串的右端点即为 \(MaxR\),所以 \(MaxR\) 可以跟 \(R_i\) 同时扩展。

考虑时间复杂度,枚举 \(i\) 这部分的时间复杂度为 \(O(N)\)\(MaxR\) 最多只会增加到 \(N\),所以均摊时间复杂度也是 \(O(N)\)。总时间复杂度为 \(O(N)\)。是最快的处理每个点的回文半径的算法。

  • 回文自动机

说到这个,我以后还会写 AC 自动机和后缀自动机,优先度很高。如果还有多余的时间,可以写子序列自动机。

回文自动机可以理解成,既是一个 DAG,又是一棵树,其中每个点表示一个回文串。其中 DAG 每条边最多有字符集大小条出边,每条出边表示一个字符。走一条边相当于把一个回文串两边同时加上这条边对应的字符。回文自动机上的每个点的回文串都是原字符串的本质不同回文子串。

为了方便表示,设字符集大小为 \(|S|\)。树上节点 \(u\) 的父亲用 \(Fail_u\) 表示。

首先抛出来一个非常厉害的结论,若在一个串最后添加一个字符,这个串最多只会新增一个回文串。因为如果新增 \(\geq 2\) 个回文串,右端点肯定都是新增那个字符。然后这些回文串的回文中心一定有先后关系,最前面的那个回文串(即最长后缀回文串)可以把后面的回文串给映射到前面去。既然后面是回文的,所以映射到前面去跟后面是相同的,所以后面的回文串一定在前面出现过。所以没出现过的新增回文串只有可能有一个,且为最长后缀回文串。

根据上面这个性质,可以知道,每个字符串的本质不同回文子串的数量级是 \(O(n)\) 的,所以回文自动机的点数和边数有保证。\(Fail_u\) 的实际意义则为:\(u\) 这个回文串的最长回文后缀(除去自己以外的)。

考虑增量法构造回文自动机。已经构造好当前字符串的回文自动机了,新增一个字符 \(c\),然后变成新的回文自动机。开一个数组 \(Length\),其中 \(Length_u\) 表示 \(u\) 这个回文串的长度。方便更新回文自动机。设边的集合为 \(Next\)\(Next_{u,i}\) 表示点 \(u\) 的字符为 \(i\) 的出边连向的点。

初始有两个点,\(0\) 号点,\(Length=0\),表示空回文串,\(1\) 号点,\(Length=-1\),表示左右各添加一个相同字符后成为一个长度为 \(1\) 的回文串。\(Fail_0=1\),方便写代码。用全局变量 \(End\) 表示当前字符串的最长回文后缀,初始为 \(0\)

新增的点,从最长回文后缀开始跳 \(Fail\),直到遇到一个回文串(设为 \(u\))满足原串中这个回文串左边的第一个字符为 \(c\),这样就满足加入 \(c\) 后,最长回文后缀为那个回文串左右各添加一个 \(c\)。若那个字符串已经有一条 \(c\) 的边了,说明当前没有新增回文串,则 \(End=Next_{u,c}\),没了。否则就新建一个点,再连上这条边。再从 \(Fail_u\) 开始找最长回文后缀满足在原串上左边为 \(c\) 的回文串,设为 \(v\)。则 \(Fail_{new}=Next_{v,c}\)。根据上面的结论,这条边 \(Next_{v,c}\) 一定是存在的。

注意特殊情况,即 \(u\)\(1\) 时,即最长回文后缀就是这个字符本身,那么 \(Fail_{new}=0\)。在写代码时,可以通过全局数组初始值全为 \(0\) 的特性来让代码更简洁,而不需要特判,具体见代码。

点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN=1e6+50;
char s[MAXN],a[MAXN];
int N;
int tot;
int Next[MAXN][26],Fail[MAXN],Length[MAXN];
int End;
long long Cnt[MAXN];
int GetFail(int u,int Id)
{
	while(a[Id-Length[u]-1]!=a[Id])
	u=Fail[u];
	return u;
}
int main()
{
	a[0]=-1;
	Length[1]=-1;
	Fail[0]=1;
	tot=1;
	scanf("%s",&s[1]);
	N=strlen(s+1);
	for(int i=1;i<=N;i++)
	{
		a[i]=s[i]-'a';
		int Last=GetFail(End,i);
		if(Next[Last][a[i]]==0)
		{
			tot++;
			Fail[tot]=Next[GetFail(Fail[Last],i)][a[i]];
			Length[tot]=Length[Last]+2;
			Next[Last][a[i]]=tot; 
		}
		End=Next[Last][a[i]];
		Cnt[End]++;
	}
	long long Max=0;
	for(int i=tot;i>=2;i--)
	{
		Max=max(Max,1ll*Length[i]*Cnt[i]);
		Cnt[Fail[i]]+=Cnt[i];
	}
	printf("%lld",Max);
}

时间复杂度为 \(O(N)\),因为跳 \(Fail\) 可以看做,每次使得深度加一,然后再使得深度变浅很多,所以也是 \(O(N)\) 级别的。

可以快速得到的东西很多,比如想要知道当前字符串的本质不同回文子串数量,则为除了 \(0\)\(1\) 这两个点之外的点数。遍历一遍即可得到所有回文串,得到回文中心和半径也就很简单了。