广义后缀自动机
1 前言
首先你需要掌握的前置知识:后缀自动机 SAM。
在那篇文章的 4.4 小节中,我们介绍了使用普通 SAM 求解多个串之间的最长公共子串的方法。实际上,这种做法并不是最正规的。对于多个串之间的子串问题,最常采用的数据结构是广义后缀自动机(广义 SAM)。
广义 SAM,顾名思义,即对多个字符串建立的 SAM。它利用这些字符串的 Trie 树,在这上面建立对应的 SAM。也就是说,广义 SAM 只是将 SAM 搬到 Trie 树上而已。
这里要注意题目中给出所有字符串和直接给出 Trie 树的复杂度并不相同,因为后者这保证了 Trie 的节点数,而没有保证串的总长。
实际上,对于节点数为
的 Trie 树,其代表的字符串总长可以达到 。而有一些广义 SAM 的复杂度是基于字符串总长而非节点个数的。做题时需要注意这点。
2 伪广义 SAM 及其局限
对于多个字符串的问题,我们常常会有下面的几种取巧的方法:
- 在两个串之间添加特殊字符,将它们连接成一个大串,再对其建 SAM(也就是前言中提到的方法)。
- 每次添加新串时将
置为 ,然后继续构建。
这两种尽管大部分时候都很正确,但是它们都不是真正的广义 SAM,因此称它们为伪广义 SAM。下面我们分析其局限性。
2.1 连接法
考虑这样一个问题:我们不求
显然我们依然可以连接,但是此时会有不同。在求
但是如果题目不保证字符串总长的情况下,特殊字符的数量就会迅速增加,导致字符集大小变为字符串个数
因此连接法的局限性在于:不能完全保证线性的复杂度。
2.2 归零法
这种方法的局限性没有上面算法那么强,因此也被广泛使用,但是它仍有局限性。
考虑在插入串 ab
后后缀自动机的结构。此时如果我们再插入一个串 a
,由于 a
这个转移的,所以新建的这个代表 a
的节点就根本不会有任何转移边指向它。于是这个节点就成为了一个空节点。
但是这个空间点却是实实在在存在于 SAM 中的,显然它不满足 SAM 节点最少这一特点,同时也会影响我们在 parent 树上进行计算的结果。
因此归零法的局限性在于:会出现空节点。
所以最后得出了结论:上面两种做法都不够正确,因此有必要学习真正的广义 SAM。
3 广义 SAM 的构建
3.1 概念拓展
我们首先要将单串 SAM 的定义拓展到 Trie 上。
定义 Trie 树为
那么现在我们重新定义一个字符串的
然后我们对于
3.2 构建
3.2.1 BFS 离线构建
首先要做的第一件事就是对所有字符串建出 Trie 树。然后接下来假设我们要插入 Trie 树上的节点
显然,
显然上面的过程可以使用 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;
}
现在说明该做法不会产生空节点的原因:
考虑归零法为什么会出现空节点,当我们在
但是在 BFS 的过程中,我们是一层一层的加入新节点。而 Trie 上每一个节点的儿子的转移都互不相同。也就是说,每一次给
所以 BFS 离线构造保证了不会出现空节点。
BFS 离线构造的时间复杂度是
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 的证明中为什么不会有空节点,显然因为我们保证了在给
更直观的讲,如果两个串为 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];
}
这里判断了如果
if(p == lst) flg = 1, tot--;
这里如果
if(!flg) sam[tot - 1].link = tot;
return flg ? tot : tot - 1;
这两处特判都是在判断当前字符节点是拆分的点还是新建的点。
那么至此我们就写出了正确的离线 DFS 构建方法,时间复杂度为
模板题代码如下:
#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 是一致的,为
模板题代码如下:
#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,这样看这道题似乎并不好做。
其实这道题的关键就在于你要读懂题目中的那条特殊性质:只与一个空地相邻的空地数量不超过
这就启示我们要暴力枚举叶子节点。那么通过尝试不难发现,从叶子节点出发的所有路径正好包含树上的所有路径,因此暴力枚举完叶子节点后这道题就是正常的求解不同子串个数了。
当然这道题给出的树并不是真正的 Trie,因为每个节点的儿子的转移并不一定两两不同。所以我们需要从叶子节点出发,重构一个与这棵树等价的 Trie,然后再跑广义 SAM 的板子即可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律