回文自动机学习笔记
1. 引入
回文自动机(PAM),又称回文树,是一种高效存储所有回文子串的自动机,用于解决回文串相关的问题
2. 构建
2.1. 状态
我们用PAM上的一个节点表示一个回文子串,作为PAM的一个状态,但是回文串分为奇偶两个状态,因为它是一个类似于树形的结构,所以像manacher一样在中间价一个分隔符是很麻烦的
所以可以将自动机中的状态分为两部分,一部分存奇回文,另一部分存偶回文
同理,跟也就分为奇根和偶根,像AC自动机一样,作为初始状态
因为存储的是回文串,所以对于一个串记录其中一半位置的字符是什么
定义PAM上的一个点到根的路径上的字符串表示它所代表的回文串的其中的一半,也就是对于一个点它实际表示的回文串,在PAM上的读法是从它开始沿着树读到根,再原路返回,对于奇回文,与根相连的边只走一次,对于偶回文,与根相连的边走两次
例如abaabc建出的回文树(省略fail指针)就是:
根据上面的描述,例如点4表示aba,点6表示baab,点7表示c
2.2. fail指针
与AC自动机一样,PAM也有fail指针,这里的\(fail(x)\)表示x的除自己外的最长回文后缀
对于初始状态,可以将偶根的fail指针指向奇根,因为奇根永远不会失配,所有长度为1的串都是回文串
同时,对于每个节点,我们可以记录\(len(x)\)表示它实际代表的回文串长度,用于在PAM构造时判断它在原串中的位置
对于点x,记它在PAM上的父亲为\(fa(x)\),则有\(len(x)=len(fa(x))+2\)
同时为了让奇回文串也满足条件,令\(len(1)=-1\)
继续以abaabc为例,加上fail指针后是:
2.3. 具体构造
PAM的构造方法是在线的
所有一个长度为n的字符串s,假设已经构造完了n-1个字符,现在要加入第n个字符,记为c,前n-1个字符最长回文后缀对应的状态是now
要新增的状态是以第n个字符结尾的回文串,考虑到。当一个回文串前后各去掉一位,结果仍是回文串,所以将以第n个字符结尾的字符串去掉一位,必然是以n-1结尾的一个字符串
所有我们要找的就是以n-1结尾的回文串中,前一位恰好是c的最长的串
因为上一个节点狗仔到了now,所以可以不断令\(now=fail(now)\),直到满足条件
设满足条件的状态为pos,那么以n结尾的最长回文后缀就是在x的前后各加一个c
根据PAM的定义,这个状态就是pos通过字符c的边连向的儿子
如果pos没有对应的儿子,代表这个回文串是新增的,我们可以添加一个新的状态,否则既然已经存在就不要管了
那么对于这个新的状态,可以证明只有最长的这个回文后缀是新增的。考虑一个比它短的回文后缀,可以在最长的里面对称到一个不包括 n的字符串,这一定是以前出现过的状态
记新节点为new,容易得出\(fa(new)=pos\),\(len(new)=len(pos)+2\)
发现求fail指针和求最长以n结尾的回文后缀是一样的,找到第一个能在前后各加一个c的回文串即可
2.4. trans指针
小于等于当前节点长度一半的最长回文后缀
当建出一个新的节点时,如果它的长度小于等于2,那么它的trans直接指向它的fail
否则同理从它的父亲的trans指针开始跳fail,直到跳到某一节点表示的回文串的两侧都能扩展这个字符,并且扩展后的长度小于等于当前节点长度的一半
那么,就让新建节点的trans指向该节点的儿子
2.5. 代码
https://gxyzoj.com/d/gxyznoi/p/117
#include<cstdio>
#include<string>
#include<iostream>
using namespace std;
int n,tot=1;
string s;
struct node{
int len,fail,dep,ch[26];
}tr[500005];
int getfail(int u,int i)
{
// printf("%d %d\n",u,i);
while(i-tr[u].len-1<=0||s[i-tr[u].len-1]!=s[i])
{
u=tr[u].fail;
}
return u;
}
int main()
{
cin>>s;
n=s.size();
s=" "+s;
tr[0].fail=1,tr[1].len=-1;
int now=0,lst=0;
for(int i=1;i<=n;i++)
{
s[i]=(s[i]-97+lst)%26+97;
int pos=getfail(now,i);
int c=s[i]-97;
if(!tr[pos].ch[c])
{
tr[++tot].fail=tr[getfail(tr[pos].fail,i)].ch[c];
tr[pos].ch[c]=tot;
tr[tot].len=tr[pos].len+2;
tr[tot].dep=tr[tr[tot].fail].dep+1;
}
now=tr[pos].ch[c];
lst=tr[now].dep;
printf("%d ",lst);
}
return 0;
}
3. 例题
3.1. [SHOI2011] 双倍回文
https://gxyzoj.com/d/gxyznoi/p/P118
3.1.1. 思路
trans指针模版
对于每个回文串,判断它的trans的长度是不是原串的\(\frac{1}{2}\),且它是不是偶回文即可
3.1.2. 代码
#include<iostream>
#include<string>
#include<algorithm>
using namespace std;
int n,tot=1,ans;
string s;
struct node{
int len,fail,trans,ch[26];
}tr[500005];
int get_fail(int u,int i)
{
while(i-tr[u].len-1<=0||s[i]!=s[i-tr[u].len-1])
{
u=tr[u].fail;
}
return u;
}
int get_trans(int u,int i)
{
while(((tr[u].len+2)<<1)>tr[tot].len||s[i-tr[u].len-1]!=s[i])
{
u=tr[u].fail;
}
return u;
}
int main()
{
scanf("%d",&n);
cin>>s;
s=" "+s;
tr[0].fail=1,tr[1].len=-1;
int now=0;
for(int i=1;i<=n;i++)
{
int pos=get_fail(now,i);
// printf("%d\n",pos);
int c=s[i]-97;
if(!tr[pos].ch[c])
{
tr[++tot].fail=tr[get_fail(tr[pos].fail,i)].ch[c];
tr[pos].ch[c]=tot;
tr[tot].len=tr[pos].len+2;
if(tr[tot].len<=2) tr[tot].trans=tr[tot].fail;
else
{
int p=get_trans(tr[pos].trans,i);
tr[tot].trans=tr[p].ch[c];
}
}
now=tr[pos].ch[c];
}
for(int i=2;i<=tot;i++)
{
if(tr[tr[i].trans].len*2==tr[i].len&&tr[tr[i].trans].len%2==0)
{
ans=max(ans,tr[i].len);
}
}
printf("%d",ans);
return 0;
}
3.2. I Love Palindrome String
https://gxyzoj.com/d/gxyznoi/p/P119
和上一题很像,但这次对于奇数是可以过中点的,所以trans指针就很难实现了,考虑哈希
可用哈希记录每一位的值,然后在加新点时直接计算前一半和后一半是否一样
对于计数方面,则是从叶子像跟转移,如果该串路径fail经过的一个点x,则x的个数就要+1
3.3. [CERC2014]Virus synthesis
https://gxyzoj.com/d/gxyznoi/p/P120
这道题要求次数最少,所以操作2的次数要尽量多,因为操作2是将一个串和它的反串拼接,所以拼出的结果必然是回文串,而且长度为2的倍数
所以可以将原来的串分成一个偶回文串和一些其他字符,这时,最少次数为偶回文串的操作次数加其他字符个数
设\(f_i\)表示结尾是点i的回文串的最少操作次数,初始值为长度
对于回文串v,它可以由回文串x左右各加同一个字符转移过来,所以\(f_v=min(f_v,f_x+1)\)
也可以从长度不超过v一半的回文串转移过来,即\(f_v=min(f_v,f_x+\dfrac{len_v}{2}-len_x)\)
这部分可以使用bfs,因为只有偶回文串可以求解,所以要从0开始