其他分享
首页 > 其他分享> > 「笔记」后缀自动机 new

「笔记」后缀自动机 new

作者:互联网

目录

之前学过现在忘光了呜呜。2021.12.30 重写,好像写的有点乱。

一、一些概念

后缀自动机(SAM)是可以且仅可以接受一个母串 \(S\) 的后缀的 DFA。SAM Drawer

1. Endpos 集合

子串的结束位置集合。比如 banana 中,\(\text{endpos}(\text{ana})=\{4,6\}\)。

对于两个子串 \(t_1,t_2\),若 \(\text{endpos}(t_1)=\text{endpos}(t_2)\),则 \(t_1,t_2\) 属于一个 \(\text{endpos}\) 等价类

对于非空子串 \(t_1,t_2\,(|t_1|\leq |t_2|)\):

根据合并等价类的思想,我们将 \(\text{endpos}\) 集合完全相同的子串合并到同一个节点。这样一来大大优化了时间和空间复杂度。SAM 的每个节点都表示一个 \(\text{endpos}\) 等价类

2. Parent Tree

先继续谈谈 \(\text{endpos}\) 集合。

我们知道,SAM 里的每个节点都代表了一堆 \(\text{endpos}\) 集合相同的子串。容易发现,对于越短的子串,其 \(\text{endpos}\) 集合往往越大。更具体地,若 \(t_1\) 为 \(t_2\) 的后缀,则 \(|\text{endpos}(t_1)|\geq |\text{endpos}(t_2)|\),当且仅当取得等号时,\(t_1,t_2\) 会被压缩到同一个节点中。

而对于 \(t_2\) 的每一个后缀,一定有一个分界点,使得对于长度 \(\geq\) 该分界点的后缀,它和 \(t_2\) 的 \(\text{endpos}\) 集合相同;而长度 \(<\) 该分界点的后缀,因为短,所以有机会可以在 \(S\) 中出现更多次,\(\text{endpos}\) 集合会更大,于是就和 \(t_2\) 分开了。因此,每个节点 \(p\) 中存储的一定是一堆长度连续的子串,且短的串是长的串的后缀

对于 SAM 的每个节点都能找到一个这样的“分界点”,并且每个节点都对应了一个唯一的“分界点”。而如果 \(t_1\) 是 \(t_2\) 的一个后缀且没有和 \(t_2\) 分在一个节点中,那么 \(t_1\) 也可能成为别的子串的后缀(如 ab 既可以是 cab 的后缀,也可以是 zab 的后缀)。这样我们看到:长的串只能“对应”唯一的一个短的串,而短的串可以“对应”多个长的串,如果将“短的串”视为“长的串”的父亲,这就构成了一棵严格的树形结构。我们称为 Parent 树

注意到短串对应的多个长串,它们的 \(\text{endpos}\) 集合无交(因为它们没有后缀关系,一个出现的位置另一个必然做不到也在这个位置出现)。对于一个父节点,其若干个儿子的 \(\text{endpos}\) 相当于将父节点的 \(\text{endpos}\) 分割成若干不相交的子集,最终会产生不多于 \(n\) 个叶节点。所以树的节点数也只有 \(\mathcal O(n)\)。

在 Parent 树中,一个节点 \(i\) 表示一个类,节点 \(i\) 的父亲记为 \(link_i\)(也被称为“后缀链接”)。显然 \(\text{endpos}(i)\subsetneq \text{endpos}(link_i)\),\(link_i\) 代表的子串均为 \(i\) 子串的后缀。

设节点 \(i\) 对应对应的等价类中最长的子串为 \(\max(i)\),最短的为 \(\min(i)\)。则 \(|\min(i)|=|\max(link_i)|+1\),这个也很好理解。

Parent 树本质上是 \(\text{endpos}\) 集合构成的一棵树,体现了 \(\text{endpos}\) 的包含关系。

二、后缀自动机

1. 状态 & 转移

在 SAM 中我们把一个 \(\text{endpos}\) 等价类作为一个状态。

SAM 是由一个 Parent 树和一个 DAG 组成的,它们的状态集合相同。Parent Tree 和 DAG 是两种完全不同的边(一个是 \(link_x\),一个是 \(ch_{x,c}\)),只是共用相同的节点。

当我们在 DAG 上从一个状态 \(x\) 走到 \(ch_{x,c}\)时,意味着在 \(ch_{x,c}\) 表示的部分字符串(“部分”是因为可以有多个点连向同一个点,接的 \(c\) 相同,但是起点不同)是的 \(x\) 后面 追加一个字符 \(c\) 得到的。在 SAM 的 DAG 上跑出来的串都是原串的子串。

比如 abab 的 SAM 长这样(Max 表示 \(|\max(p)|\),size 表示 \(\text{endpos}\) 集合的大小,节点旁边写着的是 \(\text{endpos}\) 集合和所代表的字符串,黑色边表示 DAG 上的转移边,红色边是 Parent 树上的边):

