后缀数组&自动机&树

后缀数组&自动机&树

后缀系列字符串结构总结

博主的一些习惯

字符串叫\(S\),长度叫\(n\),字符串\(a,b\)相接为\(a+b\),下标从一开始,\(S_{i,j}\)表示\(i\)\(j\)这一段子串

字符串之间\(<,>\)表示字典序比较

所有例题的题解都可以在文末找到

\[\ \]

\[\ \]

1: SA(后缀数组)

需要数组\(rk[i]\)表示第\(i\)个后缀的排名(不存在相同),\(sa[i]\)表示排名为\(i\)的后缀编号,以及辅助数组\(cnt,tmp\)

1-1 后缀排序

由于博主水平不够,所以没有去学习dc3(\(O(n)\))算法,下文介绍的是da(\(O(n\log n)\))算法

后缀排序,顾名思义就是要对于\(S\)的每个后缀\(S_{i,n}\)(下称\(Suf_i\)),按照字典序排序,空字符的字典序最小

考虑用倍增+基数排序实现

对于当前已经确定的长度\(k\),即\(S_{i,i+k-1}\)(超出部分为空字符)的排序已经完成,接下来考虑对于\(S_{i,i+2k-1}\)的排序

\(S_{i,i+2k-1}=S_{i,i+k-1}+S_{i+k,i+2k-1}\),所以可以根据已经排序完的部分为值对于两部分使用基数排序合并得到\(2k\)

如果你还不是很熟悉这样情况下基数排序的过程,可以先用快排实现一次

流程大致如下

根据首字母初始化sa,rk数组
for(k=1;k<=n;k<<=1) {
   	rep(i,1,n) tmp[i]=i;
    sort(...);
    求出sa,rk,注意当前相同的串的rk也要相同
}

代码实现

int n;
char s[N];
int cnt[N],tmp[N],rk[N],sa[N],lcp[N];


void PreMake(){
    memset(cnt,0,800);
    rep(i,1,n) cnt[(int)s[i]]++;
    rep(i,1,200) cnt[i]+=cnt[i-1];
    rep(i,1,n) rk[i]=cnt[(int)s[i]],sa[i]=i; //瞎初始化一波,不用我这样写
    rep(i,n+1,n*2) rk[i]=0;// 注意一下边界的清空
    for(reg int k=1;k<=n;k<<=1) {
        rep(i,0,n) cnt[i]=0;
        rep(i,1,n) cnt[rk[i+k]]++;
        rep(i,1,n) cnt[i]+=cnt[i-1];
        drep(i,n,1) tmp[cnt[rk[i+k]]--]=i; // 按照第二关键字排序
        
        rep(i,0,n) cnt[i]=0;
        rep(i,1,n) cnt[rk[i]]++;
        rep(i,1,n) cnt[i]+=cnt[i-1];
        drep(i,n,1) sa[cnt[rk[tmp[i]]]--]=tmp[i];//按照第一关键字排序,求出 sa
        
        rep(i,1,n) tmp[sa[i]]=tmp[sa[i-1]]+(rk[sa[i]]!=rk[sa[i-1]]||rk[sa[i]+k]!=rk[sa[i-1]+k]);
        rep(i,1,n) rk[i]=tmp[i];//求出rk,注意相同的 
    }
}

实测贼慢,这个板子应该不行。。。

\[\ \]

\[\ \]

1-2 LCP

SA的另一个重要部分就是\(LCP\)数组(有些地方称之为\(height\)数组,没有关系啦)

\(LCP\): Longest Common Prefix ,最长公共前缀

\(LCP[i]\)\(LCP(Suf_{sa[i]},Suf_{sa[i+1]})\)

性质1 :\(LCP(Suf_i,Suf_j)=min(LCP(Suf_i,Suf_k),LCP(Suf_k,Suf_j)) (rk[i]\leq rk[k] \leq rk[j])\)

首先对于任意串\(S_1,S_2,S_3\),有\(LCP(S_1,S_2)\ge min(LCP(S_1,S_3),LCP(S_2,S_3))\),这个想必不用多说

