SAM 学习笔记

· · 算法·理论

SAM

定义

简单地说,SAM 是一个能接受 s 所有状态的最小 DFA

SAM 形态上是一个 DAG,有源点 t_0,从他出发可以到达所有点。

「转移」就是 DAG 上的边,所有没有「转移」的边都是终止状态,他们是 s 的后缀。

基本性质

每个 s 的子串,都对应一条从 t_0 出发的路径。

endpos

比如 $s = \text{abcab},t=\text{ab}$,$\operatorname{endpos}(t) = \{1,4\}$(下标从 $0$ 开始)。 一些重要的性质: ### 1.若 $\text{endpos}(t') = \text{endpos}(t)$(设 $|t'|<|t|$),当且仅当 $t'$ 的每次出现都为 $t$ 的后缀形式。 证明显然。 ### 2.若 $|t'|<|t|$,则 $\text{endpos}(t) \cap \text{endpos}(t') = \varnothing$ 或 $\text{endpos}(t) \sube \text{endpos}(t')$。 第二种情况是 $t'$ 是 $t$ 的后缀,否则为第一种。 证明显然。 ### 3.$\text{endpos}$ 等价类长度区间连续,覆盖 $[\text{minlen}(S),\text{len}(S)]$。 证明:设一个等价类的集合为 $u$。 若 $|u| = 1$,显然成立。 若 $|u| > 1$,找到 $u$ 中最长的串 $t$ 与最短的串 $t'$。 有性质 1 可知 $t'$ 是 $t$ 的后缀,且仅作为 $t$ 的后缀出现。 考察长度在 $(\text{minlen}(u),\text{len}(u))$ 内的 $t$ 的后缀串,设为 $t_x$。 由于更短的 $t'$ 与 $t$ 在同一集合内,且 $t_x$ 出现次数应该在 $t$ 与 $t'$ 之间,故 $t_x$ 的出现次数 $=$ $t$ 的出现次数。 再次运用性质 1,可知 $t_x \in u$。 证毕。 --- 先插入一个概念再来看性质 4、5。 ## 后缀链接 link 后缀链接 $\text{link}(u)$ 表示对应到 $t$ 的不在 $u$ 中的最长后缀对应的 endpos 等价类。 比如 $s = \text{abcab},t=\text{ab}$,$\operatorname{link}(\{4\})$($\{\text{cab},\text{bcab},\text{abcab}\}$) $= \{1,4\}$($\{\text{b},\text{ab}\}$)。 可以认为初始状态 $t_0$ 的等价类就是他自己。规定 $\text{endpos}(t_0) = \{-1,0,1,\dots,|s|-1\}$。 ### 4.$\text{link}$ 连成一棵树。 证明:每次跳 $\text{link}$ 都会到 $len$ 更短的状态(性质 3,link 的定义),故一定会到 $t_0$。 ### 5.$\text{link}$ 连成的树就是 $\text{endpos}$ 的包含关系树。 证明:由性质 2 知 $\text{endpos}$ 会连成树。 考察非 $t_0$ 状态 $u$,由 link 的定义和性质 2 可知: $$ \text{endpos}(u)\subsetneq \text{endpos}(\text{link}(u)), $$ 这里是 $\subsetneq$,因为若相等,则应该被合并为一个节点。 结合前面的性质,可知命题成立。 ## 代码实现 请保证在理解了前面的知识下再来看。 首先定义结构体与框架。 ```cpp struct state {int len, link, nxt[26];}; namespace SAM { state st[N << 1]; void init() { } void insert(char c) { } } ``` 一开始 SAM 只有一个节点 $t_0$,代码中编号为 $0$,以后的节点从 $1$ 开始往下编。 定义 $st[0].link = -1$,表示连接到虚拟状态,$st[0].len = 0$。 ### 插入 假设来了一个字符 $c$,我们如何插入它?(记 `C = c - 'a'`,一个节点的后缀表示该点代表的 endpos 集合的最长的字符串的后缀) 1. 记 $last$ 为上一次操作后,整个字符串对应的节点(初始为 $0$)。 2. 新建一个状态 $cur$,$st[cur].len \gets st[last].len + 1$。 3. 接着令 $p\gets last$,遍历 $p \gets st[p].link$,如果没有 $st[p].nxt[C]$,就加入一个到 $cur$ 的转移($link$ 遍历到的都是后缀,都需要加入到 $cur$ 的转移);若存在则进行下一步。 4. 若 $p = -1$,说明之前没有人有 $C$ 这个转移,令 $st[cur].link \gets 0$,退出。 5. 否则说明我们能找到 $p$,记 $st[p].nxt[C] = q$。 6. 若 $st[p].len + 1 = st[q].len$,说明 $q$ 刚好是 $cur$ 的一个后缀(可以由 $p$ 是 $last$ 的后缀得到),直接 $st[cur].link \gets q$ 即可。 7. 否则我们需要「分离」$q$ ,造出一个 $len = st[p].len+1$ 的状态,新建一个状态 $clone$,复制 $q$ 除 $len$ 以外的信息,$st[clone].len = st[p].len + 1$,由于 $clone$ 是 $q$ 的后缀,所以从 $p$ 往上跳到的节点的 $nxt[C]$ 都要改成 $clone$,接着将 $st[q].link \gets clone$,这样就可以 $st[cur].link \gets clone$ 了。 8. 最后更新 $last \gets cur$ ,完成全部过程。 ### 代码 ```cpp struct state {int len, link, nxt[26];}; namespace SAM { state st[N << 1]; int cnt, last; void init() { st[0].len = 0; st[0].link = -1; cnt = 0; last = 0; } void insert(char c) { int cur = ++cnt, C = c - 'a'; st[cur].len = st[last].len + 1; int p = last; while (p != -1 && !st[p].nxt[C]) { st[p].nxt[C] = cur; p = st[p].link; } if (p == -1) st[cur].link = 0; else { int q = st[p].nxt[C]; if (st[p].len + 1 == st[q].len) st[cur].link = q; else { int clone = ++cnt; st[clone] = st[q]; st[clone].len = st[p].len + 1; while (p != -1 && st[p].nxt[C] == q) { st[p].nxt[C] = clone; p = st[p].link; } st[q].link = st[cur].link = clone; } } last = cur; } } ``` 这是一个 $O(n)$ 的在线算法。 ## 应用 ### 不同子串个数 有两种方法。 1. 观察 SAM 的结构,可以发现是 DAG,直接 dp。 2. 观察 $link$ 的结构,已知这是一棵树,又能发现 $u$ 节点的子串数量刚好是 $st[u].len - st[st[u].link].len$,求和即可。——(1) 一般用第二种。 ### 子串(节点)出现次数 延续 $link$ 的思想,可以发现,若将所有每次新增的节点(不包括 $clone$)的点权全设为 $1$,一个节点的出现次数正好是 $link$ 的子树和。读者自证不难。 ### [【模板】后缀自动机(SAM)](https://www.luogu.com.cn/problem/P3804) 直接用上方的思路即可。 ```cpp #include <bits/stdc++.h> using namespace std; #define FASTIO ios::sync_with_stdio(false); cin.tie(0); cout.tie(0) const int N = 1e6 + 5; struct state {/*同上*/}; namespace SAM { state st[N << 1]; int cnt, last, sz[N << 1]; void init() { // 同上 } void insert(char c) { int cur = ++cnt, C = c - 'a'; st[cur].len = st[last].len + 1; sz[cur] = 1; // 同上 } } string s; int n, a[N << 1], tot[N]; int main() { #ifdef ONLINE_JUDGE FASTIO; #endif cin >> s; SAM::init(); n = s.size(); for (int i = 0; i < n; i++) SAM::insert(s[i]); long long ans = 0; for (int i = 1; i <= SAM::cnt; i++) tot[SAM::st[i].len]++; for (int i = 1; i <= SAM::cnt; i++) tot[i] += tot[i-1]; for (int i = 1; i <= SAM::cnt; i++) a[tot[SAM::st[i].len]--] = i; // 按照 len 排序,性质 4 的证明是依据 for (int i = SAM::cnt; i; i--) { int u = a[i]; SAM::sz[SAM::st[u].link] += SAM::sz[u]; if (SAM::sz[u] > 1) ans = max(ans, 1ll * SAM::st[u].len * SAM::sz[u]); } cout << ans; return 0; } ``` ### [ [SDOI2016] 生成魔咒](https://www.luogu.com.cn/problem/P4070) 考虑单次的增量。 由(1)可知,插入后与插入前相差的子串个数应该就是多出的 $st[cur].len - st[st[cur].link].len$。 注意要开 map 存转移。 ```cpp struct state { int len, link; map <int, int> nxt; }; namespace SAM { /* 自行完成 */ } int n; int main() { #ifdef ONLINE_JUDGE FASTIO; #endif cin >> n; SAM::init(); long long ans = 0; for (int i = 1, x; i <= n; i++) { cin >> x; SAM::insert(x); ans += SAM::st[SAM::last].len - SAM::st[SAM::st[SAM::last].link].len; cout << ans << "\n"; } return 0; } ``` ### 第 k 小子串 由前一个思路,我们可以知道每个节点代表的子串数。 接着按转移进行累加即可。 接下来就容易了,直接从 $t_0$ 开始即可。 ### [[TJOI2015] 弦论](https://www.luogu.com.cn/problem/P3975) 首先建出 SAM。 若 $t=0$,相当于考虑从 $u$ 出发能到达的子串个数,也就是把除了 $t_0$ 的点的点权设为 $1$ 后从 DAG 上能到达的点的点权和。 若 $t=1$,相当于点权为对应的 endpos 等价类的大小。 ```cpp namespace SAM { state st[N << 1]; int cnt, last, sz[N << 1], sum[N << 1]; void init() { /* 自行完成 */ } void insert(char c) { /* 自行完成 */ } } string s; int n, t, k, a[N << 1], tot[N]; void prt(int u, int k) { if (k <= SAM::sz[u]) return; k -= SAM::sz[u]; for (int i = 0; i < 26; i++) { int v = SAM::st[u].nxt[i]; if (!v) continue; if (k > SAM::sum[v]) {k -= SAM::sum[v]; continue;} cout << (char)('a' + i); prt(v, k); return; } } int main() { #ifdef ONLINE_JUDGE FASTIO; #endif cin >> s >> t >> k; SAM::init(); n = s.size(); for (int i = 0; i < n; i++) SAM::insert(s[i]); for (int i = 1; i <= SAM::cnt; i++) tot[SAM::st[i].len]++; for (int i = 1; i <= SAM::cnt; i++) tot[i] += tot[i-1]; for (int i = 0; i <= SAM::cnt; i++) a[tot[SAM::st[i].len]--] = i; for (int i = SAM::cnt; i >= 0; i--) SAM::sz[SAM::st[a[i]].link] += SAM::sz[a[i]]; for (int i = 1; i <= SAM::cnt; i++) t ? SAM::sum[i] = SAM::sz[i] : SAM::sum[i] = SAM::sz[i] = 1; SAM::sz[0] = SAM::sum[0] = 0; for (int i = SAM::cnt; i >= 0; i--) for (int j = 0; j < 26; j++) if (SAM::st[a[i]].nxt[j]) SAM::sum[a[i]] += SAM::sum[SAM::st[a[i]].nxt[j]]; if (SAM::sum[0] < k) cout << "-1"; else prt(0, k); return 0; } ``` 练习:[SUBLEX - Lexicographical Substring Search](https://www.luogu.com.cn/problem/SP7258) ### LCS(substring) 记两个串为 $s,t$。 首先建出 $s$ 串的 SAM。 然后相当于在 $s$ 串上跑 $t$ 的匹配。 考虑对每一个 $t$ 的前缀,算出他最长的在 $s$ 中出现的后缀。 记 $u$ 表示当前节点,$l$ 表示匹配长度,$C$ 表示当前字符需要的转移。 + 若 $u$ 有 $nxt[C]$ 的转移,$l \gets l + 1$,$u \gets st[u].nxt[C]$ 即可,否则进行下一步。 + 若 $u$ 已经到达 $-1$,说明无法匹配,令 $u \gets 0, l \gets 0$ 即可,否则需要缩短要匹配的后缀,令 $u \gets st[u].link,l \gets st[u].len$,返回上一步。 代码: ```cpp void match(string s) { memset(mat, 0, sizeof(mat)); int u = 0, l = 0; for (int i = 0; i < (int)s.size(); i++) { int C = s[i] - 'a'; while (u != -1 && !st[u].nxt[C]) u = st[u].link, l = st[u].len; if (u != -1) l++, u = st[u].nxt[C]; else u = l = 0; mat[i] = l; } } ``` 练习:[LCS - Longest Common Substring](https://www.luogu.com.cn/problem/SP1811) ### 配合线段树合并 ### [Security](https://www.luogu.com.cn/problem/CF1037H) 考虑最优方案就是尽量多匹配 $x$ 的前缀,再接上一个比 $x$ 下一个字符大的字符。 首先建出 $s$ 的 SAM,利用线段树合并在 $link$ 树上求出 endpos 等价类的集合。 ```cpp int merge(int u, int v, int l, int r) { if (!u || !v) return u + v; if (l == r) return u; int ret = ++cnt; ls[ret] = merge(ls[u], ls[v], l, mid); rs[ret] = merge(rs[u], rs[v], mid + 1, r); return ret; } void modify(int &u, int l, int r, int a) { u = ++cnt; if (l == r) return; if (a <= mid) modify(ls[u], l, mid, a); else modify(rs[u], mid + 1, r, a); } bool query(int u, int l, int r, int a, int b) { if (!u) return 0; if (a <= l && r <= b) return 1; if (a <= mid && query(ls[u], l, mid, a, b)) return 1; if (b > mid && query(rs[u], mid + 1, r, a, b)) return 1; return 0; } void dfs(int u) { for (int v : g[u]) { dfs(v); rt[u] = merge(rt[u], rt[v], 1, V); } } namespace SAM { /* 自行完成 */ void insert(char c) { int cur = ++cnt; st[cur].len = st[last].len + 1; modify(rt[cur], 1, V, st[cur].len); /* 自行完成 */ } } ``` (注:本题中的线段树合并需要再 `merge` 函数中新开点,以维护除根节点以外节点的信息不被破坏。) 接着就相对简单了,记 $to_i$ 表示匹配了 $x$ 的 $[0,i)$ 字符,接上一个字符的最小字符。 剩余看代码解释即可。 ```cpp int main() { #ifdef ONLINE_JUDGE FASTIO; #endif cin >> s; n = s.size(); SAM::init(); for (int i = 0; i < n; i++) SAM::insert(s[i] - 'a'); for (int i = 1; i <= SAM::cnt; i++) g[SAM::st[i].link].push_back(i); dfs(0); cin >> m; while (m--) { int l, r, k; cin >> l >> r >> s; k = s.size(); int i, u = 0, v; for (i = 0; ; i++) { to[i] = -1; for (int j = (i == k ? 0 : s[i] - 'a' + 1); j < 26; j++) // 枚举接哪个字符 { v = SAM::st[u].nxt[j]; if (v && query(rt[v], 1, V, l + i, r)) // 如果当前节点存在这样的字符串 { to[i] = j; break; } } if (i == k) break; v = SAM::st[u].nxt[s[i]-'a']; // 匹配下一个字符 if (!v || !query(rt[v], 1, V, l + i, r)) break; // 匹配不了了 u = v; } while (i >= 0 && to[i] == -1) i--; if (i < 0) cout << "-1\n"; else { for (int j = 0; j < i; j++) cout << s[j]; cout << (char)(to[i] + 'a') << "\n"; } } return 0; } ``` ## 总结 SAM 是字符串算法中最通用的一种。 他还有扩展版广义 SAM。 先慢慢理解好 SAM,以应对以后的较难字符串问题吧!