zoukankan      html  css  js  c++  java
  • 后缀数组学习笔记

    后缀数组详解

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

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

    后缀是什么?

    后缀是一个字符串从某一位到最后一位的一个子串,比如字符串(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} eq B_{la+1}, B_{lb+1} eq C_{lb+1}).假设 (C_{lc+1}=A_{lc+1}).则 (A,C) 的排名应相邻,而 (A,C) 中一定存在一个 (X) 使得 (X_{lc+1} eq B_{lc+1})​,B 的排名却在 AB 中间,矛盾.因此 (C_{lc+1} eq 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]


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

  • 相关阅读:
    C语言 realloc为什么要有返回值,realloc返回值具体解释/(解决随意长度字符串输入问题)。
    opencv中的vs框架中的Blob Tracking Tests的中文注释。
    Java实现 蓝桥杯VIP 算法提高 棋盘多项式
    Java实现 蓝桥杯VIP 算法提高 棋盘多项式
    Java实现 蓝桥杯VIP 算法提高 棋盘多项式
    Java实现 蓝桥杯VIP 算法提高 棋盘多项式
    Java实现 蓝桥杯VIP 算法提高 分苹果
    Java实现 蓝桥杯VIP 算法提高 分苹果
    Java实现 蓝桥杯VIP 算法提高 分苹果
    Java实现 蓝桥杯VIP 算法提高 分苹果
  • 原文地址:https://www.cnblogs.com/BCOI/p/10309886.html
Copyright © 2011-2022 走看看