分享

史上最通俗的后缀自动机详解

 长沙7喜 2019-03-31

本文共分四节,内容大致符合政治答题思路如下:①干什么以及基本定义②基本思想以及特殊定义③构造方法以及程序具体解析④基本应用以及类比。

虽然这篇文章是针对初学者,但是读这篇文章之前,请务必了解trie、基本的集合知识、基本字符串知识和基本图论知识。如果需要看懂第四节的一些内容,还需要了解后缀数组。

本文只是对后缀自动机最基本的思想与构造方法进行详细解析,对于更高级的扩展层面不会多加分析,想了解此方面的同学请自行查找资料(clj的ppt等)。

本文很多的表述会在上文中解释,所以如果需要完全理解本文,请务必仔细按顺序阅读。

本文借鉴了许多其他文章的表述方法,如有雷同,请见谅。

1. 后缀自动机要干什么

如果要在一个DAG(有向无环图)上表示出一个字符串的所有子串,应该怎么办?

很显然,一个最简单的方法是建立一个trie(字典树),如图。(对于aabab建trie,红色为根,黄色为终止节点,边的方向未画出)

方法是将原串(n为其长度,下文默认)的每一个后缀都加入字典树。

大家请观察一下这个字典树,看看它有什么性质。

我们能够直观地总结出来的性质有:

  1. 有一个源点,若干个终止点。边代表在目前的字符串后加上的字母。从源点到任意一个节点的任意路径可以形成一个字符串。

  2. 源点到任意节点的任意路径形成的字符串均为原串子串。从源点到任意节点的任意路径不能形成的字符串均为原串子串。(简单来说,这个图可以表示,且仅可以表示出原串的所有子串)

  3. 从源点到任意终止节点的任意路径形成的字符串均为原串后缀

  4. 从源点出发的任意两条不同路径形成的字符串不相同。

如果满足以上四个性质,那我们便可以用此DAG处理许多事情。比如,判断某一个串是否为原串的子串(做法:从源点跑这个串,跑到NULL就说明不是子串)、不同子串个数(做法:DAG上DP)等(后缀自动机可以处理的问题则多得多,因为它有更特殊的性质,这个之后再说)。

但是,我们发现了一个问题。这样建立的DAG节点数是O(n^2)的。当n很大时,这样的复杂度无法接受。事实上,我们发现图中许多的节点都可以合并,比如:b之后的部分与ab之后的部分完全一致,可以合并。

我们现在的任务是,构造一个节点数、边数尽量少的DAG满足以上四点条件。

2. 后缀自动机特别在哪

先来看一些定义吧。(这部分是后缀自动机的精髓,也是后缀自动机真正厉害的地方,至于发明者是怎么想到的我完全不能想象(可能是我太菜了))

对于一个子串,它在原串中可能出现在若干的位置。而一个子串p出现的这些位置的右端点标号组成的集合,我们称之为endpos(p)(例如原串为abcab时,endpos(ab)={2,5})

现在我们需要证明三个结论,它们将在算法中发挥重要的作用。

1.如果两个子串的endpos相同,则其中子串一个必然为另一个的后缀

设较短的一个串为t,较长的一个为p,则当命题不成立时,二子串在后len_t(t的长度)个字符上一定有至少一位不同。但是,因为t已经在endpos(t)的这些位置匹配上了,即后len_t个字符已经在这些位置完全匹配,所以不可能出现这种情况,否则在这些位置上p将不能匹配,endpos(p)必然不等于endpos(t)(实际上是完全不相交)。所以命题得证。(其实这个从直观上意会一下就可以了,证明反而更加不清晰。)

2.对于任意两个子串t和p(len_t ≤ len_p),要么endpos(p) ∈ endpos(t),要么endpos(t)∩endpos(p)=∅

实际上是结论1的逆命题,说明方法同上,分t为或不为p后缀两种情况讨论。

3.对于endpos相同的子串,我们将它们归为一个endpos等价类。对于任意一个endpos等价类,将包含在其中的所有子串依长度从大到小排序,则每一个子串的长度均为上一个子串的长度减1,且为上一个子串的后缀(简单来说,一个endpos等价类内的串的长度连续)

显然长度覆盖的区间是连续的,不可能存在空隙。后缀的命题可由结论2得到。

对于任意两个endpos等价类,它们不会同时包含同一个子串。

