后缀数组学习笔记

后缀数组详解

这里是模板题链接:【模板】后缀排序

因为这个东西实在是太绕了,所以写篇博客记录一下.

后缀是什么?

后缀是一个字符串从某一位到最后一位的一个子串,比如字符串\(abcde\)的后缀有\(abcde,bcde,cde,de,e\).这里我们用后缀\(i\)表示从第\(i\)位到最后一位所表示的后缀.

后缀数组是什么?

顾名思义,是一个记录后缀的数组.首先我们需要知道后缀排序是什么.后缀排序是指一个字符串的所有后缀按照字典序排序的结果.后缀数组用\(sa[i]\)表示经过后缀排序后排名为\(i\)的后缀,即\(sa[i]\)表示了原字符串中的第\(sa[i]\)位到第\(n\)位的一个子串.例如字符串\(ababa\)的所有后缀经过后缀排序后的顺序为\(a,aba,ababa,ba,baba\),则\(sa[3]=1\)(排名为\(4\)的后缀是\(ababa\)).

如何求解后缀数组

有两种求法:倍增\((O(nlogn))\)和DC3\((O(n))\).这里主要讲一下倍增的方法.

先来看一下倍增求后缀排序的流程图:

想必大家看了上面那张图之后都懂了,我就不讲了
大体思路就是每次将排序的长度翻倍,这样就可以在\(logn\)次内完成排序.第一次排\(n\)个字符直接比较ascii码就可以了.那么此时第一个字母的关系就可以确定了,那第二个字母的大小呢?其实字符串有一个性质:后缀i的后一半是后缀i+1的前一半.比如说字符串\(abcdef\)的子串中,\(abcd\)的后一半\(cd\)\(cdef\)的前一半.

那么这样在排序的时候,就可以确定两个关键字,先按第二关键字排,再按第一关键字排.也就是当遇到第一关键字相同的时候,第二关键字小的会被排在前面.经过这样的一次排序后,第一二关键字就可以被合并成一个关键字进行下一次合并了.其实这就是基数排序的思想.