img
2. 构建

增量法,通过 \(S\) 的 SAM 求出 \(S+c\) 的 SAM。加入字符 \(c\) 后,子串只增加了 \(S+c\) 的后缀,已有的子串不受影响。

\(S+c\) 的某些后缀可能在 \(S\) 出现过,在 SAM 中有其对应的节点。

void insert(int c){
	int p=lst,x=lst=++tot;
	len[x]=len[p]+1;
	while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
	if(!p){fa[x]=1;return ;}
	int q=ch[p][c],Q;
	if(len[q]==len[p]+1){fa[x]=q;return ;}
	Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
	fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
	while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
} 

lst 表示上一次添加的位置,fa[p] 表示 \(p\) 在 Parent 树上的父亲(也就是上面说的 \(link_p\)),len[p] 表示 \(|\max(p)|\)。

3. 复杂度

SAM 的 空间复杂度

构建 SAM 的 时间复杂度:均摊 \(\mathcal O(n)\)。

4. 模板

P3804 【模板】后缀自动机 (SAM)

给出一个只包含小写字母的字符串 \(S\),求 \(S\) 的所有出现次数不为 \(1\) 的子串的出现次数乘上该子串长度的最大值。

\(|S|\leq 10^6\)。

出现次数等价于 \(\text{endpos}\) 集合的大小。

上面提到 \(\text{endpos}\) 的分割关系构成一棵 Parent 树,记 \(sz_i=|\text{endpos}(i)|\),首先不考虑信息丢失,那么 \(sz_i=\sum_{fa_j=i} sz_j\)。

接下来考虑丢失的那个(丢失是因为这个位置长度到顶了,无法往前扩展。这也暗示了最多只能丢失一个)。向前扩展导致长度到顶的只有一个位置,而这个必然是一个前缀,也就是说只有在一个可以表示主串一个前缀的状态的 \(\text{endpos}\) 才会拥有这样的元素。代码中只要在 insert() 中加上一句 sz[x]=1 即可。

#include<bits/stdc++.h>
using namespace std;
const int N=2e6+5,M=30;
int n,lst=1,tot=1,ch[N][M],len[N],fa[N],sz[N];	//数组开两倍!注意 lst=tot=1
long long ans;
char s[N];
vector<int>v[N];
void insert(int c){
	int p=lst,x=lst=++tot;
	sz[x]=1,len[x]=len[p]+1;
	while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
	if(!p){fa[x]=1;return ;}
	int q=ch[p][c],Q;
	if(len[q]==len[p]+1){fa[x]=q;return ;}
	Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
	fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
	while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
} 
void dfs(int x,int fa){
	for(int y:v[x])
		if(y!=fa) dfs(y,x),sz[x]+=sz[y];
	if(sz[x]>1) ans=max(ans,1ll*sz[x]*len[x]); 
}
signed main(){
	scanf("%s",s+1),n=strlen(s+1);
	for(int i=1;i<=n;i++) insert(s[i]-'a');
	for(int i=2;i<=tot;i++) v[fa[i]].push_back(i);
	dfs(1,0),printf("%lld\n",ans);
	return 0;
}

为了减小常数,有时我们可以用“基数排序”代替树形 DP。

具体来说,在 DAG 或 Parent 树上 DFS 的操作,可以用拓扑序替代:\(len_p>len_{fa_p}\)(短串是长串的父亲),\(len_p<len_{ch_{p,c}}\)。

以在 Parent 树上 DFS 为例,我们按 \(len\) 值从小到大对节点排个序,就得到了整棵树从树根到树叶的拓扑序。把这个拓扑序倒过来,for 循环一遍,就相当于树形 DP 啦。

for(int i=1;i<=tot;i++) cnt[len[i]]++;
for(int i=1;i<=tot;i++) cnt[i]+=cnt[i-1];
for(int i=1;i<=tot;i++) id[cnt[len[i]]--]=i;

模板题完整代码:

#include<bits/stdc++.h>
using namespace std;
const int N=2e6+5,M=30;
int n,lst=1,tot=1,ch[N][M],len[N],fa[N],sz[N],cnt[N],id[N];	//数组开两倍! 
long long ans;
char s[N];
vector<int>v[N];
void insert(int c){
	int p=lst,x=lst=++tot;
	sz[x]=1,len[x]=len[p]+1;
	while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
	if(!p){fa[x]=1;return ;}
	int q=ch[p][c],Q;
	if(len[q]==len[p]+1){fa[x]=q;return ;}
	Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
	fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
	while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
} 
signed main(){
	scanf("%s",s+1),n=strlen(s+1);
	for(int i=1;i<=n;i++) insert(s[i]-'a');
	for(int i=1;i<=tot;i++) cnt[len[i]]++;
	for(int i=1;i<=tot;i++) cnt[i]+=cnt[i-1];
	for(int i=1;i<=tot;i++) id[cnt[len[i]]--]=i;
	for(int i=tot,x;i>=1;i--) x=id[i],sz[fa[x]]+=sz[x];
	for(int i=1;i<=tot;i++)
		if(sz[i]>1) ans=max(ans,1ll*sz[i]*len[i]);
	printf("%lld\n",ans);
	return 0;
}

