后缀数组&后缀自动机

SA

后缀排序的中心思想是倍增,为了优化常数会使用一些比较特别的技巧。写法上主要分为两个部分,即预处理部分和倍增部分。首先定义几个数组,sai 是排名为 i 的数组的位置,rki 是后缀 i 的排名,pli 是第一关键字 i 的位置,id 是按第二关键字排序后的数组(存的是原数组下标)。倍增部分也主要分为几步,先统计出 id 数组(尾巴上的肯定优先,剩下的按 sa 序依次加入);然后统计 pl 并填数,注意初始化;最后统计新一轮的 n 值(首先要把 rk 的值复制到 ork 中),最后在 p=m 退出。板子。

void main(){
	int n=200,p=0;
	for(int i=1;i<=m;i++)pl[rk[i]=w[i]]++;
	for(int i=1;i<=n;i++)pl[i]+=pl[i-1];
	for(int i=m;i;i--)sa[pl[rk[i]]--]=i;
	for(int w=1;;w<<=1,n=p,p=0){
		for(int i=m;i>m-w;i--)id[++p]=i;
		for(int i=1;i<=m;i++)sa[i]>w&&(id[++p]=sa[i]-w);
		memset(pl,0,sizeof(pl));
		for(int i=1;i<=m;i++)pl[rk[id[i]]]++;
		for(int i=1;i<=n;i++)pl[i]+=pl[i-1];
		for(int i=m;i;i--)sa[pl[rk[id[i]]]--]=id[i];
		memcpy(ork,rk,sizeof(int)*(m+1));p=0;
		for(int i=1;i<=m;i++)rk[sa[i]]=chk(sa[i-1],sa[i],w)?p:++p;
		if(p==m)break;
	}
} 

height 数组定义为后缀 saisai1lcp 长度。有结论是说 hrkihrki1。所以有线性的代码:

for(int i=1,k=0;i<=m;i++){
	if(k)k--;
	while(w[i+k]==w[sa[rk[i]-1]+k])k++;
	h[rk[i]]=k;
}

有许多结论。一个是说后缀 x 和后缀 ylcp 值是 mini=rkx+1rkyhi。另一个结论是一个串本质不同子串的数量,考虑添加每个后缀的前缀并去除掉重复部分,而重复部分就是之前的后缀中和它前缀相同的部分的交集,也就是求得的 hi,所以本质不同的子串数量是 (m+12)hi

另外就是一个非常常见的柿子,给定一个集合 A,求 x,yAlcp(sx,sy)。发现问题可以变成 x,yBmini=x+1yhi,然后把这玩意提取出来就可以用单调栈来快速处理了。

再有就是练习题时间了。

优秀的拆分:首先枚举贡献点,然后把问题拆分成以一个点开头的 AA 串数量乘上以那个点结尾的数量。然后考虑枚举 A 部分的长度,每隔 |A| 个点设置一个关键点,可以想到一个合法的 AA 串会恰好包含两个关键点。一对相邻的关键点分别向两个方向求出最长公共前后缀,最后会得到这样一张关键的图:

关键点并没有画出来。黑色的串是最长的相同前后缀拼起来形成的串,红色的是一个长度为 2|A| 的串。会发现紫色部分是相同的,所以红色的串一定是合法的。也就是说最右边的空白中的点都是可以作为右端点的,差分实现即可。然后调了两个小时,这启示我在开始敲键盘之前一定要把各种细节就先想好,否则会在一些地方纠结很久并浪费很多时间。

CF822E 首先有一个贪心的想法,当确定了用 A 的一个前缀去匹配 B,并且使用的次数是固定的情况下肯定是越往后匹配越优。所以用 fx,y 代表 Ax 前缀使用 y 次机会最多能匹配到 B 的最长前缀的长度,然后考虑这个状态能转移到哪些。fx+1,y 不用说,令 lenAx+1 后缀和 Bfx,y+1 后缀的最长公共前缀,那么这个状态也可以贪心地转移到 fx+len,y+1,过程中统计是否有合法状态即可。求公共前缀可以考虑把 AB 拼起来(中间加一个分割符号),然后把问题转化成求新后缀的公共前缀即可。

品酒大会 提出了一个常用的做法。两个后缀的 lcp 不小于 s 当且仅当他们之间的元素都不小于 s,也就是说把所有 hxs 对应的位置 sax,sax1 合并之后呢二者在同一个集合内,所以可以用并查集来实现。具体到这道题就只需要离线下来,按 h 降序排序之后呢合并,维护每个集合的最大次大最小次小以及集合的 size,这些就非常套路了。

SAM

以下内容为初学笔记,非常幼稚。

有一个串 aabab,对它的所有后缀建立字典树,会得到:

点太多了,不是非常优秀,而且复杂度太高了。所以想着能不能用一些什么方法把复杂度和点数降下来,这就是后缀自动机想要做的事。先搞一个定义,一个串的 endpos 简称(pl) 是指的这个串在原串中出现位置(指结尾位置)集合。于是就有了一些结论:

  • pl(x)=pl(y),那么 xy 的后缀(或者相反)。
  • 对于两个串 x,y,有 pl(x)pl(y),要么 pl(x)pl(y)=
  • 所有 pl(x) 相同的 x 称为一个等价类,一个等价类中串的长度连续。

