后缀自动机(SAM)
后缀自动机
首先说明:有些证明其实是没有必要知道的,毕竟你不知道也不会妨碍你做题。更多的应该是知道这个东西是怎么被想出来的,以及怎么用的。
为什么是后缀自动机而不是前缀自动机呢?很简单,因为后缀的前缀是好处理的,前缀的后缀则没有很好的性质。
首先确定我们的目标:构建一个自动机,在自动机上从根节点开始走,可以走出所有的子串,且所有不是子串的串不会被走出来。
首先确定一个至少可行的方案:对于每个后缀建 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\),显然是满足要求的。那么我们希望根据 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;
\(1\) 号节点(根节点)表示 \(\emptyset\) 的 endpos 集合,即 \(\{1,2,\dots,n\}\)。增量构造,如果加入一个字符 \(c\),那么 \(s[1\dots n]\) 肯定是没有出现过的,必然要新建一个节点,endpos 等价类为 \(\{n\}\),最长串长度为 \(n\)。但是,显然并不是所有的 \(s[i\dots n]\) 都没有在之前出现过,除非根节点都没有出边 \(c\),那么直接把 \(link\) 连向根节点即可,即 if(!u)... 这一句。上面那个 while 马上会解释。
接下来就相对麻烦了。我们需要考虑的是原来所有的后缀,现在加上 \(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)
加入字符的时候,每次最早新建的节点意味着一个前缀,那它的父亲都是这个前缀的后缀,那全部都有一次出现。把 \(link\) 树建出来求一下子树和即可。时间复杂度线性。
#6071. 「2017 山东一轮集训 Day5」字符串
一个稍微难一点的例子。
之前提到,构建自动机的初衷之一就是接受所有子串,不接受所有非子串。考虑如何判定一个串是否合法:不断的在当前串的 SAM 上跑,失配了就在下一个上跑。如果要计数,那么建出 DAG 即可。时间复杂度线性。
SP1812 LCS2 - Longest Common Substring II
考虑一下两个串的情况。对一个串建 SAM,另一个串在上面匹配,如果失配了就跳 \(link\),当然也可能彻底失配了(新字符)。每次记一下当前匹配的长度即可。当然和 ACAM 一样,需要做一次类似树上取 \(\max\) 状物。那多个串其实是一样的,每个点匹配取 \(\min\) 即可。时间复杂度线性。
如果只保证了 \(\sum len\) 呢?灵活一点,取长度最小的来建 SAM 就可以了啊!

浙公网安备 33010602011771号