前言
先后看了通俗的详解、妹妹的博客,然后又听了
T
r
y
M
y
E
d
g
e
\sf TryMyEdge
TryMyEdge 讲解,大概知道了后缀自动机在干什么。
感觉这玩意儿还是挺不容易搞懂的。但是 S i s t e r \sf Sister Sister 说 “这个东西确实不难”,我直接泪目 😭
壹、什么是后缀自动机
0.第一印象
跟我整非诚勿扰呢?
其实后缀自动机,就是对 所有后缀(也等价于所有子串)建立 t r i e \tt trie trie 。于是它就可以解决各种字符串匹配问题,尤其涉及子串匹配时。
众所周知
t
r
i
e
\tt trie
trie 是一种自动机,所以后文都统称 自动机
,因为打英文太麻烦。
1.终止节点等价类
用 endpos ( T ) \operatorname{endpos}(T) endpos(T) 表示,字符串 T T T 在原串 S S S 中所有 出现位置的结束点 的集合。举栗子, S = “ a a b a b a ” S=“aababa” S=“aababa”,下标从 1 1 1 开始,则 endpos ( “ a b a ” ) = { 4 , 6 } \operatorname{endpos}(“aba”)=\{4,6\} endpos(“aba”)={4,6} 。
这个时候,我们按照 endpos \operatorname{endpos} endpos 划分等价类,因为他们有很多相似的性质。还用上面的栗子, endpos ( “ b a ” ) = { 4 , 6 } \operatorname{endpos}(“ba”)=\{4,6\} endpos(“ba”)={4,6} ,所以 “ b a ” “ba” “ba” 和 “ a b a ” “aba” “aba” 属于同一等价类。
很显然的,等价类中的串是长度连续的、有后缀关系的。毕竟要在相同的位置实现匹配。
2.后缀树( parent tree \text{parent tree} parent tree)
我们根据 endpos \operatorname{endpos} endpos 可以建出一棵树,点表示等价类,而父子关系是这样确定的:对于某个等价类,其中有一个 最短的 字符串 S 0 S_0 S0 。将其首字母去掉,得到的字符串就不在当前等价类中了(因为 S 0 S_0 S0 是最短),则它所在的等价类就是当前等价类的父节点。
继续举栗子!沿用上面的 S = “ a a b a b a ” S=“aababa” S=“aababa” 吧。容易看出 endpos = { 4 , 6 } \operatorname{endpos}=\{4,6\} endpos={4,6} 的等价类是 { “ a b a ” , “ b a ” } \{“aba”,“ba”\} {“aba”,“ba”} ,知 S 0 = “ b a ” S_0=“ba” S0=“ba”,其去掉首字母为 “ a ” “a” “a” 属于等价类 endpos = { 1 , 2 , 4 , 6 } \operatorname{endpos}=\{1,2,4,6\} endpos={1,2,4,6},这就是父节点。容易发现 S 0 S_0 S0 去掉首字母就是父节点的等价类中最长的。
显然每个等价类都存在父节点(除了空串 ∅ \varnothing ∅ 所在的等价类),并且没有环存在。所以它当然是一棵树 😉
容易发现 点数是
O
(
n
)
\mathcal O(n)
O(n) 的。因为父节点的
endpos
\operatorname{endpos}
endpos 必然包含子节点的
endpos
\operatorname{endpos}
endpos ,且兄弟节点的
endpos
\operatorname{endpos}
endpos 无交集,那么从上往下看,我们进行的是集合分拆。叶子结点是
∣
endpos
∣
=
1
|\operatorname{endpos}|=1
∣endpos∣=1 。肯定最多
2
n
2n
2n 个节点了呗。
举栗子!我很喜欢 S = “ a a b a b a ” S=“aababa” S=“aababa” 的样例。那么就有这几个节点:
- endpos = { 1 , 2 , 3 , 4 , 5 , 6 } \text{endpos}=\{1,2,3,4,5,6\} endpos={1,2,3,4,5,6} ,包含字符串 ø \text{\o} ø 。
- endpos = { 1 , 2 , 4 , 6 } \operatorname{endpos}=\{1,2,4,6\} endpos={1,2,4,6} ,包含字符串 “ a ” “a” “a”,父节点为 1 1 1 。
- endpos = { 3 , 5 } \operatorname{endpos}=\{3,5\} endpos={3,5} ,包含字符串 “ b ” , “ a b ” “b”,“ab” “b”,“ab”,父节点为 1 1 1 。
- endpos = { 2 } \operatorname{endpos}=\{2\} endpos={2} ,包含字符串 “ a a ” “aa” “aa”,父节点为 2 2 2 。
- endpos = { 4 , 6 } \operatorname{endpos}=\{4,6\} endpos={4,6} ,包含字符串 “ b a ” , “ a b a ” “ba”,“aba” “ba”,“aba”,父节点为 2 2 2 。
- endpos = { 3 } \operatorname{endpos}=\{3\} endpos={3} ,包含字符串 “ a a b ” “aab” “aab”,父节点为 3 3 3 。
- endpos = { 5 } \operatorname{endpos}=\{5\} endpos={5} ,包含字符串 “ b a b ” , “ a b a b ” , “ a a b a b ” “bab”,“abab”,“aabab” “bab”,“abab”,“aabab”,父节点为 3 3 3 。
- endpos = { 4 } \operatorname{endpos}=\{4\} endpos={4} ,包含字符串 “ a a b a ” “aaba” “aaba”,父节点为 5 5 5 。
- endpos = { 6 } \operatorname{endpos}=\{6\} endpos={6} ,包含字符串 “ b a b a ” , “ a b a b a ” , “ a a b a b a ” “baba”,“ababa”,“aababa” “baba”,“ababa”,“aababa”,父节点为 5 5 5 。
注意观察 endpos \operatorname{endpos} endpos 的包含关系,以及每个等价类中字符串的关系,还可以观察一下 S 0 S_0 S0 去头等等。
2.1.别样思考
原先每个节点都对应一个子串;父节点对应的字符串为子节点对应的字符串去掉首字母的结果。若父节点 endpos \operatorname{endpos} endpos 与子节点 endpos \operatorname{endpos} endpos 不同,则该父节点必然存在别的子节点,或者该父节点是前缀节点。所以 endpos \operatorname{endpos} endpos 就是对树链进行了缩点。
用 S = “ a a b a b a ” S=“aababa” S=“aababa” 举个例子。根节点 ∅ \varnothing ∅ 被忽略了。
白边为缩点前的后缀树。红框则为等价类,即缩点。
3.自动机
本来 t r i e \tt trie trie 很好建;我们只是要把点改为 endpos \operatorname{endpos} endpos 等价类。
谁允许你缩点的!首先需要证明,存在合法的自动机。那就等价于下面这三条:
- 对于任意一条路径,它代表的字符串是依次走过的边上写的字符拼接而成的。
- 路径终点所在的节点对应的等价类包含该路径对应的字符串。
- 所有子串都唯一与一条路径对应。
其实我们担心的就是,对于某等价类 A A A,一部分字符串加上字符 c c c 会得到等价类 B B B 中的字符串,但另外一些则不会。这种情况不会发生。利用反证法,若 S 1 + c ∈ B ( S 1 ∈ A ) S_1+c\in B\;(S_1\in A) S1+c∈B(S1∈A) 且 S 2 + c ∉ B ( S 2 ∈ A ) S_2+c\notin B\;(S_2\in A) S2+c∈/B(S2∈A) 则某位置 p p p 作为结尾只能匹配 S 1 + c S_1+c S1+c 而不能匹配 S 2 + c S_2+c S2+c,等价于 ( p − 1 ) (p{-}1) (p−1) 作为结尾只能匹配 S 1 S_1 S1,与 S 1 , S 2 S_1,S_2 S1,S2 在同一等价类中矛盾。
所以,在之后的阅读中,你可以 以等价类中最长的串作为代表。 S i s t e r \sf Sister Sister 很早就意识到了这一点,所以理解得很快。
更厉害的是,边数是
O
(
n
)
\mathcal O(n)
O(n) 的!我并不会证明,烦请去别处搜搜。
3.1.别样思考
类似 后缀树
的想法,说白了就是缩点。同一个等价类内的点的同种出边,走到的是同一等价类。但是入边则没有该性质。
贰、怎么建立自动机
-1.妹妹的寄语
对于一个点,用 l e n len len 表示其包含的字符串中 最长的一个 的长度。这玩意儿非常有用,你甚至可以直接假设每个等价类中只有最长的这一个。 f a fa fa 是后缀树上的父节点。
struct Node { int fa, ch[26], len; } node[MAXN<<1];
0.碳链为骨架
包含前缀的 n n n 个节点,其 endpos \text{endpos} endpos 不同,故建出 t r i e \tt trie trie 树必然是一条链。
那么我们就依靠这条链,在线加入每个字符(巨佬 D i a m o n d D u k e \sf DiamondDuke DiamondDuke 称其为增量法)。在加入这个字符之前的串叫做 “旧串” ,而目前的串是 “现串” 。
1.创建新点
int p = lst, np = lst = ++ cntNode; node[np].len = node[p].len+1;
因为新加入的字符必然导致 骨架
变长
1
1
1,创建新点不可避免。
l a s las las 表示链条的尾端, p p p 是 “旧串” 的后缀, n p np np 则是当前点,显然它的 endpos = { n } \operatorname{endpos}=\{n\} endpos={n},这里的 n n n 是 “新串” 长度。
2.当前节点
看看 后缀树
,可见新点无非是新加了一条链。这条链会缩成一个点,那就是
n
p
np
np,代表
endpos
=
{
n
}
\operatorname{endpos}=\{n\}
endpos={n} 的点。考虑连向该点的自动机边,肯定是 “旧串” 的后缀。代表 “旧串” 后缀的点,若其没有当前字符的出边,则连向当前新点
n
p
np
np 。若其有,则
endpos
⫌
{
n
}
\operatorname{endpos}\supsetneqq\{n\}
endpos⫌{n},立即终止。
代码实现时,利用 后缀树
,跳过 “未缩点的后缀树” 的树链,因为它们的 自动机
出边相同。
for(; p&&!node[p].ch[c]; p=node[p].fa) node[p].ch[c] = np;
温馨提示: 1 1 1 是根节点。
3.后缀树父亲
首先,我们要找到 n p np np 在后缀树上的父亲。等价于,删去尽量短的前缀,使得 endpos \operatorname{endpos} endpos 变化,即不再为 { n } \{n\} {n} 。使用一些分类讨论。代码接上文。
if(!p){ node[np].fa = 1; return; }
连根节点 ∅ \varnothing ∅ 都没找到这个儿子,说明旧串根本冇该字符!所以删去前缀的唯一方案就是删光。于是父节点为 ∅ \varnothing ∅ 对应的根节点 1 1 1 。
特判掉该情况,根据上一步求 p p p 的过程,我们知道 S p + c S_p+c Sp+c 对应的点就是 n p np np 在后缀树上的父亲。但我们必须考虑一个问题:缩点发生变化。该点的加入,导致父节点成为 “分岔点”,那么缩点时就必须在此处断开。最好的情况,当然是此处本来就是 “分岔点”,即链底为 S p + c S_p+c Sp+c 。用 l e n len len 即可判断。于是有了下面这几行代码。
int q = node[p].ch[c];
if(node[q].len == node[p].len+1) return void(node[np].fa = q);
否则,原来的链 裂开了。只好拆成两个点了。那就新建一个节点
n
q
nq
nq 。但是,该节点该代表链的上半部分,即
n
p
np
np 在 后缀树
上的父亲,还是下半部分呢?答案是上半部分,没有为什么。
现在考虑 自动机
边的细分。原本连向点
q
q
q 的,可能连向
n
q
nq
nq 了,原因就是其
endpos
\operatorname{endpos}
endpos 含有
n
n
n 。这样的点其实已经被定位出来了,就是当前的
p
p
p 及其祖先。所以将这些边重连即可。
int nq = ++ cntNode; if(node[p].len == node[np].len-1) lst = nq;
node[nq] = node[q], node[nq].len = node[p].len+1;
for(; node[p].ch[c]==q; p=node[p].fa) node[p].ch[c] = nq;
node[q].fa = node[np].fa = nq;
于是
SAM
\textit{SAM}
SAM 你真正需要学会的东西就讲完啦!下面都是空洞的理论分析。有些东西会用就行。
时间复杂度
对于循环一,也就是加边的复杂度,容易看出是 O ( n ) \mathcal O(n) O(n) 的。为何?考虑沿 f a fa fa 数组要跳多少次才能到根,记之为 v a l val val ,那么 v a l ( q ) ⩽ v a l ( p ) + 1 val(q)\leqslant val(p)+1 val(q)⩽val(p)+1 。因为 p p p 的祖先(代表 p p p 的后缀)都存在一个加上 c c c 跳到 q q q 的祖先的关系(可能相同);反之, q q q 的祖先去掉 c c c 会成为 p p p 的祖先(不可能相同)。 + 1 +1 +1 是为了弥补单字符 c c c 的存在。
然后你又加上 n p np np 和自己, v a l val val 最多变大了 2 2 2 。然后跳一次 f a fa fa 会使得 v a l val val 减一。所以总复杂度 O ( n ) \mathcal O(n) O(n) 。
对于循环二,我证明不来。你需要知道那么多吗?你不需要。反正 邻接矩阵 导致复杂度变成
O
(
∣
Σ
∣
⋅
n
)
\mathcal O(|\Sigma|\cdot n)
O(∣Σ∣⋅n) ,这里
Σ
\Sigma
Σ 表示字符集。
叁、广义后缀自动机
即,对于多个字符串,建立一个自动机,可以接受任一子串。中间加一个分割符,全部拼接起来。(甚至不加分割符。)
我选择直接丢链接。简单来说,若给出
t
r
i
e
\tt trie
trie,则只能
b
f
s
\rm bfs
bfs,每次将
l
a
s
t
last
last 设为父节点的新建节点,时间复杂度
O
(
∣
t
r
i
e
∣
)
\mathcal O(|\tt trie|)
O(∣trie∣) 。若
d
f
s
\rm dfs
dfs 需要加入第二特判,复杂度为叶子节点深度和。
若给出多个串,可以只加入特判,复杂度 O ( ∑ ∣ S ∣ ) \mathcal O(\sum|S|) O(∑∣S∣) 。特判目的是,处理 该串已存在 的情况。在 t r i e \tt trie trie 上 b f s \rm bfs bfs 则无此情况(单串属于该类)。有两个特判:
- 第一特判:该串已经成为 parent tree \text{parent tree} parent tree 的某个缩点链末端。直接跳过去即可。在 t r i e \tt trie trie 上 d f s \rm dfs dfs 等价于第一特判。
- 第二特判:该串已存在于缩点链中间。此时 “拆链” 是拆自己这条链。
肆、广义 SAM \textit{SAM} SAM 代码
namespace SAM{
struct Node { int fa, ch[26], len; };
Node node[MAXN<<1]; int cntNode = 1, lst;
inline void reset(){ lst = 1; }
void append(const int &c){
if(node[lst].len+1 == node[node[lst].ch[c]].len)
return void(lst = node[lst].ch[c]); // existent
int p = lst, np = lst = ++ cntNode; node[np].len = node[p].len+1;
for(; p&&!node[p].ch[c]; p=node[p].fa) node[p].ch[c] = np;
if(!p){ node[np].fa = 1; return; } int q = node[p].ch[c];
if(node[q].len == node[p].len+1) return void(node[np].fa = q);
int nq = np; if(node[p].len != node[np].len-1) nq = ++ cntNode;
node[nq] = node[q], node[nq].len = node[p].len+1;
for(; node[p].ch[c]==q; p=node[p].fa) node[p].ch[c] = nq;
node[q].fa = nq; if(nq != np) node[np].fa = nq;
}
}