后缀自动机学习笔记

本文章最后编辑于2018年8月18日。


写在前面

推荐hihocoder上的讲解,可以说是十分清楚了。

用好后缀自动机不是十分容易,但看完本文的总结应该能至少达到签到水平…


正文

后缀自动机(Suffix Automaton,简称SAM)。对于一个字符串S,它对应的后缀自动机是一个最小的确定有限状态自动机(DFA),接受且只接受S的后缀。

先给出一张图和一个表格。

对于字符串STR=”aabbabd”,它的后缀自动机是:

状态 子串 endpos
S 空串 {0,1,2,3,4,5,6}
1 a {1,2,5}
2 aa {2}
3 aab {3}
4 aabb,abb,bb {4}
5 b {3,4,6}
6 aabba,abba,bba,ba {5}
7 aabbab,abbab,bbab,bab {6}
8 ab {3,6}
9 aabbabd,abbabd,bbabd,babd,abd,bd,d {7}

接下来从状态(State)、转移函数(Transition Function)、后缀链接(Suffix Link)三个方面全方位了解一下后缀自动机的性质。

状态:

记号:

  • 表示字符串s的长度。
  • 表示字符串s在STR中所有出现的结束位置集合。
  • 表示状态st中包含的所有子串的集合。
  • 表示状态st包含的最长的子串,表示st包含的最短的子串。

性质:

  • 从起始状态S出发,沿着图中蓝线转移,对于STR的子串最终会到达一个合法状态,不是子串的话最终会无路可走。
  • 对于STR的两个子串和,不妨设,是的后缀当且仅当,不是的后缀当且仅当
  • 对于一个后缀自动机的状态,里的字符串集都相同,或者说同一个状态下的字符串构成了一个等价类,这样记表示该状态的集。
  • 对于一个后缀自动机的状态,以及任意,都有是的后缀。
  • 对于一个状态,以及任意的的后缀,如果的长度满足:,那么。
  • 换句话说,包含的是的一系列连续后缀。

转移函数:

  • 记字符集合表示对于一个状态st,从它开始下一个遇到的字符可能有哪些,形式化的写出来,有。对于一个状态st来说和一个中的字符c,你会发现中的所有子串后面接上一个字符c之后,新的子串仍然都属于同一个状态。比如对于状态4,,aabb,abb,bb后面接上字符a得到aabba,abba,bba,这些子串都属于状态6。
  • 定义转移函数。换句话说,我们在(随便哪个子串都会得到相同的结果)后面接上一个字符c得到一个新的子串s,找到包含s的状态x,那么就等于x。
  • 对应图中蓝线,比如。

后缀链接:

  • 前面我们讲到包含的是的一系列连续后缀。这连续的后缀在某个地方会“断掉”。比如状态7,包含的子串依次是aabbab,abbab,bbab,bab。按照连续的规律下一个子串应该是”ab”,但是”ab”没在状态7里,这是因为串变短了,就有可能在别的地方也会出现,即endpos集合大小变大了。比如aabbab,abbab,bbab,bab(状态7)的endpos集为{6},而”ab”(状态8)的endpos集为{3,6},”b(状态5)的endpos集为{3,4,6}。
  • 于是我们可以发现一条状态序列:7->8->5->S。这个序列的意义是longest(7)即aabbab的后缀依次在状态7、8、5、S中。我们用Suffix Link这一串状态链接起来,这条link就是上图中的绿色虚线。
  • 后缀链接组成了一棵以S为根的树。

构造后缀自动机:

构造比较复杂,还是推荐看 http://hihocoder.com/contest/hiho128/problem/1

具体改天再写~

时间复杂度,空间上状态数不超过。

代码中son表示转移函数,pre表示后缀链接,cnt表示endpos集合大小。

添加字符的过程中只能标出endpos大小为1的那些状态,需要对后缀链接构成的树形结构从叶子开始做拓扑排序,得到所有状态的endpos集合大小。

代码:

