【学习笔记】后缀自动机SAM

38 篇文章 0 订阅
13 篇文章 0 订阅

前言

先后看了通俗的详解妹妹的博客,然后又听了 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 的样例。那么就有这几个节点:

  1. endpos = { 1 , 2 , 3 , 4 , 5 , 6 } \text{endpos}=\{1,2,3,4,5,6\} endpos={1,2,3,4,5,6} ,包含字符串 ø \text{\o} ø
  2. endpos ⁡ = { 1 , 2 , 4 , 6 } \operatorname{endpos}=\{1,2,4,6\} endpos={1,2,4,6} ,包含字符串 “ a ” “a” a,父节点为 1 1 1
  3. endpos ⁡ = { 3 , 5 } \operatorname{endpos}=\{3,5\} endpos={3,5} ,包含字符串 “ b ” , “ a b ” “b”,“ab” b,ab,父节点为 1 1 1
  4. endpos ⁡ = { 2 } \operatorname{endpos}=\{2\} endpos={2} ,包含字符串 “ a a ” “aa” aa,父节点为 2 2 2
  5. endpos ⁡ = { 4 , 6 } \operatorname{endpos}=\{4,6\} endpos={4,6} ,包含字符串 “ b a ” , “ a b a ” “ba”,“aba” ba,aba,父节点为 2 2 2
  6. endpos ⁡ = { 3 } \operatorname{endpos}=\{3\} endpos={3} ,包含字符串 “ a a b ” “aab” aab,父节点为 3 3 3
  7. 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
  8. endpos ⁡ = { 4 } \operatorname{endpos}=\{4\} endpos={4} ,包含字符串 “ a a b a ” “aaba” aaba,父节点为 5 5 5
  9. 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+cB(S1A) S 2 + c ∉ B    ( S 2 ∈ A ) S_2+c\notin B\;(S_2\in A) S2+c/B(S2A) 则某位置 p p p 作为结尾只能匹配 S 1 + c S_1+c S1+c 而不能匹配 S 2 + c S_2+c S2+c,等价于 ( p − 1 ) (p{-}1) (p1) 作为结尾只能匹配 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;
	}
}
  • 13
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值