后缀自动机/广义后缀自动机(SAM/GSAM)学习笔记
后缀自动机 SAM
概念与性质
SAM 是一种基于字符串建立的确定性有限状态自动机,在 SAM 上我们可以找到字符串中的所有子串,并且完成很多跟子串有关的计算。
具体的,SAM 维护了字符串中所有前缀的后缀,也就是整个字符串的所有子串。我们定义 EndPos 表示某个子串在字符串中所有出现时右端点编号的集合。在 SAM 中,EndPos 相同的点会表示在同一个节点上。
SAM 由一个 DAG 和 Parent Tree 组成。
DAG 是自动机的基本结构,其中的任意一条从初始节点出发的路径都表示原串中的一个子串,一般认为这个子串就保存在终点上。
在 Parent Tree 上,父节点是子节点的连续真后缀,那么不难想到一个节点的所有祖先表示的所有子串就是这个节点表示的子串的所有后缀。
我们发现,由于父节点表示的串是子节点表示的串的子串,所以父节点的 EndPos 是会包含子节点的 EndPos 的。这样也就不难理解“父节点是子节点的连续真后缀”这句话。
思想
变量与约定
我们约定 \(\Sigma\) 表示字符集,\(|\Sigma|\) 表示字符集大小,\(|S|\) 表示字符串 \(S\) 的长度。
定义以下变量:
- \(len_p\) 表示 \(p\) 节点表示的所有子串中长度最大的串的长度。
- \(ch_{p,j}\) 表示 \(p\) 节点表示的串在末尾加上 \(j\) 字符后转移到的状态节点。
- \(fa_p\) 表示 \(p\) 节点在 Parent Tree 上的父节点编号。
构造
思路
我们考虑每次插入一个字符,增量法构造 SAM。
我们从前往后一个一个插入字符,因为我们插入的字符在最末尾,所以能够利用这个字符得到新状态的就只有原来的所有后缀。我们先找到 \(S[1,i-1]\) 串在 SAM 中的节点 \(p\),那么在 Parent Tree 中 \(p\) 节点的所有祖先就都能够通过我们插入的字符 \(c_i\) 拓展出新的状态,新建节点 \(np\) 来表示我们得到的新状态,先初始化 \(np\) 的相关信息,由于转移到 \(np\) 的状态中最长的是 \(p\),所以我们将 \(len_{np}\) 赋为 \(len_{p}+1\) 接下来再根据题目要求维护一些其他值。然后把祖先的 \(c_i\) 字符转移赋为 \(np\) 即可。
但是祖先可能已经有转移到 \(c_i\) 的边了,我们不能将所有祖先转移到 \(c_i\) 的边赋为 \(np\)。不过有一点很好的性质:若某个节点已经有了转移到 \(c_i\) 的边,那么它在 Parent Tree 上的所有祖先一定也已经有了转移到 \(c_i\) 的边,这个可以尝试自己理解。
那么我们就可以从 \(p\) 开始,在 Parent Tree 上一路跳父亲,顺便将访问到的节点转移到 \(c_i\) 的边都连到新建的 \(np\) 节点上。
我们也就发现,前面要找的“\(S[1,i-1]\) 串在 SAM 中的节点 \(p\)”其实就是上次插入时新建的节点 \(np'\),我们在用完 \(lst\) 之后就直接将 \(lst\) 赋为 \(np\) 就行(初始时 \(lst\) 应当是空节点,毕竟一开始也就只有这一个点)。
至此,我们插入的这个字符就已经满足了 SAM 的 DAG 性质。
接下来考虑去满足 SAM 的 Parent Tree 性质。
一种情况是我们没有找到任何一个祖先有向 \(c_i\) 转移的边,这时候也就代表不存在任何一个串能成为 \(np\) 代表的串的后缀,这时候我们直接将他的父亲赋为空节点 \(1\) 即可。
另一种情况是我们找到了一个祖先有向 \(c_i\) 转移的边,设 \(q=ch_{p,c}\)。直觉上我们直接将 \(fa_{np}\) 赋为 \(q\) 就行。这么做确实是对的,但是不完全对。
当有 \(len_{p'}+1=len_{q}\) 时(从这里开始 \(p'\) 为我们从 \(p\) 向上跳父亲找到的第一个有转移到 \(c_i\) 的边的节点),显然 \(q\) 上所有的状态都是 \(p'\) 以及 \(p'\) 的祖先表示的状态加上 \(c_i\) 得到的状态,我们加入 \(c_i\) 后这些状态的 EndPos 都会增加一个位置 \(i\),由于这些状态在加上 \(c_i\) 之前就已经是 \(p\) 与 \(p'\) 之间的状态的后缀,所以在加上 \(c_i\) 后他们一定是 \(np\) 表示的状态的后缀。
若 \(len_{p'}+1\ne len_{q}\)。此时若直接将 \(q\) 赋为 \(np\) 的父亲就是错误的。因为在 \(q\) 中只有一部分是由 \(p\) 以及 \(p\) 的祖先转移而来,这一部分是会在加上 \(c_i\) 后增加一个 \(i\) 进入 EndPos 的。但是剩下的部分(即 \(len > len_{p'} + 1\) 的那一部分)是不会受到添加的 \(c_i\) 的影响的,既然这两部分的 EndPos 不同,那么这两个部分就不能在同一个节点上了。我们新建一个节点 \(nq\),将原本的 \(q\) 拆分成两个部分,将 \(len \le len_{p'} + 1\) 的部分拿出来放入 \(nq\) 中,剩下的部分留在 \(q\) 中,这样其它点与 \(q\) 有关的信息就不需要更改。而原本 \(q\) 能转移到的点 \(nq\) 和新的 \(q\) 也是能转移到的,毕竟他们是原本 \(q\) 的一部分。由于 \(nq\) 是更短的那一部分,所以 \(nq\) 继承原本 \(q\) 的父亲,新的 \(q\) 的父亲就是 \(nq\)。现在我们成功把问题转化为了上面那种情况,这时候就可以直接将 \(fa_{np}\) 赋为 \(nq\) 了。
但是由于我们拆点的行为,\(p'\) 以及 \(p'\) 的祖先原本指向 \(q\) 的边就错了,我们需要从 \(p'\) 开始往上跳,将沿途遍历到的转移到 \(c_i\) 的指向 \(q\) 的边改到 \(nq\) 上。所幸我们要改的也一定是连续的一段。
经过上面的操作,我们就将 \(c_i\) 插入了 SAM。将 \(S\) 中的所有字符一个一个插入 SAM 我们就建出了基于 \(S\) 的 SAM。
code
void samInsert(int c) {
int p = lst, np = ++cnt;
sam[np].len = sam[p].len + 1;
lst = np;
while(p && !sam[p].ch[c]) {
sam[p].ch[c] = np;
p = sam[p].fa;
}
if(!p) {
sam[np].fa = 1;
} else {
int q = sam[p].ch[c];
if(sam[p].len + 1 == sam[q].len) {
sam[np].fa = q;
} else {
int nq = ++cnt;
sam[nq] = sam[q];
sam[nq].len = sam[p].len + 1;
sam[q].fa = sam[np].fa = nq;
while(p && sam[p].ch[c] == q) {
sam[p].ch[c] = nq;
p = sam[p].fa;
}
}
}
}
一个特性
由于 SAM 经常用于与子串有关的题目,所以这里介绍一下这个特性:Parent Tree 中一个节点 \(p\) 的子树包含了所有/包含 \(p\) 表示的串/的串的节点(“/”是断句,不然我自己都看不懂……),换句话说就是 \(p\) 表示的串会且仅会出现在这个子树中。我们又知道,每一个串都一定会被前缀贡献,所以我们在插入字符的时候把新建的节点(即表示前缀 \([1,i]\) 的节点)的出现次数赋为 \(1\),最后做一遍子树和就可以求出每个子串的出现次数,这样是不会算重的。
时空
空间
通过上面的描述,我们每次加入字符最多只会增加两个节点,所以 SAM 的空间复杂度应当是 \(O(|S|)\)。
时间
笔者比较菜,不会证,经过巨佬们的分析它的时间复杂度均摊为 \(O(|S|)\)。
这里放一个我们上课发的资料,不允许外传,认识的人可以找 saltfish 要密码。
例题
LuoGu P3804。
板子题。
我们通过 SAM 找到每一个节点上的子串的出现次数然后统计答案即可。
#include <iostream>
#include <cstring>
using namespace std;
#define LL long long
const int N = 2e6 + 5;
struct SAM {
int len, ch[26], fa;
};
int n;
char s[N];
int cnt = 1, lst = 1, siz[N];
int h[N], to[N], nxt[N], tot;
LL ans;
SAM sam[N];
void add(int u, int v) {
nxt[++tot] = h[u], to[h[u] = tot] = v;
}
void samInsert(int c) {
int p = lst, np = ++cnt;
siz[np] = 1;
sam[np].len = sam[p].len + 1;
lst = np;
while(p && !sam[p].ch[c]) {
sam[p].ch[c] = np;
p = sam[p].fa;
}
if(!p) {
sam[np].fa = 1;
} else {
int q = sam[p].ch[c];
if(sam[p].len + 1 == sam[q].len) {
sam[np].fa = q;
} else {
int nq = ++cnt;
sam[nq] = sam[q];
sam[nq].len = sam[p].len + 1;
sam[q].fa = sam[np].fa = nq;
while(p && sam[p].ch[c] == q) {
sam[p].ch[c] = nq;
p = sam[p].fa;
}
}
}
}
void dfs(int u) {
for(int i = h[u]; i; i = nxt[i]) {
int v = to[i];
dfs(v);
siz[u] += siz[v];
}
if(siz[u] > 1) {
ans = max(ans, 1ll * siz[u] * sam[u].len);
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> (s + 1);
n = strlen(s + 1);
for(int i = 1; i <= n; i++) {
samInsert(s[i] - 'a');
}
for(int i = 2; i <= cnt; i++) {
add(sam[i].fa, i);
}
dfs(1);
cout << ans << '\n';
}
乱搞
KMP
思路
我们考虑怎么用 SAM 求出 \(fail\) 数组。
回归 fail 的本质:当前前缀的最大后缀,使得有一个前缀与这个后缀相同。
“前缀的最大后缀”非常眼熟,这个东西可以直接上 Parent Tree 来做。我们从代表 \(i\) 为右端的前缀的串的节点开始往上跳,那么我们找到的第一个表示另一个前缀的节点,那么由于父亲一定是这个节点中字符串的后缀,长度会更小,所以现在找到的一定就是我们要找的后缀,记录一下每个前缀所在的节点,把它的长度记到节点上就行。
以上是求一个 \(fail\) 的过程,为 \(O(n)\)。可以倍增优化到 \(O(\log n)\)。
从下往上找貌似没法优化了,我们考虑从上往下找:从根节点开始遍历整颗 Parent Tree,记录一下当前遇到的最近的标记,直接用这个值更新遍历到的点的 \(fail\) 就行(得到更新的点当然是包含一个前缀的点)。
现在我们就可以 \(O(n)\) 求 \(fail\) 了。
接下来是字符串匹配,我们考虑 KMP 是怎么做的。用 \(fail\) 数组去做肯定不是我们乱搞的目的,我们考虑 KMP 求匹配的另一种方式:把文本串和模式串拼起来求一遍 \(fail\)。我们好像就做完了。
Code
广义后缀自动机 GSAM
引子
普通的 SAM 只能解决单串的问题。为了更好地解决多个字符串的相关问题,GSAM 应运而生。
GSAM 能够对于多个字符串完成相关的操作,支持的操作与 SAM 并没有区别,能够插入多个字符串,然后支持 SAM 的操作。
思想
思想
构造
总的来说,GSAM 的构造分为离线和在线两类,而离线做法中似乎仅有 Trie 上 bfs/dfs 构造是正确的。
离线
思路
在 Trie 上遍历每一个节点,考虑到某一个节点时我们将其插入,按照我们在 SAM 上的方法"我们先找到 \(S[1,i-1]\) 串在 SAM 中的节点 \(p\)",这个点 \(p\) 明显就是现在插入节点在 Trie 上的父节点。
那么我们在 Trie 上从根开始遍历每一个节点插入 SAM 就行。dfs 的时间复杂度为 \(O(G(\sum|S|))\),bfs 的时间复杂度为 \(O(\sum|S|)\)。
那么这种 GSAM 构造就和 SAM 完全一样了。可以照搬 SAM 的代码。
但是 dfs 的 \(O(G(\sum|S|))\) 时间复杂度在题目给出 Trie 的情况下是会被卡到 \(O((\sum|S|)^2)\) 的,而 bfs 就不会有任何变化,那我还学 dfs 干嘛。
code(bfs)
由于 dfs 实在是没什么用,所以这里只给出 bfs 代码,dfs 代码的思路是一样的。
void insert(char *s) {
int p = 1, len = strlen(s + 1);
for(int i = 1; i <= len; i++) {
int to = s[i] - 'a';
if(!trie[p].ch[to]) {
trie[p].ch[to] = ++trieTot;
trie[trieTot].fa = p;
trie[trieTot].c = to;
}
p = trie[p].ch[to];
}
}
int GSAMextend(int c, int lst) {
int p = lst, np = ++GSAMTot;
sam[np].len = sam[p].len + 1;
while(p && !sam[p].ch[c]) {
sam[p].ch[c] = np;
p = sam[p].fa;
}
if(!p) {
sam[np].fa = 1;
} else {
int q = sam[p].ch[c];
if(sam[p].len + 1 == sam[q].len) {
sam[np].fa = q;
} else {
int nq = ++GSAMTot;
sam[nq] = sam[q];
sam[nq].len = sam[p].len + 1;
sam[q].fa = sam[np].fa = nq;
while(p && sam[p].ch[c] == q) {
sam[p].ch[c] = nq;
p = sam[p].fa;
}
}
}
return np;
}
void buildGSAM() {
for(int i = 0; i < 26; i++) {
if(trie[1].ch[i]) {
q.push(trie[1].ch[i]);
}
}
pos[1] = 1;
while(!q.empty()) {
int p = q.front();
q.pop();
pos[p] = GSAMextend(trie[p].c, pos[trie[p].fa]);
for(int i = 0; i < 26; i++) {
if(trie[p].ch[i]) {
q.push(trie[p].ch[i]);
}
}
}
}
在线
咕咕咕

浙公网安备 33010602011771号