ことばがありあまれどなお、 このゆめはつづいてく|

trsins

园龄:3年10个月粉丝:18关注:2

【学习笔记】后缀自动机 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


二. endpos 数组

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

abaabacaabendpos(ab)={2,5,10}

显然会出现有两个不同子串 t1,t2,而 endpos(t1)=endpos(t2),如 abaabacaabendpos(ab)=endpos(b)={2,5,10}

那么我们称在 abaabacaab 里,ab,b 为同一个 endpos 等价类

对于 endpos,有几个引理:

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

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

例子:

aabab

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

引理 2: 对于一个字符串的子串 u,v(|u||v|),那么必然有 endpos(u)endpos(v)endpos(u)endpos(v)=

即要么其中一个的 endpos 包含另一个的,要么两者的 endpos 没有交集。

显然有可能 endpos 两者无交集。而若两者的 endpos 有交集,那么不妨设 xendpos(v) 包含且不被 endpos(u) 包含,那么由于两者 endpos 有交集,那么 v 的必然为 u 后缀,那么显然 x 必然被 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: 每个 endpos 等价类里的子串升序排序后长度连续,且前一个串是这个串的后缀。

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

例子:

原串:acabaacbacaba

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

三. parent tree

虽然 endpos 还有引理,不过涉及到 parent tree,就必要先讲这个。

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

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

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

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

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

那么,我们以 abcabstrstrs 为原串建一个 parent tree

fSP1Cn.png

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

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

结点中的数字为 endpos 集。

这时,我们就引出新的 endpos 引理:

引理 4:若两个等价类 p,qpparent tree 上是 q 的父亲,那么 p 的最长子串的长度 +1,等于 q 的最短子串的长度。

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

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

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

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

但是 trie 的节点数是 O(n2) 的。

考虑 parent tree 的节点数量。

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

考虑在 s 前加入两个不同字符,成为新字符串 t1,t2,那么必定 endpos(t1)endpos(t2)=,因为 endpos(t1)endpos(t2) 必定不被包含。

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


四. 后缀自动机的构建

后缀自动机的本质,是通过 parent tree 来维护的。

fSP1Cn.png

我们用 fau 表示在 parent treeu 的直接父亲。

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

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

也就是 endpos(chu,c)=endpos(len(endpos(u))+c)

可能有点绕,举个例子:

abcabstrs 的等价类在 u 节点,我们给它加上 t,就变成了 abcabstrst,其等价类在 10 节点。那么 chu,ta=v

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

我们先以 AABAB 为例子。

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

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

首先将空节点 1 加入。

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

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

3 的后缀:

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

加入 AAB,即 4 号节点。先连接 34,转移是 B。但是 1,2 号节点都没有字符为 B 的出边,于是连接 14,24,转移是 B

4 的后缀:

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

加入 AABA,即 5 号节点。直接连接 45

5 的后缀:

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

加入 AABAB,此时与 4 时同理,连接 26,16

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 的连边。

首先 17,27,转移为 B。然后连接 75,转移为 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 每次吞进一个字符,对于这个字符而改变内部结构,再吞进下一个字符,以此类推。

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

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

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

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

lennp=lenp+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;
	}
	//......
}

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

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

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

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

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

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

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

fSP1Cn.png

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

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

反过来看,相当于删去前面的字符,那么就相当于在 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. lenq=lenp+1
int q=sam[p].ch[c];
else if(sam[q].len==sam[p].len+1)sam[np].fa=q;

q=samp.chc,我们需要知道 p 的最长子串 +c 是否是 q 的最长子串。

换句话说:

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

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

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

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

举个例子:

fSP1Cn.png

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

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

那么 q 即为 samp,ta,然后这个等价类最长子串就是 ST,所以 fanp=q,也就是在 parent treeSTABCABSTRST 的父亲,也就是说 {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. lenqlenp+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 号节点。

显然 lenp+1 不总是一定为 lenq

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

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

lennq=lenp+1

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

考虑 ch,它表示的是在后面加字符后归在的等价类,显然 chnqchq 其实没有区别。那么直接令 nq 继承 qch

最后是 fafaq 之前和 q 在树上成父子关系,根据引理 4,当时的 lenfaq+1 必然等于 q 最小子串长度。

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

fanq=faq,faq=fanp=nq

举个例子:

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

但是有些结点的 chc 指向的是 q,而分裂后,这些 chc 需要指向 nq

所以考虑对于 p 不断往上 fa。判断条件为 samp,chc=q

qendpos 不包含 {n},而 p 的最长子串 +cendpos 包含 {n}

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

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


这就讲完了代码部分。

六. 复杂度

七. 练习题目

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

n105

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

1n5×105

本文作者:trsins

本文链接:https://www.cnblogs.com/trsins/p/17970738

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   trsins  阅读(528)  评论(0编辑  收藏  举报
历史上的今天:
2022-01-17 【做题记录】Ynoi2018 天降之物
2022-01-17 【学术】连分数
2022-01-17 【做题记录】Ynoi2015 盼君勿忘
2022-01-17 【做题记录】BJOI2016 水晶
2022-01-17 【做题记录】P4965 薇尔莉特的打字机
2022-01-17 【做题记录】POI2011 Lightning Conductor
2022-01-17 【做题记录】CF961G Partitions
点击右上角即可分享
微信分享提示