同时 SAM 还有一些有用的性质:

三、简单应用

1. 子串相关

从 DAWG 的起始节点 \(q_0\) 出发,每条路径唯一对应 \(S\) 的一个子串。因为 SAM 即能表示出所有子串,又不会出现两条不同路径表示同一个子串。

2. 最长公共子串
3. 字典序相关

四、广义 SAM

广义 SAM:SAM 的多串版本。即对多个串建立 SAM。

1. 离线做法

将所有串离线插入到 Trie 树中,依据 Trie 树构造广义 SAM。

  1. 将所有字符串插入到 Trie 树中。
  2. 对 Trie 进行 BFS 遍历,记录下顺序以及每个节点的父亲。
  3. 将得到的 BFS 序列按照顺序,把 Trie 树上的每个节点插入到 SAM 中。\(last\) 为它在 Trie 树上的父亲对应的 SAM 上的节点(其中 \(last\) 表示插入字符之前的节点)。也就是每次找到插入节点的父亲作为 \(last\) 往后接即可。

用 BFS 而不是 DFS 是因为 DFS 可能会被卡。

insert 部分和普通 SAM 一样。加上返回值方便记录 \(last\)。

//Luogu P6139
#include<bits/stdc++.h>
using namespace std;
const int N=3e6+5,M=27;
int n,ch[N][M],pos[N],fa[N],len[N],tot=1;
long long ans;
char s[N];
queue<int>q;
struct Trie{ int ch[N][M],fa[N],c[N],tot;}T; 
void insert_(char* s){
	int len=strlen(s+1),p=1;
	for(int i=1;i<=len;i++){
		int k=s[i]-'a';
		if(!T.ch[p][k]) T.ch[p][k]=++T.tot,T.fa[T.tot]=p,T.c[T.tot]=k;
		p=T.ch[p][k];
	}
}
int insert(int c,int lst){	//将 c 接到 lst 后面。返回值为 c 插入到 SAM 中的节点编号 
	int p=lst,x=++tot;
	len[x]=len[p]+1;
	while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
	if(!p){fa[x]=1;return x;}
	int q=ch[p][c],Q;
	if(len[q]==len[p]+1){fa[x]=q;return x;}
	Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
	fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
	while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
	return x;
} 
signed main(){
	scanf("%d",&n),T.tot=1;
	for(int i=1;i<=n;i++) scanf("%s",s+1),insert_(s);
	for(int i=0;i<26;i++)
		if(T.ch[1][i]) q.push(T.ch[1][i]);	//插入第一层字符
	pos[1]=1;	//Tire 树上的编号为 1 的节点(根节点)在 SAM 上的位置为 1(根节点) 
	while(q.size()){
		int x=q.front();q.pop();
		pos[x]=insert(T.c[x],pos[T.fa[x]]);	//pos[x]: Trie 上节点 x 的前缀字符串(路径 根到 x 所表示的字符串)在 SAM 中的对应节点编号
		for(int i=0;i<26;i++)
			if(T.ch[x][i]) q.push(T.ch[x][i]);
	}
	for(int i=2;i<=tot;i++) ans+=len[i]-len[fa[i]];
	printf("%lld\n",ans);
	return 0;
}
2. 在线做法

不建立 Trie,直接把给出的串插入到广义 SAM 中。insert 部分和普通 SAM 存在差别。

//Luogu P6139
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=3e6+5,M=27;
int n,m,ch[N][M],pos[N],fa[N],len[N],lst,tot=1,ans;
char s[N];
int insert(int c,int lst){	//返回值为 c 插入到 SAM 中的节点编号
	int p=lst,x=0; 
	if(!ch[p][c]){	//如果这个节点已存在就不需要新建了
		x=++tot,len[x]=len[p]+1;
		while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
	} 
	if(!p){fa[x]=1;return x;}	 //1 
	int q=ch[p][c],Q=0;
	if(len[q]==len[p]+1){fa[x]=q;return x?x:q;}	//2
	Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
	fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
	while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
	return x?x:Q;	//3
} 
signed main(){
	scanf("%lld",&n);
	for(int i=1;i<=n;i++){ 
		scanf("%s",s+1),m=strlen(s+1),lst=1;
		for(int j=1;j<=m;j++) lst=insert(s[j]-'a',lst);
	}
	for(int i=2;i<=tot;i++) ans+=len[i]-len[fa[i]];
	printf("%lld\n",ans);
	return 0;
}

可以证明最坏复杂度为线性。

标签:ch,后缀,text,len,fa,int,endpos,new,自动机
来源: https://www.cnblogs.com/mytqwqq/p/15748431.html