回文自动机-PAM

PAM-回文自动机

内容来自Vscoder

解释:

回文自动机是接受一个字符串的所有回文子串的自动机。

定义:

节点

回文自动机中每个点表示在它的父节点两侧各加上一个儿子字符。

奇根偶根

由于回文串有奇数长和偶数长两种情况,所以我们的回文自动机会有两种根-奇根和偶根。

偶根的节点编号为 \(0\) ,所代表的回文串长度为 \(0\) , \(fail\) 指针指向奇根

奇根的节点编号为 \(1\) ,所代表的回文串长度为 \(-1\) ,\(fail\) 指针指向自己。

\(fail\) 指针

一个节点的 \(fail\) 指针,指向的是这个节点的最长回文后缀。

新加入一个字符时,我们要从当前节点不断跳 \(fail\) 指针,直到跳到某一个节点所表示的回文串的两侧都能扩展一个待添加的字符.

我们就看这个节点有没有这个儿子,如果有就直接走下去,没有就新建一个节点.

新建节点的长度等于这个节点的长度加上 \(2\)(因为是回文串,要在两侧各扩展一个字符)

对于每个节点的 \(fail\) 指针,我们可以考虑一个结点的最长回文后缀,必然是在它父节点的某个回文后缀两侧拓展一个当前字符得到的。

所以新建一个节点之后,我们可以从它父亲的 fail 节点开始,不断的跳 fail 指针

直到跳到第一个两侧能拓展这个字符的节点为止,那么该节点的儿子就是新建节点最长回文后缀

之后我们再看一下奇根和偶根的 \(fail\) 指针,由于奇根的子节点表示的回文串长度为 \(1\),也是就该字符本身

所以奇根相当于是可以向两侧扩展任意字符的,所以我们把偶根的 \(fail\) 指针指向奇根

而如果跳到了奇根,一定能向两侧扩展,所以奇根的 \(fail\) 指针自然就无所谓了

建立过程:

const int N=1e5+5;
char s[N];
int cnt,last;//cnt表示节点数,last表示当前节点
int sum[N];//统计每个回文串的出现次数
int son[N][26];//每个节点的儿子
int len[N],fail[N];//len表示当前回文串的长度,fail就是这个节点的最长回文后缀

int new_node(int length){len[++cnt]=length; return cnt;}
int getfail(int x,int now){
    while(s[now-len[x]-1]!=s[now]) x=fail[x];//跳到第一个两侧能拓展这个字符
    return x;
}
void build_PAM(){
    cnt=1,last=0;
    len[0]=0,len[1]=-1; fail[0]=1; fail[1]=1;
    for(int i=1;s[i];i++){
        int now=getfail(last,i),x=s[i]-'a';//从当前节点开始,找到可拓展的节点
        if(!son[now][x]){//没有这个儿子
            int newnode=new_node(len[now]+2);//新建节点
            fail[newnode]=son[getfail(fail[now],i)][x];//找到最长回文后缀
            son[now][x]=newnode;//将这个now点对应x后缀设为新节点
        }
        sum[son[now][x]]++;//求出每个字符串出现的个数 
        last=son[now][x];
    }
}

应用:

1. 求本质不同的回文串的个数

直接输出 \(cnt-1\) 即可。

2. 统计每个回文串的出现次数,

还要从叶子节点向根遍历一遍

因为我们当时统计回文串时只统计了完整的回文串,但并没有记录它的子串

所以我们要按照拓扑序将每个节点的最长回文后缀的出现次数加上该节点的出现次数

这样我们就得到了一个字符串的所有回文子串的出现次数

for(int i=cnt;i>=2;i--) sum[fail[i]]+=sum[i];

3. 统计当前节点为结尾的回文子串个数

考虑在建立过程中在线求。

我们开一个数组 \(num[i]\) 记录节点号为 \(i\) 的回文子串个数。

因为建立的新节点 \(x\),是在 \(fail[x]\) 基础上左右添加了一个相同字符,则有公式:

\(num[x]=num[fail[x]]+1\)

输出 \(num[x]\) 即可。

例题:P5496 【模板】回文自动机(PAM)

拓展:

此外,回文自动机还有一种常见操作,就是在构造的时候求出 \(trans\) 指针。

\(trans\) 指针含义:小于等于当前节点长度一半的最长回文后缀

求法和 \(fail\) 指针的求法类似:

  1. 当我们新建一个节点,如果\(len \leq 2\) ,那么这个节点的 \(trans\) 指针指向它的 \(fail\) 节点。

  2. 否则,同理从它父亲的 \(trans\) 指针指向的节点开始跳 \(fail\) 指针,直到跳到某个节点 左右两边能拓展该字符,且长度满足含义,那么该节点 \(trans\) 指针指向该节点的儿子。

void build_PAM(){
    cnt=1,last=0;
    len[0]=0,len[1]=-1; fail[0]=1; fail[1]=1;
    for(int i=1;s[i];i++){
        int now=getfail(last,i),x=s[i]-'a';
        if(!son[now][x]){
            int newnode=new_node(len[now]+2);
            fail[newnode]=son[getfail(fail[now],i)][x];
            son[now][x]=newnode;
            //顺带求出trans指针 
            if(len[newnode]<=2) trans[newnode]=fail[newnode];
            else{
                int tmp=trans[now];
                while(s[i-len[tmp]-1]!=s[i]||((len[tmp]+2)<<1)>len[newnode]) tmp=fail[tmp];
                 //拓展后的长度为len[tmp]+2
                trans[newnode]=son[tmp][x];
            }
        }
        last=son[now][x];
    }
}

例题:P4287 [SHOI2011]双倍回文

关于预处理:

有时候会因为输入从 \(s[0]\)\(s[1]\) 而导致初始化不正确.

因此定义: 读入都从 \(s[1]\) 开始,则初始化代码为:

void init(){
    len[1]=-1; fail[1]=fail[0]=1; cnt=1; last=0;
}

此外,当多组数据时,可以当在此搜索到这一层时,将其清 \(0\),这样可以防止超时,如下面:

int new_node(int x){
    len[++cnt]=x;
    memset(son[cnt],0,sizeof(son[cnt])*5); 
    return cnt;
}

当读入字符数量少,或不标准为大写/小写,我们可以进行编号处理。

val['A']=0, val['T']=1,val['C']=2, val['G']=3;

insert(val[s[i]]);

像其他自动机一样,\(fail\) 数组可以看成是拓扑序,因此我们可以从上到下,算出来每个点对应的回文串在这个串里的个数有多少:

for(int i=cnt;i>=2;i--) if(fail[i]>1) num[fail[i]]+=num[i];

据此,我们可以求出 \(x\) 表示的节点的回文串的后缀出现次数的和

for(int i=2;i<=cnt;i++) num[i]+=num[fail[i]];

精选例题:

[CERC2014]Virus synthesis

P5685 [JSOI2013]快乐的 JYY

在我博客里都有题解哦 _

posted @ 2021-09-24 23:01  Evitagen  阅读(79)  评论(1)    收藏  举报