后缀自动机学习笔记
本文抄写自 OIwiki 是对 OIwiki 的注解。
用途
以合并部分子串的方式,储存字符串所有的子串。
根本思想
有一种数据结构叫作后缀树。它的思想是:将所有后缀插入字典树。如下。

容易发现它有非常多的部分是重复的。

于是,为了消除此类冗余,将字典树的结构修改为图,便有了 SAM。
引自知乎
概念阐释
-
endpos:这是一个集合。\(endpos(x)\) 代表子串 \(x\) 在 \(s\) 中所有结束位置的集合。
-
等价类:这也是一个集合。一个等价类中包含所有 \(endpos(i)\) 完全相等的子串 \(i\)。
有如下引理(其实很显然,看了理解了就可以了):
-
同一等价类中,子串绝对存在后缀包含关系。
-
若子串 \(u\) 为子串 \(v\) 的后缀,\(endpos(u)\) 被包含于 \(endpos(v)\);否则二者的 \(endpos\) 无交。
-
同一等价类中,串的长度绝对连续,且没有重复。
接下来是有关 SAM 构建的概念。
-
SAM 本身:和其他自动机一样,以字典树结构为主体——或者更应该说是字典图,是一个 DAG。它的更为严谨的定义是:一个接受 \(s\) 的所有后缀的最小 DFA。
-
SAM 中的节点:代表一个等价类。而它在 SAM 图中距离原点最长的路径代表着 “该等价类中的最长子串”,下文将该字串记为 \(w\)。
-
原点:代表空串的节点 0。
-
终止状态:由于 SAM 是一个 DFA,它的终止状态即为字符串 \(s\) 的所有后缀。在算法的最后我们会介绍怎么设置终止状态。
-
len:每个节点上储存的数据。代表 \(w\) 的长度,也代表 SAM 图上原点到该节点的最长距离。
-
后缀链接(link):每个节点上储存的数据。类似其它自动机的 fail。它指向 \(w\) 最长的一个后缀,which 满足不在该等价类中。容易发现,后缀链接可构成一棵以 0 为根的树。
-
转移边:每个节点上储存的数据。就是普通字典树边,储存字符信息。
引自 OIwiki | 左图为 SAM 图,右图为 link 形成的树
算法过程
同其它自动机一样,也是在线地一个一个插入字符。
设插入前的字符串为 \(s\)。对于当前字符 \(c\),算法流程如下:
-
令 \(last\) 为添加字符 \(c\) 之前,\(s\) 所在等价类对应的节点。
-
创建新节点 \(cur\),代表串 \(s+c\) 所在等价类,将 \(len(cur)\) 赋值为 \(len(last)+1\)。
-
从 \(last\) 开始,循环跳 \(link\)。记当前遍历到的节点为 \(p\),每次执行如下:
-
如果 \(p\) 不存在字符 \(c\) 的转移,则将 \(c\) 转移指向 \(cur\),并继续循环。(将新子串添加到 \(cur\) 等价类内。)
-
否则,现在及之前的字符 \(c\) 转移都已经赋值完成。设 \(p\) 的 \(c\) 转移指向节点 \(q\)。
此时显然无法再将任何子串加入 \(cur\) 等价类了。现在的首要任务是:
-
通过分析,找出 \(link(cur)\) 所对应的值。
-
分析 \(p\) 所代表的等价类在加上了字符 \(c\) 之后发生的变化:可能,有的子串因为新增的 \(c\),加入了新的等价类;而有的保持在原本的等价类。
因此,需要再分两种情况:
-
如果 \(len(p)+1 = len(q)\),这说明原点到 \(q\) 的所有路径中(也就是等价类 \(q\) 所包含的所有子串中),经过 \(p\) 的这一条路径刚好是最长的那一条(最长子串)。此时,即使加入了新字符 \(c\),也不会产生新的等价类。因此直接将 \(link(cur)\) 指向 \(q\) 即可。
-
否则则一定产生了一个新的等价类,它的最长子串就是经过转移边 \((p, q)\) 的路径。
于是就创建一个新的状态 \(clone\),复制 \(q\) 除 \(len\) 以外的所有信息(后缀链接和转移),并将 \(len(clone)\) 赋值为 \(len(p)+1\)。
复制之后,将 \(link(cur)\) 指向 \(clone\),也将 \(link(q)\) 指向 \(clone\)。
最终需要做的是修改一些原本指向 \(q\) 的转移边。具体地,对于一个点 \(x\),如果 \(len(x)+1 < len(clone)\) 则将转移边指向 \(clone\),否则则保持它指向 \(q\)(利用等价类长度连续的引理)。容易发现,只要继续从 \(p\) 开始跳 \(link\),就能够找到每一个满足 \(len(x)+1 < len(clone)\) 的 \(x\)(跳 \(link\) 时,\(len\) 单调递减,故 \(len(x)+1 < len(p)+1 = len(clone)\))。
-
-
-
在跳到 \(link(0) = -1\) 这个虚拟节点时,停止循环。将 \(last\) 的值更新为 \(cur\)。
最后提一下怎么设置“终止状态”(即代表字符串所有后缀的节点):从代表整个字符串的节点开始,往上跳后缀链接 \(link\),遇到的所有节点设为终止状态。一般来说,可以忽略这一个操作。
复杂度证明
如果我们考虑算法的各个部分,算法中有两处时间复杂度不明显是线性的:
-
第一处是遍历所有状态 \(last\) 的后缀链接,添加字符 \(c\) 的转移。
-
第二处是修改指向 \(q\) 的转移,将它们重定向到 \(clone\) 的过程。
第一处显然可以用均摊证明整体的 \(O(n)\) 复杂度。
第二处的复杂度需要用到一个结论:总转移数的上界为 \(3n\)。(证明 没看懂,咕了。。。)
回过来看第二处的复杂度证明。明显复杂度等价于指向 \(clone\) 的转移数,而 \(clone\) 不会重复遍历,因此第二处的均摊复杂度等价于总转移数,为 \(O(n)\)。
应用
1. 每个子串出现次数
点击查看代码
#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int MAXN = 1e6+5;
int n, tot;
char s[MAXN];
vector<int> ord[MAXN];
struct Trie{
int link, len, sz, ch[26];
#define sz(x) tree[x].sz
#define len(x) tree[x].len
#define link(x) tree[x].link
#define ch(x, y) tree[x].ch[y]
} tree[MAXN<<1];//注意因为有复制操作,要两倍大小
int main(){
scanf("%s", s+1);
n = strlen(s+1);
link(0) = -1;
int p = 0;
for(int i = 1; i <= n; i++){
int c = s[i]-'a', cur = ++tot;
len(cur) = len(p)+1, sz(cur) = 1;
for(; p != -1 and !ch(p, c); p = link(p)) ch(p, c) = cur;
if(p != -1){
int q = ch(p, c);
if(len(p)+1 == len(q)) link(cur) = q;
else{
int clone = ++tot; tree[clone] = tree[q];
len(clone) = len(p)+1, sz(clone) = 0;
link(cur) = link(q) = clone;
for(; p != -1 and ch(p, c) == q; p = link(p)) ch(p, c) = clone;
// 每个不同的 ch(p, c) 在树链上是连续的颜色段
}
}
p = cur;
}
ll ans = 0;
for(int i = 1; i <= tot; i++) ord[len(i)].push_back(i);
for(int i = n; i >= 1; i--)
for(int j = 0; j < ord[i].size(); j++){
int x = ord[i][j];
sz(link(x)) += sz(x);
if(sz(x) > 1) ans = max(ans, 1ll*len(x)*sz(x));
}
cout<<ans;
return 0;
}
/*
利用“前缀的后缀就是所有子串”(或者说,一个新增点肯定是与 link 链上的所有点形成新子串的 )
将所有表示前缀的节点(非复制节点,即正常插入的节点)的 size 设为 1
然后按照 link 形成的后缀链接树累加起来
*/
2. 不同子串个数
法一:DP 求 DAG 不同路径条数。(每个节点存储的 \(dp[i]\),实际表示从这个点开始的子串个数。)
法二:总数量 - 重复数量(上一题)。
法三:在线做法。观察可知,每插入一个新字符,它只与新增的转移边形成新的“不同子串”。于是每次连接转移 \((p, cur)\) 时,使 ans 加上 \(len(p) - len(link(p))\) 即可。
P4070 [SDOI2016] 生成魔咒:用 map 对转移边储存进行优化。
3. 第 k 大的子串
运用上一题中的 dp 数组,预处理完成后进行扫描即可。
4. 最长公共子串
【待补】
对比
-
与后缀数组对比:
-
与 AC 自动机对比:
广义后缀自动机
广义后缀自动机是用来解决多模式串匹配的一个工具。以下题为例:
给定 \(n\) 个由小写字母组成的字符串 \(s_1, s_2, \dots, s_n\),求它们的所有本质不同子串的个数。
网上流传的主流写法有三种:
-
通过用特殊符号将多个串直接连接后,再建立 SAM。
-
对每个串,重复在同一个 SAM 上进行建立,每次建立前,将 last 指针置零。
-
(正解)用所有模式串建出一棵 Trie 树,对其进行 bfs 遍历构建 SAM,insert(x) 时以 x 在 Trie 上的父亲为 last,其余和普通 SAM 一样。
【弄不懂为什么第一种有错。复杂度难道不是线性的吗?抑或是说是插入特殊符号的特判出了问题?】
第二种其实是可以的,但需要加上一些特判。 这篇博客 中给出了对于不加特判的情况的卡掉的方案。
在讨论第三种之前先研究一下第二种法案建出来的 SAM 图。这时图中的每个节点已不再是一个等价类了,而是 等价类的集合。如下图,每个节点旁有两个花括号,每个花括号内都是一个不同的模式串的等价类。这样问题就被扩展了。
【感觉我这里还没有理解清楚……但是得先咕掉了……】

