后缀数组详解

后缀数组详解

什么是后缀数组

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

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

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

譬如P3809 【模板】后缀排序

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

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

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

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

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

前置知识

后缀

这个大家应该都懂吧。。

比如说aabaaaabaabaaaab

它的后缀为

img

基数排序

我下面会详细讲

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

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

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

倍增法

首先定义一坨变量

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

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

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

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

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

可能大家觉得sa rak r a k 这两个数组比较绕,没关系,多琢磨一下就好

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

具体一点

rak[sa[i]]=i r a k [ s a [ i ] ] = i

sa[rak[i]]=i s a [ r a k [ i ] ] = i

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

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

开始时,每个后缀的第一个字母的大小是能确定的,也就是他本身的 ascll a s c l l

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

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

img

接下来呢?

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

过程详解

按照上面说的,开始时 rak r a k 为字符的ASCLL码,第二关键字为它们的相对位置关系

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

img

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

各个数组的大小

img

然后我们进行倍增。

这里再定义几个变量

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

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

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

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

下面是倍增的过程

img

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

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

img

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

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

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

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

算了直接上图吧,。。

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

img

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

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

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

img

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

此时我们用 sa s a 数组来更新 rak r a k 数组

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

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

而此时 tp t p 数组是没有用的,所以我们直接交换 tp t p rak r a k

img

当然你也可以写为

img

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

img

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

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

img

放一下代码

#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', 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仅仅是一个计数器 
        for(int i = 1; i <= w; i++) tp[++p] = N - w + 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]] = (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;
}

再补一下调试结果

img

基数排序

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

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

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

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

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

img

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

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

第一行:把桶清零

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

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

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

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

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

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

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

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

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

rak[i] r a k [ i ] :长度为 w w 的后缀中,从第i个位置开始的后缀的排名

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

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

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

煮个栗子, rak r a k 相同, tp[1]=2,tp[2]=4 t p [ 1 ] = 2 , t p [ 2 ] = 4 ,那么从 4 4 开始的后缀排名比从2开始的后缀排名靠后

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

首先我们倒着枚举 i i

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

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

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

得到了排名,我们也就能更新 sa s a

height数组

个人感觉,上面说的一大堆,都是为 height h e i g h t 数组做铺垫的, height h e i g h t 数组才是后缀数组的精髓、

先说定义

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

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

height[i] h e i g h t [ i ] lcp(sa[i],sa[i1]) l c p ( s a [ i ] , s a [ i − 1 ] ) ,即排名为 i i 的后缀与排名为i1的后缀的最长公共前缀

H[i] H [ i ] height[rak[i]] h e i g h t [ r a k [ i ] ] ,即 i i 号后缀与它前一名的后缀的最长公共前缀

性质:H[i]H[i1]1

证明:自己证的,可能会有瑕疵

这个不等式最关键的是在下界,也就是说我们只需要证明 H[i]never<H[i1]1 H [ i ] n e v e r < H [ i − 1 ] − 1

其实这是显然的。

i1 i − 1 号后缀的前一名后缀为 j j lcp(i1,j)=x

那么 i i 号后缀可以看做由i1号后缀删除第 i1 i − 1 个字符得来

因此 i i 号后缀的前一名后缀k也可以由 j j 删除一个字符得来,因此lcp(i,k)>=lcp(i1,j)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[xy]) l c p ( x , y ) = m i n ( h e i g h [ x − y ] ) , 用rmq维护,O(1)查询

可重叠最长重复子串

Height数组里的最大值

不可重叠最长重复子串 POJ1743

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

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

本质不同的子串的数量

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

后记

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

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

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

不过我还是比较好奇。

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

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


作者:自为风月马前卒

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值