广义后缀自动机
1 前言
首先你需要掌握的前置知识:后缀自动机 SAM。
在那篇文章的 4.4 小节中,我们介绍了使用普通 SAM 求解多个串之间的最长公共子串的方法。实际上,这种做法并不是最正规的。对于多个串之间的子串问题,最常采用的数据结构是广义后缀自动机(广义 SAM)。
广义 SAM,顾名思义,即对多个字符串建立的 SAM。它利用这些字符串的 Trie 树,在这上面建立对应的 SAM。也就是说,广义 SAM 只是将 SAM 搬到 Trie 树上而已。
这里要注意题目中给出所有字符串和直接给出 Trie 树的复杂度并不相同,因为后者这保证了 Trie 的节点数,而没有保证串的总长。
实际上,对于节点数为 \(n\) 的 Trie 树,其代表的字符串总长可以达到 \(n^2\)。而有一些广义 SAM 的复杂度是基于字符串总长而非节点个数的。做题时需要注意这点。
2 伪广义 SAM 及其局限
对于多个字符串的问题,我们常常会有下面的几种取巧的方法:
- 在两个串之间添加特殊字符,将它们连接成一个大串,再对其建 SAM(也就是前言中提到的方法)。
- 每次添加新串时将 \(lst\) 置为 \(t_0\),然后继续构建。
这两种尽管大部分时候都很正确,但是它们都不是真正的广义 SAM,因此称它们为伪广义 SAM。下面我们分析其局限性。
2.1 连接法
考虑这样一个问题:我们不求 \(n\) 个串之间的公共子串,改为求至少在 \(k\) 个串里出现的最长公共子串。
显然我们依然可以连接,但是此时会有不同。在求 \(n\) 个子串时,由于第一个串前面没有插入特殊字符,因此特殊字符总是不会对答案造成影响,它们便可以相同。但是求 \(k\) 个串的时候,由于我们不能保证 \(k\) 个串前的特殊字符的情况,因此两个字符串之间的特殊字符应当两两不同。
但是如果题目不保证字符串总长的情况下,特殊字符的数量就会迅速增加,导致字符集大小变为字符串个数 \(n\)。而 SAM 的线性复杂度正是基于字符集大小为常数这一条件的。
因此连接法的局限性在于:不能完全保证线性的复杂度。
2.2 归零法
这种方法的局限性没有上面算法那么强,因此也被广泛使用,但是它仍有局限性。
考虑在插入串 ab 后后缀自动机的结构。此时如果我们再插入一个串 a,由于 \(lst\) 等于 \(t_0\),而 \(t_0\) 是有 a 这个转移的,所以新建的这个代表 a 的节点就根本不会有任何转移边指向它。于是这个节点就成为了一个空节点。
但是这个空间点却是实实在在存在于 SAM 中的,显然它不满足 SAM 节点最少这一特点,同时也会影响我们在 parent 树上进行计算的结果。
因此归零法的局限性在于:会出现空节点。
所以最后得出了结论:上面两种做法都不够正确,因此有必要学习真正的广义 SAM。
3 广义 SAM 的构建
3.1 概念拓展
我们首先要将单串 SAM 的定义拓展到 Trie 上。
定义 Trie 树为 \(S\),树上 \(x\) 到 \(y\) 路径连成的字符串记作 \(S_{x,y}\)。
那么现在我们重新定义一个字符串的 \(\text{endpos}\) 集合为:\(\text{endpos}(s)=\{y|y\in\text{subtree}(x),S_{x,y}=s\}\)。也就是每次在 Trie 上出现的结尾的位置。
然后我们对于 \(\text{endpos}\) 等价类以及 \(\text{link}\) 后缀链接的定义不变,这样建出的 SAM 结构就是广义 SAM 了。
3.2 构建
3.2.1 BFS 离线构建
首先要做的第一件事就是对所有字符串建出 Trie 树。然后接下来假设我们要插入 Trie 树上的节点 \(x\)。
显然,\(fa_x\) 之前的所有前缀都已经被插入了,所以我们大可以不用管之前的前缀,从 \(fa_x\) 开始构建即可。如果我们令 \(pos_x\) 表示 Trie 树上第 \(x\) 个节点在 SAM 上对应的编号,那么我们在插入 \(x\) 的时候,就可以将 \(pos_{fa_x}\) 当成 \(lst\),然后直接按照正常方式插入即可。
显然上面的过程可以使用 BFS 进行实现,于是我们就可以使用 BFS 离线构造广义 SAM。
这样做本质上运用的是归零法的思想,但是它不会产生空节点,原因下面再讲。
我们来看例题 【模板】广义后缀自动机(广义 SAM),代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 2e6 + 5;
const int Inf = 2e9;
int n;
string s[Maxn];
struct Trie {
int fa, c, son[26];
}tr[Maxn];
int cnt = 1;
void ins(string s) {//构建字典树
int n = s.size() - 1, u = 1;
for(int i = 1; i <= n; i++) {
int ch = s[i] - 'a', to = tr[u].son[ch];
if(!to) {
tr[u].son[ch] = ++cnt;
tr[cnt].fa = u, tr[cnt].c = ch;
}
u = tr[u].son[ch];
}
}
struct SAM {
int len, link, son[26];
}sam[Maxn];
int tot = 1;//这里 tot 写 1 是因为模板题要输出点数,需要算上 0 号节点
//实际上按照正常写法然后最后输出时加一也可以
int insert(int x, int lst) {//正常插入
sam[++tot].len = sam[lst].len + 1;
int pos = lst, ch = tr[x].c;
int ret = tot;
while(pos != -1 && sam[pos].son[ch] == 0) {
sam[pos].son[ch] = tot;
pos = sam[pos].link;
}
if(pos == -1) sam[tot].link = 0;
else {
int p = pos, q = sam[pos].son[ch];
if(sam[p].len + 1 == sam[q].len) {
sam[tot].link = q;
}
else {
sam[++tot] = sam[q];
sam[tot].len = sam[p].len + 1;
sam[q].link = sam[tot - 1].link = tot;
while(pos != -1 && sam[pos].son[ch] == q) {
sam[pos].son[ch] = tot;
pos = sam[pos].link;
}
}
}
return ret;
}
queue <int> q;
int pos[Maxn];
long long ans;
void build() {//在字典树上 bfs 构建广义 SAM
sam[0].link = -1;
for(int i = 0; i < 26; i++) {
if(tr[1].son[i]) {
q.push(tr[1].son[i]);
}
}
while(!q.empty()) {
int u = q.front();
q.pop();
pos[u] = insert(u, pos[tr[u].fa]);//记录 pos
ans += sam[pos[u]].len - sam[sam[pos[u]].link].len;//计算答案
//这里计算的方法与普通 SAM 的计算不同子串个数的方法一致
for(int i = 0; i < 26; i++) {
if(tr[u].son[i]) {
q.push(tr[u].son[i]);
}
}
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n;
for(int i = 1; i <= n; i++) {
cin >> s[i];
s[i] = ' ' + s[i];
ins(s[i]);
}
build();
cout << ans << '\n' << tot;
return 0;
}
现在说明该做法不会产生空节点的原因:
考虑归零法为什么会出现空节点,当我们在 \(lst\) 有一条 \(c\) 的转移边的时候,试图再给 \(lst\) 加一条 \(c\) 的转移边,就会出现空节点的情况。
但是在 BFS 的过程中,我们是一层一层的加入新节点。而 Trie 上每一个节点的儿子的转移都互不相同。也就是说,每一次给 \(fa_x\) 加上转移的时候,它对应的转移在 \(fa_x\) 其它所有的儿子中都不会出现,因此 \(fa_x\) 的转移边不可能出现重复的转移边。
所以 BFS 离线构造保证了不会出现空节点。
BFS 离线构造的时间复杂度是 \(O(n)\),\(n\) 代表 Trie 的节点个数。
3.2.2 DFS 离线构建
上面所说的过程显然也可以使用 DFS 实现,那么我们会写出下面的代码:
int pos[Maxn];
void dfs(int x) {
for(int i = 0; i < 26; i++) {
int to = tr[x].son[i];
if(to) {
pos[to] = insert(i, pos[x]);
ans += sam[pos[to]].len - sam[sam[pos[to]].link].len;
dfs(to);
}
}
}
但是会发现这样做无法通过模板题的样例,实际上,它是错误的。
错误原因和归零法的原因一样:出现了空节点。考虑在 BFS 的证明中为什么不会有空节点,显然因为我们保证了在给 \(fa_x\) 加上 \(c\) 的转移的时候,它之前不可能有 \(c\) 的转移这一事实。但是 DFS 不保证这条性质,因此会出现与归零法相同的错误。
更直观的讲,如果两个串为 ab、b,不难发现直接 DFS 与原先的归零法没有任何区别。
那么我们就要在插入的时候加入一些些特判来解决以上的问题。先看代码:
int insert(int ch, int lst) {
if(sam[lst].son[ch] && sam[sam[lst].son[ch]].len == sam[lst].len + 1) { //
return sam[lst].son[ch]; //
} //
sam[++tot].len = sam[lst].len + 1;
int pos = lst;
while(pos != -1 && sam[pos].son[ch] == 0) {
sam[pos].son[ch] = tot;
pos = sam[pos].link;
}
bool flg = 0;
if(pos == -1) {
sam[tot].link = 0;
return tot;
}
else {
int p = pos, q = sam[pos].son[ch];
if(sam[p].len + 1 == sam[q].len) {
sam[tot].link = q;
return tot;
}
else {
if(p == lst) flg = 1, tot--; //
sam[++tot] = sam[q];
sam[tot].len = sam[p].len + 1;
sam[q].link = tot;
if(!flg) sam[tot - 1].link = tot; //
while(pos != -1 && sam[pos].son[ch] == q) {
sam[pos].son[ch] = tot;
pos = sam[pos].link;
}
return flg ? tot : tot - 1; //
}
}
}
不难发现本质上不同的地方在于这几处,我们来解释一下他们的含义。
if(sam[lst].son[ch] && sam[sam[lst].son[ch]].len == sam[lst].len + 1) {
return sam[lst].son[ch];
}
这里判断了如果 \(lst\) 有 \(c\) 的转移边并且是连续的情况,显然此时我们不用再次插入这个字符,直接返回即可。
if(p == lst) flg = 1, tot--;
这里如果 \(p\) 该能等于 \(lst\),实际上就说明 \(lst\) 有 \(c\) 的转移边但是不连续的情况。此时我们仍需要拆分节点,不过最后我们拆分出的节点实际上就是我们原先要插入的字符对应的点。所以这里我们将最开始新建的字符的点去掉。
if(!flg) sam[tot - 1].link = tot;
return flg ? tot : tot - 1;
这两处特判都是在判断当前字符节点是拆分的点还是新建的点。
那么至此我们就写出了正确的离线 DFS 构建方法,时间复杂度为 \(O(m)\),即字符串总长。在前言我们就提到过,这个时间复杂度上界实际上是 \(O(n^2)\) 的。
模板题代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 2e6 + 5;
const int Inf = 2e9;
int n;
string s[Maxn];
struct Trie {
int c, fa, son[26];
}tr[Maxn];
int tot = 1;
void ins(string s) {
int n = s.size(), u = 1;
for(int i = 0; i < n; i++) {
int ch = s[i] - 'a';
if(!tr[u].son[ch]) {
tr[u].son[ch] = ++tot;
tr[tot].fa = u;
tr[tot].c = ch;
}
u = tr[u].son[ch];
}
}
struct SAM {
int len, link, son[26];
}sam[Maxn];
int insert(int ch, int lst) {
if(sam[lst].son[ch] && sam[sam[lst].son[ch]].len == sam[lst].len + 1) {
return sam[lst].son[ch];
}
sam[++tot].len = sam[lst].len + 1;
int pos = lst;
while(pos != -1 && sam[pos].son[ch] == 0) {
sam[pos].son[ch] = tot;
pos = sam[pos].link;
}
bool flg = 0;
if(pos == -1) {
sam[tot].link = 0;
return tot;
}
else {
int p = pos, q = sam[pos].son[ch];
if(sam[p].len + 1 == sam[q].len) {
sam[tot].link = q;
return tot;
}
else {
if(p == lst) flg = 1, tot--;
sam[++tot] = sam[q];
sam[tot].len = sam[p].len + 1;
sam[q].link = tot;
if(!flg) sam[tot - 1].link = tot;
while(pos != -1 && sam[pos].son[ch] == q) {
sam[pos].son[ch] = tot;
pos = sam[pos].link;
}
return flg ? tot : tot - 1;
}
}
}
long long ans;
int pos[Maxn];
void dfs(int x) {
for(int i = 0; i < 26; i++) {
int to = tr[x].son[i];
if(to) {
pos[to] = insert(i, pos[x]);
dfs(to);
}
}
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n;
for(int i = 1; i <= n; i++) {
cin >> s[i];
ins(s[i]);
}
tot = 1;
sam[0].link = -1;
dfs(1);
for(int i = 1; i <= tot; i++) {
ans += sam[i].len - sam[sam[i].link].len;
}
cout << ans << '\n' << tot;
return 0;
}
3.2.3 在线构建
不难发现一点,上面的 DFS 中,我们通过改造 insert 函数来规避空节点问题。既然空节点问题都规避了,那我们还要 DFS 干什么?直接使用归零法不就行了吗?
实际上,将归零法的函数加上上面这些特判也是正确的,这就是在线构建法。其时间复杂度与 DFS 是一致的,为 \(O(m)\)。
模板题代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 2e6 + 5;
const int Inf = 2e9;
int n;
string s[Maxn];
int tot = 1, lst;
struct SAM {
int len, link, son[26];
}sam[Maxn];
void insert(int ch) {
if(sam[lst].son[ch] && sam[sam[lst].son[ch]].len == sam[lst].len + 1) {
lst = sam[lst].son[ch];
return ;
}
sam[++tot].len = sam[lst].len + 1;
int pos = lst;
while(pos != -1 && sam[pos].son[ch] == 0) {
sam[pos].son[ch] = tot;
pos = sam[pos].link;
}
bool flg = 0;
if(pos == -1) {
sam[tot].link = 0;
lst = tot;
}
else {
int p = pos, q = sam[pos].son[ch];
if(sam[p].len + 1 == sam[q].len) {
sam[tot].link = q;
lst = tot;
}
else {
if(p == lst) flg = 1, tot--;
sam[++tot] = sam[q];
sam[tot].len = sam[p].len + 1;
sam[q].link = tot;
if(!flg) sam[tot - 1].link = tot;
while(pos != -1 && sam[pos].son[ch] == q) {
sam[pos].son[ch] = tot;
pos = sam[pos].link;
}
lst = flg ? tot : tot - 1;
}
}
}
long long ans;
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n;
sam[0].link = -1;
for(int i = 1; i <= n; i++) {
cin >> s[i];
lst = 0;
for(int j = 0; j < s[i].size(); j++) {
insert(s[i][j] - 'a');
}
}
for(int i = 1; i <= tot; i++) {
ans += sam[i].len - sam[sam[i].link].len;
}
cout << ans << '\n' << tot;
return 0;
}
4 应用
实际上,大部分在 SAM 上的应用都可以直接搬到广义 SAM 上做。例如前面提到的不同子串个数、最长公共子串等。
浅讲一道例题:[ZJOI2015] 诸神眷顾的幻想乡。
我们发现这道题可以看作是在一棵近似 Trie 树的树上求不同的子串个数,但是与正常的求解不同的是,子串可以跨过 LCA,这样看这道题似乎并不好做。
其实这道题的关键就在于你要读懂题目中的那条特殊性质:只与一个空地相邻的空地数量不超过 \(20\) 个。它的意思并不是说一个节点的度数不超过 \(20\),而是说节点度数为 \(1\) 的节点数不超过 \(20\),也就是叶子节点个数不超过 \(20\)。
这就启示我们要暴力枚举叶子节点。那么通过尝试不难发现,从叶子节点出发的所有路径正好包含树上的所有路径,因此暴力枚举完叶子节点后这道题就是正常的求解不同子串个数了。
当然这道题给出的树并不是真正的 Trie,因为每个节点的儿子的转移并不一定两两不同。所以我们需要从叶子节点出发,重构一个与这棵树等价的 Trie,然后再跑广义 SAM 的板子即可。

浙公网安备 33010602011771号