Loading

学习笔记——SAM

前言

不想学博弈论不想学 SA 不想学插头 dp,学 lct 被 AxDea D 飞了,那就来学 SAM。

SAM?

SAM 是后缀自动机,名义上是后缀,但实际上它能表示出一个字符串的所有不同子串。不同于你的 \(O(n^2)\) 枚举,SAM 构造,节点和边的数量也都是 \(O(n)\) 级别的。

更具体的,SAM 表现为一张 DAG,每条边上有一个字母(类似于 AC 自动机和 trie),然后从源点开始到任意节点结束都能形成一个字符串,而这些字符串刚好都是原来字符串的子串,并且已经包含了所有的子串。


前置的定义

在构造 SAM 之前,需要知道一些定义。

endpos

我们对于一个字符串 \(S\) 的任意一个子串 \(T\),那么 \(T\)\(S\) 中所有出现的位置的 结束位置 下标的集合叫做 \(T\) 的 endpos。

  • 例:对于串 \(\texttt{aabaabbcbbaab}\),那么子串 \(\texttt{aab}\) 的 endpos 是 \(\{3,6,13\}\)。记作 \(\operatorname{edp}(\texttt{aab})=\{3,6,13\}\)

endpos 等价类

我们把 endpos 相同的串的集合称作一个 endpos 等价类,简称类。

  • 例:对于串 \(\texttt{aabaabbcbbaab}\)\(\{\texttt{ab},\texttt{aab}\}\) 是一个类,因为它们的 endpos 都是 \(\{3,6,13\}\)

前置的引理

在构造 SAM 之前,需要知道一些引理。下面我们认为 \(S_1\)\(S_2\) 是原串 \(S\) 的两个不同子串,并且有 \(|S_1|\le |S_2|\)

Lemma 1.1

\(S_1\)\(S_2\) 的后缀 \(\Rightarrow\) \(\operatorname{edp}(S_2)\subseteq\operatorname{edp}(S_1)\)

  • 很好理解,既然 \(S_1\)\(S_2\) 的后缀,那么有 \(S_2\) 的地方就有 \(S_1\)

Lemma 1.2

\(\operatorname{edp}(S_2)\cap\operatorname{edp}(S_1)\not=\varnothing\) \(\Rightarrow\) \(\operatorname{edp}(S_2)\subseteq\operatorname{edp}(S_1)\),且 \(S_1\)\(S_2\) 的后缀

  • 如果两个串的 endpos 有交,那么其中一个必然是另一个的后缀,然后由 Lemma 1.1 推出 \(\operatorname{edp}(S_2)\subseteq\operatorname{edp}(S_1)\)

  • 这条定理说明每个字符串之间 endpos 没有交,只能是相互包含的关系。

Lemma 1.3

在一个类中,找出其长度最大的串 \(u\),那么剩下的串长度从 \(u\) 开始递减。(长度是连续的)

  • 例:对于串 \(\texttt{abcdabcdd}\),在 \(\operatorname{edp}=\{4,8\}\) 类中有 \(\texttt{abcd,bcd,cd}\),可以看到长度是递减的。

  • 怎么证明?假设其中最长的串是 \(\text{maxs}\),最短的是 \(\text{mins}\),那么对于 \(\text{maxs}\) 的任意一个长度大于 \(|\text{mins}|\) 的后缀 \(\text{suf}\),由 Lemma 1.2 可以知道,这个 \(\text{suf}\) 一定是 \(\text{maxs}\) 的后缀,\(\text{mins}\) 一定是 \(\text{suf}\) 的后缀。用 Lemma 1.1 推得 \(\operatorname{edp}(\text{maxs})\subseteq\operatorname{edp}(\text{suf})\subseteq\operatorname{edp}(\text{mins})\),那由于 \(\operatorname{edp}(\text{maxs})=\operatorname{edp}(\text{mins})\),所以 \(\operatorname{edp}(\text{maxs})=\operatorname{edp}(\text{suf})=\operatorname{edp}(\text{mins})\),也就是说,\(\text{suf}\) 一定和 \(\text{maxs},\text{mins}\) 在同一类。


