后缀自动机(SAM)
Neil_Qian
·
·
算法·理论
后缀自动机
首先说明:有些证明其实是没有必要知道的,毕竟你不知道也不会妨碍你做题。更多的应该是知道这个东西是怎么被想出来的,以及怎么用的。
为什么是后缀自动机而不是前缀自动机呢?很简单,因为后缀的前缀是好处理的,前缀的后缀则没有很好的性质。
首先确定我们的目标:构建一个自动机,在自动机上从根节点开始走,可以走出所有的子串,且所有不是子串的串不会被走出来。
首先确定一个至少可行的方案:对于每个后缀建 trie 树,然后拼在一起。但是这样有 O(n^2) 个节点。
此时引入一个概念:endpos 等价类。其实没那么困难,说白了就是,如果几个字符串在原字符串中出现的位置(这里认为是右端点)相同,那么他们就在同一个等价类。举个例子,ababa 中,aba 的出现位置为 \{3,5\},ba 的位置为 \{3,5\},a 的位置为 \{1,3,5\},因此 aba 和 ba 在同一个等价类,a 则不在。endpos 集合还有一个理解方式就是假装在暴力 kmp,但是每个位置同时开始,现在哪些位置还幸存。
一个简单的观察是:一个等价类中不可能有两个长度相同的串。观察上面的例子,就很容易发现一个性质:对于一个等价类,所有串长是连续的,且其它串都是最长串的后缀。第二条性质是显然的,对于第一条,考虑两个串如果长度不相邻,那么它们之间的串也会被限制,长度短的串说明没有其它匹配位置,长度长的串说明能匹配到这个位置。
继续观察,任意两个串的 endpos 集合如果相交但不包含,既然有相交那就意味着长度短的串一定是长度长的串的后缀,那么长度长的串能匹配的位置长度短的串也能匹配,应该是包含关系,矛盾。根据这个观察,可以得到 endpos 等价类会形成一个树形结构,儿子连向父亲的边表示儿子的 endpos 集合是父亲的 endpos 集合的子集。那么对于长度为 n 的串,最多会有多少个不同的 endpos 集合呢?令 f_n 表示这个答案,有 f_n=1+\max\sum_{\sum p_i=n}f_{p_i}(当然大于等于也行),如果我们声称 f_n=2n-1,显然是满足要求的。那么我们希望根据 endpos 等价类来建树,每个 endpos 集合为一个节点,来代表这些字符串。
注:如无特殊说明,以下的“父亲”指这棵树上的父亲,“儿子”指自动机上的出边。
接下来就是构造了,先放代码再来解释:(len 表示该等价类的最长串的长度,link 表示其在树上的祖先,即最小的 endpos 等价类包含它的等价类)
struct SAM{
static const int N=2e6+10,S=28;struct Node{int len,link,ch[S];}tr[N];int cnt=1,lst=1;
inline void insert(int c){
int nw=++cnt,u=lst;tr[nw].len=tr[lst].len+1,lst=nw;
while(u&&!tr[u].ch[c])tr[u].ch[c]=nw,u=tr[u].link;
if(!u)tr[nw].link=1;
else{
int t=tr[u].ch[c];
if(tr[t].len==tr[u].len+1)tr[nw].link=t;
else{
int nww=++cnt;tr[nww]=tr[t],tr[nww].len=tr[u].len+1,tr[t].link=tr[nw].link=nww;
while(u&&tr[u].ch[c]==t)tr[u].ch[c]=nww,u=tr[u].link;
}
}
}
}sam;
接下来就相对麻烦了。我们需要考虑的是原来所有的后缀,现在加上 $c$ 了会如何变化。因此令 $lst$ 是 $\{n-1\}$ 对应的节点,从这里开始往上找,找到的没有 $c$ 出边的那些点对应的字符串(意味着加上一个字符 $c$ 没有出现过),直接连一条边到新建的节点即可。直到某一个祖先发现有出边为 $c$,那就意味着这个节点的 $len$ 是最长的能接上 $c$ 的字符串:
- `if(tr[t].len==tr[u].len+1)`:既然 `tr[u].len` 是最长的,那么这个节点根本就没有新的贡献,那个 $+1$ 只是因为多了一个字符 $c$ 而已,把新的节点的 $link$ 连向 $t$ 即可;
- `else`:这个就稍微麻烦一点了,因为 `tr[t].len` 长度更长,也就是说现在 $t$ 中的字符串要分家了,长度长的吃不到新来的字符串,长度短的才有新的,怎么办?上面提到了,其实是有 $2n$ 个节点的,那就果断新建一个节点,来表示那些短的变化的串。具体流程:新建节点,最长串是原来的最长串加一个 $c$ 所以要 $+1$,复制儿子(之前说过出边代表着这个节点所有字符串的出边情况)和 $link$(取代之前的 $t$ 的位置),$t$ 分家以后剩下的都是更长的,endpos 不含 $n$ 但是由于分家前是一样的,$t$ 将会是它的祖先;而这是最小的含 $n$ 的 endpos 集合了,因此 `tr[nw].link` 连向它,这里两个集合,一个没有 $n$,一个只有 $n$,也与前面的树形结构呼应。当然,最后链上的节点肯定是要改一下的,毕竟分家以后祖先的那些点应该连到长度更小的等价类(毕竟后代 $u$ 的长度都不够连到分家以后的更长的那一段)。
那就做完了!但你连边的复杂度真的对吗?对的,因为这些边改了儿子以后,不会再被改一次了。时间复杂度线性。
### [P3804 【模板】后缀自动机(SAM)](https://www.luogu.com.cn/problem/P3804)
加入字符的时候,每次最早新建的节点意味着一个前缀,那它的父亲都是这个前缀的后缀,那全部都有一次出现。把 $link$ 树建出来求一下子树和即可。时间复杂度线性。
### [#6071. 「2017 山东一轮集训 Day5」字符串](https://loj.ac/p/6071)
一个稍微难一点的例子。
之前提到,构建自动机的初衷之一就是接受所有子串,不接受所有非子串。考虑如何判定一个串是否合法:不断的在当前串的 SAM 上跑,失配了就在下一个上跑。如果要计数,那么建出 DAG 即可。时间复杂度线性。
### [SP1812 LCS2 - Longest Common Substring II](https://www.luogu.com.cn/problem/SP1812)
考虑一下两个串的情况。对一个串建 SAM,另一个串在上面匹配,如果失配了就跳 $link$,当然也可能彻底失配了(新字符)。每次记一下当前匹配的长度即可。当然和 ACAM 一样,需要做一次类似树上取 $\max$ 状物。那多个串其实是一样的,每个点匹配取 $\min$ 即可。时间复杂度线性。
如果只保证了 $\sum len$ 呢?灵活一点,取长度最小的来建 SAM 就可以了啊!