(转)后缀数组讲解

目录

 


回到顶部

什么是后缀数组

后缀数组是处理字符串的有力工具 —罗穗骞

个人理解:后缀数组是让人蒙逼的有力工具!

就像上面那位大神所说的,后缀数组可以解决很多关于字符串的问题,

譬如这道题

 

注意:后缀数组并不是一种算法,而是一种思想。

实现它的方法主要有两种:倍增法O(nlogn)O(nlogn) 和 DC3法O(n)O(n)

其中倍增法除了仅仅在时间复杂度上不占优势之外,其他的方面例如编程难度,空间复杂度,常数等都秒杀DC3法

 

我的建议:深入理解倍增法,并能熟练运用(起码8分钟内写出来&&没有错误)。DC3法只做了解,吸取其中的精髓;

 

但是由于本人太辣鸡啦,所以本文只讨论倍增法

 

回到顶部

前置知识

后缀

这个大家应该都懂吧。。

比如说aabaaaabaabaaaab

它的后缀为

基数排序

我下面会详细讲

现在,你可以简单的理解为

基数排序在后缀数组中可以在O(n)O(n)的时间内对一个二元组(p,q)(p,q)进行排序,其中pp是第一关键字,qq是第二关键字

比其他的排序算法都要优越

回到顶部

倍增法

首先定义一坨变量

sa[i]sa[i]:排名为ii的后缀的位置

rak[i]rak[i]:从第ii个位置开始的后缀的排名,下文为了叙述方便,把从第ii个位置开始的后缀简称为后缀ii

tp[i]tp[i]:基数排序的第二关键字,意义与sasa一样,即第二关键字排名为ii的后缀的位置

tax[i]tax[i]:ii号元素出现了多少次。辅助基数排序

ss:字符串,s[i]s[i]表示字符串中第ii个字符串

 

可能大家觉得sasa和rakrak这两个数组比较绕,没关系,多琢磨一下就好

事实上,也正是因为这样,才使得两个数组可以在O(n)O(n)的时间内互相推出来

具体一点

rak[sa[i]]=irak[sa[i]]=i

sa[rak[i]]=isa[rak[i]]=i

 

那我们怎么对所有的后缀进行排序呢?

我们把每个后缀分开来看。

开始时,每个后缀的第一个字母的大小是能确定的,也就是他本身的asciiascii值

具体点?把第ii个字母看做是(s[i],i)(s[i],i)的二元组,对其进行基数排序。这样我们可以保证asciiascii小的在前面,若asciiascii相同则先出现的在前面

 

这样我们就得到了他们的在完成第一个字母的排序之后的相对位置关系

 

接下来呢?

不要忘了, 我们算法的名称叫做“倍增法”,每次将排序长度*2,最多需要log(n)log(n)次便可以完成排序

因此我们现在需要对每个后缀的前两个字母进行排序

 

此时第一个字母的相对关系我们已经知道了。

那第二个字母的大小呢?我们还需要一次排序么?

其实大可不必,因为我们忽略了一个非常重要的性质:第ii个后缀的第二个字母,实际是第i+1i+1个后缀的第一个字母

 

因此每个后缀的第二个字母的相对位置关系我们也是知道的。

我们用tptp这个数组把他记录出来,对(rak,tp)(rak,tp)这个二元组进行基数排序

tp[i]tp[i]表示的是第二关键字中排名为ii的后缀的位置,rakrak表示的是上一轮中第ii个后缀的排名。

对于一个长度为ww的后缀,你可以形象的理解为:第一关键字针对前w2w2个字符形成的字符串,第二关键字针对后w2w2个字符形成的字符串

 

接下来我们需要对每个后缀的前四个字母组成的字符串进行排序

此时我们已经知道了每个后缀前两个字母的排名,而第ii个后缀的第3,43,4个字母恰好是第i+2i+2个后缀的前两个字母。

他们的相对位置我们又知道啦。

 

这样不断排下去,最后就可以完成排序啦

 

我相信大家看到这里肯定是一脸mengbi

下面我结合代码和具体的排序过程给大家演示一下

 

回到顶部

过程详解

按照上面说的,开始时rakrak为字符的ascii码,第二关键字为它们的相对位置关系

这里的aa数组是字符串数组

