浅谈后缀自动机:概述,构建与简单应用

前言

不得不说我是真的弱,寒假里颓废的一批,PKUWC爆炸之后过了一个多月才来学这个东西,看到巫蛊偶大神一年前各种后缀自动机神仙题的AC记录,感觉完全被他吊打。
后缀自动机是一种应用十分广泛的处理字符串问题的数据结构,大多数目前难度较高的字符串题都要用到这个东西。本文简单讲述后缀自动机的原理与构造,并讲解一点其简单应用。
本人觉得这个东西还是比较抽象的,首先需要感性理解,然后再去看一些公式之类的东西比较好。本人之前直接刚论文结果搞了很长时间还没搞懂。本文严谨性比较差,一些精确的定义可以看完本文之后再去看论文。

后缀树

后缀树是一个和后缀自动机密切关联的东西,由于它特别好理解,所以先放在前面了。
后缀树你可以理解为将一个串全部的后缀扔到一个trie树里面去,但是如果串长为n,那么这棵trie树上的节点最坏情况下可以达到$\Theta(n^2)$的级别。
举个例子,比如设字符串S=”ababaa”,它的后缀有:
a,
aa,
baa,
abaa,
babaa,
ababaa,
那么这棵trie树是长这个样子的:

可以发现,如果一个串是S的子串,那么它能在这棵trie树上最后能到一个节点上,如果它是S的一个后缀,那么能走到一个黑色的节点上面。
然后我们发现,其实这棵树上有用的节点并不多,我们可以建立这些黑色节点的虚树来压缩节点(也就是删掉所有只有一个儿子的白色节点)。

可以证明这棵树的节点个数是$O(n)$的,我们可以发现一些性质:
1.假定我们刚才删去的节点表示的子串都被合并到了它最近的子孙上,那么此时一个点表示的子串有一个性质:这些子串在字符串中出现次数相同,且出现次数均为该子树中黑色节点的个数。
2.两个子串的LCP(最长公共前缀)相当于这两个子串在后缀树上的LCA(最近公共祖先)所代表的最长子串长度。
…….
至于如何快速构建后缀树,后缀树原本的构建方法非常麻烦,这里不作介绍,现在我们可以利用后缀自动机来进行构建,代码复杂度会变得简单很多。
后缀树给了我们一个启发:如果要表示一个字符串所有的子串或者后缀,并不需要很多节点,因为很多子串的相同性质可以把那些点合并起来。

后缀自动机

概述

现在请你暂时忘了后缀树,我们来看后缀自动机。
后缀自动机就是一个可以接受所有后缀的有限自动机。有限自动机(这个理解不严谨)你可以理解为一张有向图,然后里面有一个起始点,另外每个点对于字符集中的每一个字符都有唯一对应的出边,拿一个串在上面从起始点开始每次按下一位的字符走到对应的边上,走完整个串停在的节点就是其终止状态,我们可以通过终止状态判断它的是什么样的串。(AC自动机就是一个很好的例子)
所以如果你拿一个串在后缀自动机上跑,如果它最后听在的位置是一个你标记过的节点,那么它是一个后缀,否则如果它跑到外面去了或者停在了你没有标记的节点上面它就不是一个原串的后缀)。
当然,直接扔进trie树里面这个是会节点数太多的(之前已经说过了),我们还是可以用类似的方法,只不过这个和后缀树执行不一样的压缩方式。(这里我们首先不去标记哪些位置是后缀)
现在,我们对于所有的子串,如果删掉首字母之后其在原串中出现次数不变,那么我们把这该子串与其删掉首字母之后的子串缩在一起,如果出现次数变化了,那么就令该节点的fail为其删掉首字母之后的子串所对的节点。比如对于字符串”abb”它的后缀自动机大概是长这样的(黑边表转移边,红边表示fail):

“aba”大概是长这样的:

其空间复杂度是$O(n)$的,证明方法很多,我们可以从后面构造的角度上来证明它。

构建后缀自动机

构建后缀自动机有一个时间复杂度为$O(n|\sum|$)的算法(如果字符集很大可以使用平衡树(C++可以直接用map)把他变成$O(n\lg |\sum|)$),代码实现比较简单,这里来了解一下。

算法流程

我们考虑对于一个字符串S,如果已经构建出了$S_1..S_{n-1}$的后缀自动机如何构造出$S_1..S_n$的后缀自动机。
你可以先按照定义随便找一个串试一试,将字符依次插入,然后把后缀自动机画出来看一看。
接下来看一下这个建自动机的流程。
首先新建一个节点x表示$S_1..S_n$对应的节点。
我们考虑哪些节点需要因为最后一个字符$S_n$的而改变转移边。令$S_1..S_{n-1}$的子串对应的节点为last,根据前面的说明,当然是last,last的fail,last的fail的fail,last的fail的fail的fail…
那我们就一个个遍历上去,如果没有$S_n$这个字符的转移,那么直接把转移边接到x上好了。
可以发现一但有了转移边,那么再上面的节点也是有转移边的。
所以我们接下来考虑如果有转移边了怎么办。
我们令第一个出现转移边的节点为t。那么这两排东西可能是长这个样子的。(红边表示fail,黑边表示$S_n$的转移边)

那么显然x的fail需要有的最长子串就在这个节点t里面,但是t里面可能还有更长的子串。那么如何判断还有更长的子串呢?我们只需要在做的时候记录一下一个节点所代表的最长子串即可。如果没有更长的子串,那么直接把x的fail设成t就好了。如果有了呢?一个简单的想法就是讲该节点裂开来,变成两个节点。我们令裂出来的节点为q。
那么x的fail为q,t的fail也为q,我们把对应的转移边改过来就好了。由于q只能从last的fail那一片转移过来所以对其他点的转移边没有影响。现在这张图大概长这样了:

其他点均没有影响,所以我们就做完了。

代码

代码比较简单,打两遍基本上就记住了。(这里默认字符集为26,而且是0..25)

时空复杂度分析

空间复杂度很简单,因为每次最多开两个点,所以开两倍空间就好了。
接下来来证明时间复杂度上界为$O(n|\sum|)$
我们令此时表示$S[1:n]$的这个串表示的点为x,$cnt(x)$表示x的fail个数(即是其后缀的出现次数不同的子串个数),$fail(x)$表示其fail,$maxlen(x)$表示其表示的最大子串长度。
我们只需要定义势能函数(常数忽略):
$\Phi(D)=cnt(x)+maxlen(fail(x))$
考虑我们之前算法中玄学复杂度的地方:一个是寻找第一个找不到转移边的点,另外一个是更新last的fail们的转移边。
对于第一个,每次寻找一个必定会减少fail的个数,用这个$cnt(x)$去吃掉就好了,每次新建节点后$maxlen(fail(x))$至多增加1,但是每次改变转移边的次数不会超过其maxlen的减少值(因为多一次改变就意味这至少多一个子串被划到了t里面去)。
一开始势能函数为0,最后肯定不小于0,所以合法。我们就证完了。

其他定义与性质

定义一个串的$Right$集合为其作为后缀出现过的前缀的右端点的集合。比如说”abaa”这个串,$Right(“a”)=\lbrace 1,3,4 \rbrace$,$Right(“aa”)=\lbrace 4\rbrace$,可以证明一个串在原串中的出现次数为$Right$集合的大小,一个点表示的所有串$Right$集合相同。
一条路径和一个子串一一对应。
一个自动机本质不同的子串个数为$\sum maxlen(x)-maxlen(fail(x))$,其实就相当于把所有节点表示的串的个数加起来。
后缀自动机的fail形成了一棵树,这棵树正好是其反串的后缀树,所以我们可以使用后缀自动机来建后缀树。
一个原串前缀属于的点我们称为np节点,$Right$集合大小为其fail树中np节点个数(非常显然)

广义后缀自动机

广义后缀自动机简单来说就是把一堆字符串扔到同一个后缀自动机里面去,做法是每扔一个之后重新从源点开始接字符,至于复杂度,我们可以考虑将这些字符串接在一起然后用不同的分隔符隔开之后建后缀自动机的复杂度。我们发现此时的构建过程与现在这个做法非常像,大概就可以证一下了。代码方面有一点要注意,就是有可能上一个点已经有出边了,这时候我们只需要判一下需不需要裂点,如果需要的话裂个点就好了。代码:

例题

例题1:[模板]后缀自动机

[洛谷P3804][模板]后缀自动机
就是求$\max \lbrace |\Right(x)|maxlen(x) \rbrace

例题2:[洛谷P4248][AHOI2013]差异

[洛谷P4248][AHOI2013]差异
把前面两个长度的和直接算,考虑到lcp相当于后缀树上的lca的最长子串长度,所以直接把后缀树建出来然后每个点作为lca的代价算一算就好了。

例题3:[洛谷P3975][TJOI2015]弦论

[洛谷P3975][TJOI2015]弦论
考虑后缀自动机的转移边组成了一个DAG,我们只需要算出每一个点往下走所有不同路径的带权和即可。然后类似于线段树上二分的方法算一下就好了。

例题4:[洛谷P5212]SubString

[洛谷P5212]SubString
加个LCT维护一下Right集合大小就好了,略微卡常,开O2可过。

例题6:[洛谷P3346][ZJOI2015]诸神眷顾的幻想乡

[洛谷P3346][ZJOI2015]诸神眷顾的幻想乡
由于叶子节点少,所以你需要用到的串很少,直接暴力把所有串扔到广义后缀自动机上然后搞一下就好了。

例题7:[洛谷P4770][NOI2018]你的名字

[洛谷P4770][NOI2018]你的名字
这道才是真正的毒瘤题,前面6道加起来都没这道题神仙题难。
首先考虑68分怎么做,简单来说就是有一个很长的串$S$,有很多比较短的串$T_i$你要求出对于每一个$T_i$在$S$中没有出现过的子串个数。由于$|S|$很大,所以我们每次统计答案的时候时间复杂度不能带$|S|$。
考虑枚举$T_i$的每一个前缀,求出其有多少个后缀在$S$中出现过。贪心地,从$S$后缀自动机的源点开始,我们每次要向下按转移边走一次得到下一个前缀,如果该点没有转移边,那么说明我们需要削去前面的若干个字符才能与$S$中的子串匹配,也就是我们不断往fail上跳知道有一个fail有这样的转移边,每一个前缀相同的后缀个数就相当于这样做得到该前缀时还剩余的子串长度(就是没有被削去的)的和。由于削去次数有限,所以这一步时间复杂度是$O(|T|)$的。
然后我们把这些长度放入$T_i$建出来的后缀自动机里面通过fail树DP求出相同子串个数,然后再用总的子串个数减掉相同的得到不同的。
这样我们的时间复杂度是$O(|S|+\sum |T|)$
然后考虑有$l,r$限制的情况。还是考虑之前的做法,只不过在一些点中得到的一些长度是不合法的。我们只需要找到该位置右端点在不超过r的前提下的最大值,判一下左端点在不在范围内就好了。这个我们用一个可持久化线段树维护dfs序来寻找子树内代表最右前缀的np节点即可。如果一个$T$的前缀在节点上不合法,直接削它即可。
时间复杂度$O(|S|+\sum |T|\lg |S|)$
细节比较多,代码写到精神恍惚。

Tagged with:

发表评论