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

一. 后缀自动机的定义

SAM(Suffix Automaton) 是一种有限状态自动机,仅可以接受一个字符串的所有后缀。

如果您不懂自动机,那么换句话说:

SAM 是一个有向无环图

称每个结点为状态,边为状态间的转移,每个转移标有一个字母,同一节点引出的转移不同。

SAM 存在一个源点 \(S\),称为初始状态,其它各结点均可从源点出发到达。也存在一个或多个终止状态 \(T\),每一条从 \(S\) 出发至 \(T\) 的路径所组成的字符串即为字符串的后缀。而对于这个字符串的每个后缀也必定在 SAM 的某条\(S\) \(T\) 的路径上。

满足条件的同时,SAM 的点数是最少的。

接下来展示一个简单的 SAM:(以 abbb 为例)

红点为源点,蓝点为终止节点。箭头全部从左向右。

abbb 的所有后缀:

b: 1->5
bb: 1->5->6
bbb: 1->5->6->7
abbb: 1->2->3->4->7


二. \(\text{endpos}\) 数组

\(\text{endpos(t)}\),表示对于字符串 \(s\) 的任意一个非空字符串 \(t\),每一个 \(t\)\(s\) 中出现的位置的右端。

abaabacaab\(\text{endpos(ab)}=\{2,5,10\}\)

显然会出现有两个不同子串 \(t_1,t_2\),而 \(\text{endpos}(t_1)=\text{endpos}(t_2)\),如 abaabacaab\(\text{endpos(ab)=endpos(b)}=\{2,5,10\}\)

那么我们称在 abaabacaab 里,ab,b 为同一个 \(\text{endpos}\) 等价类

对于 \(\text{endpos}\),有几个引理:

引理 \(1:\) 对于一个字符串中的两个子串 \(u,v\),若 \(\text{endpos(u)=endpos(v)}\),那么一个将是另一个的后缀

显然成立。因为若 \(v\) 不是 \(u\) 的后缀,那么必定有一个 \(v\) 的最后一位字符就必然不等于 \(u\) 的最后一位字符。

例子:

aabab

endpos(b)=endpos(ab)={3,5}
u=ab,v=b

引理 \(2:\) 对于一个字符串的子串 \(u,v(|u|\ge|v|)\),那么必然有 \(\text{endpos}(u)\subseteq \text{endpos}(v)\)\(\text{endpos}(u)\cap\text{endpos}(v)=\varnothing\)

即要么其中一个的 \(\text{endpos}\) 包含另一个的,要么两者的 \(\text{endpos}\) 没有交集。

显然有可能 \(\text{endpos}\) 两者无交集。而若两者的 \(\text{endpos}\) 有交集,那么不妨设 \(x\)\(\text{endpos}(v)\) 包含且不被 \(\text{endpos}(u)\) 包含,那么由于两者 \(\text{endpos}\) 有交集,那么 \(v\) 的必然为 \(u\) 后缀,那么显然 \(x\) 必然被 \(\text{endpos}(u)\) 包含。

例子:

原串:abacabbacabca
//example1:
u=aba,v=aca
endpos(u)={3}
endpos(v)={5,10}
//example2:
u=aca,v=ca
endpos(u)={5,10}
endpos(v)={5,10,13}

引理 \(3:\) 每个 \(\text{endpos}\) 等价类里的子串升序排序后长度连续,且前一个串是这个串的后缀。

也很显然。若字符串 \(u,v\) 在同一个 \(\text{endpos}\) 等价类里,且 \(u\) 的长度严格小于 \(v\) 的长度且 \(u,v\) 长度不连续。由引理 \(1\) 显然 \(u\)\(v\) 的后缀,那么 \(u,v\) 中间的那部分子串减去某个前缀后加上 \(u\) 显然亦为同一个 \(\text{endpos}\) 等价类里。

例子:

原串:acabaacbacaba

u=aba
v=acaba
endpos(u)=endpos(v)={5,13}
w=(c)+aba=caba,endpos(w)={5,13}

三. \(\text{parent tree}\)

虽然 \(\text{endpos}\) 还有引理,不过涉及到 \(\text{parent tree}\),就必要先讲这个。

考虑对于一个字符串 \(s\),如果我们在 \(s\) 前面加上一个字符 \(c\),那么会将 \(\text{endpos}(s)\) 分成若干份(也可能是一份)