#include<bits/stdc++.h>
using namespace std;
#define N 1000010
struct SAM
{
    int tot,last;
    int son[N<<1][26],maxlen[N<<1],pre[N<<1];
    int cnt[N<<1],in[N<<1],q[N<<1];
    void init()
    {
        tot=0;
        last=newnode();
    }
    int newnode()
    {
        ++tot;
        memset(son[tot],0,sizeof(son[tot]));
        maxlen[tot]=pre[tot]=cnt[tot]=in[tot]=0;
        return tot;
    }
    void add(int c)
    {
        int now=newnode();
        maxlen[now]=maxlen[last]+1;
        cnt[now]=1;
        while(last && son[last][c]==0)
            son[last][c]=now,last=pre[last];
        if(last)
        {
            int x=son[last][c];
            if(maxlen[x]==maxlen[last]+1) pre[now]=x;
            else
            {
                int nq=newnode();
                maxlen[nq]=maxlen[last]+1;
                memcpy(son[nq],son[x],sizeof(son[x]));
                pre[nq]=pre[x];
                pre[x]=pre[now]=nq;
                while(last && son[last][c]==x)
                    son[last][c]=nq,last=pre[last];
            }
        }
        else pre[now]=1;
        last=now;
    }
    void endpos()
    {
        int l=0,r=0;
        for(int i=1;i<=tot;++i) ++in[pre[i]];
        for(int i=1;i<=tot;++i)
            if(!in[i])
                q[r++]=i;
        while(l!=r)
        {
            int x=q[l++];
            if(pre[x]==0) continue;
            cnt[pre[x]]+=cnt[x];
            if((--in[pre[x]])==0)
                q[r++]=pre[x];
        }
    }
} sam;
char s[N];
int main()
{
    sam.init();
    scanf("%s",s);
    for(int i=0;s[i];++i) sam.add(s[i]-'a');
    sam.endpos();
}

广义后缀自动机

解决多个串的问题,做法很简单,只需要添加每个串前让last=1即可,或者也可以对trie树建后缀自动机,则新加点时last为trie上的父节点。分析与证明在2015年国家集训队论文里有。

如果要记录一些信息,比如strval表示每个状态里的子串在多少个串中出现过,需要额外加一个lc维护该状态上一次出现是哪个串,每次沿着Parent向上更新出现次数,遇到lc=当前串的就停止。时间复杂度还不太会分析,待补。

void add(int id,int c)//id表示第几个串,编号从1开始
{
    int now=newnode();
    maxlen[now]=maxlen[last]+1;
    cnt[now]=1;
    while(last && son[last][c]==0)
        son[last][c]=now,last=pre[last];
    if(last)
    {
        int x=son[last][c];
        if(maxlen[x]==maxlen[last]+1) pre[now]=x;
        else
        {
            int nq=newnode();
            maxlen[nq]=maxlen[last]+1;
            memcpy(son[nq],son[x],sizeof(son[x]));
            pre[nq]=pre[x];
            lc[nq]=lc[x];strval[nq]=strval[x];//额外的信息
            pre[x]=pre[now]=nq;
            while(last && son[last][c]==x)
                son[last][c]=nq,last=pre[last];
        }
    }
    else pre[now]=1;
    last=now;
    //广义后缀自动机
    while(now && lc[now]!=id)
    {
        lc[now]=id;
        ++strval[now];
        now=pre[now];
    }
}

//main

sam.init();
for(int i=1;i<=n;++i)
{
    sam.last=1;
    scanf("%s",s);
    for(int j=0;s[j];++j) sam.add(i,s[j]-'a');
}

 

练习题目:

1.hihocoder #1445 : 后缀自动机二·重复旋律5

求字符串本质不同子串的个数,直接累加,利用maxlen数组及suffix link不难得出。

ll count()
{
    ll ans=0;
    for(int i=1;i<=tot;++i)
        ans+=maxlen[i]-maxlen[pre[i]];
    return ans;
}
2.spoj1811 LCS 

