对于后缀自动机的一丢丢理解

好久没更博客了,写一篇证明我还没有退役

如果有错误请大佬们纠正。

SRE实战 互联网时代守护先锋,助力企业售后服务体系运筹帷幄!一键直达领取阿里云限量特价优惠。

后缀自动机是一种可以读进一个字符串的所有后缀的有限状态自动机,由于一个子串本质上就是一个后缀的前缀,后缀自动机也能读进所有子串,所以后缀自动机常用于处理子串问题,它的功能比后缀数组更强大。

一些概念

\(endpos:\)一个子串在原串中出现位置(专指结束位置)的集合(注意:是集合)

\(longest:\)一个节点所能代表的最长的字符串(我们一般只记录长度,下面说的所有\(longest\)也都是指长度)

\(shortest:\)一个节点所能代表的最短的字符串

\(child[c]:\)在一个节点后面加上字符c所能转移到的自动机上的节点

\(parent:\)在后缀自动机中,每一个节点代表着\(endpos\)集合相同的一些串,显然,这些串一定是其中最长串的一个后缀,并且长度是连续的,由于更短的后缀可能在更多位置出现,所以它们的\(endpos\)集合不一样,不会在同一节点,其中最长的不属于这个节点的后缀属于的节点就是这个节点的\(parent\),可能有点抽象,举个例子;

对于串:\(aabbabc\)

子串\(ab\)\(endpos\)集合是{3,6},而子串\(ab\)的一个后缀\(b\)\(endpos\)集合为{3,4,6},此时串\(ab\)所在的节点不能代表串\(b\),而串\(b\)又是\(ab\)的后缀中最长的不能被代表的串,所以\(ab\)所在的节点的\(parent\)就是\(b\)所在的节点.

显然\(parent\)不会成环,而每个节点都有\(parent\),所以所有\(parent\)最终会构成一颗树,叫做\(parent\)树。

构造

采用增量构造。假设已经建好了前\(n-1\)个字符的后缀自动机,现在要添加第\(n\)个字符。

相当于对整个串的所有后缀都添加一个字符形成新的串。

所以我们只需要对于代表整个串的节点不断跳\(parent\)去构造

如果没有这个串出现,我们就直接新增这个串

如果已经有这个串出现,否则我们可能需要新建节点

看代码感性理解一下(看时好好回忆\(parent\)树的性质)

我知道不一定能看懂构造(确实比较抽象),所以背模板吧,基本上都是这样写的

多做题理解性质就会慢慢知道为啥要这么建了

/*
fa表示parent树上的父亲,len表示longest的长度,p表示上次的终止节点,np表示新建的节点

q,nq与p,np类似,但是解释起来比较麻烦,所以就咕咕咕了.
*/
void insert(int c){
    int p=last,np=last=++cnt;len[np]=len[p]+1;endpos[np]=1;
    while(p&&!ch[c][p])ch[c][p]=np,p=fa[p];
    if(!p)fa[np]=1;
    else {
        int q=ch[c][p];
        if(len[p]+1==len[q])fa[np]=q;
        else {
            int nq=++cnt;
            for(int i=0;i<26;++i)ch[i][nq]=ch[i][q];
            len[nq]=len[p]+1;fa[nq]=fa[q];
            fa[q]=fa[np]=nq;
            while(p&&ch[c][p]==q)ch[c][p]=nq,p=fa[p];
        }
    }
}

//合并endpos,这里只统计endpos集合的大小,如果需要知道具体是多少就需要线段树合并,或者set启发式合并

queue<int>Q;
void solve(){
    for(int i=2;i<=cnt;++i)deg[fa[i]]++;
    for(int i=2;i<=cnt;++i)if(!deg[i])Q.push(i);
    while(!Q.empty()){
        int u=Q.front();Q.pop();
        endpos[fa[u]]+=endpos[u];//上面说过,一个节点在parent树上的儿子的endpos没有交,所以直接简单相加即可
        if(!(--deg[fa[u]]))Q.push(fa[u]);
    }
}

应用

然而应用才是最重要的

先说说几个性质

一个节点的\(shortest\)等于它的\(parent\)\(longest+1\),所以不必统计\(shortest\)

一个节点的\(endpos\)为它在\(parent\)树上的儿子的\(endpos\)的并,而这些儿子显然是没有交的.

倒序建立的后缀自动机的\(parent\)树就是原串的后缀树,然后自动机节点的\(longest\)就是后缀树节点所代表的串(后缀树的建立十分复杂,所以我们往往会通过后缀自动机建后缀树)

还有上面对\(parent\)的定义中所述性质

有了这些性质我们就可以做很多事情啦.

某个串中本质不同的子串个数等子串统计类问题(比后缀数组好写)

后缀自动机每个节点都代表着若干互不相同子串,已经tame他们的出现次数(就是\(endpos\)集合的大小)

所以本质不同的子串个数就是(\(\sum\) 每个节点代表的串的个数)

也就是(\(\sum\) 每个节点的 \(longest-shortest+1\))

而一个节点\(shortest\)就是它在\(parent\)树上的父亲的\(longest+1\)

所以本质不同的子串个数就是(\(\sum\)......)(此处省略若干字)

【模板】后缀自动机

[SDOI2016]生成魔咒

两个串不同(或者本质不同)公共子串个数

如果不要求本质不同(也就是相同子串位置不同需要重复统计)

对一个串建后缀自动机,将另一个串拿上去匹配就\(ok\)啦,失配了就跳\(parent\)树.每匹配一次都加上当前匹配长度

或者也可以建广义的后缀自动机,只要每次新加串的时候把\(last\)重置就好了(至于为什么可以这么做或者怎么做,等你熟悉了后缀自动机自然就明白了)

如果要求本质不同你可能就要建广义后缀自动机(这个比较好用)

也可以对两个串都建后缀自动机,然后利用\(parent\)树的性质判重。

[HAOI2016]找相同字符

然后如果不是对整个串进行操作,而是每次询问一个区间的信息,此时我们可以利用节点的\(endpos\)集合来当前串判断是否在给定区间内,所以我们需要知道每个节点的\(endpos\)具体是多少,那么就需要线段树合并,利用上述性质,来合并\(endpos\)

[NOI2018]你的名字
(这个题有点不一样,但做法大致没有变)

\(parent\)树上\(DP\)或者在后缀自动机上\(DP\)(注意区分两者的区别)

这个没有具体的套路,灵活运用吧.

\(parent\)树上\(DP\)一般就和合并\(endpos\)的操作差不多。

在后缀自动机上\(DP\),可以参考一下[TJOI2015]弦论

建后缀树

后缀树就是对所有后缀建\(AC\)自动机,然后把只有一个儿子的节点合并,想象一下是什么样子的。

然而后缀树的构建十分复杂,不过正如上面所说,倒序建立的后缀自动机的\(parent\)树就是原串的后缀树,所以,如果需要使用后缀树的题目也可以用后缀自动机来做。

后缀树也有很多优秀的性质,比如说两个串的\(lcp\)就是它们的在后缀树上的\(lca\)(这也跟\(parent\)树的性质有关),同样也可以在后缀树上\(DP\)

[AHOI2013]差异

[NOI2015]品酒大会

到这里就差不多了,剩下的就是灵活运用解决问题啦。

上面的题目大多都可以用后缀数组解决,但是后缀自动机更简单粗暴。

当然也有不能解决的,至少我不知道怎么用后缀数组去写[NOI2018]你的名字

有问题可以私信我或者在评论中提出(如果我还没有退役的话,有时间就会回复的)

扫码关注我们
微信号:SRE实战
拒绝背锅 运筹帷幄