举个例子,还是 acabaacba,对于 ba\(\text{endpos}(ba)=\{5,9\}\)。我们发现我们可以在 ba 前面加上 ac,那么就变为 abacba,而 \(\text{endpos}(aba)=\{5\},\text{endpos}(cba)=\{9\}\),正好将 \(\text{endpos}(ba)\) 分成两份。

由于引理 \(2\),那么两个非后缀关系的子串的 \(\text{endpos}\) 必无交集,所以考虑对于一个字符串的子串的 \(\text{endpos}\) 等价集节点,那么这些分割关系构成了树形结构

由于此时是个森林,考虑用一个超级根聚合起森林。

空串的等价集为全集(它无处不在),所以用空寂作为超级根。

那么,我们以 abcabstrstrs 为原串建一个 \(\text{parent tree}\)

fSP1Cn.png

引入一个概念:我们称等价类 \(u\) 中的最长子串 \(len_u\) 指的是:该等价类 \(u\) 的所有子串中长度最长的子串。

那么每个节点旁边蓝色的就是代表当前节点(等价类)的最长子串。

结点中的数字为 \(\text{endpos}\) 集。

这时,我们就引出新的 \(\text{endpos}\) 引理:

引理 \(4\):若两个等价类 \(p,q\)\(p\)\(\text{parent tree}\) 上是 \(q\) 的父亲,那么 \(p\) 的最长子串的长度 \(+1\),等于 \(q\) 的最短子串的长度。

我们考虑在一个等价类中的某个子串前再添加一个字符,显然,若选择的子串是这个等价类中的最长子串,形成的字符串就归于其儿子的等价类中,否则就仍在这个等价类中。

如果选择的是最长子串,那这个新形成的字符串肯定这个儿子等价类 中最短的一个。

引理 \(5:\) 等价类的数量是 \(O(n)\) 级别的。

如果说在一张图上表示一个字符串的所有子串,那么显然可以建 trie。

但是 trie 的节点数是 \(O(n^2)\) 的。

考虑 \(\text{parent tree}\) 的节点数量。

对于一个原串中的等价类,其中的最长子串 \(s\),在 \(s\) 前添加一个字符且新字符串仍为原串子串,那么对于新的字符串 \(t\),必然会得到若干个新的等价类,这一过程相当于 \(\text{parent tree}\) 中将父亲分裂成儿子

考虑在 \(s\) 前加入两个不同字符,成为新字符串 \(t_1,t_2\),那么必定 \(\text{endpos}(t_1)\cap\text{endpos}(t_2)=\varnothing\),因为 \(\text{endpos}(t_1)\)\(\text{endpos}(t_2)\) 必定不被包含。

所以,对于每个等价类集,最多分割出的大小不会超过原字符串集合大小。由于 \(\text{parent tree}\) 一共只有 \(n\) 个叶子结点,所以是 \(O(n)\) 的。


四. 后缀自动机的构建

后缀自动机的本质,是通过 \(\text{parent tree}\) 来维护的。

fSP1Cn.png

我们用 \(fa_u\) 表示在 \(\text{parent tree}\)\(u\) 的直接父亲。

我们之前在 \(\text{parent tree}\) 已经知道了在子串前面加字符的做法,那么现在讨论关于在后面加字符的做法。

\(ch_{u,c}\) 表示在 \(u\) 结点对应的等价类最长子串后面加上一个字符 \(c\) ,形成的新字符串所属的等价类对应的结点。

也就是 \(\text{endpos(ch}_{u,c})=\text{endpos}(len_{(\text{endpos(u)})+'c'})\)

可能有点绕,举个例子:

abcabstrs 的等价类在 \(u\) 节点,我们给它加上 t,就变成了 abcabstrst,其等价类在 \(10\) 节点。那么 \(ch_{u,'t'-'a'}=v\)

SAM 的一个合法的连边方案应该满足,从源点出发到达点 \(u\) 经过的边形成的字符串,应该是点 \(u\) 的等价类的一个字符串,也就意味着,形成的字符串应该是点 \(u\) 的字符串的后缀。

我们先以 AABAB 为例子。

由于单张图片上传大小太大,所以我直接画出整个自动机 AABAB。显然现在的自动机有误,待会解释。

下图蓝色表示边上转移的字符,灰色表示点。

首先将空节点 \(1\) 加入。

然后加入字符 A,即 \(2\) 号节点。