求两个串最长公共子串,对一个串建后缀自动机,另一个串在上面走,如果当前状态没有当前字符的转移,就沿着suffix link往回跳,很好理解。

int LCS(char *s)
{
    int now=1,len=0,ans=0;
    for(int i=0;s[i];++i)
    {
        int x=s[i]-'a';
        if(son[now][x])
            ++len,now=son[now][x];
        else
        {
            while(now && son[now][x]==0) now=pre[now];
            if(now==0) len=0,now=1;
            else len=maxlen[now]+1,now=son[now][x];
        }
        ans=max(ans,len);
    }
    return ans;
}
3.bzoj3998: [TJOI2015]弦论

求一个串的第K小子串,T为0表示不同位置的相同子串算作一个,T=1则表示不同位置的相同子串算作多个。

还是先计算集合大小cnt,如果T==0,则每个状态cnt值都是1,如果T==1,则还是和之前的代码一样,在suffix link构成的树上做拓扑排序,转移出cnt。

另外还需要知道从 每个状态往后走会有多少种子串,记为,还是用同样的拓扑序转移,。然后从根开始按照字典序dfs一下就可以求出第K小子串了。

4.bzoj3238: [AHOI2013]差异

给一个字符串S,求,其中T_i表示i开始的后缀,lcp表示最长公共前缀。

把原串倒过来建后缀自动机,这样就把前缀变成了后缀。两个前缀的最长公共后缀是对应后缀自动机上两结点的LCA,实上统计的话,枚举LCA做类似树dp的事情数一数即可。

void addedge()
{
    for(int i=2;i<=tot;++i)
        e[pre[i]].push_back(i);
}
void dfs(int x)
{
    ll now=0;
    for(int i=0;i<e[x].size();++i)
    {
        int y=e[x][i];
        dfs(y);
        ans-=2LL*now*cnt[y]*maxlen[x];
        now+=cnt[y];
    }
    if(cnt[x]==1) ans-=2*now*maxlen[x];
    cnt[x]+=now;
}

或者这样,类似容斥一下的统计

void dfs(int x)
{
    for(int i=0;i<e[x].size();++i)
    {
        int y=e[x][i];
        dfs(y);
        cnt[x]+=cnt[y];
    }
    if(x!=1) ans-=1LL*cnt[x]*(cnt[x]-1)*(maxlen[x]-maxlen[pre[x]]);
}
5.是男人就过 8 题–Pony.AI A String Game

Alice和Bob玩游戏,有n个串Si,保证都是t的子串,每人轮流从n个串中取出一个串,并在结尾添加一个字符后放回去,要求是添加字符之后也要保证该串是t的子串,无法操作的人输,Alice先手,问谁赢。

显然是要在t的后缀自动机上博弈,要注意不能直接从根开始dfs,需要先做搞出拓扑序。

int calc(char *s,int n)
{
    int now=1;
    for(int i=0;i<n;++i)
        now=son[now][s[i]-'a'];
    return ans[now];
}
void work()
{
    for(int i=1;i<=tot;++i)
        for(int j=0;j<26;++j)
            if(son[i][j])
            {
                e[son[i][j]].push_back(i);
                in[i]++;
            }
    int l=0,r=0;
    for(int i=1;i<=tot;++i)
        if(!in[i])
            q[r++]=i;
    while(l!=r)
    {
        int x=q[l++];
        int now=0;
        while(vis[x][now]) ++now;
        ans[x]=now;
        for(auto &y:e[x])
        {
            vis[y][ans[x]]=true;
            --in[y];
            if(!in[y])
                q[r++]=y;
        }
    }
}
6.bzoj1396: 识别子串

给一个串,求每个位置的最短识别子串的长度,最短识别子串定义是,在整个串中出现一次且覆盖到这个位置的最短的一个子串。

做法是找集合大小为1的那些串,用线段树维护答案,具体是区间和一个数取min,以及和一个等差数列取min,稍微转化一下就很好写了。

 

未完待续…