回文自动机及其可持久化

这玩意也叫回文树。主要解决回文串的问题。

回文串是一种十分特殊的字符串,拥有很多优美的性质。近年来,算法竞赛中有关回

文串的题目比较热门,但由于与回文串相关的算法比较贫乏,导致题目的解法比较单一。
回文树是一种新兴的数据结构,由Mikhail Rubinchik在2015年发表。(战斗民族发明的数据结构)。

这玩意在IOI2017中国国家候选队论文集里有,翁文涛dalao的《回文树及其应用》。

首先我们定义一些变量。
1.len[i]表示编号为i的节点表示的回文串的长度
2.net[i][c]表示编号为i的节点表示的回文串在两边添加字符c以后变成的回文串的编号(和Trie类似)。
3.fa[i]表示节点i失配以后跳转不等于自身的节点i表示的回文串的最长后缀回文串(类似后缀自动机的parent指针,就是找父亲)。
4.cnt[i]表示节点i表示的本质不同的串的个数(建树时求出的是极大回文串的个数,最后按照拓朴序跑一遍以后才是正确的)。
5.num[i]表示以节点i表示的最长回文串的最右端点为回文串结尾的回文串个数,也可以用来在建树过程中统计下标j的为结尾的回文串个数。
6.last指向新添加一个字母后所形成的最长回文串表示的节点。
7.S[i]表示第i次添加的字符(一开始设S[0] = -1(可以是任意一个在串S中不会出现的字符))。
8.p表示添加的节点个数。
9.n表示添加的字符个数。

那么我们就可以猛的搞一波事情了。

下面是一个字符串abbaabba的回文自动机的建立过程。 

我们可以发现,回文自动机是两颗树交错地生长在一起。

一颗的根为0,表示是偶数个回文串,一颗是1,表示是奇数数个回文串。

0指向1。(因为我们失配后最小的回文是单个字符本身。)
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 
这里写图片描述 

从这里我们可以发现令len[1]=-1的好处了,我们可以不用特判而加入单个字符的回文串。

那么我们就把朴素的PAM(回文自动机)讲完了,看一道例题:BZOJ 3676

#include<bits/stdc++.h>
#define N 300007
using namespace std;
int w,p,q,tot,now,last,cnt[N],fa[N],net[N][26],len[N],L;
int c[N],id[N];
long long ans;
char a[N];
void extend(int x){
    w=a[x]-'a'; p=last;
    while (a[x]^a[x-len[p]-1]) p=fa[p];
    if (net[p][w]) cnt[net[p][w]]++;
    else {
        q=++tot; len[q]=len[p]+2;now=p;
        do now=fa[now];while(a[x]^a[x-len[now]-1]);
        fa[q]=net[now][w];
        net[p][w]=q; cnt[q]=1;
    }  last=net[p][w];
}
int main () {
    freopen("a.in","r",stdin);
    scanf("%s",a+1);
    L=strlen(a+1);
    fa[0]=tot=1; len[1]=-1;
    for (int i=1;i<=L;i++) 
     extend(i);
    for (int i=2;i<=tot;i++) c[len[i]]++;
    for (int i=1;i<=L;i++) c[i]+=c[i-1];
    for (int i=2;i<=tot;i++) id[c[len[i]]--]=i;
    for (int i=tot-1;i;i--)  cnt[fa[id[i]]]+=cnt[id[i]];
    for (int i=2;i<=tot;i++) ans=max(ans,1ll*len[i]*cnt[i]);
    printf("%lld\n",ans);
}

我们考虑如何删除一个节点。

我们考虑可持久化

 我们发现直接对PAM可持久化的时间复杂度是不对的。我们发现PAM的时间复杂度是基于势能分析的,那么我们就不能直接持久化。因为我们如果反复插入删除复杂度高的操作,会退化成O(nq)的复杂度(q为操作数)。

我们需要一种不基于势能分析的插入算法之前的插入算法的瓶颈在于,每次插入一个字符c,都要沿着当前最长回文后缀t的f ail链往上找到第一个v使得v在s中的前驱(即v的前一个字符)为c。注意到除了v = t的情况,v的前驱总是在t内的,也就是说对于每个t,是要找到一个最长的t的回文后缀满足其前驱为c,这个回文后缀只与t相关而不与s相关。因此,对于每个回文树中的节点t,另外维护一个失配转移数组quick[c],存储t的最长的满足前驱为c的回文后缀。那么在插入时,我们只需首先检查当前最长回文后缀t的前缀是否为c,假如不是,那么合法的v直接就是t的quick[c]。接下来考虑怎么维护每个节点的quick。对于一个节点t,t的quick与f a的quick几乎没有什么差别,因为t的回文后缀只是在f a的回文后缀中再加入了f a而已。首先将把fa的quick 复制一遍作为t的quick,令c为f a在t中的前驱,用f ailt更新t的quick[c]即可。直接暴力操作每次插入的时空复杂度都是O(1)。可以将每个t的quick可持久化,一种做法是用可持久化线段树来可持久化数组。每次复制时只需要将版本复制,复杂度降为O(1)。插入的时空复杂度为O(log n )。最后可以得到一个不基于势能分析的单次插入时空复杂度为O(log ) 的算法。