加入 AA,即 \(3\) 号节点。由于 AAA 的后缀,所以直接从 \(2\to 3\),转移为 A

\(3\) 的后缀:

A: 1->2
AA: 1->2->3

加入 AAB,即 \(4\) 号节点。先连接 \(3\to 4\),转移是 B。但是 \(1,2\) 号节点都没有字符为 B 的出边,于是连接 \(1\to4,2\to4\),转移是 B

\(4\) 的后缀:

B: 1->4
AB: 1->2->4
AAB: 1->2->3->4

加入 AABA,即 \(5\) 号节点。直接连接 \(4\to 5\)

\(5\) 的后缀:

A: 1->2
BA: 1->4->5
ABA: 1->2->4->5
AABA: 1->2->3->4->5

加入 AABAB,此时与 \(4\) 时同理,连接 \(2\to 6,1\to 6\)

\(6\) 的后缀:

B: 1->6
AB: 1->2->6
BAB: 1->4->6
ABAB: 1->2->4->5->6
AABAB: 1->2->3->4->5->6

检查一下这个自动机。从 \(1,2\) 号点出发,有两条字符为 B 的边,显然不符合 SAM 的性质。

我们考虑新建一个节点 \(7\),其字符串为 \(4,6\) 节点的最长公共后缀,即 AB

我们考虑关于 \(7\) 的连边。

首先 \(1\to7,2\to7\),转移为 \(B\)。然后连接 \(7\to5\),转移为 A。这样,保证了 \(5\) 也满足。

\(4,5,6\) 的后缀:

4:
B: 1->7
AB: 1->2->7
AAB: 1->2->3->4
5:
A: 1->2
BA: 1->7->5
ABA: 1->2->7->5
AABA: 1->2->3->4->5
6: 
B: 1->7
AB: 1->2->7
BAB: 1->7->5->6
ABAB: 1->2->7->5->6
AABAB: 1->2->3->4->5->6

我们看看 AABABA 的 SAM:

五. SAM 的代码实现

刚才我们已经模拟出了一个 SAM,现在我们考虑它如何用代码实现。

先放代码。代码中的 \(len,ch,fa\) 等如上文,只是用结构体表示第一维。

#include<bits/stdc++.h>
using namespace std;

const int N=2e6+3;

int n,last=1,tot=1,ans;
char s[N];

struct SAM{
	int len,fa;
	int ch[26];
	SAM(){memset(ch,0,sizeof(ch));len=0;}
}sam[N];

inline void add(int c){
	int p=last,np=++tot;
	last=np;
	sam[np].len=sam[p].len+1;
	while(p&&!sam[p].ch[c]){
		sam[p].ch[c]=np;
		p=sam[p].fa;
	}
	if(!p)sam[np].fa=1;
	else{
		int q=sam[p].ch[c];
		if(sam[q].len==sam[p].len+1)sam[np].fa=q;
		else{
			int nq=++tot;
			sam[nq]=sam[q];
			sam[nq].len=sam[p].len+1;
			sam[q].fa=sam[np].fa=nq;
			while(p&&sam[p].ch[c]==q){
				sam[p].ch[c]=nq;
				p=sam[p].fa;
			}
		}
	}
}

int main(){
	scanf("%s",s+1);
	n=strlen(s+1);
	for(int i=1;i<=n;i++)add(s[i]-'a');
	return 0;
}

一步一步讲解。


struct SAM{
	int len,fa;
	int ch[26];
	SAM(){memset(ch,0,sizeof(ch));len=0;}
}sam[N];

inline void add(int c){
	int p=last;
	np=++tot;
	last=np;
	sam[np].len=sam[p].len+1;
	//......
}

int main(){
	scanf("%s",s+1);
	n=strlen(s+1);
	for(int i=1;i<=n;i++)add(s[i]-'a');
	return 0;
}

构建 SAM 的方法是增量法,即 SAM 每次吞进一个字符,对于这个字符而改变内部结构,再吞进下一个字符,以此类推。

每次吞字符的行为之间需要传递一些信息,除了以上提到的 \(fa\)\(ch\) 照例保留外,还需要传递一个 \(last\) 变量,表示上一次添加字符后的串 \(s\) 所属的等价类

显然,上一次加完字符后的串 \(s\) ,一定是 \(last\) 这个等价类的最长子串