Parent Tree

根据 Lemma 1.2 可以发现,这些子串之间形成了一棵树形结构。我们把这个树形结构叫做 Parent Tree。

更具体地,考虑一个类中最长串 \(\text{maxs}\),如果我们向这个串的前面加入一个字符,得到一个字符串 \(\text{news}\),那原来类中元素的 endpos 中的位置分成了两种,一种是在 \(\operatorname{edp}(\text{news})\) 中的位置,一种是不在其中。然后我们在 \(\text{maxs}\) 前面加上不同字符就把 endpos 中的位置分成了 \(\sum\) 种(\(\sum\) 是字符集),作为这个类的儿子节点(空的删掉),然后就构成了这棵 Parent Tree。

一棵 Parent Tree

一些说明:上面节点中的 \(s\) 表示这一类中的 \(\text{maxs}\),也就是最长的串。

反正还是看图理解吧。那这样一棵树有什么用呢?其实在后面给出 SAM 的构造的时候会说,我们对于一个节点需要能够找到它在 Parent Tree 上的父亲。而且 SAM 上节点的意义和 Parent Tree 节点的意义是相同的,使得其节点数也和 Parent Tree 相同,从而保证复杂度(见下方引理)

其实不难发现通过枚举在 \(\text{maxs}\) 前面加的点来构造这棵树的过程,是能够不重不漏地表达出所有原串的子串的,这和 SAM 的性质相同。

所以 SAM 是基于 Parent Tree 构造的。

接下来有几个引理:

Lemma 2.1

对于 Parent Tree 上的节点 \(u\) 和它的一个儿子 \(v\)\(u\) 中的 \(\text{maxs}\) 的长度加一就是 \(v\) 中的 \(\text{mins}\) 的长度。

  • 很好证明,我们通过在 \(\text{maxs}\) 前面加一个字符的方式产生 \(u\) 的儿子,那么这个 \(\text{news}\) 属于 \(v\)。然后易得长度小于 \(\text{news}\) 的串不在 \(v\) 中(它们的 endpos 要么不是 \(v\) 所代表的,要么不止 \(v\) 所代表的)。

Lemma 2.2

Parent Tree 的点数和边数都是 \(O(n)\) 级别的。

  • 这个东西我不会证,但是会感性理解,具体可以看底下的的参考文献~

SAM 的构造

进行一个 SAM 的构造

先来看代码吧。

struct node{int ch[26],fa,len;}tr[MAXN<<1];
int tot=1,lst=1;
void ins(int c){
	int p=lst,np=++tot;lst=np;
	tr[np].len=tr[p].len+1;
	while(p&&!tr[p].ch[c]) tr[p].ch[c]=np,p=tr[p].fa;
	if(!p) tr[np].fa=1;
	else{
		int v=tr[p].ch[c];
		if(tr[v].len==tr[p].len+1) tr[np].fa=v;
		else{
			int nv=++tot;tr[nv]=tr[v];
			tr[nv].len=tr[p].len+1;
			while(p&&tr[p].ch[c]==v) tr[p].ch[c]=nv,p=tr[p].fa;
			tr[v].fa=tr[np].fa=nv;
		}
	}
}

首先,SAM 是用增量法构造的,就是通过在后面加入一个字符,然后插入新字符串的所有后缀。这样最终必然能表出原串的所有子串。

然后我们考虑插入这些后缀的时候会对 Parent Tree 上哪些节点产生影响。很容易可以想到,就是节点表示的 endpos 中包含了原来的长度的那条链。比如,对于上面那个图,\(\{1,2,3,4,5,6,7\}\to\{1,2,4,5,7\}\to\{4,7\}\to\{7\}\) 这一条链,在插入第 \(8\) 个字符的时候会有影响。我们把这条链叫做 终止链