然后我们对其进行排序,我们暂且先不管它是如何进行排序,因为排序的过程非常难理解,一会儿我重点讲一下。

 

各个数组的大小

 

然后我们进行倍增。

 

这里再定义几个变量

MM:字符集的大小,基数排序时会用到。不理解也没关系

pp:排名的多少(有几个不同的后缀)

注意在排序的过程中,各个后缀的排名可能是相同的。因为我们在倍增的过程中只是对其前几个字符进行排名。

但是,对于每个后缀来说,最终的排名一定是不同的!毕竟每个后缀的长度都不相同

 

下面是倍增的过程

ww表示倍增的长度,当各个排名都不相同时,我们便可以退出循环。

M=pM=p是对基数排序的优化,因为字符集大小就是排名的个数

 

 

这两句话是对第二关键字进行排序

假设我们现在需要得到的长度为ww,那么sa[i]sa[i]表示的实际是长度为w2w2的后缀中排名为ii的位置(也就是上一轮的结果)

我们需要得到的tp[i]tp[i]表示的是:长度为ww的后缀中,第二关键字排名为ii的位置。

之所以能这样更新,是因为ii号后缀的前w2w2个字符形成的字符串是i−w2i−w2号后缀的后w2w2个字符形成的字符串

算了直接上图吧,。。

(注意此图的边界与代码中有区别,原因是代码中的ww表示我们已经得到了长度为ww的结果,现在正要去更新长度为2w2w的结果)

 

 

此时的pp并不是统计排名的个数,只是一个简单的计数器

注意:有一些后缀是没有第二关键字的,他们的第二关键字排名排名应该在最前面。

 

此时第一二关键字都已经处理好了,我们进行排序

排完序之后,我们得到了一个新的sasa数组

此时我们用sasa数组来更新rakrak数组

 

我们前面说过rakrak数组是可能会重复的,所以我们此时用pp来表示到底出现了几个名次

还需要注意一个事情,在判断是否重复的时候,我们需要用到上一轮的rakrak

而此时tptp数组是没有用的,所以我们直接交换tptp和rakrak

当然你也可以写为

 

 

在判断重复的时候,我们实际上是对一个二元组进行比较。

 

当满足判断条件时,两个后缀的名次一定是相同的(想一想,为什么?)

 

 然后愉快的输出就可以啦!

 

放一下代码

 

复制代码

#include<cstdio>
#include<cstring>
#include<algorithm>
const int MAXN = 1e6 + 10;
using namespace std;
char s[MAXN];
int N, M, rak[MAXN], sa[MAXN], tax[MAXN], tp[MAXN];
void Debug() {
    printf("*****************\n");
    printf("下标"); for (int i = 1; i <= N; i++) printf("%d ", i);     printf("\n");
    printf("sa  "); for (int i = 1; i <= N; i++) printf("%d ", sa[i]); printf("\n");
    printf("rak "); for (int i = 1; i <= N; i++) printf("%d ", rak[i]); printf("\n");
    printf("tp  "); for (int i = 1; i <= N; i++) printf("%d ", tp[i]); printf("\n");
}
void Qsort() {
    for (int i = 0; i <= M; i++) tax[i] = 0;
    for (int i = 1; i <= N; i++) tax[rak[i]]++;
    for (int i = 1; i <= M; i++) tax[i] += tax[i - 1];
    for (int i = N; i >= 1; i--) sa[ tax[rak[tp[i]]]-- ] = tp[i];
    //这部分我的文章的末尾详细的说明了
}
void SuffixSort() {
    M = 75;
    for (int i = 1; i <= N; i++) rak[i] = s[i] - '0' + 1, tp[i] = i;
    Qsort();
    Debug();
    for (int w = 1, p = 0; p < N; M = p, w <<= 1) {
        //w:当前倍增的长度,w = x表示已经求出了长度为x的后缀的排名,现在要更新长度为2x的后缀的排名
        //p表示不同的后缀的个数,很显然原字符串的后缀都是不同的,因此p = N时可以退出循环
        p = 0;//这里的p仅仅是一个计数器000

        
        for (int i = 1; i <= w; i++) tp[++p] = N - w + i;  // 给末尾的w个字符写入第二关键字排序,因为它们没第二关键字,所以默认为排名最靠前
        // 下面for的i表示从小到大遍历第二关键字的排序,i表示排名
        for (int i = 1; i <= N; i++) if (sa[i] > w) tp[++p] = sa[i] - w; //这两句是后缀数组的核心部分,我已经画图说明
        Qsort();//此时我们已经更新出了第二关键字,利用上一轮的rak更新本轮的sa
        std::swap(tp, rak);//这里原本tp已经没有用了
        rak[sa[1]] = p = 1;
        for (int i = 2; i <= N; i++)
            // 理论上rak[sa[i]] = i,但是因为可能有重复排名,所以需要变通一下
            // 需要判断排名i和i-1的是否第一和第二关键字是否排序相同
            rak[sa[i]] = (tp[sa[i - 1]] == tp[sa[i]] && tp[sa[i - 1] + w] == tp[sa[i] + w]) ? p : ++p;
        //这里当两个后缀上一轮排名相同时本轮也相同,至于为什么大家可以思考一下
        Debug();
    }
    for (int i = 1; i <= N; i++)
        printf("%d ", sa[i]);
}
int main() {
    scanf("%s", s + 1);
    N = strlen(s + 1);
    SuffixSort();
    return 0;
}

