1 条题解
-
0
Update:2022.7.28。补充了对模板题解法的进一步说明,并对某些结论进行了补充或证明。
前言
想必各位都学过 Manacher 算法(
没关系,没学过不影响今天的内容)。不得不承认,Manacher 的确是处理回文问题 一个强力工具,可以在 的时间复杂度内求出一个字符串的最长回文子串。但是,对于回文计数类问题(比如求一个字符串有多少个本质不同的回文子串),Manacher 就心有余而力不足了。所以,我们需要一个更为强大的数据结构处理这个问题。这就是今天的回文自动机(PAM)。
概念
作为一个数据结构,首先需要明确回文自动机是个什么东西。
在开始之前,需要明确一些符号:
- :正在处理的字符串。默认下表从 开始。
- :字符串 的长度。
- :指 从第 位到第 位组成的字串。
- :指在回文自动机中编号为 的节点的父亲。
回文自动机是一棵树(因此也叫作回文树),它存的是一个字符串中的所有回文子串 的 一半。
而由于回文串又分奇回文和偶回文,所以回文自动机有两个根节点, 存的是长度为偶数的回文串, 存的是长度为奇数的回文串。
而它的每条边,代表一个字符 ,指的是从父节点所代表的字符串的头尾各加一个 的到儿子所代表的字符串。
什么意思呢?我们不妨来看张图。
以字符串 作例子。它的所有回文子串分别是:
- 奇数:;
- 偶数:。
它们建出来的回文自动机是这样的:

有没有发现回文自动机和 Manacher 的关系很像 AC 自动机与 KMP 的关系,都是爸爸和儿子。从图中我们得到两个结论:
- 除根节点外,每个节点恰好对应一个本质不同的回文子串,所以该树节点数 就是本质不同的回文子串个数(这很显然,原因跟 Trie 的储存原理是一样的,互不相同)。
- 若 号节点对应字符串长度为 , 号为 。则其余节点深度每 ,所对应的回文串长度 (因为从下到上读一遍,又从上往下又读一遍)。
所以你现在知道为什么只存回文的一半了吧?除了节省空间,还是为了避免本质相同的子串。
原理
现在我们来看看回文自动机是怎么工作的。
插入新节点
这个地方我看其他讲解的的时候懵懵懂懂的,希望这里能讲明白。
我们考虑轮到 插入时的情况。即前 个字符都已经在树上了。
显然,加入该字符时肯定会出现若干个新的回文子串。那是不是要枚举所有新出现的回文子串呢?
显然不是。回文串左右两边相等。当插入第 时,除了最长的新回文子串,其它看似新增的回文子串,其实在 之前就已经在里面了。
以下是不严谨的证明:首先我们需要明确,新增的所有回文子串必定是当前串的后缀(因为它们全都包含当前的 ,不然不叫新增)。也就是说,只要不包含 的均不是新增的。如果新增的某个串(设为 )不是最长子串(设为 ),即 ,那因为 也是回文的,所以在以 为结尾,长度为 的子串必定是 (可以理解为把 以 的回文中心为对称轴做对称)。而这个 明显不包含 ,所以并不是新增的。
说穿了,精髓就是长回文串一定包含短回文串。
所以回文自动机的空间复杂度是 的。
例如 已经插入完了 ,现在轮到插入 ,那真正没有出现过的只有 。其他的,比如说 ,在 就已经出现过了。
现在,我们就要考虑如何将 这个最长新回文子串插入到树上了。
首先, 是由 从头尾分别添加 得到的,而这个 肯定已经在树上了(插入 时已经加到树上了)。那我们只要找到 对应的节点,直接连一条 的边就完事了。
怎么找这个 呢?我敢肯定,这个 一定是 中的最长回文后缀(不信你看是不是)。为什么呢?很简单,因为 是插入 时新增的节点,肯定就是 的最长回文后缀了。
那么,由这个“最长回文后缀”你想到了什么?
没错,AC 自动机的 指针。所以,我们也定义一个类似 AC 自动机的 表示 代表的字符串的非自身最长回文后缀。只要我们不停地眺,那 中总有一个可以在两边加上你要加的 (即加上 后仍为子串)(调到 号根节点必定有解,后面会说)。然后看看合法点有没有 这条边,如果有就直接往下走,没有就新建节点。
为什么是非自身?如果加上自身,它的 指针不就指向自己了?
然后你的程序就会 T 成二臂……记 为 对应的回文子串长度。假设 为以第 位结束的最长回文子串的位置。当我们求 时,合法的转移点就必须满足 ,这个与 AC 自动机寻找 指针的原理是一样的,只是变成了判断是否回文而已。
下面是完整的回文自动机的图:

最后一个问题:为什么 ?有什么用?比如说当 插入 的时候,它的 怎么跳都是没用的(因为压根儿就没 )。这时, 只能当做单个字符挂在 的下面,而不能挂在 的下面。因此,我们需要让 指向 。至于 ,不用管,用不到,因为单独一个字符也算回文,不可能在 下失配的。
最后,后缀自动机的时间复杂度为 ( 为节点数量)。这是我自己的
xjb分析:首先, 中的每个字符都要插入,所以至少是 。然后,对于每个字符,采用 指针一层一层向上跳(可以证明, 连向的都是长度更小的,因为定义是非自身最长回文前缀),最多跳 层(最坏情况就是每个 都指向它的父亲),所以最终就是 的,十分优秀。Solution
讲完原理,看看这道题怎么做:P5496 【模板】回文自动机(PAM)。
这题要求我们输出以字符串每个位置结尾的回文子串个数。因为回文自动机本身就涉及到后缀,所以要求结尾个数,只需要定义 为以 结尾的字符串的回文子串个数。更新的话,找到 后, 就是除了 之外的回文串数量。只要 (即加上哪一个新增的最长回文子串),就是 的值。
@管理员大大 这个题没啥好分析的吧,弄懂原理就很简单了。最后就是代码了,细节都在注释中。
#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 双倍回文 和其他一些比较基础的题目,加深理解。
到此结束吧,记得留下你们的赞哦!
- 1
信息
- ID
- 11536
- 时间
- 500ms
- 内存
- 256MiB
- 难度
- 10
- 标签
- 递交数
- 4
- 已通过
- 1
- 上传者