前言:
回文自动机($PAM$),也叫回文树
可以用 $O(n)$ 的时间复杂度求出一个字符串的所有回文子串
本蒟蒻是学了两遍才学明白的,这里推荐一下B站上关于回文自动机的讲解
当然如果不方便看视频的话,也可以看一下我自己关于回文自动机的一些理解
正文:
节点含义
类比 $AC$ 自动机每个节点的含义
回文自动机每个节点的含义表示在它的父节点两侧各加上一个儿子字符
奇根偶根
由于回文串有奇数长度和偶数长度两种
所有我们的回文自动机自然会有两个根——奇根和偶根
偶根的节点编号为 0,所代表的回文串的长度为 0,$fail$ 指针指向奇根
奇根的节点编号为 1,所代表的回文串的长度为 -1,$fail$ 指针指向自身(其实无所谓)
$fail$ 指针
我们再来说一下 $fail$ 指针的含义
一个节点的 $fail$ 指针,指向的是这个节点的最长回文后缀
所以在新加入一个字符的时候,我们要从当前节点不断的跳 $fail$ 指针
直到跳到某一个节点所表示的回文串的两侧都能扩展一个待添加的字符
我们就看这个节点有没有这个儿子,如果有就直接走下去,没有就新建一个节点
新建节点的长度等于这个节点的长度加上 2(因为是回文串,要在两侧各扩展一个字符)
那每个节点的 $fail$ 指针要怎么求那
我们可以考虑一个节点的最长回文后缀
必然是在它父节点的某个回文后缀两侧各拓展一个当前字符得到的
所以新建一个节点之后,我们可以从它父亲的 $fail$ 节点开始,不断的跳 $fail$ 指针
直到跳到第一个两侧能拓展这个字符的节点为止,那么该节点的儿子就是新建节点最长回文后缀
之后我们再看一下奇根和偶根的 $fail$ 指针,由于奇根的子节点表示的回文串长度为 1,也是就该字符本身
所以奇根相当于是可以向两侧扩展任意字符的,所以我们把偶根的 $fail$ 指针指向奇根
而如果跳到了奇根,一定能向两侧扩展,所以奇根的 $fail$ 指针自然就无所谓了
代码实现起来非常的简单
char s[maxn]; int cnt,last;//cnt表示节点数,last表示当前节点 int sum[maxn];//统计每个回文串的出现次数 int son[maxn][26];//每个节点的儿子 int len[maxn],fail[maxn];//len表示当前节点回文串的长度,fail如上所述 int new_node(int length)//新建一个节点 { len[++cnt]=length; return cnt; } int get_fail(int pre,int now)//跳fail指针 { while(s[now-len[pre]-1]!=s[now]) pre=fail[pre]; return pre; } void build_PAM() { cnt=1,last=0;//奇根编号为1,其他节点从2开始 len[0]=0,len[1]=-1;//初始化,如上所述 fail[0]=1,fail[1]=1;//初始化,如上所述 for(int i=1;s[i];i++) { int cur=get_fail(last,i);//从当前节点开始,找到可扩展的节点 if(!son[cur][s[i]-'a'])//没有这个儿子 { int now=new_node(len[cur]+2);//新建节点 fail[now]=son[get_fail(fail[cur],i)][s[i]-'a'];//找到最长回文后缀 son[cur][s[i]-'a']=now;//父子相认 } sum[last=son[cur][s[i]-'a']]++;//顺带求出每个回文串的出现次数 } }
应用
如果要求本质不同的回文串的个数,直接输出 $cnt-1$ 即可(除去奇根)
如果要统计每个回文串的出现次数,还要从叶子节点向根遍历一遍
因为我们当时统计回文串时只统计了完整的回文串,但并没有记录它的子串
所以我们要按照拓扑序将每个节点的最长回文后缀的出现次数加上该节点的出现次数
这样我们就得到了一个字符串的所有回文子串的出现次数
for(int i=cnt;i>=2;i--) sum[fail[i]]+=sum[i]
另外,回文自动机还有一种常见操作就是在构造的时候顺带求出一个 $trans$ 指针
$trans$ 指针的含义是小于等于当前节点长度的一半最长回文后缀,求法和 $fail$ 指针的求法类似
当我们新建一个节点后,如果它的长度小于等于 2,那么这个节点的 $trans$ 指针指向它的 $fail$ 节点
否则的话,我们同理从它父亲的 $trans$ 指针指向的节点开始跳 $fail$ 指针
直到跳到某一个节点所表示的回文串的两侧都能扩展这个字符
并且拓展后的长度小于等于当前节点长度的一半
那么新建节点的 $trans$ 的指针就指向该节点的儿子
for(int i=1;s[i];i++) { int cur=get_fail(last,i); if(!son[cur][s[i]-'a']) { int now=new_node(len[cur]+2); fail[now]=son[get_fail(fail[cur],i)][s[i]-'a']; son[cur][s[i]-'a']=now; //顺带求出trans指针 if(len[now]<=2) trans[now]=fail[now]; else { int tmp=trans[cur]; while(s[i-len[tmp]-1]!=s[i]||((len[tmp]+2)<<1)>len[now]) tmp=fail[tmp]; //拓展后的长度为len[tmp]+2 trans[now]=son[tmp][s[i]-'a']; } } last=son[cur][s[i]-'a']; }
有了 $trans$ 指针之后,我们就可以很轻松的切了这道双倍回文(题目来自洛谷P4287)
后序:
关于马拉车($Manacher$)算法
它可以 $O(n)$ 得到一个字符串以任意位置为中心的回文子串
而且我们的回文自动机好像并不能取代它,比如它的模板题(题目来自洛谷P3805)
然而本蒟蒻对这个算法还不是很熟,所以有关它的总结,可能要很久~很久~以后才会写了