先后看了通俗的详解、妹妹的博客,然后又听了 T r y M y E d g e \sf TryMyEdge TryMyEdge 讲解,大概知道了后缀自动机在干什么。
感觉这玩意儿还是挺不容易搞懂的。但是 S i s t e r \sf Sister Sister 说 “这个东西确实不难”,我直接泪目
跟我整非诚勿扰呢?
其实后缀自动机,就是对 所有后缀(也等价于所有子串)建立 t r i e \tt trie trie 。于是它就可以解决各种字符串匹配问题,尤其涉及子串匹配时。
众所周知 t r i e \tt trie trie 是一种自动机,所以后文都统称 自动机
,因为打英文太麻烦。
用 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” 属于同一等价类。
很显然的,等价类中的串是长度连续的、有后缀关系的。毕竟要在相同的位置实现匹配。
我们根据 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 \operatorname{endpos} endpos 的包含关系,以及每个等价类中字符串的关系,还可以观察一下 S 0 S_0 S0 去头等等。
原先每个节点都对应一个子串;父节点对应的字符串为子节点对应的字符串去掉首字母的结果。若父节点 endpos \operatorname{endpos} endpos 与子节点 endpos \operatorname{endpos} endpos 不同,则该父节点必然存在别的子节点,或者该父节点是前缀节点。所以 endpos \operatorname{endpos} endpos 就是对树链进行了缩点。
用 S = “ a a b a b a ” S=“aababa” S=“aababa” 举个例子。根节点 ∅ \varnothing ∅ 被忽略了。
本来 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) 的!我并不会证明,烦请去别处搜搜。
类似 后缀树
的想法,说白了就是缩点。同一个等价类内的点的同种出边,走到的是同一等价类。但是入边则没有该性质。
对于一个点,用 l e n len len 表示其包含的字符串中 最长的一个 的长度。这玩意儿非常有用,你甚至可以直接假设每个等价类中只有最长的这一个。 f a fa fa 是后缀树上的父节点。
struct Node { int fa, ch[26], len; } node[MAXN<<1];
包含前缀的 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 称其为增量法)。在加入这个字符之前的串叫做 “旧串” ,而目前的串是 “现串” 。
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 是 “新串” 长度。
看看 后缀树
,可见新点无非是新加了一条链。这条链会缩成一个点,那就是 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 是根节点。
首先,我们要找到 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 则无此情况(单串属于该类)。有两个特判:
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;
}
}