接下来证明$LCP(Suf_i,Suf_j)>min(LCP(Suf_i,Suf_k),LCP(Suf_k,Suf_j)) $不存在

\(LCP(Suf_i,Suf_k)=a,LCP(Suf_k,Suf_j)=b\)

\(a>b\),有\(S_{j+b} \ne S_{k+b}\),而因为\(a>b\)可以得到\(S_{k+b}=S_{i+b}\),即\(S_{i+b}\ne S_{j+b}\)故不存在

\(b>a\),同理

\(a=b\),则\(S_{i+a}\ne S_{k+a},S_{k+a}\ne S_{j+a}\),又因为按照字典序排序,所以又有\(S_{i+a}<S_{k+a}<S_{j+a}\),同样的可以排除上面的情况

得证(额这个自己搞的勉强看看吧)

\[\ \]

性质2:\(LCP[rk[i+1]]\ge LCP[rk[i]]-1\)

事实上\(LCP[rk[i]]=LCP(Suf_i,Suf_{sa[rk[i]-1]})\)

\(Suf_i=a,Suf_{sa[rk[i]-1]}=b,Suf_{i+1}=c,Suf_{sa[rk[i+1]-1]}=d,LCP[rk[i]]=x,LCP[rk[i+1]]=y\)

以下仅讨论\(x\ge 2\)的情况,\(x\leq 1\)的情况额。。。

\(c\)串就是\(a\)去掉了首字母,所以有\(a_{1,x}=b_{1,x},a_{2,x}=c_{1,x-1}\)

现在\(d\)的字典序小于\(c\),若\(d_{1,x-1}\ne c_{1,x-1}\),则\(d_{1,x-1}<c_{1,x-1}\)

而我们已经知道存在后缀\(e=b_{2,len(b)}\)满足\(e_{1,x-1}=c_{1,x-1}\)由于\(x\ge 2\),又有\(e<c\)

\(d<e<c\)矛盾

\(d_{1,x-1}=c_{1,x-1}\)

根据性质2,可以得到一个\(O(n)\)处理\(LCP\)数组的方法

for(reg int i=1,h=0;i<=n;++i) {
    if(h) h--; // LCP[rk[i+1]]>=LCP[rk[i]]-1
    int j=sa[rk[i]-1];
    while(i+h<=n && j+h<=n && s[i+h]==s[j+h]) h++;
    lcp[rk[i]-1]=h;
}
//由于0<=h<=n,h最多减少n次,所以h最多增加n*2次

\[\ \]

1-3 应用:

1:查询\(LCP(S_{i,n},S_{j,n})\)

根据性质1,可以转化为求\(min(LCP[rk[i]..rk[j]-1]) (rk[i]\leq rk[j])\)

注意一下边界问题,用 ST表/线段树 维护即可

\[\ \]

2:求不同的子串个数

对于排序后的子串\(sa[i]\),答案就是\(\sum n-sa[i]+1-LCP[i-1]\)

对于\(sa[i]\)这个后缀提供的\(n-sa[i]+1\)个前缀,字典序小于它的串中已经出现过的前缀数量就是最大的\(LCP(Suf_i,Suf_j)\),也就是\(LCP[i-1]\)

SPOJ-DISUBSTR - Distinct Substrings

\[\ \]

3.求LCS

将两个串接在一起,中间加上一些奇怪的字符

然后就是求下标分别落在两个串中的所有\(i,j\)\(LCP(Suf_i,Suf_j)\)的最大值

按照\(SA\)的顺序可以发现只用考虑最近的\(i,j\),所以对于每个 \(i\) 找到最近的 \(j\) 即可,就是一个尺取

尺取\(L,R\)之后可以用单调队列查询

POJ-2774

\[\ \]

4:\(k\)大子串

由于后缀数组已经排好序,所以可对于\(sa[i]\)考虑其贡献的个数为\(n-sa[i]+1-lcp[i-1]\)