引自知乎
第三种本质上是对第二种的改进:第二种可能会重复建立一些节点,而使用 Trie 结构则去除了这些重复。这样建立的 DFA 可保证节点数量最小。(听说其实也可以用 dfs 而非 bfs,但是特判太多容易写挂,故不在此讨论。)
bfs 写法代码:
点击查看代码
inline void Insert_SAM(int p, int cur, int c){
//不用为 cur 新建节点。因为我们直接利用已经建好的那棵 Trie 树建立 SAM。
len(cur) = len(p)+1;
ans += p ? len(p)-len(link(p)) : 1;
//为了 ch(p, c) 能够进行,要从 link(p) 开始
for(p = link(p); p != -1 and !ch(p, c); p = link(p)){
ch(p, c) = cur;
ans += p ? len(p)-len(link(p)) : 1;
}
if(p == -1) return;
int q = ch(p, c);
if(len(q) == len(p)+1) {link(cur) = q; return;}
int clone = ++tot;
link(clone) = link(q);
for(int i = 0; i < 26; i++)
if(len(ch(q, i))) ch(clone, i) = ch(q, i);
//len 在这里实际用来判断一个节点是否已经被插入 SAM
len(clone) = len(p)+1;
link(cur) = link(q) = clone;
for(; p != -1 and ch(p, c) == q; p = link(p)) ch(p, c) = clone;
return;
}
inline void bfs(){
queue<int> que;
que.push(0); link(0) = -1;
while(!que.empty()){
int cur = que.front(); que.pop();
for(int i = 0; i < 26; i++){
if(!ch(cur, i)) continue;
Insert_SAM(cur, ch(cur, i), i);
que.push(ch(cur, i));
}
}
return;
}
还有一种写法,支持在线插入模式串。(其实就是上述的第二种加上了一些特判。)代码如下:
点击查看代码
inline void Insert(){
for(int i = 1, p = 0; i <= n; i++){
int c = s[i]-'a';
if(ch(p, c)){//如果想插入的位置已经存在节点了,不用新建
int q = ch(p, c);
//按照“等价类的集合”是否变动来决定是否拆分该节点
//(和普通 SAM 的判断操作其实是一样的)
if(len(p)+1 == len(q)) p = q;//last = q
else{
int clone = ++tot; tree[clone] = tree[q];
len(clone) = len(p)+1;
link(q) = clone;
for(; p != -1 and ch(p, c) == q; p = link(p)) ch(p, c) = clone;
p = clone;//last = clone
}
}
else{//剩下同普通 SAM
int cur = ++tot; len(cur) = len(p)+1;
for(; p != -1 and !ch(p, c); p = link(p)){
ch(p, c) = cur;
ans += p ? len(p)-len(link(p)) : 1;
}
if(p != -1){
int q = ch(p, c);
if(len(p)+1 == len(q)) link(cur) = q;
else{
int clone = ++tot; tree[clone] = tree[q];
len(clone) = len(p)+1;
link(q) = link(cur) = clone;
for(; p != -1 and ch(p, c) == q; p = link(p)) ch(p, c) = clone;
}
}
p = cur;
}
}
return;
}

浙公网安备 33010602011771号