回文自动机学习笔记
最近学了回文自动机这个神奇的东西,写篇文章加深下印象,如有不足或错误,欢迎指出。
墙裂建议自行画图加深理解。
回文串的定义点此。
问题引入
给定一个字符串 $s$,求以 $s$ 每个位置结尾的回文子串个数。
如果用暴力枚举每个串的方式,时间复杂度可能是 $O(n^2)$ 或者更高,主要是花在寻找以新位置结尾的回文串的重新枚举上。
回文串有个显然的性质,即去掉相同长度的前缀与后缀仍是回文串,利用这种包含关系,也许可以提高计算的效率。
回文自动机利用了这个性质,可以 $O(n)$ 处理求本质不同回文子串个数的这类回文串题目。
基本概念
回文自动机,即 Palindrome Automaton(PAM),又名回文树。
回文自动机由两颗树构成,分别有奇根和偶根,对应着长度为奇数和偶数的回文串,奇根编号是 $1$,偶根编号是 $0$。
在回文自动机中,包含了所有的回文串,每个节点表示一个回文串,其信息存到边上,维护 $last$ 表示以上次插入的字符结尾的最长回文串对应节点,$last$ 初始值为 $1$。对于点 $x$,维护 $len_x$,表示 $x$ 代表的回文串长度。用 $ch_{x,\Sigma}$ 表示 $x$ 每条出边对应的儿子,类似 Trie。
特别地,$len_0=0,len_1=-1$,即偶根长度为 $0$,奇根长度为 $-1$。奇根长度 $-1$ 的妙处后文会提到。
以回文串 $\texttt{abaabba}$ 为例,下图即为仅含回文树边的回文自动机。
某个点代表的回文串在图上的表示,是从奇根或偶根开始,经过一条转移边,即在回文串的前后各加一个相同的字符,这样对于回文串的表示显然是有正确性的。特别地,奇根所在树的节点回文中心的字符仅加一次。如路径 $1\rightarrow3\rightarrow4$ 表示的回文串是 $\texttt{aba}$,路径 $0\rightarrow5\rightarrow6$ 表示的回文串是 $\texttt{baab}$。
因为每次转移会使其表示的回文串前后各扩展一个相同的字符,使长度加 $2$,所以对于点 $x$,若其父节点为 $fa$,则 $len_x=len_{fa}+2$。
和其他自动机一样,回文自动机也有 $fail$ 指针,对于点 $x$,$fail_x$ 指向点 $x$ 代表的回文串最长的回文真后缀所对应节点,特别地,$fail_0=1,fail_1=1$。很多题解都说奇根的 $fail$ 指针没有意义,但在实现中指向自身要好处理。
还是回文串 $\texttt{abaabba}$,下图即其完整的回文自动机。
构造方法
对于字符串 $s$,若已构造好前 $i-1$ 个字符的回文自动机,加入字符 $s_i$,从 $last$ 表示的回文串开始,然后不断跳 $fail$ 边,直到找到某个节点 $x$,使 $s_{i-len_x-1}=s_i$,即该节点表示的回文串上一个字符与待添加字符相同时,$s_{i-len_x-1}$ 至 $s_i$ 所构成的回文串即为以 $i$ 结尾的最长回文串。
定义一个 $\text{getfail}$ 函数,$\text{getfail}(x,i)$ 返回的即为加入 $s_i$ 后满足上述条件的点 $x$。
int getfail(int x, int i) {
while(i - len[x] - 1 < 0/*判断边界*/ || s[i - len[x] - 1] != s[i]) x = fail[x];
return x;
}
这个正确性证明很显然,因为以 $i$ 结尾的长度不为 $1$ 的回文串,必定包含 $s_{i-1}$,又因为回文串去掉相同长度的前后缀仍是回文串,所以若以 $i$ 结尾的最长回文串长度不为 $1$,必定包含以 $i-1$ 结尾的回文串,可以通过 $fail$ 指针找到所有以 $i-1$ 结尾的回文串。
以 $i$ 结尾的回文串中必有长度为 $1$ 的回文串,即 $s_i$ 单独成串,此时不包含 $s_{i-1}$,需单独处理。前文提到 $len_1=-1$,所以 $s_{i-len_1-1}=s_i$,即 $fail$ 指针指向奇根时,奇根会让下一次匹配到自身,显然符合,又因为所有节点的 $fail$ 指针都直接或间接指向奇根,也就保证了必定能找到满足条件的点 $x$,且 $i-1$ 结尾的回文串均可通过 $fail$ 指针找到,$\text{getfail}$ 的正确性得证。
若 $x$ 现在有一条表示 $s_i$ 的出边,即已存在与 $i$ 结尾的最长回文串本质相同的节点,直接更新 $last$ 就好。若没有,则新建一个,再求它的 $fail$ 指针。还是先找到 $last$ 表示的回文串中满足两边扩展 $s_i$ 仍是回文串的回文后缀,发现和上文的求法是类似的,可以用 $\text{getfail}(fail_x,i)$ 来求出其满足条件的回文后缀所对应的节点。
插入 $s_i$ 的代码如下:
void insert(char c, int i) {
int x = getfail(last, i), w = c - 'a';
if(!ch[x][w]) {
len[++cnt] = len[x] + 2;
int tmp = getfail(fail[x], i);
fail[cnt] = ch[tmp][w];
//things to do
ch[x][w] = cnt;
}
last = ch[x][w];
}
注意设定父子关系是最后处理,原因是当 $ch_{tmp,w}$ 不存在时,即新建的节点的 $fail$ 未匹配到,因 $ch_{twp,w}$ 初值为 $0$,$fail_{cnt}$ 会被设为 $0$,即新建的节点 $fail$ 指针指向偶根,$len_0=0$,空串显然可行。
复杂度证明
证明过程和 KMP 很相似。
可以证明,对于一个字符串 $s$,它的本质不同回文子串个数最多只有 $|s|$个(然而我不会,网上应该都有)。
因为回文自动机上每个节点代表的回文串均不同且包含了 $s$ 所有的回文串,所以最多有 $|s|$ 个节点,令 $n=|s|$。
若不计 $\text{getfail}$ 复杂度,其它操作复杂度 $O(n)$ 比较显然。
因为 $fail$ 指向的是回文真后缀,所以长度必更小,跳 $fail$ 边深度单调非递增,且同一深度最多跳两次 $fail$ 边(奇根所在树与偶根所在树),因每次新建节点 $fail$ 指针指向节点深度只会加 $1$,所以最多可跳 $2n$ 次 $fail$ 边。
总时间复杂度 $O(n)$,空间复杂度为 $O(n\Sigma)$。
例题
以模板题为例。
用 $sum_x$ 表示答案,显然以 $i$ 结尾的回文串个数即以 $i$ 结尾的最长回文串的最长回文真后缀的回文串个数,即 $sum_x=sum_{fail_x}+1$。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 10, Sigma = 26;
char s[N];
int lastans, n;
struct Palindrome_Automaton {
int ch[N][Sigma], fail[N], len[N], sum[N], cnt, last;
Palindrome_Automaton() {
cnt = 1;
fail[0] = 1, fail[1] = 1, len[1] = -1;
}
int getfail(int x, int i) {
while(i - len[x] - 1 < 0 || s[i - len[x] - 1] != s[i]) x = fail[x];
return x;
}
void insert(char c, int i) {
int x = getfail(last, i), w = c - 'a';
if(!ch[x][w]) {
len[++cnt] = len[x] + 2;
int tmp = getfail(fail[x], i);
fail[cnt] = ch[tmp][w];
sum[cnt] = sum[fail[cnt]] + 1;
ch[x][w] = cnt;
}
last = ch[x][w];
}
} PAM;
int main() {
scanf("%s", s + 1);
int len = strlen(s + 1);
for(n = 1; n <= len; n++) {
s[n] = (s[n] - 97 + lastans) % 26 + 97;
PAM.insert(s[n], n);
printf("%d ", lastans = PAM.sum[PAM.last]);
}
return 0;
}
还有一些简单题:

浙公网安备 33010602011771号