同样是BZOJ 3676,严格O(1)写法,不过常数很大呀。

//想要可持久化的同学请可持久化quick数组。

 

#include<bits/stdc++.h>
#define N 300007
using namespace std;
int w,p,q,tot,now,last,cnt[N],fa[N],net[N][26],len[N],L,quick[N][26];
int c[N],id[N];
long long ans;
char a[N];
void extend(int x){
    w=a[x]-'a'; p=last;
    if (a[x-len[p]-1]^a[x]) p=quick[p][w];
    if (net[p][w]) cnt[net[p][w]]++;
    else {
        q=++tot; len[q]=len[p]+2; now=fa[p];
        if (a[x-len[now]-1]==a[x]) fa[q]=net[now][w];
        else fa[q]=net[quick[now][w]][w];
        memcpy(quick[q],quick[fa[q]],sizeof quick[q]);
         quick[q][a[x-len[fa[q]]]-'a']=fa[q];
        net[p][w]=q; cnt[q]=1;
    }  last=net[p][w];
}
int main () {
    freopen("a.in","r",stdin);
    scanf("%s",a+1);
    L=strlen(a+1);
    fa[0]=tot=1; len[1]=-1;
    for (int i=0;i<26;i++) quick[1][i]=quick[0][i]=1;
    for (int i=1;i<=L;i++) 
     extend(i);
    for (int i=2;i<=tot;i++) c[len[i]]++;
    for (int i=1;i<=L;i++) c[i]+=c[i-1];
    for (int i=2;i<=tot;i++) id[c[len[i]]--]=i;
    for (int i=tot-1;i;i--)  cnt[fa[id[i]]]+=cnt[id[i]];
    for (int i=2;i<=tot;i++) ans=max(ans,1ll*len[i]*cnt[i]);
    printf("%lld\n",ans);
}

 

 

在之前回文树的构造中,实现了在一个串末端加入一个字符并维护出新的回文树的操作。现在可以尝试对字符串的开头加入一个字符并维护新的回文树。与末端插入类似的,可以发现在前端插入时,比如当前字符串为s,要求出cs的回文树,那么以c为开头的产生的新的回文串最多只有1个,并且是cs的最长回文前缀。并且由之前的过程可知,只需要找到s的一个最长的回文前缀t,满足s[|t| + 1] = c,那么新的最长回文前缀就是ctc。要找到t,不妨在回文树上对每个节点再维护一个f ail0指针,表示这个节点的最长回文前缀指向的节点,那么每次加入新字符的时候,只需要沿着s的最长回文前缀的f ail0链找到一个最长的合法的t即可。注意到回文树上的每个节点都是一个回文串(除了根节点),对于一个回文串t,会有t的翻转与t相同,也就是说,对于t的每个回文后缀t[i…|t|],他的翻转与t[1…|t| 􀀀 i + 1] 的是相同的,而他本身就是一个回文串,因此t[i…|t|]的翻转就等于t[i…|t|],也就是说t[i…|t|] =t[1…|t| 􀀀 i + 1],所以,t的回文前缀与回文后缀的字符串集合其实是相同的。也就是说,回文树上一个节点的f ail0指针,其实就是它的f ail指针。因此只需要维护出字符串s的最长回文前缀与最长回文后缀,就能支持字符串的前端和末端插入了。需要注意在前(后)端插入时可能会对最长回文后(前)缀造成影响,另外维护一下就好了。时间复杂度的分析与之前类似,可以得到也是总时间复杂度O(|s| log ),空间复杂度O(|s|)。

 