根据第一个和第二个结论,所有等价类可以形成一个树形结构,事实上 SAM 就是把这棵树上的节点重新编号连边后形成的东西。比如串 aababa 的这棵树(即 parent tree,下面叫 pt)是这样的:

  • mlen(x) 是等价类 x 中最短的字符串长度,而 len(x) 是最长的,那么有结论:pl(x)pl(y) 的父亲当且仅当 mlen(pl(y))=len(pl(x))+1

性质就差不多了,还有一些正确性和复杂度上的证明然鹅我并不很想研究。于是就是最重要的构造部分了,放个非常经典的图。

主程序非常无脑,一个一个字符加,构成了最下面那排节点。

for(int i=1;i<=m;i++)insert(w[i]-'a');

首先是新建一个节点(毕竟新串的 pl 不属于任何其它等价类),有:

int p=lastCnt;int np=lastCnt=++cnt;
t[np].len=t[p].len+1;

然后开始遍历加入该字符之前的串的后缀并检查哪些位置的 pl 会变。有:

for(;p&&!t[p].ch[c];p=t[p].fa)t[p].nxt[c]=np;
if(!p)t[np].fa=1;else{}

如果进到了第二个分支就说明 p+c 在原串中出现过,那么由于这个点到根的所有点都是自己的后缀,那么这条路上的所有点的 pl 集合大小都应该加一。所以呢就可以考虑直接把这个点和新的点建立联系,如下:

int q=t[p].ch[c];
if(t[q].len==t[p].len+1)t[np].fa=q;else{}

还有其它情况,若 len(q)len(p)+1 怎么办。这个情况等价于 len(q)len(p)+2,也就是说有一个比 longest(p)+c 更长的串属于 q,而这个东西一定不是新串的后缀,因为如果是的话那么去掉 c 之后一定是旧串的后缀,既然长度更长那么应该先被跳到。所以,属于 q 的长度不大于 len(p)+1 的串是新串的后缀,但大于的却不是,所以 q 对应的集合就无法放在同一个圈内而需要拆分了,这就是上方杂乱节点的由来。

新建一个叫做 nq 的节点,用于存放那些 pl 中多了一个元素 m 的串。然后考虑它的出边,fa 以及 len。显然 len(nq)=len(p)+1,由上面第二类情况的分析可知。出边和 fa 方面,会发现其实和原节点的差不多的,所以可以直接整体赋值。然后由于这玩意的 pl 比别人恰好多了一个 m,所以它就能当 qnp 的父亲啦。写出来是这样的:

int nq=++cnt;t[nq]=t[q];
t[nq].len=t[p].len+1;
t[q].fa=t[np].fa=nq;

最后就是和上面第二部分一样的套路,更新一路上的出边。

for(;p&&t[p].ch[c]==q;p=t[p].fa)t[p].nxt[c]=nq;

总的就是这样的:

inline void insert(int c){
	int p=lastCnt;int np=lastCnt=++cnt;f[np]=1;
	t[np].len=t[p].len+1;
	for(;p&&t[p].nxt[c]==0;p=t[p].fa)t[p].nxt[c]=np;
	if(!p)return t[np].fa=1,void();
	int q=t[p].nxt[c];
	if(t[q].len==t[p].len+1)return t[np].fa=q,void();
	int nq=++cnt;t[nq]=t[q];
	t[nq].len=t[p].len+1,t[np].fa=t[q].fa=nq;
	for(;p&&t[p].nxt[c]==q;p=t[p].fa)t[p].nxt[c]=nq;
}

用自然语言描述这一过程就是说,进来一个字符,更新一串点的出边;如果到头了就返回,否则检查一下是否有合适的父亲。如果有,回去;如果没有,说明有个集合太霸道,从中扯出来一部分,并钦定它是父亲,然后打扮一下这个节点,走完流程,更新出边。好感性的语言啊。

复杂度什么的,由于 ZC 没有脑子,自然是没有那个能力去看的,暂且略过罢。先看一下应用部分。其实吧后缀自动机大多数的应用都来源于它最底层的性质:从根随机游走,任意合法的路径(结束节点没有任何限制)都是原串的子串,并且不重不漏,这个性质听起来就已经非常美妙啦。然后点呢有一些实际含义,比如每个节点的 len 代表这个集合中最长的子串的长度,然后上面代码中的 f 是每个节点对应的 pl 集合的大小。

  • 判断子串

在自动机上顺着边跑,如果跑到 0 了就说明不是,否则是。

  • 不同子串的个数

后缀数组上直接用总串数减去 hi 即可。后缀自动机上考虑 DP,用 fx 代表从 x 出发的子串个数,由于 SAM 上任意从原点出发、并在任意点结尾的串都是原串的子串并且不会重复,所以用 fx=ynxtx(fy+1),然后 f1 就是答案。

  • 求子串不去重字典序第 k

首先预处理出每个节点 pl 集合的大小。对于在串中第一次出现的点(即 case1),直接令 fx=1 即可。然后进行一次 DP,方程是 fx=fx+ynxtxfy

然后用 gx 代表从 x 出发不去重的子串个数,有 gx=fx+ynxtxgy,所以找第 k 大就只需要贪心地找下去,按字典序遍历儿子,如果当前儿子少了就下一个,否则就进入到这个儿子当中,然后就可以啦。

还有很多很多方法和技巧,以后遇到了再说。

posted @   Feynn  阅读(120)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 周边上新:园子的第一款马克杯温暖上架
点击右上角即可分享
微信分享提示