下文有一些地方直接用endpos(i)表示endpos等价类i中子串的endpos,请读者注意。

由以上三个定理,我们就可以得到一些延伸结论:

4.endpos等价类个数的级别为O(n)

这个命题比较重要。

对于一个类(endpos等价类简称),根据结论3,其中有最长的一个子串(p)。在p第一个字符前添加任意一个字符(满足新形成的字符串为原串子串),得到的字符串必然不属于此类,因此会得到若干个新的类。我们由结论2得到新形成的字符串的endpos必然为endpos(p)的子集。并且在p前分别添加两个不同的字符,所得到的两个字符串的endpos必然完全不相交。所以对于此操作(在p前添加一个字符),我们可以认为是对一个原集合进行分割,分割得到几个新的集合,且保留原集合。当然,新的集合还可以继续分割,但是总的分割的次数(指断痕的个数,{1,2,3,4,5}分割成{1,3},{2,4},{5}的分割次数为2)不会超过原集合的大小,所以最终形成的集合个数也不会超过2n(线段树的分割方法为子集个数最多的分法)。

由此,因为一切endpos都是从\1,2,…,n}(n为原串长度)分割出来的,所以endpos等价类个数级别为O(n)。(这一部分看不懂可以参考clj的ppt)

考虑分割关系。一个原集合分割成若干个子集的操作,是不是很像树的一个节点延伸出若干个子节点?我们发现,所有endpos等价类依靠这种分割关系,恰好可以构造出一个树形结构。(有时一个类的某些信息会在其儿子处丢失,例如图中{1,2,4,6}的儿子是{2}和{4,6},它们丢失了位置1的信息。至于原因,第四节会讲。)(下图例子的原串为aababa)

于是,类之间就有了父子关系。

我们称这棵树为parent tree

下一个结论

5.一个类a中,有最长的子串,也有最短的子串,我们称最长子串的长度为len(a),最短子串长度为minlen(a)。对于存在父子关系的两个类,设fa(a)表示类a的父亲(也是一个类)。则:len(fa(a))+1=minlen(a)

这个结论很显然,从我们推理结论4的步骤中就可以看出。在一个类中的最长子串前再添加一个字符,形成的字符串就必然属于其儿子中的一类,且这个新形成的字符串肯定是它所属的类中最短的一个。

因此,我们只用在parent tree中保存len即可,minlen可由其父亲推出来。我们定义longest(s)表示s类中的最长子串,shortest(s)表示s类中的最短子串。

我们把每一个类的最长子串写在节点旁,以方便理解。(原串还是aababa)

现在,我告诉大家一个令人震惊的事情:我们要的后缀自动机的节点就是parent tree中的节点!妙啊(可以认为是parent tree和后缀自动机两个图共用同样的节点)只不过边与parent tree中的不同。其中空串所属的节点(parent tree的根)就是后缀自动机的源点。而终止节点便是最大子串(整个原串)所属于的节点(属于:这个节点的类包含此子串),以及其在parent tree上的祖先(标橙)!为什么要这么做呢?因为其节点数很少,且边数也很少(下有证明)。最重要的是,这样形成的图依靠parent tree,有非常有趣的性质,这个本节最后会说。

我们需要考虑如何建立parent tree,以及如何在这些点上连后缀自动机的边,使得从源点出发到达点i的任意一条路径形成的字符串均属于节点i所代表的类。

完成的后缀自动机如下(蓝色为后缀自动机的边):


(太丑了是吗……之后的部分会给更好的图。其实这个图形不符合之后我们讲的后缀自动机的形态,不过这里只是为了展现后缀自动机有多么复杂而已……)

还可以理解为,延parent tree的边往下走是在字符串前面添加字符,延自动机的边往下走是在字符串后面添加字符。在第四节我们将看到,正是因为上面的特性,parent tree主要用来求节点(即各个字符串)的性质,而后缀自动机本身则主要用来直接跑字符串。

我们先简单说明一下满足条件的后缀自动机是一定存在的。还记得第一节提到的后缀自动机必须满足的四个基本性质吗?对于前三个后缀自动机可以满足。对于第四个,由于上文说道,对于任意两个endpos等价类,它们不会同时包含同一个子串,因此到达任意两个不同节点可能形成的字符串均不会重复。现在我们只需要说明边的正确性即可。若对于一个类的所有子串,我们都在它们后面添加一个相同的字符,则得到的所有新的字符串必定都属于另一个相同的类(想想是不是这样)。因此边存在正确性。所以后缀自动机存在。(这里的解释还是不够严谨,如果需要严谨解释可以用类似数学归纳法的方法结合程序说明。因为程序是递推的,初始状态的后缀自动机满足条件,程序正确,所以加入一个新的字符后的后缀自动机也满足条件。这里如果看不懂,可以看完第三节后再尝试理解)

