0
点赞
收藏
分享

微信扫一扫

后缀数组的学习笔记与例题


【前言】

后缀数组真神奇。

 

【后缀】

对于一个字符串,定义suffix(i)为以i起点至结尾的子串。例如ababc,suffix(3)=bc

 

【后缀树】

后缀树容易理解,就是把一个字符串的所有后缀串插入到一个字典树上(字典树不再累赘)。如图为"ababc"的后缀树:

后缀数组的学习笔记与例题_RMQ

建造这棵树的代价是O(n*n)的空间复杂度,没什么实用性,只是为了更好的理解后缀数组。注意,对于每个分支,我已按从左到右的顺序排列,即最左端的suffix(0)就是所有后缀串中字典序最小的。

而我们想要的所谓的后缀数组就是 sa={ 0, 2, 1, 3, 4 } ,可以理解为一张成绩单,成绩是字典序从小到大。

 

【后缀数组】

后缀树中已提到sa数组是一张成绩单,我们有了这个成绩单,就厉害了。比如我们想知道某个字符串是否出现在原字符串中,就可以在成绩单上二分查找,由于比较函数strcmp线性复杂度,因此总时间复杂度为O(n*longn)。

 

【后缀数组的求解】

最直白的方法就是所有后缀串直接sort,但是代价是O(n*n)。下面介绍O(n*logn)的算法。

注:本文所有下标从0开始!

sa[] :将所有后缀串按字典序从小到大的一张成绩单,如sa[0]表示第0名后缀串是suffix(sa[0]);

c[] :  c[k]记录字符k出现的次数

temp1[], temp2[]:临时存储空间,借给*x和*y。

倍增加上基数排序的思想。

基数排序:比如对{45, 23,81 }一个数列进行排序,我们可以先按个位数排好{81, 23,45 },再按十位数排好{ 23,45,81 },这就是基数排序。实际上我们是把十位数看做第一关键字,各位数看做第二关键字

倍增:初始时我们把每个字符看做长度为1的串,然后它和它的后一个串组合为一个二元组,按照基数排序对所有二元组排序,排完后,所有长度为2的串在成绩单上有序了(注意suffix(n-1)没有第二元,视为0,即最小)。同理再把长度2倍增为4,思考一下。

 

关于这个倍增+基数排序的过程难以用语言描述,在代码中以注释讲解。

int get_sa(char *s,int n) //n是字符串s长度
{
    int p,i,m=MAX,*x=temp1,*y=temp2; //x[i]始终为串对i离散化的大小
    for(i=0;i<m;i++) c[i]=0;
    for(i=0;i<n;i++) c[ x[i]=s[i] ]++;
    for(i=1;i<m;i++) c[i]+=c[i-1];
    for(i=n-1;i>=0;i--) sa[--c[x[i]]]=i; //把i串对放到成绩单中
    for(int k=1;k<=n;k<<=1) //倍增
    {
        p=0; //串对长度翻倍,y[p]存新成绩单,仅按第二关键字排序
        //按第二关键字
        for(i=n-k;i<n;i++) y[p++]=i; //第二关键字为空,故放在前k名即可
        for(i=0;i<n;i++)if(sa[i]>=k)y[p++]=sa[i]-k;//遍历翻倍前的成绩单,作为二关依次排
        //成绩单y[i]按第一关键字
        for(i=0;i<m;i++) c[i]=0;
        for(i=0;i<n;i++) c[x[y[i]]]++;
        for(i=1;i<m;i++) c[i]+=c[i-1];
        for(i=n-1;i>=0;i--) sa[--c[x[y[i]]]]=y[i];
        //计算新的离散化成绩x,y数组充当临时数组
        swap(x,y);  //这里不要多想,只是借用一下y,把x的值扔给y,然后计算新的x
        p=0; x[sa[0]]=p++;
        for(i=1;i<n;i++)
        {
            int sec1=(sa[i-1]+k>=n ? -1 : y[sa[i-1]+k]); //取第二关键字,没有的-1代替,勿用0
            int sec2=(sa[i]+k>=n ? -1 : y[sa[i]+k]);
            x[sa[i]]=(y[sa[i-1]]==y[sa[i]] && sec1==sec2)?p-1:p++; //两个关键字均相等
        }
        if(p>=n)break; //已完全离散化
        m=p;
    }
}

 

【rank和height】

仅仅有了sa数组,我们还是做不了大事。

height[]:height[i]表示成绩单上第i名与其前一名的最长公共前缀,很明显第0名必为0;

rank[]:  sa的反函数,记录suffix(i)是第几名。

这两个数组好求:

void get_height(char *s,int n)
{
    for(int i=0;i<n;i++)ranks[sa[i]]=i;
    int k=0;
    for(int i=0;i<n;i++)
    {
        if(k>0)k--; //前一个匹配到了k个,这次至少配到k-1个
        if(ranks[i]==0) //第0名没有height
        {
            height[0]=0;
            continue;
        }
        int j=sa[ranks[i]-1]; //j是i的前一名
        while(s[i+k]==s[j+k])k++;
        height[ranks[i]]=k;
    }
}

有了height数组,这个功能比较强大,我们可以求任意两个后缀的最长公共前缀,即LCP,求法:用RMQ维护height数组的区间最小值,若要访问suffix(i)与suffix(j)的LCP,即为{ height[ rank[i] + 1] , height[ rank[i] +2] ......height[ rank[j] ] }中的最小值。如果不好理解,在后缀树模拟一下就明白了。

 

【多余的解释】

博文中与刘汝佳那本训练指南上讲的基本一致,但经过实践我发现了若干错误。博文中有刘汝佳源代码。

build函数倒数第四行:

for (int i=1;i<n;i++) t[sa[i]]=(t2[sa[i-1]]==t2[sa[i]] && t2[sa[i-1]+k]==t2[sa[i]+k])?p-1:p++;

这里相当于对排好序的成绩单做一个离散化,t2[sa[i-1]]==t2[sa[i]] 用来判断第一关键字,没有问题。 t2[sa[i-1]+k]==t2[sa[i]+k])用来判断第二关键字,这里有问题!首先数组越界是必然的,越界也没关系,数组开两倍,后面越界了都是0。但是,成绩单是从第0名开始!!故x数组必有一项为0!!比如sa[i-1]+k取的那一项恰好t2[sa[i-1]+k]==0,而sa[i]+k>=n,必然t2[sa[i]+k]==0,显然一定满足相等,而实际上这里的第二关键字是不相等的(前者为0,后者不存在,故不能认为相等)!

避免此bug的方法:1.排名从1开始就可以。2. 单独判断第二关键字,不要为了少些代码,通过越界省事。3.。。。。。

声明:我并没有否定刘汝佳前辈的意思,思路完美++,只是这个bug困扰了我一个上午。

 

【例题 SPOJ SUBST1 】

求一个串中不同子串的个数。任意子串必为某一后缀串的前缀,某一子串有重复只能是与字典序最接近的子串。

故重复串必出现在每两个字典序相邻的后缀串的最长公共前缀,即LCP。

任意后缀串所包含的子串总个数 = n - sa[i] ,注意下标i是这个后缀串的排名

去掉重复的就是 :n - sa[i] - height[i] ;

求总和。

 

【例题 2018icpc焦作网络赛 H String and Times】

题意:给出一个字符串,两个整数a,b,求有多少个子串满足出现次数在[a,b]之间。

分析:先考虑如何求 >=x 的子串个数怎么求,然后分别求出>=a和>=b+1的个数,相减即可。

求出现次数>=x的子串个数:看着后缀树会好理解些。在成绩单sa上,任选一个起点 i,既然要求出现次数>=x,那么从 i 到 i+x-1 的LCP(最长公共前缀)的出现次数一定>=x,而且LCP的前缀也都出现>=x次。故从成绩单上取出所有的连续x个的LCP,但是会有重复,怎么去重?当我查询到 i -> i+x-1 的LCP累计到答案时,与i-1 -> i-1+x-1 重复了多少个呢?重复了 i-1 -> i+x-1 的LCP个,结合后缀树仔细思考一下。

 

【例题 poj3261】

题意:给出n个数的序列,求出现次数至少为k的最长连续子序列长度。

分析:焦作网络赛那个H题求的是出现至少k次的个数,这里求的最长长度,注意区分。在成绩单sa上枚举起点 i ,终点为i+k-1,取一下LCP,最终的LCP最大值即为答案。

【代码】:https://paste.ubuntu.com/p/C52PwmF2D7/

 

【例题 poj1743】

题意:给出n个数的序列,求一个最长连续子序列,满足:出现至少两次,长度至少5次,不可以重叠,相似视为同一个。两个序列相似定义为 两个序列的间隔相等,例如1,2,3,5,6与4,5,6,8,9相似。

分析:求出原序列的n-1个间隔构成的序列,求其后缀数组。然后二分答案,对于答案len,判断其是否可行:在height数组找到一段连续的区间满足 区间长度>=len 且 其中对应的sa最大值与最小值只差>len(为了产生不重叠);

【代码】:https://paste.ubuntu.com/p/5bHSnNZXQF/

 

举报

相关推荐

0 条评论