复制代码

 

 

 

 

再补一下调试结果

 

回到顶部

基数排序

如果你对上面的主体过程有了大致的了解,那么基数排序的过程就不难理解了

在阅读下面内容之前,我希望大家能初步了解一下基数排序

https://baike.baidu.com/item/%E5%9F%BA%E6%95%B0%E6%8E%92%E5%BA%8F/7875498?fr=aladdin

大致看一下它给出的例子和c++代码就好

 

 

先来大致看一下,代码就44行

 

 

MM:字符集的大小,一共需要多少个桶

taxtax:元素出现的次数,在这里就是名次出现的次数

 

第一行:把桶清零

第二行:统计每个名词出现的次数

第三行:做个前缀和(啪,废话)

可能大家会疑惑前缀和有什么用?

利用前缀和可以快速的定位出每个位置应有的排名

具体的来说,前缀和可以统计比当前名次小的后缀有多少个。

第四行:@#¥%……&*

我知道大家肯定看晕了,我们先来回顾一下这几个数组的定义

这里我们假设已经得到了ww长度的排名,要更新2w2w长度的排名

sa[i]sa[i]:长度为ww的后缀中,排名为ii的后缀的位置

rak[i]rak[i]:长度为ww的后缀中,从第ii个位置开始的后缀的排名

tp[i]tp[i]:长度为2w2w的后缀中,第二关键字排名为ii的后缀的位置

我们考虑如果把串长为ww扩展为2w2w会有哪些变化

首先第一关键字的相对位置是不会改变的,唯一有变化的是rakrak值相同的那些后缀,我们需要根据tptp的值来确定他们的相对位置

煮个栗子,rakrak相同,tp[1]=2,tp[2]=4tp[1]=2,tp[2]=4,那么从44开始的后缀排名比从22开始的后缀排名靠后

再回来看这句话应该就好明白了

首先我们倒着枚举ii,

那么sa[tax[rak[tp[i]]]−−]sa[tax[rak[tp[i]]]−−]的意思就是说:

我从大到小枚举第二关键字,再用rak[i]rak[i]定位到第一关键字的大小

那么tax[rak[tp[i]]]tax[rak[tp[i]]]就表示当第一关键字相同时,第二关键字较大的这个后缀的排名是啥

得到了排名,我们也就能更新sasa了

 

回到顶部

height数组

个人感觉,上面说的一大堆,都是为heightheight数组做铺垫的,heightheight数组才是后缀数组的精髓、

先说定义

ii号后缀:从ii开始的后缀

lcp(x,y)lcp(x,y):字符串xx与字符串yy的最长公共前缀,在这里指xx号后缀与与yy号后缀的最长公共前缀

height[i]height[i]:lcp(sa[i],sa[i−1])lcp(sa[i],sa[i−1]),即排名为ii的后缀与排名为i−1i−1的后缀的最长公共前缀

H[i]H[i]:height[rak[i]]height[rak[i]],即ii号后缀与它前一名的后缀的最长公共前缀

 

性质:H[i]⩾H[i−1]−1H[i]⩾H[i−1]−1

证明引自远航之曲大佬

 

