
概述 - SAM
后缀自动机(suffix automaton, SAM)是一个能解决许多字符串相关问题的有力的数据结构。
其主要解决的问题是子串相关的问题。
一些符号
-
\(\Sigma\) 为字符集,\(|\Sigma|\) 为字符集大小,在代码中使用常量
SIG表示 -
\(\text{endpos}(t)\) 表示 \(t\) 的结束位置集合。在下文中定义。
-
\(\text{endpos}(u)\) 表示等价类 \(u\) 中任意字符串的结束位置集合。
-
\(\text{link}(u)\) 表示节点 \(u\) 的后缀链接。
-
\(\text{longest}(u), \text{shortest}(u)\) 为节点 \(u\) 表示的字符串中最长 / 最短的。
-
\(\text{maxlen}(u),\text{minlen}(u)\) 为节点 \(u\) 表示的字符串中最长 / 最短字符串长度。\(\text{maxlen}\) 单独出现时也记作 \(\text{len}\)。
定义 / 性质
对于字符串 \(s, |s| = n\) 的 SAM:
SAM 是接受一个字符串所有后缀的最小 DFA。
-
SAM 是一张 DAG,节点称为状态,边称为转移。转移边上标有字符。
-
初始状态唯一且能到达所有状态。
-
终止状态不唯一,初始状态到终止状态的路径上的转移构成一个 \(s\) 的后缀。反之每个后缀可以由初始状态到一个终止状态的路径构成。
-
SAM 的节点个数、转移边个数、构造复杂度均为 \(\mathcal{O}(n)\)。具体地,节点个数最多为 \(2n - 1\),转移边个数最多为 \(3n - 4\)。
-
SAM 中实际存储了 \(s\) 的所有子串:
-
SAM 是接受 \(s\) 所有后缀的最小 DFA,也就是包含了所有后缀的前缀信息,即所有子串。
-
SAM 是 \(s\) 反串 \(s'\) 的后缀树,存储了 \(s'\) 后缀(即 \(s\) 前缀)的前缀(即 \(s\) 的前缀的后缀),即所有子串。
-
构成
endpos 等价类
对于一个 \(s\) 的子串 \(t\),记它在 \(s\) 中所有结束位置(每次出现是一段连续的 \(|t|\) 个字符,那么结束位置为这 \(|t|\) 个字符的末尾)的集合为 \(\text{endpos}(t)\)。
若干个 \(\text{endpos}\) 相等的 \(t\),称为一个 \(\text{endpos}\) 等价类。在 SAM 中,我们将 \(\text{endpos}\) 等价类合并考虑,每个节点就是一个 \(\text{endpos}\) 等价类。
可以发现一个性质,对于一个 \(\text{endpos}\) 等价类,记其中字符串按照长度从小到大排序为 \(t_{1, \cdots, m}\)。那么 \(\forall 1 \le i \lt m\):
-
\(\text{len}(t_{i + 1}) = \text{len}(t_i) + 1\)。
-
\(t_i\) 为 \(t_{i + 1}\) 的后缀。
举例:\(s = \texttt{cdabcbcdabc}\)。
-
\(\text{endpos} = \{ 5, 11 \} : \texttt{cdabc, dabc, abc}\)。
-
\(\text{endpos} = \{ 5, 7, 11 \} : \texttt{bc}\)。
-
\(\text{endpos} = \{ 1, 5, 7, 11 \} : \texttt{c}\)。
证明平凡:
-
\(t_i\) 若不是 \(t_{i + 1}\) 的后缀,则不会同时出现在一个位置。
-
若 \(\text{len}(t_{i+1})\gt\text{len}(t_i)+1\),则存在一个 \(t_{i + 1}\) 的后缀 \(t'\),满足 \(\text{len}(t') \gt \text{len}(t_i)\)。
此时 \(t_i\) 是 \(t'\) 的后缀,那么只有 \(t_i\) 出现 \(t'\) 才能出现,则 \(\text{endpos}(t') \subseteq \text{endpos}(t_i)\)。
同理,因为 \(t'\) 是 \(t_{i + 1}\) 的后缀,\(\text{endpos}(t_{i + 1}) \subseteq \text{endpos}(t')\)。
又 \(\text{endpos}(t_i) = \text{endpos}(t_{i + 1})\),所以 \(\text{endpos}(t') = \text{endpos}(t_i) = \text{endpos}(t_{i + 1})\) 。\(t'\) 也是这个等价类里面的。
转移 next & 后缀链接 link
转移边 \(\text{next}(u, c) = v\) 满足 \(u\) 代表的所有字符串在末尾加入字符 \(c\) 后得到的结果在 \(v\) 表示的等价类中。
显然不可能出现这些加了 \(c\) 得到的字符串出现在两个 \(\text{endpos}\) 等价类中,不然原来的字符串就不构成等价类了。
在代码中记为 st[u].nxt[c]。
对于节点 \(u\),记 \(\text{shortest}(u)\) 删去第一个字符所得的 \(t'\) 所在的节点 \(v\),定义 \(\text{link}(u) = v\)。
还是举例:\(s = \texttt{cdabcbcdabc}\)。
-
\(\text{endpos}(A) = \{ 5, 11 \} : \texttt{cdabc, dabc, abc}\)。
-
\(\text{endpos}(B) = \{ 5, 7, 11 \} : \texttt{bc}\)。
-
\(\text{endpos}(C) = \{ 1, 5, 7, 11 \} : \texttt{c}\)。
则 \(\text{link}(A) = B, \text{link}(B) = C\)。
在代码中记为 st[u].lnk。
后缀链接树
容易发现,对于所有点 \(u\),将 \(\text{link}(u)\) 向 \(u\) 连边可以得到一棵树。
性质:后缀链接树上每个点 \(u\) 的 \(\text{endpos}(u)\) 为 $S \cup \left ( \bigcup _ {v \in \mathrm{son}(u)} \text{endpos}(v)\right ) $。
-
当 \(\text{longest}(u)\) 为 \(s\) 的一个前缀时,\(S = \{ \text{len}(\text{longest}(u)) \}\)。
-
否则 \(S = \varnothing\)。
证明平凡:
-
显然 \(\text{longest}(u)\) 一定是所有出现在 \(u\) 在后缀链接树上的子树(不为 \(u\))的字符串的真后缀。
-
若 \(\text{longest}(u)\) 不是 \(s\) 的前缀,则一定作为真后缀出现在「「\(u\) 在后缀链接树上的子树内所有点 \(v\)(\(u \not = v\))」代表的字符串」中,故 \(\text{endpos}(u) = \bigcup _ {v \in \mathrm{son}(u)} \text{endpos}(v)\)。
-
否则只会多一个作为 \(s\) 前缀出现的位置 \(\text{len}(\text{longest}(u))\)。
SAM 的后缀链接树实际上是 \(s\) 反串 \(s'\) 的后缀树。\(s'\) 的后缀树是 \(s'\) 所有后缀组成的 trie 上,对这些后缀的结束节点求虚树所得。
后缀树是把后缀按照前缀对齐,SAM 后缀链接树是把前缀按照后缀对齐。
线性建 SAM
点击查看代码
const int LEN = 2e6 + 5;
const int SIG = 26;struct SAM{struct state{int len, lnk, cnt;int nxt[SIG];} st[LEN << 1];int siz = 0;SAM(){siz = 0;st[0].len = 0;st[0].lnk = -1;}int new_node(){int x = ++siz;memset(st[x].nxt, 0, sizeof(int) * 26);return x;}int clone_node(int lst){int x = new_node();st[x].lnk = st[lst].lnk;st[x].len = st[lst].len;rep(i, 0, SIG - 1){st[x].nxt[i] = st[lst].nxt[i];}return x;}int extend(int c, int lst){int x = new_node();st[x].cnt = 1;st[x].len = st[lst].len + 1;int p = lst;while(p != -1 && !st[p].nxt[c]){st[p].nxt[c] = x;p = st[p].lnk;}if(p == -1){st[x].lnk = 0;return x;}int q = st[p].nxt[c];if(st[p].len + 1 == st[q].len){st[x].lnk = q;}else{int cl = clone_node(q);st[cl].len = st[p].len + 1;while(p != -1 && st[p].nxt[c] == q){st[p].nxt[c] = cl;p = st[p].lnk;}st[q].lnk = st[x].lnk = cl;}return x;}vector<int> tr[LEN << 1];void build_tr(){rep(x, 1, siz){tr[st[x].lnk].push_back(x);}}
} SAM;
算法流程(extend)函数:
-
传入字符 \(c\)(编码为数字,范围 \([0, |\Sigma|)\))和拓展节点 \(lst\)。
代表extend函数会给 \(lst\) 节点构造出 \(nxt_c\) 的节点并保证所有点的指针全部正确。 -
新建节点 \(x\),\(\text{len}(x) \leftarrow \text{len}(lst) + 1\)。
-
由于 \(x\) 的加入,更新一些原本为空的 \(nxt_c\):
令 \(p \leftarrow lst\)。循环:
-
若 \(\text{next}(p,c)\) 未定义,赋值为 \(x\)。
-
否则,跳出循环。
显然满足了对 \(\text{next}\) 指针的定义。
-
-
给 \(x\) 节点找 \(\text{link}\),也就是找到 \(\text{longest}(x)\) 的一个最长后缀满足不与 \(\text{longest}(x)\) 在一个等价类里。
相当于找到 \(\text{longest}(x)\) 的一个已经出现的最长后缀,记为 \(t\)。-
若此时 \(p\) 已经跳没了,即跳到了初始状态,则特判掉,\(\text{link}(x)\) 赋值为初始状态。
-
否则,\(t\) 就是 \(\text{longest}(p)\) 添加 \(c\) 得到,就在 \(q = \text{next}(p, c)\) 中。
这里是因为跳到这个 \(p\) 以前经过的节点都没有 \(\text{next}(...,c)\),说明其最长串加上 \(c\) 得到的串(该串为 \(\text{longest}(x)\) 的后缀)未曾出现。-
若 \(\text{len}(q) = \text{len}(p) + 1\),即 \(\text{longest}(q)\) 恰好为 \(t\)。
直接令 \(\text{link}(x) \leftarrow q\) 即可。 -
否则,说明 \(q\) 中存在一些字符串长度大于 \(t\),显然这些字符串不会是 \(\text{longest}(x)\) 的子串。
那么我们发现,\(q\) 这个等价类就出现了分化:长度小于等于 \(t\) 的字符串的 \(\text{endpos}\) 会新增,其他不变。
也就是说,等价类 \(q\) 已经解体。我们克隆一个节点 \(cl\),继承 \(q\) 除了 \(\text{len}\) 以外的 \(\text{next}\) 指针和 \(\text{link}\) 指针。
让它的 \(\text{len}_{cl} \leftarrow \text{len}_p + 1\),代表原来 \(q\) 中长度小于等于 \(t\) 的。
然后执行 \(\text{link}(q), \text{link}(x) \leftarrow cl\)。
-
-
关于复杂度:每次最多建立两个新节点 \(x, cl\),且第一次调用 extend 不可能新建 \(cl\),故节点数 \(\le 2n - 1\)。