后缀数组详解

什么是后缀数组

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

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

就像上面那位大神所说的,后缀数组能够解决不少关于字符串的问题,算法

譬如这道题编程

 

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

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

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

 

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

 

可是因为本人太辣鸡啦,因此本文只讨论倍增法3d

 

前置知识

后缀

这个你们应该都懂吧。。调试

好比说$aabaaaab$

它的后缀为

基数排序

我下面会详细讲

如今,你能够简单的理解为

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

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

倍增法

首先定义一坨变量

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

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

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

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

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

 

可能你们以为$sa$和$rak$这两个数组比较绕,不要紧,多琢磨一下就好

事实上,也正是由于这样,才使得两个数组能够在$O(n)$的时间内互相推出来

具体一点

$rak[sa[i]]=i$

$sa[rak[i]]=i$

 

那咱们怎么对全部的后缀进行排序呢?

咱们把每一个后缀分开来看。

开始时,每一个后缀的第一个字母的大小是能肯定的,也就是他自己的$ascii$值

具体点?把第$i$个字母看作是$(s[i],i)$的二元组,对其进行基数排序。这样咱们能够保证$ascii$小的在前面,若$ascii$相同则先出现的在前面

 

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

 

接下来呢?

不要忘了, 咱们算法的名称叫作“倍增法”,每次将排序长度*2,最多须要$log(n)$次即可以完成排序

所以咱们如今须要对每一个后缀的前两个字母进行排序

 

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

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

其实大可没必要,由于咱们忽略了一个很是重要的性质:第$i$个后缀的第二个字母,实际是第$i+1$个后缀的第一个字母

 

所以每一个后缀的第二个字母的相对位置关系咱们也是知道的。

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

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

对于一个长度为$w$的后缀,你能够形象的理解为:第一关键字针对前$\frac{w}{2}$个字符造成的字符串,第二关键字针对后$\frac{w}{2}$个字符造成的字符串

 

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

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

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

 

这样不断排下去,最后就能够完成排序啦

 

我相信你们看到这里确定是一脸mengbi

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

 

过程详解

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

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

而后咱们对其进行排序,咱们暂且先无论它是如何进行排序,由于排序的过程很是难理解,一下子我重点讲一下。

 

各个数组的大小

 

而后咱们进行倍增。

 

这里再定义几个变量

$M$:字符集的大小,基数排序时会用到。不理解也不要紧

$p$:排名的多少(有几个不一样的后缀)

注意在排序的过程当中,各个后缀的排名多是相同的。由于咱们在倍增的过程当中只是对其前几个字符进行排名。

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

 

下面是倍增的过程

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

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

 

 

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

假设咱们如今须要获得的长度为$w$,那么$sa[i]$表示的实际是长度为$\frac{w}{2}$的后缀中排名为$i$的位置(也就是上一轮的结果)

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

之因此能这样更新,是由于$i$号后缀的前$\frac{w}{2}$个字符造成的字符串是$i - \frac{w}{2}$号后缀的后$\frac{w}{2}$个字符造成的字符串

算了直接上图吧,。。

(注意此图的边界与代码中有区别,缘由是代码中的$w$表示咱们已经获得了长度为$w$的结果,如今正要去更新长度为$2w$的结果)

 

 

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

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

 

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

排完序以后,咱们获得了一个新的$sa$数组

此时咱们用$sa$数组来更新$rak$数组

 

咱们前面说过$rak$数组是可能会重复的,因此咱们此时用$p$来表示到底出现了几个名次

还须要注意一个事情,在判断是否重复的时候,咱们须要用到上一轮的$rak$

而此时$tp$数组是没有用的,因此咱们直接交换$tp$和$rak$

固然你也能够写为

 

 

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

 

当知足判断条件时,两个后缀的名次必定是相同的(想想,为何?)

 

 而后愉快的输出就能够啦!

 

放一下代码

 

#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;
        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;
}

 

 

 

 

再补一下调试结果

 

基数排序

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

在阅读下面内容以前,我但愿你们能初步了解一下基数排序

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

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

 

 

先来大体看一下,代码就$4$行

 

 

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

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

 

第一行:把桶清零

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

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

可能你们会疑惑前缀和有什么用?

利用前缀和能够快速的定位出每一个位置应有的排名

具体的来讲,前缀和能够统计比当前名次小的后缀有多少个。

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

我知道你们确定看晕了,咱们先来回顾一下这几个数组的定义

这里咱们假设已经获得了$w$长度的排名,要更新$2w$长度的排名

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

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

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

咱们考虑若是把串长为$w$扩展为$2w$会有哪些变化

首先第一关键字的相对位置是不会改变的,惟一有变化的是$rak$值相同的那些后缀,咱们须要根据$tp$的值来肯定他们的相对位置

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

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

首先咱们倒着枚举$i$,

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

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

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

获得了排名,咱们也就能更新$sa$了

 

height数组

我的感受,上面说的一大堆,都是为$height$数组作铺垫的,$height$数组才是后缀数组的精髓、

先说定义

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

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

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

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

 

性质:$H[i] \geqslant 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])$, 用rmq维护,O(1)查询

可重叠最长重复子串

Height数组里的最大值

不可重叠最长重复子串 POJ1743

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

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

本质不一样的子串的数量

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

后记

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

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

如今再来看也是死磕了一天多才看懂。

不过我仍是比较好奇。

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

哦对了,后缀数组还有一个很是有用的数组叫作$height$,这个数组更神奇,,有空再讲吧。 已补充

相关文章
相关标签/搜索