考虑个数累前缀和\(Sum[i]\),二分就\(Sum[p]\ge k\)能得知要找的串在哪个后缀\(p\)的前缀集里,但是这个后缀可能不止出现的一次

求得的长度\(len=k-Sum[p-1]+lcp[p-1]\)就是排名再加上已经出现的

所有包含这个串的后缀是一段连续的区间\(l,r\)满足\(\forall_{i\in [l,r]}LCP(sa[i],p)\ge len\)

可以解决一些问题HDU-5008

\[\ \]

5:长度至少为x出现最多次的串,出现至少x次最长的串

第一种情况我们可以将\(LCP\)数组分段,每段内的\(LCP[i]\ge x\),那么出现这些后缀长度为\(x\)的前缀均相同,然后统计每段最多包含几个后缀即可

第二种情况可以直接套一个二分。。。

例题 [USACO06DEC]牛奶模式Milk Patterns

如果要求不能有重复部分的话就要判断一下出现位置的距离 POJ - 1743

类似这样分组还可解决很多问题,如:POJ-3294

\[\ \]

6.利用LCP求循环

\(LCP(Suf_i,Suf_j)\ge j-i+1(i\leq j)\)时就出现了循环,可以根据匹配长度来判断循环数

当然还有一些别的方法,原理都类似

POJ-3693

\[\ \]

\[\ \]

2.SAM(后缀自动机)

一个感觉非常复杂而又神奇的数据结构

由于博主能(太)(菜)(了)限,很多地方不提供严谨证明

基础定义

1.\(endpos\)集合

对于\(S\)的一个子串,它在S中出现的每一个位置的右端点构成的集合就是\(endpos\)集合,也可以说是\(rightpos\)

2.状态定义

对于所有\(endpos\)为同一集合的子串归到同一状态中

一个状态可以表示为:它的\(endpos\)集合,以\(endpos\)中某一元素为结尾(那个都一样),向左边延伸长度\([l,r]\)的子串集合

当延伸长度\(<l\)时,\(endpos\)集扩大,\(>r\)时缩小

3.后缀链接\(link\)

就是当长度为\(l-1\)时对应的状态,它的\(endpos\)集合较大

可以发现\(endpos[u]=插入时当前节点对应的位置(如果存在)\bigcup endpos[v_1]\bigcup endpos[v_2].. (link[v_i]=u)\)

事实上就是因为\(v_i\)的前面几个不同的字符被去掉了

将这个性质推到底层,到\(|endpos|=1\)的状态,就可以表示出任意一个节点的\(endpos\)

(这三个东西一定要看懂!)

\[\ \]

\[\ \]

状态的存储: \(trans[..][..],link[..],len[..]\)

其中\(trans\)是状态转移,即在当前串后加上一个字符得到的状态,\(link\)是后缀链接,\(len\)是最长延伸长度

事实上最短延伸长度就是\(len[link]+1\),其中空状态的\(len\)\(0\)

空状态我们设它就是\(0\)号状态,它的\(len\)没有意义

\(trans\)构成的结构是一个\(DAG\)转移,而\(link\)构成的是一颗树

根据字符集\(\sum\)大小不同,\(trans\)可以选择用\(map\)或者数组来存储

\[\ \]

构建自动机

构建后缀自动机是一个在线算法,可以不断向后面添加字符

首先我们要知道当前已添加的串对应的状态\(lst\),要添加的字符\(c\)

那么我们遍历\(lst\)\(link\)祖先就能找到当前串所有的后缀对应的状态\(st_i\)

由于现在整个串对应的\(endpos\)之前一定没有出现过,所以要开一个新的状态\(cur\)

同时\(trans[cur][i]=NULL,len[cur]=len[lst]+1\)(这两个比较明显吧)

接下来就是要考虑\(link\)的寻找以及新添加字符对于已经存在的状态的影响

1.\(trans[st_i][c]=NULL\)

不存在转移,意味着这个字符从未出现过,而新加入字符产生了转移,所以要\(trans[st_i][c]=cur\),同时\(link[cur]=0\)

\[\ \]