另外,得到点的数量级还不够。下面再证一个比较重要的结论:

6.后缀自动机的边数为O(n)

考虑对于一个后缀自动机:先求出其任意一个生成树,舍弃其其它边。我们知道,后缀自动机上有若干个终止节点。于是我们便从每个终止节点往回跑所有属于它(这个类)的子串(从终止节点跑其实就是跑原串的后缀)。注意这里的往回跑是指:对于一个子串,按照原本后缀自动机到达其的唯一路径经过的边往回跑,而非只考虑边的字符,因为可能有多条代表相同字符的边指向同一个节点。

对于每个终止节点:我们按一定顺序跑遍属于它的子串。如果能顺利跑回源点,则跑下一个子串。否则,连上本应该跑回的边,沿它跑回下一个节点。此时,从这个节点出发,一定有一条或多条路径(经过现存的边)回到源点(因为有树的基础结构),则我们往回跑其中任意一条路径。这样,实际走的路径形成的字符串不一定是原本希望跑的子串,但是因为加了一条新边,且路径是从同样的终止节点开始的,所以得到的字符串必然属于此类,且未被跑过。我们只需要将这个字符串在将来要跑的子串中划掉即可。之后重跑我们原本希望跑的子串,直到真正顺利跑完这个子串,再按顺序跑下一个子串。可以发现,我们在此过程中增加的边数不会超过此节点endpos的大小。

这样,当跑完所有终止节点时,在原本的生成树上增加的边不会超过后缀的个数,即n个。而此时,增加了边的后缀自动机已经完整了。于是,生成树的边数加n,数量级为O(n)。(这里与clj的ppt上的证明方法是刚好相反的,clj的证明是从源点开始向各个终止节点跑后缀,而非从终止节点往回跑到源点。)

为了方便理解以上内容,我们再画图讲解一下。下文的图解内容完全模拟上述证明方法。

以上是原串abcd的后缀自动机,我们随机取它的一个生成树:

终止节点只有5一个。属于5的后缀有(abcd,bcd,cd,d)。我们按照(cd,d,abcd,bcd)的顺序跑后缀。跑cd时,我们发现可以直接沿5-4-1跑回源点,所以跑下一个子串。

跑d时,我们发现确实有一条d边连向5,但是跑这条边回到的点的endpos是{3},并不是应该的{1,2,3,4}(d往回跑一个字符就是去掉其结尾字符得到空串,endpos为{1,2,3,4})。所以此时我们需要连一条边,从代表{1,2,3,4}的1连到5:

跑abcd时,我们先跑回4节点,符合条件(去掉末尾剩下abc,endpos为{3},属于节点4)。接下来,我们发现无法继续跑回,于是增加从3到4的c边:

此时剩下ab,到达点3,我们还是没办法跑它跑回源点。但是,我们不一定要跑它跑回源点,只要能跑回源点就行了。于是,我们往回跑1到3的边(代表b)。那么现在,我们就等于跑了b+c+d=bcd的后缀了,它是一个之后我们要跑的后缀,我们把它划掉。然后接着往回跑abcd,连上1到2的边:

跑bcd时,我们发现它已经被划掉了,所以不用跑。

这样,我们跑完了所有后缀,增加了不多于n条边,完成了后缀自动机。(这个例子应该看得懂吧)

在进入下一节之前,我们再来说一说后缀自动机的特殊性。前面说到,后缀自动机的一般性可以让我们处理的问题有:判断子串,计算不同子串个数等。

而后缀自动机的特别之处在于parent tree与节点本身的特殊意义(endpos本质为在原串中出现的位置的集合)。这就导致可以通过在parent tree上DP得到每一个类中的子串和在原串中出现的位置相关的一些信息。例如,出现的次数,第一次出现的位置,出现在某个位置之后的次数等。这就导致后缀自动机可以处理更多类型的题目。

