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]


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

  • 相关阅读:
    java中清空session
    freemarker中修改和添加功能中包含checkbox复选框默认选中需求的解决方式
    Highcharts动态赋值学习
    CSDN Luncher
    JS导入导出Excel表格的方法
    js 下载文件的操作方法
    模板标签的作用
    css3的calc()
    JS滚轮mousewheel事件和DOMMouseScroll事件
    css BFC(格式化上下文)的理解
  • 原文地址:https://www.cnblogs.com/BCOI/p/10309886.html
Copyright © 2011-2022 走看看