下面先介绍一下变量:

  • \(s\)表示原字符串,下标以\(1\)为起点
  • \(rk[i]\)表示后缀\(i\)的排名,根据\(rk,sa\)的定义,有\(rk[sa[i]]=i,sa[rk[i]]=i\)
  • \(sec[i]\)表示第二关键字排名为\(i\)的后缀为\(sec[i]\)
  • \(buk[i]\)为一个桶,记录排名为\(i\)的后缀的数量.
  • \(m\)为桶扫的范围
  • \(num\)是一个计数器,记录排序后不同的排名的个数(排序过程中可能有几个子串是一样的,但最后形成的后缀不同,也就是说当排名个数为\(n\)个的时候,排序就完成了
    注意:因为合并过程是循环进行的,所以在合并到长度为\(l\)时,后缀\(i\)的实际意义为原字符串中的第\([i,i+l-1]\)位.

过程详解

初始状态每个后缀长度为\(1\)(上面注意事项),那么对每个单个字母进行排序,可以直接对它们的ascii码进行排序.

for(int i = 1; i <= n; i++) rk[i] = s[i], sec[i] = i;

我们再看倍增的过程.

	for(int l = 1; l <= n && num < n; l <<= 1){//l为合并的长度
		num = 0;//这里num只作为一个指针
		for(int i = 1; i <= l; i++) sec[++num] = n-l+i;
		for(int i = 1; i <= n; i++) if(sa[i] > l) sec[++num] = sa[i]-l;//这里是对第二关键字的求解
		//这层循环表示的是将后缀从长度l合成到长度2l
		//所以起点位置在[n-l+1,n]的后缀的第二关键字都为0,所以在第二关键字顺序中要排在前面
		//因为要将长度为l的后缀合成为长度为2l的后缀,所以[sa[i],sa[i]+l-1]作为新合成的长度为2l的后缀的第二关键字
		//同样是为了保证第二关键字最小,所以上面的for循环i从1到n
		//也就是说,上面求解的第二关键字,所表示的范围变成了2l
		RadixSort();//这里是基数排序,通过第一二关键字求出合并后的sa数组
		swap(rk, sec);//一二关键字合并后第二关键字就没用了,再用来保存当前第一关键字来推出下一次循环的第一关键字
		rk[sa[1]] = num = 1;//rk[sa[1]]=1可以根据定义得出,num记录不同的排名个数
		for(int i = 2; i <= n; i++)
			rk[sa[i]] = (sec[sa[i-1]] == sec[sa[i]] && sec[sa[i-1]+l] == sec[sa[i]+l]) ? num : ++num;
			//当排名为i的后缀与排名为i-1的后缀第一,二关键字都相同的时候,这两个后缀相同
			//这里指的后缀是指从某一位置开始向后l个字符形成的子串
		m = num;//更新字符集大小,用于下一次基数排序
	}

其中

	for(int i = 1; i <= l; i++) sec[++num] = n-l+i;
	for(int i = 1; i <= n; i++) if(sa[i] > l) sec[++num] = sa[i]-l;

是构造第二关键字顺序步骤中比较重要的一步.也就是将\(sa[i]\)看做合并后的第二关键字,这样可以保证合并之后的\(sec[i]\)的第二关键字顺序一定是从小到大的.

再来看看基数排序的过程:

void RadixSort(){
	for(int i = 0; i <= m; i++) buk[i] = 0;//在整个字符集范围内清空桶内元素
	for(int i = 1; i <= n; i++) buk[rk[i]]++;//记录某排名出现次数
	for(int i = 1; i <= m; i++) buk[i] += buk[i-1];//作前缀和
	for(int i = n; i >= 1; i--) sa[buk[rk[sec[i]]]--] = sec[i];
}

前面三个for应该还是很好懂的,第四个for里面嵌套得有点诡异,这里先引用一下acx巨佬对这个for的解释:
将一个后缀的第一二关键字看做是一个二元组,对桶内元素进行前缀和后相当于确定了该二元组第一维所在的范围,然后依据我们确定的第二维的顺序,从大到小弹出桶,就可以确定每个元素的位置.

用二元组的思想来理解还是比较好懂的,因为用字符串不太好举例子,这里就用几个二元组的排序举个栗子(按照第一,二关键字从小到大的顺序排):\((3,3),(1,2),(3,1),(3,2),(1,1)\)

  1. 先按照第二关键字排(不管第一关键字的顺序):\((3,1),(1,1),(1,2),(3,2),(3,3)\)
  2. 将第一关键字加入桶中:\(buk[1]=2,buk[3]=3\)
  3. 将桶内元素作前缀和:\(buk[1]=2,buk[3]=5\)(此时确定了第一维为1的元素的排名属于\([1,2]\),第一维为3的元素的排名属于\([3,5]\)
  4. 按照第二关键字从大到小的顺序从桶中弹出:\(rk[(3,3)]=buk[3]--=5,rk[(3,2)]=buk[3]--=4,rk[(1,2)]=buk[1]--=2,rk[(1,1)]=buk[1]--=1,rk[(3,1)]=buk[3]--=3\)

那么将后缀在排序\(sa\)数组的时候也是同样的道理,如果不懂可以再看看上面这个栗子.

最后再放一个完整的代码吧:

void RadixSort(){
	for(int i = 0; i <= m; i++) buk[i] = 0;
	for(int i = 1; i <= n; i++) buk[rk[i]]++;
	for(int i = 1; i <= m; i++) buk[i] += buk[i-1];
	for(int i = n; i >= 1; i--) sa[buk[rk[sec[i]]]--] = sec[i];
}

void SuffixArray(){
	for(int i = 1; i <= n; i++) rk[i] = s[i], sec[i] = i;
	int num = 0; m = 200; RadixSort();
	for(int l = 1; l <= n && num < n; l <<= 1){ // len : l -> 2l
		num = 0;
		for(int i = 1; i <= l; i++) sec[++num] = n-l+i;
		for(int i = 1; i <= n; i++) if(sa[i] > l) sec[++num] = sa[i]-l;
		RadixSort(); swap(rk, sec);
		rk[sa[1]] = num = 1;
		for(int i = 2; i <= n; i++)
			rk[sa[i]] = (sec[sa[i-1]] == sec[sa[i]] && sec[sa[i-1]+l] == sec[sa[i]+l]) ? num : ++num;
		m = num;
	}
}

后缀数组与LCP

我们花这么多时间研究这个后缀数组当然不可能就排个序就完了.其实后缀数组最重要的作用就是用来处理\(LCP\),最长公共前缀.

下面先引入一些定义:

  • \(LCP(i,j)=LCP(sa[i]​,sa[j])\):排名为\(i\)的后缀和排名为\(j\)的后缀的 \(LCP\)
  • \(height[i]=LCP(i, i-1)\):排名为\(i\)的后缀和排名为\(i-1\)的后缀的 \(LCP\)(的长度)
  • \(H[i]=height[rank[i]]\)

然后是一些定理:

  1. \(LCP(i,k)=min(LCP(i,j),LCP(j,k)) (i<j<k)\)
  2. \(LCP(i,k)=min(LCP(j,j−1)) (i≥j>k)\)
  3. \(h[i]​≥h[i−1​]−1\)

证明:

  1. (摘自acx的博客)
    假设\(LCP(i,j)=la,LCP(j, k)=lb,A=sa[i],B=sa[j],C=sa[k], lc=min(la, lb)\).则 \(A_{la+1}\neq B_{la+1}, B_{lb+1}\neq C_{lb+1}\).假设 \(C_{lc+1}=A_{lc+1}\).则 \(A,C\) 的排名应相邻,而 \(A,C\) 中一定存在一个 \(X\) 使得 \(X_{lc+1}\neq B_{lc+1}\)​,B 的排名却在 AB 中间,矛盾.因此 \(C_{lc+1}\neq A_{lc+1}\).显然 \(LCP(i, k)\geq lc\),因此 \(LCP(i, k)=lc\),证毕.
  2. 由定理\(1\),显然成立.
  3. 咕咕

那么有了这些性质之后我们就可以考虑求出\(height\)数组来做题了.

\(height\)数组的求法

其实主要是根据定理\(3\)\(height\)数组的定义来的.直接看代码注释吧.

void get_height(){
	int j, k = 0;
	for(int i = 1; i <= n; i++){ // 枚举后缀i
		if(k) k--; // H[i] >= H[i-1]+1
		j = sa[rk[i]-1]; // 计算排名为rk[i]-1的后缀j
		while(a[i+k] == a[j+k]) k++; // 求解LCP
		height[rk[i]] = k;
		//H[i] = height[rk[i]];
	}
}

一些应用

(转载自为风月马前卒博客)

两个后缀的最大公共前缀

\(lcp(x,y)=min(heigh[x−y])\)

用rmq维护,O(1)查询

可重叠最长重复子串

Height数组里的最大值

不可重叠最长重复子串 POJ1743

首先二分答案x,对height数组进行分组,保证每一组的minheight都>=x

依次枚举每一组,记录下最大和最小长度,多sa[mx]−sa[mi]>=x,那么可以更新答案

本质不同的子串的数量

枚举每一个后缀,第i个后缀对答案的贡献为len−sa[i]+1−height[i]


可能还有一些其他的性质,之后遇到了会慢慢补充的.

posted @ 2019-01-23 16:42  Brave_Cattle  阅读(230)  评论(0编辑  收藏  举报