最后,我们梳理一下后缀自动机的性质,这些性质可能会对理解后缀自动机的构造方法有很大作用:

  1. 有一个源点,边代表在当前字符串后增加一个字符。

  2. 每个点代表一个endpos等价类,到达一个点的路径形成的子串必须属于此点的类。

  3. 点之间有父子关系,到达点i的所有字符串的长度都必然大于到达fa(i)的所有字符串的长度,且到达fa(i)的任意一字符串必为到达i的任意一字符串的后缀。

3. 后缀自动机怎么构造

说了这么多,终于讲到构造了。

我们先简单描述一下后缀自动机的形态。后缀自动机大概是长这样子的

下面那整齐的一行节点表示的就是各个前缀所属的节点。显然,对于任意一个前缀,它在它所属的类中长度是最长的(不能再在其前面添加字符)。而相邻两个前缀所属点之间也肯定有连边。当然,不相邻的节点之间也会有一些边。所以我们把这些节点排成一行,方便观察。

而上面那些零零散散的节点则是不包含任意一个前缀的节点,有一些边连上去,有一些边连下来(注意,连上去的边与连下去的边分割没有那么明显,图只是示意,不要误解),它们之间还有一些连边。这就是后缀自动机的基本结构。

后缀自动机的构造是在线的,即我们通过不断添加单个字符的方式构建后缀自动机,时刻调整其状态。简单来说,我们把原串拆成一个个字符,按顺序塞进后缀自动机里。

先上程序吧。

struct NODE
{
    int ch[26];
    int len,fa;
    NODE(){memset(ch,0,sizeof(ch));len=0;}
}dian[MAXN<<1];
int las=1,tot=1;
void add(int c)
{
    int p=las;int np=las=++tot;
    dian[np].len=dian[p].len+1;
    for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np;
    if(!p)dian[np].fa=1;//以上为case 1
    else
    {
        int q=dian[p].ch[c];
        if(dian[q].len==dian[p].len+1)dian[np].fa=q;//以上为case 2
        else
        {
            int nq=++tot;dian[nq]=dian[q];
            dian[nq].len=dian[p].len+1;
            dian[q].fa=dian[np].fa=nq; 
            for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;//以上为case 3
        }
    }
}
char s[MAXN];int len;
int main()
{
    scanf('%s',s);len=strlen(s);
    for(int i=0;i<len;i++)add(s[i]-'a');
}

(很多教材都没有很好地解释循环在条件满足时停止的原因,这也是本人当初不理解程序的重要地方之一,因此下文会花较大篇幅讲解循环的意义。)

相信大家肯定看不懂吧……(网上有博客说很好理解???)

但至少主函数内的东西应该是看的懂的,即读入原串,然后通过在线的方式一个个加入字符,通过ans函数构造后缀自动机。

那么dian就是后缀自动机里的节点。结构体里的len和fa与第二节里的定义一致。而ch则与trie里的边意义相近。

