后缀自动机
SAM 是一个 DAG,每条边上有一个字符。对于一个字符串
parent 树
对于一个字符串 s = "cbaba",我们构造一颗 c, bc, abc, babc, ababc 的 Trie:
:::align{center}
:::
其实这棵树就符合我们对 SAM 的定义(
原因是它是一棵树,因此从起点到任意点的路径只有一条,即树上一个点唯一对应一个子串。而一个串的本质不同子串数量是
考虑建它所有叶子的虚树。即如果一个点只有一个儿子,就把它和他儿子压缩起来:
:::align{center}
:::
这就是真正的 parent 树。根据虚树的性质,这棵树的点数是
此时一个点可以对应多个多个子串。例如 aba, abab, ababc 这些 aba, baba, cbaba。观察这些子串,它们是从 ba 开始,向前依次加入 a,b,c 后得到的字符串。
定义一个子串 s = "cbaba",endpos("ba") = {3, 5}。
:::info[结论]{open}
- 树上每个点唯一对应一种 endpos。
- 树上每个点对应的所有子串的 endpos 相同。
- 树上每个点的儿子的 endpos 两两无交,且自己的 endpos 是所有儿子的 endpos 的并,如果这个点包含一个前缀
s[1,i] 还要并上\{i\} 。 :::
证明略。其实不难感性理解。
还可以这样理解:对于一个前缀
:::align{center}
:::
SAM
我们回到 SAM。SAM 的节点就是 parent 树上的点,它也对应一堆 endpos 相同的子串。
每个点最多有
重新强调一下,一个点代表的所有子串,是:
- 所有 endpos 相同的子串;
- 在未压缩的 parent 树上,从根走到它(原来代表的那些点)的路径的反串;
- SAM 上从起点走到它的所有路径。
- SAM 上所有入边代表的子串添加对应字符后的并。
我们考虑如何构造 SAM 和 parent 树。事实上,SAM 中的后缀链接(link)就是 parent 树的树边。
SAM 的构造是在线的。具体而言,我们每次在当前字符串的最后添加一个字符,然后尝试更新整个树和 DAG 的形态。假设我们已经加入了
我们新建一个点
设代表
同样的,
但是如果跳到某个祖先
此时最简单的想法是直接将
如何判断
所以这里分类讨论。
- 如果
len_q=len_p+1 :直接link_{cur} \gets q 即可。 - 否则,我们需要把
q 拆了。为什么说是“拆”?因为q 原本就是压缩前的 parent 树上的一堆点合并起来的,现在我们要拆成两部分,使得一部分的len= len_p+1 ,另一部分原封不动。即我们新建一个点u 。它代表的最长子串是s[x+1,i+1] 。然后做:
tr[u].link = tr[q].link;
tr[u].len = tr[p].len + 1;
tr[q].link = tr[cur].link = u;
for (int j = 0; j < 26; ++ j ) tr[u].son[j] = tr[q].son[j];
:::align{center}
情况一 :::
:::align{center}
情况二 :::
红字表示这个节点代表的最长子串的出现位置之一。
注意我们是把
然后我们考虑
:::success[构建 SAM 代码]{open}
lst = idx = 1;
struct Node {
int link, len, son[26];
}tr[N];
void insert(int c) {
int cur = ++ idx;
tr[cur].len = tr[lst].len + 1;
while (lst && !tr[lst].son[c]) {
tr[lst].son[c] = cur;
lst = tr[lst].link;
}
if (!lst) {
tr[cur].link = 1;
} else {
int p = lst, q = tr[lst].son[c];
if (tr[q].len == tr[p].len + 1) tr[cur].link = q;
else {
int u = ++ idx;
tr[u].link = tr[q].link, tr[u].len = tr[p].len + 1, tr[q].link = tr[cur].link = u;
for (int j = 0; j < 26; ++ j ) tr[u].son[j] = tr[q].son[j];
while (lst && tr[lst].son[c] == q) {
tr[lst].son[c] = u;
lst = tr[lst].link;
}
}
}
lst = cur;
}
:::
总结
我们关心的其实只有这些事情:
- SAM 是一张 DAG。从起点出发的路径与子串一一对应。
- parent 树与 SAM 共用节点。一个点对应一堆 endpos 相同的子串。这些子串可以看作是在最短的串的基础上,往左侧依次加入一些字符形成的。
- parent 树就是将所有前缀反着插入 Trie 然后压缩。
- 一个点的 endpos 是 parent 树上所有儿子的 endpos 的并(再并上自身),它们两两无交。
- 点数,复杂度都是
\mathcal O(n) 。
例题
:::info[AT_abc433_g Substring Game]{open}
给定一个字符串
这道题是对 SAM 最浅显的应用。我们把“在
:::info[P3804 【模板】后缀自动机(SAM)]{open}
给定一个字符串
这道题需要我们刻画所有子串的“出现次数”和“长度”。我们知道 SAM 中的一个节点
:::info[CF1780G Delicious Dessert]{open}
给定一个字符串
求一个子串的出现次数仍然是上一题的做法。而一个节点代表的一组子串的长度是不同的。不过这也好办,它们的长度恰好取遍
:::info[P2408 不同子串个数]{open}
给定一个字符串
SAM 上一个节点代表的一组子串是互不相同的。所以答案就是每个点代表的子串个数之和,即
:::info[P3181 [HAOI2016] 找相同字符]{open}
给定两个字符串
容易将题目转化为
一般求 LCS/LCP 这种问题都是后缀数组的强项,不过在 SAM 的后缀树的视角下会更加直观,尽管这两者是等价的。
对于两个字符串,如果我们能在 SAM 中找到它们对应的节点
但是现在的两个字符串 @, $。然后对
:::info[P6640 [BJOI2020] 封印]{open}
给定两个字符串
求两个字符串的最长公共子串,可以用类似上面的做法,在两个串之间添加特殊字符后建 SAM,然后对于 endpos 中既有
对于本题,首先尝试求出
注意到如果最终答案是把
现在的问题是如何预处理
具体的,我们在这个点上打 tag,表示这颗子树内的特殊点的答案都需要与这个 tag 取
:::info[AT_abc452_g 221 Substring]{open}
定义一个数字串是好的,当且仅当每个极长连续段的长度与这个数相同(即出现恰好
依旧从 DAG 上考虑。设
这个做法比较暴力直接。复杂度
设在原串中,有一个
- 如果
x > y :好的子串一定不包含这个段。 - 如果
x=y :好的子串要么不包含这个段,要么完全包含这个段; - 如果
x<y :好的子串要么不包含这个段,要么只包含最开头x 个数,要么只包含最后x 个数。
考虑将这个段重写:如果