所以在最长子串 \(s\) 后加上了一个字符 \(c\) 形成了一个新串 \(t\),根据引理 \(4\)\(t\) 将会归到一个新的等价类里。

所以我们开一个新点 \(np\) ,表示 \(t\) 所属的新等价类对应的结点。

\(\therefore len_{np}=len_p+1\)

struct SAM{
	int len,fa;
	int ch[26];
	SAM(){memset(ch,0,sizeof(ch));len=0;}
}sam[N];//将len、ch、fa存进结构体

inline void add(int c){
	int p=last;//当前最长子串所在的等价类
	np=++tot;//新等价类
	last=np;//更新 last
	sam[np].len=sam[p].len+1;//引理4得
	//......
}

int main(){
	scanf("%s",s+1);
	n=strlen(s+1);
	for(int i=1;i<=n;i++)add(s[i]-'a');//增量法
	return 0;
}

inline void add(int c){
	//......
	while(p&&!sam[p].ch[c]){
		sam[p].ch[c]=np;
		p=sam[p].fa;
	}
	//......
}

遍历新串后缀,到第一个出现过的子串为止。

每当我们加入一个新字符,可能之前某些子串的出现位置(即 \(\text{endpos}\) 中的元素)就会变多。

比如当前加到第 \(n\) 位,有些新子串从来没出现过,需要多开一个 \(\{n\}\) 的等价类包含它们;有些新子串曾经出现过,它们的 \(\text{endpos}\) 中就多一个 \(n\)

这一段就是找到这些新子串的出现位置。对于新子串 \(t\),它必然是加上新字符前的原字符串的某后缀,再拼接上我们现在加的这个字符 \(c\)

既然是后缀关系,对于拼上 \(c\) 的新串 \(t\),一定存在某长度的后缀,使得长度小于它的其它后缀都出现过。

比如 ABCABSTRSTR,给它加上一个 S,那么 只有ABCABSTRSTRS,BCABSTRSTRS,CABSTRSTRS...TRS,RS,S 以及 \(\varnothing\)\(\text{endpos}\) 会发生改变。

然而,在这些子串中,TRS,RS,S\(\varnothing\)之前就出现过,所以它们在它们原有的 \(\text{endpos}\) 的基础上加上 \(11\);而其余的就直接归类于 \(11\)

fSP1Cn.png

为了区分这些子串之前有没有出现过,考虑遍历后缀,找到这个第一个出现过的后缀。

又因为在一个字符串前面加一个字符等价于将其 \(\text{endpos}\) 分裂,即在 \(\text{parent tree}\) 上分裂出子树。

反过来看,相当于删去前面的字符,那么就相当于在 \(\text{parent tree}\) 上原地不动(不发生分裂)或者往上跳 \(fa\)

所以通过跳 \(fa\) 来遍历后缀。

inline void add(int c){
	//......
	while(p&&!sam[p].ch[c]){//p的判断表示防止跳出parent tree;!sam[p].ch[c] 表示p+'c'的最长子串是否曾经出现过
		sam[p].ch[c]=np;//如果没有出现过,那么它的endpos直接为np
		p=sam[p].fa;//跳fa操作
	}
	//......
}

inline void add(int c){
	//...
	if(!p)sam[np].fa=1;
	else{
		int q=sam[p].ch[c];
		if(sam[q].len==sam[p].len+1)sam[np].fa=q;
		else{
			int nq=++tot;
			sam[nq]=sam[q];
			sam[nq].len=sam[p].len+1;
			sam[q].fa=sam[np].fa=nq;
			while(p&&sam[p].ch[c]==q){
				sam[p].ch[c]=nq;
				p=sam[p].fa;
			}
		}
	}
}

分三个部分。

  1. 根作为它的父节点
if(!p)sam[np].fa=1;

这种比较容易,从 \(p\) 向上爬,如果一直爬到了根还没有 break,那么说明没有节点能做它的 \(fa\),那么只有根可以,那么直接认根做父节点。


  1. \(len_q=len_p+1\)
int q=sam[p].ch[c];
else if(sam[q].len==sam[p].len+1)sam[np].fa=q;

\(q=sam_p.ch_c\),我们需要知道 \(p\) 的最长子串 \(+c\) 是否是 \(q\) 的最长子串。

换句话说:

\(p\) 在第一个有 \(c\) 边的祖先停下了,\(q\) 即为 \(p\)\(c\) 出边到达的节点。

