后缀自动机(SAM)习记
2022/8/1 23:25:27
本文主要是介绍后缀自动机(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]
次,乘上边权在倒序拓扑时直接统计即可。
- 每条边对于所有路径会被经过
洛谷P4248 [AHOI2013]差异
求两串中各取出一个子串使得这两个子串相同的方案数
- 对一个串建立SAM,记录每个节点的前缀和
pre[u] = pre[fa[u]] + l[fa[u]] - l[fa[fa[u]]
- 将另一个串放在SAM上跑,每次答案加上
pre[now] + num[now] * (len - l[fa[now]])
即可。
洛谷P3181 [HAOI2016]找相同字符
关于广义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 的使用)。
参考资料
辰星凌【学习笔记】字符串—广义后缀自动机
牛客竞赛字符串-后缀自动机
这篇关于后缀自动机(SAM)习记的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2025-01-04敏捷管理与看板工具:提升研发、设计、电商团队工作效率的利器
- 2025-01-04智慧养老管理工具如何重塑养老生态?
- 2025-01-04如何打造高绩效销售团队:工具与管理方法的结合
- 2025-01-04解决电商团队协作难题,在线文档工具助力高效沟通
- 2025-01-04春节超市管理工具:解锁高效运营与顾客满意度的双重密码
- 2025-01-046种主流销售预测模型:如何根据场景选用最佳方案
- 2025-01-04外贸服务透明化:增强客户信任与合作的最佳实践
- 2025-01-04重新定义电商团队协作:在线文档工具的战略作用
- 2025-01-04Easysearch Java SDK 2.0.x 使用指南(三)
- 2025-01-04百万架构师第八课:设计模式:设计模式容易混淆的几个对比|JavaGuide