#include<bits/stdc++.h>
#define N 300007
using namespace std;
int w,p,q,tot,now,last,cnt[N],fa[N],net[N][26],len[N],L,quick[N][26];
int c[N],id[N],last2;
long long ans;
char a[N];
void extbeg(int x){
    w=a[x]-'a'; p=last2;
    if (a[x+len[p]+1]^a[x]) p=quick[p][w];
    if (net[p][w]) cnt[net[p][w]]++;
    else {
        q=++tot; len[q]=len[p]+2; now=fa[p];
        if (a[x+len[now]+1]==a[x]) fa[q]=net[now][w];
        else fa[q]=net[quick[now][w]][w];
        memcpy(quick[q],quick[fa[q]],sizeof quick[q]);
         quick[q][a[x+len[fa[q]]]-'a']=fa[q];
        net[p][w]=q; cnt[q]=1;
    }  last2=net[p][w];
}
void extend(int x){
    w=a[x]-'a'; p=last;
    if (a[x-len[p]-1]^a[x]) p=quick[p][w];
    if (net[p][w]) cnt[net[p][w]]++;
    else {
        q=++tot; len[q]=len[p]+2; now=fa[p];
        if (a[x-len[now]-1]==a[x]) fa[q]=net[now][w];
        else fa[q]=net[quick[now][w]][w];
        memcpy(quick[q],quick[fa[q]],sizeof quick[q]);
         quick[q][a[x-len[fa[q]]]-'a']=fa[q];
        net[p][w]=q; cnt[q]=1;
    }  last=net[p][w];
}
int main () {
    scanf("%s",a+1);
    L=strlen(a+1);
    fa[0]=tot=1; len[1]=-1;
    for (int i=0;i<26;i++) quick[1][i]=quick[0][i]=1;
    int T=L>>1;
//  for (int i=T+1;i<=L;i++) extend(i);
    for (int i=L;i;i--) extbeg(i);
    for (int i=2;i<=tot;i++) c[len[i]]++;
    for (int i=1;i<=L;i++) c[i]+=c[i-1];
    for (int i=2;i<=tot;i++) id[c[len[i]]--]=i;
    for (int i=tot-1;i;i--)  cnt[fa[id[i]]]+=cnt[id[i]];
    for (int i=2;i<=tot;i++) ans=max(ans,1ll*len[i]*cnt[i]);
    printf("%lld\n",ans);
}

两个一起用还有一些细节:

#include<bits/stdc++.h>
#define N 300007
using namespace std;
int w,p,q,tot,now,last,cnt[N],fa[N],net[N][26],len[N],L,quick[N][26],Len;
int c[N],id[N],last2;
long long ans;
char A[N],a[N];
void extbeg(int x,int pos){ Len++;
    a[pos]=A[pos];//必须拷贝在新数组里 
    w=a[x]-'a'; p=last2;
    if (a[x+len[p]+1]^a[x]) p=quick[p][w];
    if (net[p][w]) cnt[net[p][w]]++;
    else {
        q=++tot; len[q]=len[p]+2; now=fa[p];
        if (a[x+len[now]+1]==a[x]) fa[q]=net[now][w];
        else fa[q]=net[quick[now][w]][w];
        memcpy(quick[q],quick[fa[q]],sizeof quick[q]);
         quick[q][a[x+len[fa[q]]]-'a']=fa[q];
        net[p][w]=q; cnt[q]=1;
    }  last2=net[p][w];
    if (len[last2]==Len) last=last2;//注意这里 
}
void extend(int x,int pos){ Len++;
    a[pos]=A[pos];//必须拷贝在新数组里 
    w=a[x]-'a'; p=last;
    if (a[x-len[p]-1]^a[x]) p=quick[p][w];
    if (net[p][w]) cnt[net[p][w]]++;
    else {
        q=++tot; len[q]=len[p]+2; now=fa[p];
        if (a[x-len[now]-1]==a[x]) fa[q]=net[now][w];
        else fa[q]=net[quick[now][w]][w];
        memcpy(quick[q],quick[fa[q]],sizeof quick[q]);
         quick[q][a[x-len[fa[q]]]-'a']=fa[q];
        net[p][w]=q; cnt[q]=1;
    }  last=net[p][w];
    if (len[last]==Len) last2=last;//注意这里 
}
int main () {
//    freopen("a.in","r",stdin);
    scanf("%s",A+1);
    L=strlen(A+1);
    fa[0]=tot=1; len[1]=-1;
    for (int i=0;i<26;i++) quick[1][i]=quick[0][i]=1;
    int T=L>>1;
    for (int i=T+1;i<=L;i++) extend(i,i);
    for (int i=T;i;i--) extbeg(i,i);
//    for (int i=1;i<=L;i++) extend(i);
    for (int i=2;i<=tot;i++) c[len[i]]++;
    for (int i=1;i<=L;i++) c[i]+=c[i-1];
    for (int i=2;i<=tot;i++) id[c[len[i]]--]=i;
    for (int i=tot-1;i;i--)  cnt[fa[id[i]]]+=cnt[id[i]];
    for (int i=2;i<=tot;i++) ans=max(ans,1ll*len[i]*cnt[i]);
    printf("%lld\n",ans);
}

再补一句,在trie上写PAM一定要严格的O(1)算法,先一种算法复杂度为O(simga 叶子的深度)。就像ZJOI2015诸神眷顾的幻想乡一样。

我们再考虑支持前后插入删除的PAM,我们发现删除后的结果不一定是其历史版本,还有DAG的PAM,这些都佷有研究价值。

就酱紫。

posted @ 2018-01-17 14:17  泪寒之雪  阅读(880)  评论(0编辑  收藏  举报