update in 2019.3.28

在复习的时候我发现这里的证明有一个跳点,包括论文中的证明也有一点不严谨的地方

下面两处画红线的地方均没有证明"suffix(k+1)"与"i前一名的后缀之间的关系",实际上这两者之间的关系是:他们的lcp至少为h[i - 1] - 1。可以用反证法证明,在此不再赘述

 

能够线性计算height[]的值的关键在于h[](height[rank[]])的性质,即h[i]>=h[i-1]-1,下面具体分析一下这个不等式的由来。

我们先把要证什么放在这:对于第i个后缀,设j=sa[rank[i] – 1],也就是说j是i的按排名来的上一个字符串,按定义来i和j的最长公共前缀就是height[rank[i]],我们现在就是想知道height[rank[i]]至少是多少,而我们要证明的就是至少是height[rank[i-1]]-1。

好啦,现在开始证吧。

首先我们不妨设第i-1个字符串(这里以及后面指的“第?个字符串”不是按字典序排名来的,是按照首字符在字符串中的位置来的)按字典序排名来的前面的那个字符串是第k个字符串,注意k不一定是i-2,因为第k个字符串是按字典序排名来的i-1前面那个,并不是指在原字符串中位置在i-1前面的那个第i-2个字符串。

这时,依据height[]的定义,第k个字符串和第i-1个字符串的公共前缀自然是height[rank[i-1]],现在先讨论一下第k+1个字符串和第i个字符串的关系。

第一种情况,第k个字符串和第i-1个字符串的首字符不同,那么第k+1个字符串的排名既可能在i的前面,也可能在i的后面,但没有关系,因为height[rank[i-1]]就是0了呀,那么无论height[rank[i]]是多少都会有height[rank[i]]>=height[rank[i-1]]-1,也就是h[i]>=h[i-1]-1。

第二种情况,第k个字符串和第i-1个字符串的首字符相同,那么由于第k+1个字符串就是第k个字符串去掉首字符得到的,第i个字符串也是第i-1个字符串去掉首字符得到的,那么显然第k+1个字符串要排在第i个字符串前面,要么就产生矛盾了。同时,第k个字符串和第i-1个字符串的最长公共前缀是height[rank[i-1]],那么自然第k+1个字符串和第i个字符串的最长公共前缀就是height[rank[i-1]]-1。

到此为止,第二种情况的证明还没有完,我们可以试想一下,对于比第i个字符串的字典序排名更靠前的那些字符串,谁和第i个字符串的相似度最高(这里说的相似度是指最长公共前缀的长度)?显然是排名紧邻第i个字符串的那个字符串了呀,即sa[rank[i]-1]。也就是说sa[rank[i]]和sa[rank[i]-1]的最长公共前缀至少是height[rank[i-1]]-1,那么就有height[rank[i]]>=height[rank[i-1]]-1,也即h[i]>=h[i-1]-1。

 

 

代码

复制代码

void GetHeight() {
    int j, k = 0;
    for(int i = 1; i <= N; i++) {
        if(k) k--;
        int j = sa[rak[i] - 1];
        while(s[i + k] == s[j + k]) k++;
        Height[rak[i]] = k;
        printf("%d\n", k);
    }
}

复制代码

 

 

回到顶部

经典应用

两个后缀的最大公共前缀

lcp(x,y)=min(heigh[x−y])lcp(x,y)=min(heigh[x−y]), 用rmq维护,O(1)查询

可重叠最长重复子串

Height数组里的最大值

不可重叠最长重复子串 POJ1743

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

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

本质不同的子串的数量

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

回到顶部

后记

本蒟蒻也是第一次看这么难的东西。

第一次见这种东西应该是去年夏天吧,那时我记得自己在机房里瞅着这几行代码看了一晚上也没看出啥来。

现在再来看也是死磕了一天多才看懂。

不过我还是比较好奇。

这种东西是谁发明的啊啊啊啊啊脑洞也太大了吧啊啊啊啊啊啊

哦对了,后缀数组还有一个非常有用的数组叫做heightheight,这个数组更神奇,,有空再讲吧。 已补充

 

作者:自为风月马前卒

个人博客http://attack204.com//

出处:http://zwfymqz.cnblogs.com/

本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

标签: 后缀数组

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值