后缀自动机(SAM)习记
前言
本文没有构造证明,因为我不会
基础概念看看就好,自娱自乐。
后期重点更新相关题目的简单总结,方便复习
SAM
基础概念
S 的后缀自动机是一种能够识别所有 S 的子串的自动机类型的数据结构(DFA)。
暴力后缀自动机
对于字符串 \(S\),建立插入了 \(|S|\) 个后缀的 Trie 树。这样显然可以查到所有的子串,但时空复杂度都为 \(O(n^2)\) ,显然不可取,考虑如何优化
最简状态后缀自动机
给出结论:状态数 \(O(2*n)\),转移数 \(O(3*n)\)
定义
- \(s(w)\) 表示子串 w 对应的后缀自动机上的状态。
- \(trans(s, ch)\) 表示当前状态是 s,接收新字符 ch 之后到达的状态。
- \(Trans(s,str)\) 表示当前状态是 s, 接收新字符串 str 之后到达的状态。
- \(Suf[i]\) 表示从 i 位置开始的后缀,即 \(S[i, |S|]\)。
- 在后缀自动机中,一个状态表示的是结束位置相同,但长度不同的一系列串。
我们定义 \(Right(s)={r_1,r_2,...,r_m}\) ,表示 \(s\) 状态代表子串的出现位置右端点。
根据定义简单推论
- 在最简状态后缀自动机中,所有节点的 Right 集合互不相同
- 每个节点代表的串之间形成后缀关系,即所有串都是最长串的后缀。
- 每个节点代表的串的长度是连续区间,记为 [MinL(s), MaxL(s)]。
- 考虑同在 \(Right(s)\) 位置的长度为 \(MinL(s) - 1\) 的子串,设其所在的状态为 \(s′\),则必有 \(Right(s)\) 是 \(Right(s′)\) 的真子集
Right 性质
设两个状态 \(s\) 和 \(s′\),其 \(Right\) 集合分别为 \(R(s)\) 和 \(R(s′)\)。
假设 \(R(s) ∩ R(s′) = ∅\),并且 \(r ∈ R(s) ∩ R(s′)\)。
由于任何两个状态代表的串互不相同,不失一般性,可以认为 \(s\) 代表的
串都比 \(s′\) 代表的串长。
因此必然有 \(R(s) ⫋ R(s′)\)。
结论:两个不同状态的 Right 集合只存在两种关系:不相交,或者真包含。即形成一种树形结构。
SC(suffix-chain) 树(后缀链接树)
Parent 树、link 树
由 \(Right\) 集合的包含关系形成的树。
\(f(s)\) 表示状态 \(s\) 对应的 \(Right(s)\) 在 \(SC\) 树上的父节点。
SC树性质
- 每个前缀所在的状态两两不同。
- 共有 \(|S|\) 个叶子节点,分别对应于每个前缀 \(S[1, i]\) 所在的状态。
- 后缀链接树至多有 2|S| − 1 个节点,即至多有这么多不同的 Right 集合,
即后缀自动机节点个数为 \(O(2n)\)。 - 任意串 w 的后缀全部位于 s(w) 的后缀链接路径上。
- 若某个状态 s 拥有 ch 的转移边,那么 f[s] 也一定有 ch 的转移边。(但
不一定转移到同一个状态) - 每个状态 s 的 Right(s) 等价于他在后缀链接树子树的叶子节点集合。
一些显然的观察
- 对于任意状态 s,\(MaxL[f(s)]\) = \(MinL[s]\) - 1。
- 因此每个状态只需要额外记录 \(L[s] = MaxL[s]\) 以及 f(s),于是状态 s 能
- 表示长度为 \((L[f(s)], L[s]]\) 的串。
- 所有终止状态都能代表至少一个后缀。
- Right 集合概念等价于子树概念,因此不需要额外维护
结点数为 \(O(2*n)\)
转移数为 \(O(3*n)\)。\(|2S-1|\) 树边 + \(S\) 条非树边,每个后缀对应一条非树边。
小结
- 状态个数以及转移边个数都是线性。
- 每个状态 s 能够表示以任意 R(s) 中的位置作为右端点的,长度范围
在 (L[f(s)], L[s]] 的子串。每个状态可以看作是一个 Right 意义上的等价
类。\(Mxlen[f(s)] = Minlen[s] - 1\) - 在构造过程中,需要维护每个状态的 f(s),L[s],以及
trans(s, ch)。(Right 集合不需要真的去维护,转化为子树查询即可。)
模板
const int maxn = 1e6 + 10;
struct SAM {
//basic
const char* s;
int last, cnt, len;
int nxt[maxn * 2][26],fa[maxn * 2],l[maxn * 2];
//extension
int cntA[maxn * 2], id[maxn * 2];/*辅助拓扑更新*/
int num[maxn * 2];/*每个节点代表的所有串的出现次数*/
SAM () { clear(); }
void clear() {
last = cnt = 1, l[1] = fa[1] = 0, memset(nxt[1], 0, sizeof nxt[1]);
}
void init(const char * str, int _len) {
s = str, len = _len;
for (int i = 1; i <= _len; i++)
extend(str[i] - 'a');
}
void extend(int c) {
int p = last, np = ++cnt;
memset(nxt[cnt], 0, sizeof nxt[cnt]);
l[np] = l[p] + 1, last = np;
while (p && !nxt[p][c]) nxt[p][c] = np, p = fa[p];
if (!p) fa[np] = 1;
else {
int q = nxt[p][c];
if (l[q] == l[p] + 1) fa[np] = q;
else {
int nq = ++cnt;
l[nq] = l[p] + 1;
memcpy(nxt[nq], nxt[q], sizeof(nxt[q]));
fa[nq] = fa[q], fa[np] = fa[q] = nq;
while (nxt[p][c] == q) nxt[p][c] = nq, p = fa[p];
}
}
}
void build() {
memset(cntA, 0, sizeof cntA);
memset(num, 0, sizeof num);
for (int i = 1; i <= cnt; i++) cntA[l[i]]++;
for (int i = 1; i <= cnt; i++) cntA[i] += cntA[i - 1];
for (int i = cnt; i >= 1; i--) id[cntA[l[i]]--] = i;
/*更新主串节点*/
int temp = 1;
for (int i = 1; i <= len; i++) {
num[temp = nxt[temp][s[i] - 'a']] = 1;
}
/*拓扑更新*/
for (int i = cnt; i >= 1; i--) {
// basic
int x = id[i];
num[fa[x]] += num[x];
// extension
}
// extension
}
void debug(){
for (int i = cnt; i >= 1; i--){
printf("num[%d]=%d l[%d]=%d fa[%d]=%d\n",i,num[i],i,l[i],i,fa[i]);
}
}
}sam;
应用
SAM上拓扑序与 DFS
求长度为 \(x\) 的子串最大出现次数
- 拓扑更新时,更新每个点对应最大长度的最大值,打上标记。
- 拓扑更新结束,从后往前推标记即可,保证是按照拓扑序更新。
求子串 [l, r] 的出现次数
- 子串 [l, r] 一定是前缀 [1, r] 的后缀,找到前缀的定位然后倍增跳到 root 的后缀链接路径。
- 找到深度最小的满足
l[x] >= r - l + 1
的点,返回它的 \(|Right|\) 集合大小就是子串的出现次数。
求最长公共子串
- 求 \(S,T\) 最长公共子串,对 \(S\) 建立 SAM
- 将 T 在 SAM 上进行匹配,对于匹配到的每个节点,其到根后缀链接路径的点也能匹配目前的长度。
- 对每个状态点打上最大值标记,然后 拓扑序 从后往前更新即可,最后记录最大值为最长公共子串
多模式求最长公共子串
- 为了防止被卡复杂度,对 最长的串 建立 SAM
- 将其他串各自在SAM上运行,各自对所有匹配到的节点取 min。
- 最后取所有节点的全局最大值就是最长公共子串。
求单串本质不同子串个数
- 答案为 \(\Sigma l[u] -l[fa[u]]\)
- 每个节点记录了对应 right 集合,不同长度的子串,两节点不同,要么前者属于后者后缀,要么不相交, 在同链上一定是前后缀关系。
- 因此将每个节点所记录的长度区间累加就是答案。
本质不同公共子串的个数(本质不同子串的交)
- 先求最长公共子串的长度
- 再类似上者,求本质不同子串的个数,记得要和 \(0\) 取 max
本质不同子串的并集大小。
- 利用容斥答案 = S 的本质不同子串个数 + T 的本质不同子串个数 - S和T本质不同子串的交。
求子串第 \(K\) 小
- 本质不同子串第 \(K\) 小
- 记录每个节点内的路径个数(与出现次数不同),拓扑序倒序dp计数即可。
- 类似主席树求第 \(k\) 小一样的做法即可,当
sz[ne] >= k
时,k --, now = ne, break;
- 证明,SAM内每条路径对应的都是一条不同的子串。
- 记录重复子串第 \(K\) 小
- 路径转移:
sz[u] = num[u], sz[u] += sz[v]
,初始由 1 变成num[u]
出现次数 - 询问时,
sz[ne] >= k
执行k -= num[ne]
- 路径转移:
link 树上问题
求子串 [l, r] 在子串 [L, R] 的出现次数
- 即询问 \(L+r-l\leq Right(S[l,r])\leq R\) 的个数
- 对 link 树 dfs 序建立主席树,查询 \(root[dfn[x] - 1]\) 和 \(root[dfn[x] + sz[x] - 1]\) 在值域 \([L+r-l,R]\) 的个数。
求 所有后缀长度加和 - 2 * 所有后缀 \(lcp\) 之和
- 往树上两点路径,树上差分上想,对字符串的反串建 SAM,这样两节点间的 lca 由 最长公共后缀 转变等价于 最长公共前缀
- 对反串后的前缀节点(实际对应原串后缀)
sz[np] = 1
,然后拓扑序更新。 - 记录每条边的边权为
l[p]-l[fa[p]]
,答案就是树上所有路径之和。 - 按边考虑贡献:
- 每条边对于所有路径会被经过
(n-sz[x])*sz[x]
次,乘上边权在倒序拓扑时直接统计即可。
- 每条边对于所有路径会被经过
求两串中各取出一个子串使得这两个子串相同的方案数
- 对一个串建立SAM,记录每个节点的前缀和
pre[u] = pre[fa[u]] + l[fa[u]] - l[fa[fa[u]]
- 将另一个串放在SAM上跑,每次答案加上
pre[now] + num[now] * (len - l[fa[now]])
即可。
关于广义SAM
在含有多个串信息的 Trie 上建立SAM。有离线和在线两种构造方法,直接给出模板
广义SAM离线模板
const int maxn = 1e6 + 10;
struct Trie {
int idx, fa[maxn], son[maxn][26], c[maxn];
Trie() {idx = 1;}
void insert(const char* s) {
int p = 1;
for (int i = 1; s[i]; i++) {
int u = s[i] - 'a';
if (!son[p][u]) son[p][u] = ++idx, fa[idx] = p, c[idx] = u;
p = son[p][u];
}
}
}Tr;
struct SAM {
//basic
const char* s;
int cnt, len;
int nxt[maxn * 2][26],fa[maxn * 2],l[maxn * 2];
queue<int> q;
//extension
int cntA[maxn * 2], id[maxn * 2];/*辅助拓扑更新*/
int num[maxn * 2];/*每个节点代表的所有串的出现次数*/
int pos[maxn * 2]; // Trie 上节点在 SAM 上对应的节点编号
SAM () { clear(); }
void clear() {
cnt = 1, l[1] = fa[1] = 0, memset(nxt[1], 0, sizeof nxt[1]);
}
void init() {
for (int i = 0; i < 26; i++) if (Tr.son[1][i]) q.push(Tr.son[1][i]);
pos[1] = 1;
while (!q.empty()) {
int t = q.front(); q.pop();
pos[t] = extend(Tr.c[t], pos[Tr.fa[t]]);
for (int i = 0; i < 26; i++) if (Tr.son[t][i]) q.push(Tr.son[t][i]);
}
}
int extend(int c, int last) {
int p = last, np = ++cnt;
memset(nxt[cnt], 0, sizeof nxt[cnt]);
l[np] = l[p] + 1, last = np;
while (p && !nxt[p][c]) nxt[p][c] = np, p = fa[p];
if (!p) fa[np] = 1;
else {
int q = nxt[p][c];
if (l[q] == l[p] + 1) fa[np] = q;
else {
int nq = ++cnt;
l[nq] = l[p] + 1;
memcpy(nxt[nq], nxt[q], sizeof(nxt[q]));
fa[nq] = fa[q], fa[np] = fa[q] = nq;
while (nxt[p][c] == q) nxt[p][c] = nq, p = fa[p];
}
}
return np;
}
void build() {
memset(cntA, 0, sizeof cntA);
memset(num, 0, sizeof num);
for (int i = 1; i <= cnt; i++) cntA[l[i]]++;
for (int i = 1; i <= cnt; i++) cntA[i] += cntA[i - 1];
for (int i = cnt; i >= 1; i--) id[cntA[l[i]]--] = i;
/*更新主串节点*/
int temp = 1;
for (int i = 1; i <= len; i++) {
num[temp = nxt[temp][s[i] - 'a']] = 1;
}
/*拓扑更新*/
for (int i = cnt; i >= 1; i--) {
// basic
int x = id[i];
num[fa[x]] += num[x];
// extension
}
// extension
}
void debug(){
for (int i = cnt; i >= 1; i--){
printf("num[%d]=%d l[%d]=%d fa[%d]=%d\n",i,num[i],i,l[i],i,fa[i]);
}
}
}exsam;
广义SAM在线模板
const int maxn = 2e6 + 10;
struct EXSAM {
//basic
const char* s;
int cnt, len;
int nxt[maxn * 2][26],fa[maxn * 2],l[maxn * 2];
//extension
queue<int> q;
int cntA[maxn * 2], id[maxn * 2];/*辅助拓扑更新*/
int num[maxn * 2];/*每个节点代表的所有串的出现次数*/
EXSAM () { clear(); }
void clear() {
cnt = 1, l[1] = fa[1] = 0, memset(nxt[1], 0, sizeof nxt[1]), memset(num, 0, sizeof num);
}
int extend(int c, int last, int idx = 0) {
if (nxt[last][c]) {
int p = last, x = nxt[p][c];
if (l[p] + 1 == l[x]) { num[x] = 1; return x;}
int y = ++cnt;
l[y] = l[p] + 1;
memcpy(nxt[y], nxt[x], sizeof nxt[x]);
while (p && nxt[p][c] == x) nxt[p][c] = y, p = fa[p];
fa[y] = fa[x], fa[x] = y;
num[y] = 1;
return y;
}
int p = last, np = ++cnt;
memset(nxt[cnt], 0, sizeof nxt[cnt]);
l[np] = l[p] + 1, last = np;
while (p && !nxt[p][c]) nxt[p][c] = np, p = fa[p];
if (!p) fa[np] = 1;
else {
int q = nxt[p][c];
if (l[q] == l[p] + 1) fa[np] = q;
else {
int nq = ++cnt;
l[nq] = l[p] + 1;
memcpy(nxt[nq], nxt[q], sizeof(nxt[q]));
fa[nq] = fa[q], fa[np] = fa[q] = nq;
while (nxt[p][c] == q) nxt[p][c] = nq, p = fa[p];
}
}
num[np] = 1;
return np;
}
void build() {
memset(cntA, 0, sizeof cntA);
for (int i = 2; i <= cnt; i++) ++ cntA[fa[i]];
for (int i = 1; i <= cnt; i++) if (!cntA[i]) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int i = 0; i < 2; i++)
num[fa[u]] += num[u];
if (!(--cntA[fa[u]])) q.push(fa[u]);
}
}
}exsam;
广义SAM 应用
树上本质不同路径数
题意
给出一颗叶子结点不超过 \(20\) 个的无根树,每个节点上都有一个不超过 \(10\) 的数字,求树上本质不同的路径个数(两条路径相同定义为:其路径上所有节点上的数字依次相连组成的字符串相同)
思路
- 重要结论:一颗无根树上任意一条路径必定可以在以某个叶节点为根时,变成一条从上到下的路径(利于广义 SAM 的使用)。
- 而叶子节点不超过 \(20\) ,我们大可以枚举所有度为 1 的点作为无根树的根,然后将以该点为根的 Trie 树 插入SAM中,自然建立起了包含 \(cnt_{left}\) 个 Trie 树信息的广义SAM。
- 本质不同路径就变成了本质不同子串的个数,剩下的就是 SAM 的板子了。
- dfs 每个 Trie 树的时候,记录父节点的
extend()
的返回结点,方便建立广义SAM。