我们维护的 SAM 本质还是个自动机,和 AC 自动机一样我们会需要一个 ch[26] 来表示其儿子。那么最终这些边和 Parent Tree 上的边会形成一个 DAG。而我们最终的 SAM 会非常优美,通俗地讲就是:从根通过自动机的边到达某个节点所能形成的所有字符串,就是这个节点类中包含的串。

然后考虑构造这个 SAM,上面说增加一个字符,实际上需要插入增加后所有的后缀。我们从终止链的底端向上跳,然后每次从当前节点向新建节点连一条 \(c\) 的边,表示插入这个后缀。那如果一路畅通,说明所有的后缀都没有出现过,新建节点在 Parent Tree 上的父亲就是自动机的根。

然后如果有某一个节点 \(p\) 已经有一条 \(c\) 的边了,那么也就是说,当前的这个后缀已经出现过了,我们设它被包含在 \(v\) 这个节点,那么 v=tr[p].ch[c]。那么我们需要考虑,有没有这样一个已经出现的后缀,使得这个后缀也包含于 \(v\) 这个节点,而且它的长度还大于当前的后缀。

如果没有,那么很好,我们直接把新建的这个节点在 Parent Tree 上的父亲记为 \(v\)。因为 \(v\) 中的 \(\text{maxs}\) 就是当前的后缀,那么我们可以通过在 \(\text{maxs}\) 前面加字符来得到之前处理过的后缀,满足了 SAM(Parent Tree)的性质。那如果很不幸,有这样的串怎么办呢?那我们希望它没有,于是我们把 \(v\) 裂成两个节点,然后让没有这样的串的节点继续做上面说的东西,然后另一个节点包含的所有串都是长度大于当前后缀的,我们需要把其中一些后缀给拿掉,于是我们从刚在停下的地方沿终止链继续上跳,把边指向那个我们觉得好的节点/cy。具体实现就是先复制一个,然后让这个复制的节点满足 \(\text{maxs}\) 就是当前后缀,然后把边转过来,然后把 \(v\) 和新建节点的父亲都记为这个复制的节点。为什么 \(v\) 也要改呢?因为这个复制的节点的 endpos 必然是包含 \(v\) 的 endpos。

最后一个问题,怎么判断有没有这样一个串呢?已经很明显了,就是记录一下每个节点的 \(\text{maxs}\) 就行了。也就是上面代码中的 \(len\)。可以通过 Lemma 2.1 推出,如果 tr[v].len==tr[p].len+1,那么就没有这样的串。

这里主要侧重的是为什么这么建,详细的操作还是看参考文献。。。

这张图让我在垂死中弄懂了 SAM

假设你有串 \(\texttt{aababa}\),建议搭配参考文献食用。

参考文献

个人易错

建议跳过。

  • 多测清空!!!
  • Case 3 的时候几个变量名不要搞错
  • 开头不要忘记维护 len
  • 有生之年又把两个等号写成了一个

常见应用

关于某个节点包含本质不同子串个数

我们知道 SAM 是不重不漏表示所有子串的。那如果我们想知道有多少不同的子串呢?根据 Lemma 2.1Lemma 1.3,可以知道对于一个节点 \(u\),其包含的子串个数是 tr[u].len-tr[fa].len

还有一种算法就是直接在 DAG 上跑路径计数 dp。

例题:

关于子串出现次数

这个问题比较简单,考虑到每个节点表示的是一个等价类,那么这个类表示的 endpos 的大小就是这个类中所有子串出现过的次数。

那这个大小怎么算呢。我们考虑 Parent Tree 的形成过程,是在 \(\text{maxs}\) 前面加一个字符从而得到一个节点的儿子。那如果本来有一个串是原串的前缀,那么显然在加一个字符后会直接消失。通俗地讲,从下往上看的话,这个前缀的 endpos 会从这个节点开始出现。具体结合上面的 Parent Tree 那张图更好理解。

