回文自动机(PAM)学习笔记
前言:
- 参考博文:
- 如文章有错误或者有更好的理解或者有其他问题,请联系我:
- 微信/QQ同号:615863087
前置知识:
- (trie)树
- 自动机的相关概念(没有影响也不大)
初识回文自动机:
- 同其他自动机一样,回文自动机也是一个(DAG)。
- 回文自动机其实就是回文树,由俄罗斯人(MikhailRubinchik)于(2014)年夏天发明。
- 回文自动机并不是严谨的树形结构,他的结构是两棵树,不妨先理解为回文(trie)树。其中(0)号节点((0)号树的根节点)为长度为偶数的回文串的根,(1)号节点((1)号树的根节点)为长度为奇数的回文串的根。
变量定义:
-
struct PAM_Trie { int ch[30]; //字典树 int fail; //fail指针,指向当前节点所表示的回文串的最长回文后缀(不包括自己) int len; //len表示当前节点表示的回文串的长度 int num; //以第i个字符结尾的回文串的个数 }; struct PAM { PAM_Trie b[maxn]; //字典树 int len_str; //字符串的长度 int last; //上一次插入的节点编号 int cnt; //总的节点的个数 int s[maxn]; //原字符串对应的ASCII码 char c[maxn]; //原字符串 }
-
回文自动机和(trie)树一样,将信息存储在边上。
-
回文自动机的每一个节点(除了根)都表示一个回文串,一个节点向下连一条边(ch)代表在他两边各自加一个字符,即(len+=2)。
- 但对于(1)号树的根(1)号节点,因为(1)号树代表长度为奇数的回文串。所以这时候将(1)号节点的初值的(len)赋为(-1),方便接下来的操作。
-
如图所示加深理解:
-
如图所示,他每个节点所代表的回文串分别是(从(2)到(5)):(aa,a,b,aba)。(这里这张图只是增加感性认识,真实构建可能不会出现此形态的自动机。)
-
因为(1)号树是存长度为奇数的回文串,每次操作又要(len+=2),为了减少特判,相信这里大家也能明白这里为什么(len(1))的初值为(-1)。
(fail)指针:
- 与(AC)自动机类似,回文自动机的节点也有(fail)指针,他指向当前节点所表示的回文串的最长回文后缀(不包括自己)。
- 特殊的,(1)号节点的(fail)指针指向(0)号结点。(0)号节点的(fail)指针指向(1)号节点。
构建回文自动机:
-
我们现在从左往右插入字符串中的每一个字符。
-
(last)为上次插入字符的节点编号,初始(last)为(0)。
-
对于每个字符我们需要在回文自动机上寻找以他结尾的最长回文串。对于字符(i)。
-
我们看上一个插入的结点,即(last)。
-
如果(str(i-len(last)-1)==str(i)),说明两端相等。
- 这里举个例子加深理解,比如当前插入到了(abb)。这时候(b)为(last)且此时的(len(last)=2)。
- 如果我这时候的(i==4)且(str(i)==a),那么就相当于去比较前面的(a)和后面的新遍历到的(a)是否相等。
- 自然他是相等的,那么我就可以插入这个(a)了。且我们知道此时的(len(i==4)=4)。
-
否则就跳转到(fail(last)),进行上一步的匹配判断直到走到(1)号结点。(回顾一下(fail)指针:指向当前节点所表示的回文串的最长回文后缀(不包括自己))
- (1)号结点(len(1)=-1),说明此时(str(i-(-1)-1)==str(i))成立,即自己等于自己,可以回文,且长度为(len(1)+2=-1+2=1),这里是(len(1)=-1)的又一个好处。
-
在字典树中插入该节点。
-
构建该节点的(fail)指针。
- 构建(fail)指针其实是比较抽象的一步,我们模拟代码加深理解。(建议拿笔且根据文字描述来画一下图)。
- 假设说我当前操作的字符串是(abba)。我们从左到右一个一个来插入。
- 初始时(0)号结点(fail)指向(1)号结点,(1)号结点(fail)指向(2)号结点,(last=0)。
- 1:插入(a)
- (last)一开始为0,而(str(i-len(last)-1)!=str(i)),即(str(0)!=str(1)),即不能构成回文串,此时(last)跳转到(fail(last)==fail(0)==1),此时(str(i-len(last)-1)==str(i)),即(str(1)==str(1)),也就是单个字符构成了回文串,我们在图中连一条边(1-2-a)。同时更新(len(2)=len(1)+2=-1+2=1)
- 接下来给当前插入的(2)号节点构建(fail)指针。我们此时发现当前节点为(a),没有不包含自己的回文后缀,所以(fail(2)=0),也可以看下面的代码模拟一下,最后(fail)会指向0。
- 2:插入(b)
- 此时(last==2),而(str(i-len(last)-1)!=str(i)),即(str(2-1-1)!=str(2)),这时候其实本质上我们判断的是中间这个回文串的两边是不是相等的。(可以结合我上面举得例子来理解)我们发现不相等,于是(last=fail(last)==0),又不满足条件(str(i-len(last)-1)!=str(i)),再接着跳,就跳到了(1)号节点,这时候和第一步插入和构建(fail)指针都和第一步插入(a)一样。
- 所以此时连接(1-3-b),且(fail(3)=0,len(3)=1)。
- 3:插入(b)
- 此时(last==3),我们发现(str(i-len(last)-1)!=str(i)),即(str(1)!=str(3))。(如果这里插入的是(a)就相同了),于是(last=fail(last)=0),这时候(str(i-len(last)-1)==str(i)),即(str(3-0-1)==str(2)==str(3)),于是我们就在图中加入新边(0-4-b)。(len(3)=len(0)+2==2),当前回文串为(bb),由(4)号结点表示。
- 对当前插入的(b),构建(fail)指针,我们知道(fail)指针指向不包含自己的回文后缀。对于当前回文串(bb)来说,不包含他自己的回文后缀就是最后一个字母(b),那么此时(fail)指针就应该指向当前已有的且能表示(b)的这个节点,即(3)号节点,(fail(4)=3)。更新(last=4)。
- 4:插入(a)。
- 此时(last==4),我们发现(str(i-len(last)-1)==str(i)),即(str(4-2-1)==str(1)==str(4)),更新(len(4)=len(3)+2=2+2=4),连接新边(4-5-a)。
- 重点理解:对当前插入的(a)构建(fail)指针,我们知道(fail)指针指向不包含自己的回文后缀。对于当前回文串(abba)来说,不包含自己的回文后缀就是最后一个字母(a)。
- 我们模拟一下代码。当前的(last==4)。
- 当前的(last),(fail(p)==fail(last)==3)。
- 对于(3)号节点(str(4-len(3)-1)==str(4-1-1)==str(2)!=str(4)),我们让(last=fail(last)=fail(3)=0),此时(str(4-len(0)-1)==str(4-0-1)==str(3)!=str(4)),因为(ba)不是回文串,接着跳(fail(0)=1),到了(1)号结点。此时(str(4-len(1)-1)==str(4-(-1)-1)==str(4)==str(4))匹配成功,说明(abba)最长回文后缀为(a),所指向的节点为(b[1].ch(a)=2)号结点,即(fail(4)=2)。(last=5)
- 当然此时可以在插入一个(a)试试,会发现需要连(0-6-a),(len(6)=2),(fail(6)==2),(last=6)。
- 成图:
-
更新(last)为当前插入的节点。
-
强烈建议模拟一遍加深印象。
模板题:洛谷_5496
#include<bits/stdc++.h>
using namespace std;
const int maxn = 5e5 + 10;
struct PAM_Trie
{
int ch[30]; //字典树
int fail; //fail指针,指向当前节点所表示的回文串的最长回文后缀(不包括自己)
int len; //len表示当前节点表示的回文串的长度
int num; //以第i个字符结尾的回文串的个数
};
struct PAM
{
PAM_Trie b[maxn]; //字典树
int len_str; //字符串的长度
int last; //上一次插入的节点编号
int cnt; //总的节点的个数
int s[maxn]; //原字符串对应的ASCII码
char c[maxn]; //原字符串
PAM() //构造函数自动调用
{
b[0].len = 0; //初始状态下长度为0
b[1].len = -1; //1号节点len=-1方便接下来的操作
b[0].fail = 1; //0号节点fail指向1号节点
b[1].fail = 0; //1号节点fail指向0号节点
last = 0; //last初始值为0
cnt = 1; //cnt=1是因为初始的时候有1号结点
//插入的时候可用++cnt
}
//读入字符串
void read()
{
scanf("%s", c + 1);
len_str = strlen(c + 1);
}
//寻找当前
int get_fail(int las, int i)
{
//假如说我刚进来
//las是上一次的插入的节点
//我们又知道len(las)代表以las这个点的回文串
//所以这是一段中间是回文串的字符串
//我们只需要验证两端,也就是i-len(las)-1和i如果相等
//则说明i这个点的最长回文串长度是len(las)+2
//不匹配的话,las就跳转到fail指针指向的节点接着找
while(s[i-b[las].len-1] != s[i])
las = b[las].fail;
return las;
}
//新建节点
void ins(int i)
{
int p = get_fail(last, i);
//找当前节点两端能匹配的那个位置
if(!b[p].ch[s[i]]) //如果当前trie图里没有这个节点
{
//新节点的长度是前后各自拼接了一个字符
//所以当前节点的长度比原来多2
b[++cnt].len = b[p].len + 2;
//为当前位置寻找fail指针
int tmp = get_fail(b[p].fail, i);
b[cnt].fail = b[tmp].ch[s[i]];
//更新答案 同时给trie图中的该节点赋值
b[cnt].num = b[b[cnt].fail].num + 1;
b[p].ch[s[i]] = cnt;
}
//更新last(上一次插入的节点)
last = b[p].ch[s[i]];
}
//这题题目要求第i个位置答案是k,第i+1个位置代表的
//字符就变成了(c-97+k) % 26 + 97
void solve()
{
int k = 0;
s[0] = int('#'); //这里写啥都好, 只要input里不会出现就行
for(int i = 1; i <= len_str; i++)
{
c[i] = (c[i] - 97 + k) % 26 + 97;
s[i] = c[i] - 'a';
ins(i); //在自动机中插入结点
printf("%d ", b[last].num);
//输出以当前字符结尾的回文串数量
k = b[last].num; //跟随题意
}
}
}P;
int main()
{
P.read();
P.solve();
return 0;
}