回文自动机(PAM)学习笔记

chlchl

2022-07-27 17:04:08

Solution

Update:2022.7.28。补充了对模板题解法的进一步说明,并对某些结论进行了补充或证明。 ## 前言 想必各位都学过 Manacher 算法(~~没关系,没学过不影响今天的内容~~)。不得不承认,Manacher 的确是处理回文问题 一个强力工具,可以在 $O(n)$ 的时间复杂度内求出一个字符串的最长回文子串。 但是,对于回文计数类问题(比如求一个字符串有多少个本质不同的回文子串),Manacher 就心有余而力不足了。所以,我们需要一个更为强大的数据结构处理这个问题。这就是今天的**回文自动机**(PAM)。 ## 概念 作为一个数据结构,首先需要明确回文自动机是个什么东西。 在开始之前,需要明确一些符号: - $s$:正在处理的字符串。默认下表从 $1$ 开始。 - $|s|$:字符串 $s$ 的长度。 - $s_{i\sim j}$:指 $s$ 从第 $i$ 位到第 $j$ 位组成的字串。 - $fa_u$:指在回文自动机中编号为 $u$ 的节点的父亲。 回文自动机是一棵树(因此也叫作回文树),它存的是一个字符串中的**所有回文子串** 的 **一半**。 而由于回文串又分奇回文和偶回文,所以回文自动机有两个根节点,$0$ 存的是长度为偶数的回文串,$1$ 存的是长度为奇数的回文串。 而它的每条边,代表一个字符 $c$,指的是从父节点所代表的字符串的头尾各加一个 $c$ 的到儿子所代表的字符串。 什么意思呢?我们不妨来看张图。 以字符串 $ababaababa$ 作例子。它的所有回文子串分别是: - 奇数:$a,b,aba,bab,ababa$; - 偶数:$aa,baab,abaaba,babaabab,ababaababa$。 它们建出来的回文自动机是这样的: ![](https://cdn.luogu.com.cn/upload/image_hosting/bxc7hir7.png?x-oss-process=image/resize,m_lfit,h_500,w_600) ~~有没有发现回文自动机和 Manacher 的关系很像 AC 自动机与 KMP 的关系,都是爸爸和儿子。~~ 从图中我们得到两个结论: - 除根节点外,每个节点恰好对应一个本质不同的回文子串,所以该树节点数 $-2$ 就是本质不同的回文子串个数(这很显然,原因跟 Trie 的储存原理是一样的,互不相同)。 - 若 $0$ 号节点对应字符串长度为 $0$,$1$ 号为 $-1$。则其余节点深度每 $+1$,所对应的回文串长度 $+2$(因为从下到上读一遍,又从上往下又读一遍)。 所以你现在知道为什么只存回文的一半了吧?除了节省空间,还是为了**避免本质相同的子串**。 ## 原理 现在我们来看看回文自动机是怎么工作的。 ### 插入新节点 这个地方我看其他讲解的的时候懵懵懂懂的,希望这里能讲明白。 我们考虑轮到 $s_i$ 插入时的情况。即前 $i-1$ 个字符都已经在树上了。 显然,加入该字符时肯定会出现若干个新的回文子串。那是不是要枚举所有新出现的回文子串呢? 显然不是。回文串左右两边相等。当插入第 $s_i$ 时,除了最长的新回文子串,其它看似新增的回文子串,其实在 $s_i$ 之前就已经在里面了。 以下是不严谨的证明:首先我们需要明确,新增的所有回文子串必定是当前串的后缀(因为它们全都包含当前的 $s_i$,不然不叫新增)。也就是说,只要不包含 $s_i$ 的均不是新增的。如果新增的某个串(设为 $t$)不是最长子串(设为 $s$),即 $|t|\leq |s|$,那因为 $s$ 也是回文的,所以在以 $s_{i-|t|+1}$ 为结尾,长度为 $|t|$ 的子串必定是 $t$(可以理解为把 $t$ 以 $s$ 的回文中心为对称轴做对称)。而这个 $t'$ 明显不包含 $s_i$,所以并不是新增的。 说穿了,精髓就是长回文串一定包含短回文串。 所以回文自动机的空间复杂度是 $O(|s|)$ 的。 例如 $ababaababa$ 已经插入完了 $ababaab$,现在轮到插入 $a$,那真正没有出现过的只有 $abaaba$。其他的,比如说 $aba(s_{6\sim 8})$,在 $s_{3\sim 5}$ 就已经出现过了。 现在,我们就要考虑如何将 $abaaba$ 这个最长新回文子串插入到树上了。 首先,$abaaba$ 是由 $baab$ 从头尾分别添加 $a$ 得到的,而这个 $baab$ 肯定已经在树上了(插入 $s_7$ 时已经加到树上了)。那我们只要找到 $baab$ 对应的节点,直接连一条 $a$ 的边就完事了。 怎么找这个 $baab$ 呢?我敢肯定,这个 $baab$ 一定是 $s_{0\sim i-1}$ 中的最长回文后缀(不信你看是不是)。为什么呢?很简单,因为 $baab$ 是插入 $s_{i-1}$ 时新增的节点,肯定就是 $s_{i-1}$ 的最长回文后缀了。 那么,由这个“最长回文后缀”你想到了什么? 没错,AC 自动机的 $fail$ 指针。所以,我们也定义一个类似 AC 自动机的 $fail_u$ 表示 $u$ 代表的**字符串的非自身最长回文后缀**。只要我们不停地眺,那 $fail_u,fail_{fail_u},\cdots$ 中总有一个可以在两边加上你要加的 $s_i$(即加上 $s_i$ 后仍为子串)(调到 $1$ 号根节点必定有解,后面会说)。然后看看合法点有没有 $s_i$ 这条边,如果有就直接往下走,没有就新建节点。 为什么是非自身?如果加上自身,它的 $fail$ 指针不就指向自己了?~~然后你的程序就会 T 成二臂……~~ 记 $len_p$ 为 $p$ 对应的回文子串长度。假设 $s_p$ 为以第 $i-1$ 位结束的最长回文子串的位置。当我们求 $ fail_i$ 时,合法的转移点就必须满足 $s_{p-len_p-1}=s_p$,这个与 AC 自动机寻找 $fail$ 指针的原理是一样的,只是变成了判断是否回文而已。 下面是完整的回文自动机的图: ![](https://cdn.luogu.com.cn/upload/image_hosting/57vddml0.png?x-oss-process=image/resize,m_lfit,h_500,w_600) 最后一个问题:为什么 $fail_0=1$?有什么用?比如说当 $aaaaab$ 插入 $b$ 的时候,它的 $fail$ 怎么跳都是没用的(因为压根儿就没 $b$)。这时,$b$ 只能当做单个字符挂在 $1$ 的下面,而不能挂在 $0$ 的下面。因此,我们需要让 $fail_0$ 指向 $1$。至于 $fail_1$,不用管,用不到,因为单独一个字符也算回文,不可能在 $1$ 下失配的。 最后,后缀自动机的时间复杂度为 $O(|s|\log n)$($n$ 为节点数量)。这是我自己的 ~~xjb~~ 分析:首先,$s$ 中的每个字符都要插入,所以至少是 $O(|S|)$。然后,对于每个字符,采用 $fail$ 指针一层一层向上跳(可以证明,$fail$ 连向的都是长度更小的,因为定义是非自身最长回文前缀),最多跳 $\log n$ 层(最坏情况就是每个 $fail$ 都指向它的父亲),所以最终就是 $O(|s|\log n)$ 的,十分优秀。 ## Solution 讲完原理,看看这道题怎么做:[P5496 【模板】回文自动机(PAM)](https://www.luogu.com.cn/problem/P5496)。 这题要求我们输出以字符串每个位置结尾的回文子串个数。因为回文自动机本身就涉及到后缀,所以要求结尾个数,只需要定义 $cnt_i$ 为以 $i$ 结尾的字符串的回文子串个数。更新的话,找到 $fail_i$ 后,$cnt_{fail_i}$ 就是除了 $i$ 之外的回文串数量。只要 $+1$(即加上哪一个新增的最长回文子串),就是 $cnt_i$ 的值。 ~~@管理员大大 这个题没啥好分析的吧,弄懂原理就很简单了。~~ 最后就是代码了,细节都在注释中。 ```cpp #include<bits/stdc++.h> using namespace std; const int N = 5e5 + 10; char s[N]; int n, lst, len[N]; int now, tot = 1, fail[N], cnt[N], t[N][26]; //tot 记得赋成 1,已经有两个根节点了 int getfail(int u, int p){//求 u 的fail指针 ,p表示当前插入的是哪个点 while(p - len[u] - 1 <= 0 || s[p - len[u] - 1] != s[p]) u = fail[u]; //如果当前位置比 u的回文串短或者字符不相等 就一直跳 return u;//最后不跳了,你就是答案了 } int insert(char c, int id){ int p = getfail(now, id);//找到那条路中有 id 位都一样的最长回文子串 //这个点就是他爸爸 if(!t[p][c - 'a']){//爸爸没它这个儿子,新认一个 fail[++tot] = t[getfail(fail[p], id)][c - 'a']; //fail等于((((爸爸的fail那条路上)有 id 位都是一样的回文串)的节点)的同名儿子) t[p][c - 'a'] = tot;//跟trie树一样 len[tot] = len[p] + 2;//长度等于爸爸+2 cnt[tot] = cnt[fail[tot]] + 1;//回文串数量等于fail的数量加上它自己 } return cnt[now = t[p][c - 'a']];//更新它现在的位置,并返回cnt作为答案 } int main(){ scanf("%s", s + 1); n = strlen(s + 1); fail[0] = 1, len[1] = -1; //len[i] 表示节点 i 所对应的回文串长度 for(int i=1;i<=n;i++){ if(i > 1) s[i] = (s[i] - 'a' + lst) % 26 + 'a'; printf("%d ", lst = insert(s[i], i)); } return 0; } ``` ~~这代码只能说一个字:短。~~ 秒完这题,大家也可以去看看 [P4287 双倍回文](https://www.luogu.com.cn/problem/P4287) 和其他一些比较基础的题目,加深理解。 到此结束吧,记得留下你们的赞哦!