具体地,在结构体中多维护一个 siz,然后每次插入的时候,把 tr[np].siz=1,然后复制的节点令 tr[nv].siz=0(复制的节点不包含前缀的)。最后插入完成的时候,我么在 Parent Tree 上做一次子树求和即可。这样每个节点的 siz 中就存储了其 endpos 的大小。

例题:

第 k 小子串

考虑像平衡树找第 k 小的时候一样从根开始跳。

然后这题还有就是如果可以算重复的子串,那么在 SAM 上算某个点包含的子串数的时候,需要结合以上两点来做。

例题:

找一个等价类的一个 endpos

考虑每一个前缀都必然在不同的等价类中。所以枚举前缀,通过自动机找到这个前缀所在的等价类,那么这个前缀的结尾就是这个等价类中的一个 endpos。然后由于有的等价类不包含前缀,但是我们分析 Parent Tree 的性质可以发现,所有叶子节点必然包含前缀,而在 Parent Tree 上,一个节点的 endpos 必然是其父亲的子集。所以最后扫一遍树,把叶子已经处理好的 endpos 给父亲,这必然也是父亲的 endpos。

例题:

吊打 AC 自动机?

考虑用 SAM 来代替别的字符串算法,先来迫害 AC 自动机。

AC 自动机最关键的就是 fail 指针,即在失配的时候能够跳到另一个节点继续匹配。fail 指针实际上就是指向最长公共前缀,那 SAM 中也有类似的东西,那就是 Parent Tree。在 Parent Tree 上儿子是由父亲在前面加字符得到的,那么反过来,父亲就是儿子在前面减字符合并得到的。于是我们在失配的时候是可以跳到 tr[].fa 所指的节点继续匹配,当成 AC 自动机来用。

不过呢~也并不是所有题都可以用 SAM 的。比如说,你如果想用 SAM 过 AC 自动机那三道板题,那你就会这样:

一定要注意空间。SAM 是有两倍空间的。

更具体地,SAM 是对一个字符串的所有子串建立的类似 AC 自动机。一定要记住 SAM 不同于 AC 自动机的是 fa 指针的意义是在前面删去字符,也就是后缀。(和 fail 指针恰好相反?)

一般 SAM 会要求进行字符串的删改。这样我们需要知道下面两种操作:

  1. 删去当前匹配串的第一个字符。如果当前匹配串没有成功匹配,那么不用修改。否则,我们把匹配长度 plen 减一,然后向父亲跳,直到 plen 长度在节点的区间内。
  2. 在后面加一个字符。从当前位置开始匹配,如果没有出边,跳父亲直到有出边,然后把匹配长度设为 tr[].len+1,然后跳到对应的儿子,让匹配长度加一。

例题:

构造后缀树

迫害完 AC 自动机,我们来迫害后缀树。为了迫害后缀树,需要以下两条:

  1. 反串的 Parent Tree 就是原串的后缀树。据此可以在一个优秀的时间内构造后缀树(听说构造后缀树的算法是 Ukk,复杂度是一样的)。
  2. \(T_i\) 表示的是从 \(i\) 开始的后缀,则 \(\operatorname{lcp}(T_i,T_j)\) 是后缀树上 \(i,j\) 两点的 LCA 的 len。这样我们可以通过后缀树解决 \(\operatorname{lcp}\) 的问题(似乎顺便迫害了 SA)。

然后就可以用 SAM 乱杀了。

例题:

广义 SAM

用来处理多个串的所有子串的问题。即把若干个字符串建立一个自动机使得其包含所有串的所有子串。

看起来非常暴力,处理完一个串 lst=1 就行了。

然后就当 SAM 用就行了,满足上面 常见应用 的所有内容。

注意:此处对广义 SAM 的构造是假的,但是绝大多数情况下都是对的,所以一般都采用这种构造。具体对于广义 SAM 更详细更准确的构造,见此博客

线段树合并维护 edp

posted @ 2022-02-07 21:25  ZCETHAN  阅读(154)  评论(0编辑  收藏  举报