SAM学习笔记
后缀自动机——处理子串问题的强大工具
如何用一个自动机接受一个字符串的所有子串?
(以下以字符串\(\textbf{ababc}\)为例)
我会\(\operatorname{Trie}\)树!

You are too naive!
\(\Omicron(n^2)\)的时空复杂度,让人难以忍受!如何优化?
注意到\(\operatorname{Trie}\)树中每个状态都代表了一个本质不同子串,而光本质不同子串子串的理论复杂度就高达\(\Omicron(n^2)\)!
考虑如何降低状态数,我们使用自动机问题中常见的最小化操作,即节省无用的状态数。
但首先要引出一个概念:
\(\operatorname{endpos}\)集合:
定义子串\(t\)在\(s\)中出现的位置为\(\operatorname{endpos}(t)\),比如子串\(\textbf{ab}\)在\(\textbf{ababc}\)中的\(\operatorname{endpos}\)集合为\(\{2,4\}\).
对于两个子串\(t_1,t_2\),如果\(\operatorname{endpos}(t_1)=\operatorname{endpos}(t_2)\),那么称\(t_1,t_2\)在同一个\(\operatorname{endpos}\)等价类中。
一些性质
(1).在一个后缀\(\textbf{Trie}\)中,同一个\(\operatorname{endpos}\)等价类中的点,他们能接受的子串相同(也就是转移边相同).
- 很显然,任何一条转移边都来自于\(\operatorname{endpos}\)集合中的点之后的那个位置,既然你的\(\operatorname{endpos}\)相同,那么可能的转移边必然相同。
(2).对于两个子串\(t_1,t_2(|t_1|<|t_2|)\),满足\(\operatorname{endpos}(t_1)\subseteq\operatorname{endpos}\|\operatorname{endpos}(t_1)\cap\operatorname{endpos}(t_2)=\varnothing\).
- 因为任意两个\(\operatorname{endpos}\)有交集的串必定一个是另一个的子串,而子串的\(\operatorname{endpos}\)必定包括它的母串的所有\(\operatorname{endpos}\)。
(3).对于任意\(\operatorname{endpos}\)等价类,满足包含的子串长度连续。
(4).一个字符串所有子串的\(\operatorname{endpos}\)集合中,本质不同的集合只有\(2len\)个。
- 对一个\(\operatorname{endpos}\)等价类中长度最长的所有位置,再向前添加一个字符,那么由于性质(2),这个等价类必定会被分割成若干个新的\(\operatorname{endpos}\)等价类,一开始有\(1\)个点,每次划分最多只能产生\(2\)个新节点,由于初始\(\operatorname{endpos}\)集合大小才\(n\),最多划分\(n-1\)次,所以自然只会有\(2n-1\)个状态。
\(\textbf{parent}\)树:
不难想象,\(\operatorname{endpos}\)等价类的分割关系形成了一个树状结构,我们称这个结构为\(\textbf{parent}\)树(其实就是反串的后缀树)。
虽然\(\textbf{sam}\)的本体是接下来要讲到的\(\operatorname{DAG}\),但是这棵\(\textbf{parent}\)树的功能其实更加强大。
\(\textbf{SAM}\)
有了\(\operatorname{endpos}\)之后我们能做什么呢?把\(\textbf{Trie}\)上每个状态的\(\operatorname{endpos}\)画出来:

考虑到\(\textbf{endpos}\)总个数最多才\(2n\)个,又通过性质(1)注意到对于两个\(\textbf{endpos}\)相同的状态所有转移边也相同,于是自然想到把每个\(\textbf{endpos}\)等价类缩成一个点,形成了一个\(\operatorname{DAG}\):

而这个简练而优雅并且画起来格外省力的\(\operatorname{DAG}\),正是本文的主角——后缀自动机(Suffix Automation,简称\(\operatorname{SAM}\)),它以\(\Omicron(n)\)的状态数接受了一个字符串的所有子串。
一些定义
- \(len(p)\):状态\(p\)所能表示的最长的子串
- \(minlen(p)\):状态\(p\)所能表示的最短的子串
- \(link(u)\):节点\(u\)在\(\textbf{parent}\)树上的父亲
- \(ch(p,c)\):\(\textbf{sam}\)上从\(p\)出发,字符为\(c\)的转移边的终点
\(\textbf{sam}\)和\(\textbf{parent}\)树有以下性质:
(1) \(\textbf{sam}\)上一条从起点出发的路径对应一个子串.
(2) 表示两个子串的公共后缀的节点是两个节点在\(\textbf{parent}\)树上的公共祖先.
- 这是因为祖先都是后代的后缀。
(3) 对于任意状态\(p\)有\(minlen(p)=len(link(p))+1\).
- 从根到叶子代表了一个后缀的所有子串,并且这些子串都是连续的,不会凭空消失。
(4) \(\textbf{sam}\)的\(\textbf{parent}\)树上,如果某个点有字符\(c\)转移边,那么他的所有祖先必然都有字符\(c\)转移边.
- 通过后文提到的\(\textbf{sam}\)的构造法可以很直观的得到。
(5) 沿\(\textbf{sam}\)的转移边行进,\(\operatorname{endpos}\)大小单调不增。
- 和跳\(\textbf{parent}\)树的过程类似(一个是在前面加字符,一个是在后面加),沿\(\textbf{sam}\)行进时,到达的状态永远是原状态的母串,\(\operatorname{endpos}\)集合当然不可能变大。
(6) \(\textbf{sam}\)边数为\(\Omicron(n)\)。
- 不会证/kk,咕咕咕~
下图为\(\textbf{sam}\)+\(\textbf{parent}\)树:

增量法构造\(\textbf{sam}\)
强行背板子部分
具体过程:每次插入一个字符\(c\)的时候,新增节点\(o\),从上一次新增的状态\(p\),不断跳\(link\),沿途所有状态全部链向\(o\),直到某个状态\(u\)有转移边\(ch(q,c)\)指向点\(v\),直接将\(link(o)\)链向\(v\),如果不满足性质\(len(v)=len(u)+1\),那就再新建一个节点\(q\),使得\(len(v)=len(u)+1\),\(link(q)=link(v)+1\),然后把\(o\)和\(v\)连到\(q\)上然后往上跳,过程中把全部指向\(v\)的转移边全指向\(q\)。
结合代码理解:
int fa[maxn<<1],ch[maxn<<1][28],psiz[maxn<<1],len[maxn<<1];
int lstp=1,cnt=1;
inline void extend(int c){
int p=lstp,np=lstp=++cnt;
len[np]=len[p]+1,psiz[np]=1;
for (;p&&!ch[p][c];p=fa[p]) ch[p][c]=np;
if(!p){
fa[np]=1;
return;
}
int q=ch[p][c];
if(len[q]==len[p]+1){
fa[np]=q;
return;
}
int nq=++cnt;
memcpy(ch[nq],ch[q],sizeof(ch[q])),len[nq]=len[p]+1;
fa[nq]=fa[q],fa[q]=fa[np]=nq;
for (;p&&ch[p][c]==q;p=fa[p]) ch[p][c]=nq;
}

浙公网安备 33010602011771号