2.存在\(p\in st_i ,trans[p][c]=q\ne NULL\)

其中\(p\)\(link\)树上\(dep\)最大的那一个

\(p\)之前遍历到的状态依然要\(trans[st_i][c]=cur\)

存在这样的\(p\)意味着存在最长的串长度为\(len(p)+1\),它是当前串的一段后缀,所以\(link[cur]\)应当指向一个\(len\)\(len[p]+1\)的状态

然而我们并不能保证能找到这样的点

2-1.\(len[q]=len[p]+1\)

那么\(q\)就是要指向的点,直接\(link[cur]=q\)即可

2-2.\(len[q]>len[p]+1\)

这意味着\(q\)状态的延伸长度缩短到了\(len[p]+1\)时当前总串的后缀就会与它相同,产生一个新的\(endpos\)

所以我们要在\(len[p]+1\)时插入这个串

构造一个状态插入在\(link[q]\)\(q\)中间,截断它,同时也截断\(cur\),让两个状态汇合

由于是将只是状态\(q\)截断开了,所以这个状态的转移函数是从\(q\)复制过来的,所以我们称它为\(clone\),显然\(len[clone]=len[p]+1\)

我们将\(clone\)插入后,原先指向\(q\)的转移都要更改到\(clone\)(这些状态的\(endpos\)应该都会增加一个)

最后我们将\(link[cur]\)\(link[q]\)都指向\(clone\)即可

int n;
char s[N];
int trans[N<<1][26];
int stcnt,lst,len[N<<1],link[N<<1];

void Init(){
	link[0]=-1,len[0]=0;
	rep(i,0,stcnt) rep(j,0,25) trans[i][j]=0; //这个0表示NULL
	stcnt=lst=0;
}
void Extend(int c){
	int cur=++stcnt,p=lst;
	len[cur]=len[lst]+1;
	while(~p && !trans[p][c]) trans[p][c]=cur,p=link[p]; // 不存在这个转移我们就手动添加
	if(p==-1) link[cur]=0;//没有
	else {
		int q=trans[p][c];
		if(len[q]==len[p]+1) link[cur]=q;//q恰好我们需要
		else {
			int clone=++stcnt;//插入一个新的状态让cur和q合并
			memcpy(trans[clone],trans[q],104);
			link[clone]=link[q];
			len[clone]=len[p]+1;
			while(~p && trans[p][c]==q) trans[p][c]=clone,p=link[p];//覆盖q原先的转移
			link[cur]=link[q]=clone;
		}
	}
	lst=cur;
}

复杂度上限:

状态数\(\leq 2n-1\)

转移数\(\leq 4n-3\)

\[\ \]

应用

1.不同子串数

这个有两种求法
1-1.

每一种从根开始的转移路径都对应原串的的一个不同子串,所以可以直接在转移拓扑图上进行\(DAG\)路径\(dp\)

1-2.

就是每个状态对应的数量\(len[i]-len[link[i]]\)累和即可

SPOJ-DISUBSTR - Distinct Substrings

LOJ-2033.[SDOI2016]生成魔咒

2.k大子串

单纯的后缀自动机求\(k\)大子串好像不能做到\(log\)级别的插叙

处理每个状态出发的转移路径数,然后在\(DAG\)上查找

这个建议还是写后缀数组比较好

3.对于另一个串进行最长子串匹配

就是求出\(T\)的每一个前缀中最长的一个在\(S\)中出现过的后缀

就是在转移数组上不断进行,如果不存在转移就返回\(link\),注意同时维护长度

int p=0,nowlen=0;
rep(i,1,strlen(T+1)){
    while(p && !trans[p][c]) p=link[p],nowlen=len[p];
    if(trans[p][c]) p=trans[p][c],nowlen++;
}

-->优化

我们平时遇到的问题字符集大小基本上就是26个字母

对于\(trans[..][26]\),如果有多次转移每次调用都需要\(while\)可能影响效率,可以预先预处理出来,就像 \(AC\)自动机的转移直接覆盖在\(trie\)树上 一样