其中一种情况是 \(p\) 的最长子串 \(+c\) 就是 \(q\) 的最长子串。

因为这个串是最长子串,显然同等价类的其它子串都是它的后缀。所以它的后缀也会添加 \(\{n\}\)

那么我们直接令这整个集合 \(q\) 成为 \(np\)(它对应 \(\{n\}\) 等价类)的 \(fa\) 即可。

举个例子:

fSP1Cn.png

比如现在到了 ABCABSTRS,要加入 T\(np\) 的最长子串即为 ABCABSTRST,在等价类 \(\{10\}\)

然后我们通过跳 \(fa\),算出了最长的曾经出现过的后缀 ST

那么 \(q\) 即为 \(sam_{p,'t'-'a'}\),然后这个等价类最长子串就是 ST,所以 \(fa_{np}=q\),也就是在 \(\text{parent tree}\)STABCABSTRST 的父亲,也就是说 \(\{7,10\}\)\(\{10\}\) 父亲。

int q=sam[p].ch[c];//取出q
else if(sam[q].len==sam[p].len+1)//q的最长子串=p的最长子串+c,即q的长度=p长度+1
sam[np].fa=q;//q成为np的fa


  1. \(len_q≠len_p+1\)
else{
	int nq=++tot;
	sam[nq]=sam[q];
	sam[nq].len=sam[p].len+1;
	sam[q].fa=sam[np].fa=nq;
	while(p&&sam[p].ch[c]==q){
		sam[p].ch[c]=nq;
		p=sam[p].fa;
	}
}

比如像 \(7\) 号节点。

显然 \(len_p+1\) 不总是一定为 \(len_q\)

既然我们需要一个可以做 \(p\) 的父亲的点,那么我们就构造一个 \(u\) 的父节点,设它为 \(nq\)

所以 \(nq\) 的最长子串就是 \(p\) 的最长子串 \(+c\)

\(\therefore len_{nq}=len_p+1\)

\(nq\) 在分裂以前与 \(q\) 的差别在且仅在于 \(\text{endpos}\),而在后面加一个字符能转移到哪里,就不在 \(\text{endpos}\) 决定的范围内了。

考虑 \(ch\),它表示的是在后面加字符后归在的等价类,显然 \(ch_{nq}\)\(ch_q\) 其实没有区别。那么直接令 \(nq\) 继承 \(q\)\(ch\)

最后是 \(fa\)\(fa_q\) 之前和 \(q\) 在树上成父子关系,根据引理 \(4\),当时的 \(len_{fa_q}+1\) 必然等于 \(q\) 最小子串长度。

\(q\) 的最小子串在 \(nq\) 等价类里。

\(fa_{nq}=fa_q,fa_q=fa_{np}=nq\)

举个例子:

aabab 在加入第二个 b 时子串 ab 已经存在,所以 ab\(\text{endpos}\) 集合变为 \(\{3,5\}\),这样原来第一个 b 表示 aab,ab,b,它们的 \(\text{endpos}\) 都是 \(3\),而现在只能表示 aab\(\text{endpos}\)\(3\),而 ab,b\(\text{endpos}\)\(3,5\),所以需要一个新的点来维护,同时这样操作也保证了如果在后面加入 c,只会增加 c,bc,abc\(\text{endpos}\) 而不会增加 aabc\(\text{endpos}\)

但是有些结点的 \(ch_c\) 指向的是 \(q\),而分裂后,这些 \(ch_{c}\) 需要指向 \(nq\)

所以考虑对于 \(p\) 不断往上 \(fa\)。判断条件为 \(sam_{p,ch_c}=q\)

\(q\)\(\text{endpos}\) 不包含 \(\{n\}\),而 \(p\) 的最长子串 \(+c\)\(\text{endpos}\) 包含 \(\{n\}\)

所以令这条边连向新的节点 \(nq\),不断操作即可。

\(nq\) 的出边就是 \(q\) 的出边,然后 \(q\) 的所有除地面上地链上地入边都搬到了 \(nq\) 上面去。


这就讲完了代码部分。

六. 复杂度

七. 练习题目

给你一个长为 \(n\) 的字符串,求不同的子串的个数。

\(n\le 10^5\)

对于一个给定的长度为 \(n\) 的字符串,求出它的第 \(k\) 小子串。

\(1≤n≤5×10^5\)

posted @ 2024-01-17 18:40  trsins  阅读(131)  评论(0编辑  收藏  举报