void add(int c)
{
    int p=las;int np=las=++tot;
    dian[np].len=dian[p].len+1;

首先看第一行。las是什么?是未加入此字符前最长的前缀(整个串)所属的节点的编号,tot则是当前后缀自动机节点的总数。

新加入了一位字符c(减了'a'是为了方便塞进边里,可类比trie里对字符的处理)。我们设未加入c前的原串为旧串,加入c后的原串为新串。显然,此时新串的最长前缀(整个串)所属的节点显然不在原图中,因为原来的所有endpos都不可能包含这一位。而旧串的最长前缀就变成了新串的次大前缀。所以我们用p记录新串次大前缀(原串的最大前缀)所属的节点,然后新建一个节点np,存新的最大前缀所属的类。len(np)肯定就是新串的长度,即len(p)+1。

注意一下,加入c后,endpos可能发生变化的字符串只有新串的后缀(或者说,旧串的后缀加c)(它们无论在旧串出现与否,在新串中出现的次数一定会比在旧串中多1)。所以我们的任务就是处理好这些endpos发生变化的字符串(具体做法是遍历旧串后缀(事实上是遍历旧串的后缀自动机的终止节点),看看它们加c后endpos有没有改变)。另外,对于任意一个endpos变化的字符串,它的新endpos与原来endpos的差别只是多了一个n,即新串右端点的位置。因此我们判断一个串的endpos是否变化,只需要看其是否在新串最后出现即可。

for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np;

我们进行一个循环操作。这个循环操作进行下去的条件是:!dian[p].ch[c](!p只是防止其跳出根节点),即p没有一条c的出边。而p每一次循环后会跳到dian[p].fa,即fa(p)。

首先我们得知道往上跳到底是干什么。我们知道存在父子关系节点包含的子串有后缀关系。那么,这个p跳fa(p)操作的意思即:一个是longest(p)(还记得longest是什么意思吗?定义在结论5的证明下面)的后缀,且与longest(p)所属类不同的最长串所属的类。(这里的longest改成shortest也无妨)

因为由一个节点往外连一条边就等于允许到达此节点的所有字符串往尾部添加一个新的字符。并且,由于len(fa(i))+1=minlen(i),因此对于i和fa(i),它们包含的子串的长度从大到小排序时也是连续的。所以我们把每一个节点想象成所有到达它的字符串的集合。那么,这个跳fa(i)的操作可以理解为压缩地遍历一个串的所有后缀。在这里,p=fa(p)即从长到短遍历旧串的所有后缀

对于节点p,如果它没有c边(即''longest(p)+c''非旧串子串),那么引一条新边到np。这里用上面的思想解释,即将所有是旧串后缀且在其后面加c形成的新字符串不是旧串子串的字符串往新串的最长后缀所属节点连一条c边。这样的做法显然是正确的,因为这些后缀加了c产生的新字符串不是旧串的子串,但是因为是旧串的后缀,加上c后必然是新串后缀,即它们的endpos={n}=endpos(np)。因此向np连边是正确的。

注意循环的终止条件。如果长度再短,这些旧串后缀加c形成的新字符串就已经在旧串里出现过了,显然与新串最长前缀的endpos不同,所以不需要继续连边。

if(!p)dian[np].fa=1;//以上为case 1

如果已经遍历完了旧串的后缀且它们加c一个都不是旧串的子串,就说明c实际上是一个在旧串中没有出现过的字符,因此不可能存在除节点1以外的祖先,直接令fa(np)=1。

else
{
    int q=dian[p].ch[c];
    if(dian[q].len==dian[p].len+1)dian[np].fa=q;

p在第一个有c边的祖先停下了(停止原因已在上文说明)。此时,我们将q设为p的c出边到达的节点。此时,我们将做一个非常有趣的判断:len(q)是否等于len(p)+1。这个判断是什么意思呢?因为''longest(p)+c''是新串后缀,又因为len(q)==len(p)+1,所以longest(q)==''longest(p)+c'',所以longest(q)是新串后缀。而q类中的所有串都是longest(q)的后缀,所以到达q的所有串都是新串后缀,它们的endpos都增加了n,因此到达q的所有串的endpos一致,符合后缀自动机的定义。由于q是我们找到的第一个与np不同的且有后缀关系的节点,我们把fa(np)设为q。

此时我们还需要说明一下为什么不用继续跳fa,判断其他旧串后缀的情况。因为p继续跳fa,引出的c边到达节点q'。那么,q’必然是q的祖先,那么原先到达q'的串的endpos肯定都增加了一位n,满足到达同一节点的字符串endpos都相同的性质,不需要处理。到此,新串的后缀都已经被检查过了。

为了方便理解以上case 1和 case 2,我们举个例画图来讲解一下。

以aaba为例。初始只有一个源点:

加入a,创建节点2,len(2)=1,连边,1没有a的出边,跳到0。所以进入case 1,直接设fa(2)=1。(蓝边表示parent tree,红字表示len)

加入a,创建3。2没有a的出边,所以连边,跳1。1有a的出边连向2,且len(2)=1=0+1=len(1)+1,所以进入case 2,直接fa(3)=fa(2)。

加入b。创建4。3和2和1都没有b的出边,连边。所以进入case1,直接fa(4)=1。

加入a,创建5。4没有a边,跳1。1有a边连向2,且len(2)=len(1)+1。所以进入case2,fa(5)=2。

如图便是aaba的后缀自动机,大家检查一下,看看符不符合后缀自动机的性质。

else
{
    int nq=++tot;dian[nq]=dian[q];
    dian[nq].len=dian[p].len+1;
    dian[q].fa=dian[np].fa=nq; 
    for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;//以上为case 3

终于来到后缀自动机构造最难理解的部分了。

当len(q)!=len(p)+1时怎么办?其实这个式子等价于len(q)>len(p)+1,因为p可以到q,所以len(q)\ge len(p)+1。

那么,len(q)>len(p)+1代表了什么呢?它代表了还有至少一个比''longest(p)+c''更长的串属于q。而这个更长的串必定不是新串的后缀,因为如果是,那么去掉其最后一个字符得到的字符串必然是旧串的后缀,且其长度大于longest(p),因此应该先被跳到。然而事实并不是这样。所以,现在出现了一个问题:属于q的长度不大于len(p)+1的串是新串的后缀(case2说明过),但大于len(p)+1的串却不是。此时,到达q这个节点的字符串并不全属于一个类(准确来说,属于两个类,一类的endpos比另一类的endpos多出了一个n),出现了问题(q的endpos无法得到定义)。而现在,我们要想办法将其中一个类的子串移到另一个节点上,只保留其中一类的字符串,让q的endpos可以得到定义。

我们考虑把endpos多出了n的字符串转移。显然,旧串的后缀自动机中并没有endpos与其相同的节点(如果有,那p本来就应该连向它)。所以我们新建一个节点nq,让endpos多出了n的字符串转移到此节点。

新建了一个节点,要考虑它的连边,fa与len。

先考虑len。由上文我们知道,长度大于len(p)+1的字符串都不可能是新串的后缀。并且,p有一条连到nq的边。因此,我们把len(nq)设为len(p)+1。

然后考虑出边。由于nq只是q拆出来的一个点,我们考虑直接用q的边。这样做显然是正确的,因为把nq从q拆出来只是因为nq的endpos与q不一样。但是,在q后和nq后加同样的字符,得到的字符串必然属于同一个类(首先,它们之间必然存在后缀关系且在旧串中属于一个类。又因为这个类中的串必定不是新串的后缀,否则就应该先被跳到,没有受到新加入字符的影响,所以在它们新串中还是同属一个类)。

最后考虑fa。由于原来的q被拆成了两个点q与nq。len(fa(nq))<len(nq)<len(q)。又因为fa(nq)肯定为旧串中的fa(q),因为在旧串中q和nq还未被分拆。因此,可以看做是nq插入到了这个父子关系中。所以我们让fa(nq)等于旧的fa(q),然后让fa(q)等于nq,类似于链表的插入。

此时,我们考虑np的fa。这个fa肯定是q和nq之一,因为它们最先被跳到。q肯定是不行的,因为它的endpos没有n,而endpos(np)有n。所以fa(np)必为nq。

之后,我们进行一个类似case1的循环操作,不断往上跳fa。只不过,这里的判断条件变成了dian[p].ch[c]==q。意思即,因为q的endpos不包含n,而''longest(p)+c''的endpos必然含n,不符合后缀自动机性质,所以我们让这条边连向新的节点nq,这样显然是正确的(本就连向q,只是endpos多了一个n,所以连到len紧随其后的fa(q),即nq)。

那么,为什么当dian[p].ch[c]!=q时,可以不继续跳了呢?那是因为,这时dian[p].ch[c]的指向的点肯定是q的某个祖先(p变短了,并且''longest(p)+c''还是原来''longest(p)+c''的后缀,所以q与dian[p].ch[c]满足祖先关系(后缀和长度要求))。那么,我们知道q的父亲是nq,endpos包含n,因此q的祖先的endpos都包含n。所以再往上跳,dian[p].ch[c]都不会出现一个节点两种endpos的错误情况了。

另外说一下,程序里dian[nq]=dian[q]的语句是压缩的fa和边的赋值。可以拆分成:

strcpy(dian[nq].ch,dian[q].ch);
dian[nq].fa=dian[q].fa;

以上就是程序的基本思想。

我们继续上图吧(可以对照上面的讲解与程序看一看)。例子:aababa。前面aaba的图可以继续用。这是aaba的后缀自动机:

加入b,创建6。5没有b边,连边,跳2。2有b边连向4,但是len(4)!=len(2)+1,(此时ab和aab都到达4,但二者endpos不同)所以新建节点7,从4复制fa,ch,len(7)=len(2)+1=2,然后令fa(6),fa(4)等于2。之后跳fa,把2连向4的边连向7,1连向4的边连向7。(尽梨了……ppt的曲线就是那么诡异)

加入a,创建8。6没有a的边,连边,跳7。7有a边连向5,但len(5)!=len(7)+1,所以新建9,重复之前的操作。(不要吐槽parent tree了……)

以上便是构造后缀自动机的全部内容。下面,我们再来证明一些东西。第二节中,我们已经证明了后缀自动机的空间复杂度。现在让我们来证明后缀自动机构造的时间复杂度。

根据程序我们知道,可能超时的地方只有两个跳fa的循环。让我们一一证明它们的均摊时间复杂度是O(n)的。

for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np;

这是case1的循环。简单想想就知道,这个循环的真面目其实就是加边。因为后缀自动机构造的整个过程中,边数都不会减少,所以这里循环的次数应该与边数数量级相同,不会超过O(n)。

for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;

这是case3的循环。这里显然就不能认为是加边了,所以我们得换个思路。是不是可以认为,我们在遍历连向nq的边?因为nq一个新建节点,所以下一次进行这个循环的时候,我们不会再遍历上一个nq的边。又因为所有边被遍历的均摊复杂度是线性的(本人并不怎么会证明,网上资料也不多,如果需要详细了解请参考更高级的教材)。于是,我们可以认为程序对每一个节点的入边都遍历了一次。因此,均摊时间复杂度与边数相同。

4. 后缀自动机该怎么用

(下文将把后缀自动机和后缀数组进行对比,请未学习后缀数组的同学补一下,或者跳过一些内容)

后缀数组和后缀自动机能处理的相同问题很多,并且各自也有对方不能处理的问题。

后缀数组的时间复杂度是O(n\log n)。后缀自动机的时间复杂度是O(n),可能常数会稍微大一点点,但应该还是必后缀数组快(取决于个人代码常数)。但是后缀自动机的做法往往比后缀数组无脑。

下文会列举几个后缀自动机可以解决的问题。如果后缀数组可以解决,也会列出简单解法。

问题1:判断子串

直接在后缀自动机上跑边,跑完串还未跑到NULL则为原串子串。

后缀数组:跑出sa,然后从最小的后缀开始,一个个往后枚举,记录下当前匹配到的位置,如果匹配不上就下一个后缀,否则位置向后移一位。如果枚举完了后缀还没有完全匹配则不是原串子串。

问题2:不同子串个数

DAG上DP。对于一个节点i,f[i]表示从i出发的子串个数(不含空串)。那么,f[i]就等于∑_{(i,j) ∈ Edge}(f[j]+1)。f[1]即是答案。

或者直接求∑(len(i)-len(fa(i))),因为后缀自动机上无重复字符串。

后缀数组:每一个后缀的长度减去其height之和。

问题3:在原串所有子串中(相同的不算一个)字典序第i大的是哪个(P3975

这题比较重要。

首先处理出每个节点的endpos大小,即每个类中的串在原串中出现的次数。考虑dp,f[i]代表i的大小。对于不包含任意一个个前缀的节点,f[i]=∑ {(i,j) ∈ Edge}f[j],因为比longest(i)前面多一个字符的所有字符串的endpos的并集必然等于endpos(longest(i))。而对于包含前缀的节点,f[i]=(∑ {(i,j) ∈ Edge}f[j])+1,因为第1位的信息在加字符时丢失了。(可以参考第二节的parent tree示意图)(具体到程序来说,只需在case1里加一句f[np]=1即可)

然后DP出g[i],表示从i出发的子串个数(不含空集且计算重复),则g[i]=∑_{(i,j) ∈ Edge}(g[j]+f[j])。

最后,在后缀自动机上dfs,按字典序遍历出边,排名还够的减去这边的所有子串,不够的跑到这条出边连向的节点,重复以上步骤。当排名变为非正数时输出跑过的路径形成的字符串。具体做法还请参考本题题解。

问题4:判断两个串的最长公共子串

把两个串拼起来,跑后缀自动机。然后用类似于上面处理出现次数的方法,跑出一个子串在拼起来的串前半部分出现的次数和后半部分出现的次数。然后遍历节点,找len最大的前后出现次数都不为0的节点。以上思路还可以处理多个字符串的最长公共子串。

后缀数组:同样是拼起来,然后处理sa和height,对于每个后缀,找到其之后第一个属于另一半部分的后缀(可以O(n)做到,具体做法请读者思考),求它们的lcp,最后取最大值。

还有一些题目与方法本文可能未涉及到,请多多谅解。毕竟这只是给初学者看的文章,想要了解更高级的内容请自行寻找资料。

后缀自动机思路精巧,处理问题多样,不失为字符串题目的好算法。


    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多