我们从\(0\)号节点开始遍历\(link\)树即可,每次对于不存在的转移直接从父节点覆盖下来

\[\ \]

可以解决\(LCS\)问题 POJ-2774

还有一些比较奇怪的问题 HDU-4416

\[\ \]

4.广义后缀自动机?

是不是听起来很高大上?

博主当然不明白原理,但是mo有关系

对于处理多个串的子串问题,把每个串都加入同一个自动机,就能得到广义后缀自动机(?)

这种容易出现重复节点的问题,不建议写

有两种解决重复节点的办法:先将串插入\(trie\)树,再在\(trie\)树上广搜建\(SAM\)

也可以在插入函数内部直接特判掉

void Extend(int c) {
	int p=lst;
	if(trans[p][c]) {
		int q=trans[p][c];
		if(len[q]==len[p]+1) lst=q; 
		else {
			int clone=++stcnt;
			memcpy(trans[clone],trans[q],sizeof trans[q]);
			len[clone]=len[p]+1;
			link[clone]=link[q];
			while(~p && trans[p][c]==q) trans[p][c]=clone,p=link[p];
			lst=link[q]=clone;
		}
		return;
	}
	int cur=++stcnt;
	len[cur]=len[p]+1;
	while(~p && !trans[p][c]) trans[p][c]=cur,p=link[p];
	if(p==-1) link[cur]=0;
	else {
		int q=trans[p][c];
		if(len[q]==len[p]+1) link[cur]=q;
		else {
			int clone=++stcnt;
			memcpy(trans[clone],trans[q],sizeof trans[q]);
			len[clone]=len[p]+1;
			link[clone]=link[q];
			while(~p && trans[p][c]==q) trans[p][c]=clone,p=link[p];
			link[cur]=link[q]=clone;
		}
	}
	lst=cur;
}

如果你愿意听队爷讲解:参考文献-2015集训队论文 [ZJOI2015]诸神眷顾的幻想乡 提取码:6f4u

HDU-5853

\[\ \]

5.涉及到\(endpos\)集合的统计问题

简单的问题可以直接通过子树累和得到

--->出现\(k\)次的子串数 :HDU-6194

--->不重叠地出现两次:POJ - 1743

\[\ \]

如果是较为复杂的问题我们可以用有趣的线段树合并来解决,但是要离线

HDU-4641

6.复杂的问题处理

\(dp\)HDU-5343 HDU-5470

奇怪的统计(+树上结构):51Nod-1600

\[\ \]

\[\ \]

后缀树

\(S\)的每一个后缀插入\(trie\)树,我们可以看到这样的\(trie\)树包含的节点个数时\(O(n^2)\)

但是产生的\(trie\)树上会有很多单链,我们将它们压缩掉,就能得到后缀树

后缀链接:

后缀树的后缀链接指向该节点对应的子串的最长后缀

复杂度:

节点数\(\leq 2n-1\)

\[\ \]

事实上它就是对于\(S\)反向建立后缀得到的\(parent/link\)树,所以可以通过直接构造后缀自动机得到

但是有一个叫做\(\text{Ukkonen}\)的算法可以构造它,博主没有去学。。。

应用:

由于大部分问题都是具有推广性的,读者可以尝试用后缀树去完成\(SA/SAM\)的问题

(没有找到必须用后缀树解决的问题。。。)

比较

效率 \(da<SAM\approx\)后缀树

空间上\(da<SAM\approx\)后缀树

大部分情况下\(\text{SAM}\)不要用\(\text{map}\)存储状态

\[\ \]

\[\ \]

例题题解传送门集合:

SPOJ-DISUBSTR - Distinct Substrings

POJ-2774

HDU-5008

Luogu-2852

POJ-1743

POJ-3294

POJ-3693

LOJ-2033.[SDOI2016]生成魔咒

HDU-4416

HDU-5853

HDU-6194

HDU-4641

HDU-5343

HDU-5470

51Node-1600

posted @ 2020-01-19 13:28  chasedeath  阅读(286)  评论(0编辑  收藏  举报