浅谈后缀数组算法

blackfrog

2020-02-24 19:50:09

Personal

**2022/3/28 管理员注:代码被hack了,请读者不要直接拉去当板子** # 浅谈后缀数组算法 >后缀数组(suffix array)是一个通过对字符串的所有后缀经过排序后得到的数组。 >后缀数组同时也是后缀树的一个替代品,它比后缀树更好写,所以OIers通常会使用后缀数组,而非后缀树。 参考资料(转侵删): >[xminh的blog](https://xminh.github.io/2018/02/27/后缀数组-最详细(maybe)讲解.html) >[后缀数组-Wikipedia](https://zh.wikipedia.org/zh-hans/%E5%90%8E%E7%BC%80%E6%95%B0%E7%BB%84) >[国家集训队2009论文](https://wenku.baidu.com/view/5b886b1ea76e58fafab00374.html) >[基数排序-百度百科](https://baike.baidu.com/item/%E5%9F%BA%E6%95%B0%E6%8E%92%E5%BA%8F) >《算法竞赛入门经典》刘汝佳 ## 前言 最近看到了一些将后缀数组的文章,看上去写的不错,便对后缀数组这个算法心生兴趣,学了之后发现,他写起代码来还是对萌新有一些难度的(比如说我),所以我想把自己在学习的过程中遇到的一些困难记录下来,以免大家也在此环节纠缠不清。嫌我啰嗦的就挑代码看吧。 ## 一些约定&介绍 所谓后缀数组,数组大家都知道,那啥是后缀嘞? 一个字符串S,它里面有很多个**子串**,所谓子串,也就是字符串以任意字符为开头,再在它后面的任意一个字符结尾的字符串。之后以`str[i,j](i<=j)`来表示从S[i]~S[j]的字符串。 而**后缀**,则是子串里面特殊的一种,如果它的长度为`n`,下标以0位开头,那么`j=n-1`。z之后以`suf(i)`表示以`i`为开头的后缀 **后缀数组**(`sa[]`),就是处理这些后缀的排名。也就是说,如果把一个字符串里的所有后缀全都取出来(共n个),再让他们以**字典序排列**,我们可以通过后缀数组来清楚地看到排第一、第二、第三……的后缀是从几开头的。 后缀数组,通常还会带有一个“附加产品”——**名次数组**(`rk[]`),这个数组,可以让人知道从i开头的后缀,在所有后缀中能排第几。 如图: ![](https://cdn.luogu.com.cn/upload/image_hosting/838huxxs.png) *简单来说,`sa[]`记录的是 **“排第几的是谁”**,而`rk[]`记录的是 **“它排第几”**。* 同时,我们还能发现一个性质:`sa[rk[i]] = rk[sa[i]] = i`。 理解了后缀数组到底是什么之后,我们就可以学习后缀数组的求法。 ## 实现一个后缀数组 怎么求后缀数组,可以说是本文最主要,最重要的部分。我们有很多种求法。 ### $O(n^2 \log n)$ 做法 非常简单,就是最暴力的做法:把每个后缀当一个字符串,再将其扔进`sort()`里完事。`sort()`的复杂度是$O(n \log n)$,再加上字符串大小比较的$O(n)$总共就是 $O(n^2 \log n)$。这么暴力的东西谁都会写,当然也比正解要慢了许多。 ### $O(n \log n)$ 做法 一共有两种著名的求后缀数组的算法,我们先讲简单易学好理解的**倍增**算法。 #### 倍增算法 ~~好学归好学,关键难理解~~ 刚才字符串暴力排序的复杂度之所以高,那是因为他直接*横向*地比较了每个字符串的大小,这样根本没有优化的空间和方法。但如果我们换个思路,纵向比较呢? ![](https://cdn.luogu.com.cn/upload/image_hosting/myq8g4q8.png) 有人要说:这样做不是跟刚才一样吗? 但是其实不是,首先,我们抛弃了$O(n)$的排序比较,有更大的优化空间。第二,人是活的,我们可以将其稍加调整,不对其字符进行比较,而使用其字符所在的排名进行比较。如图: ![](https://cdn.luogu.com.cn/upload/image_hosting/qnya82mq.png) 每个字符串的第一个字符已经比较完毕,根据字典序比较的原则,接下来就应该比较第二个字符。当然,比较第二个字符的前提是第一个字符也要按照字典序排列。也就是说,我们形成了一个**双关键字**的排序。 ![](https://cdn.luogu.com.cn/upload/image_hosting/1arqmdxz.png) 那接下来呢?比较第三个字符吗?并不是。倍增算法就体现在这里。我们会发现,其实应该将它们两两合并,变成4个关键字仍然不影响排序。但是,我们上一步已经两两合并了,也就是说,4个关键字,实质上只要管2个关键字,这就是*倍增*。接下来倍增为8,依然如此。 ![](https://cdn.luogu.com.cn/upload/image_hosting/5vjgnace.png) **那么我们什么时候可以停止倍增呢?** 要知道,如果像奥尔加团长一样不停下来的话,就会**TLE**,所以,当倍增数$>n$的时候,就可以停了。(因为所有第二关键字都是0)并且,如图所示,如果`sa[]`数组没有任何数字是相同的话,也可以提前停止。(因为第一关键字不出现相等的情况)。 不过,排序是$O(n \log n)$的,倍增一共$O( \log n)$ 次,咋就$O(n \log n)$呐? 要知道,字符的排序,有一个特性:最大值较小。因为字符数量有限,考虑所有数字和字母,最大的'z'也不过一百多。再加上特殊的双关键字排序,我们完全可以不用快排,而改用**基数排序**。 #### 基数排序 基数排序就是把数据统统扔进桶里面的排序( 在执行基数排序的时候,我们要建一个数组,这个数组的没一个元素,就是所谓的“桶”。 *例 : 排序`(1,2)`,`(3,2)`,`(1,3)`,第一个数为第一关键字,第二个数为第一关键字。* 1. 我们先按照**第二关键字**,一个一个把数据扔进桶里。 | | 桶1 | 桶2 | 桶3 | | ---- | --------------- | ------ | ------ | | 无 | `(1,2)`,`(3,2)` | `(1,3)`| 2. 将桶里面的东西全抽出来,不改变在桶内的数据,然后再按**第一关键字**扔进桶里。 | | 桶1 | 桶2 | 桶3 | | ---- | --------------- | ------ | ------ | | `(1,2)`,`(1,3)` | 无 | `(3,2)`| 再将其抽出来后,就是一个排完序的数组啦~ 这样排序的正确性在于:我们第一次排完序之后,实际上就已经保证了**同一第一关键字,第二关键字的相对顺序正确**。这样,我们只要保持原来相对顺序不变,只管第一关键字排序就行了。这也是第一次排序是按照第二关键字的原因。 那么,我们先看一下基数排序的代码(单关键字,双关键字本质上就是做它两遍): ```cpp //b数组:桶 for(int i = 0;i<n;i++)b[s[i]]++; //++表示将一个数据放入桶 for(int i = 1;i<=m;i++)b[i]+=b[i-1]; //通过求前缀和的方法,将每一个桶内的东西排上名次 for(int i = n-1;i>=0;i--)sa[--b[s[i]]]=i; //由于我们求得是sa[],所以b[s[i]]表示排名(刚才已经前缀和过了)而--的原因是为了消除并列的情况,i表示此后缀的标号。 ``` 不难发现,使用基数排序后,排序的复杂度达到了$O(n)$。再加上倍增所用的$O( \log n)$,总复杂度就是$O(n \log n)$。 思路都讲完了,接下来就上代码了。理解了思路不一定写的出代码,因为代码有很多细节需要考虑。 首先,是初始化的代码。初始化先使用基数排序,直接求出倍增之前的`sa[]`数组,顺便还能初始化一下`x[]`数组: ```cpp /*初始化阶段:基数排序*/ //m是桶的上限,也就是ascii码中最大的编号 for(int i = 0;i<n;i++)b[x[i]=s[i]]++; for(int i = 1;i<=m;i++)b[i]+=b[i-1]; for(int i = n-1;i>=0;i--)sa[--b[x[i]]]=i; ``` 接下来就到了倍增的环节。大家可能认为,每一次倍增就要进行基数排序两次(双关键字),其实不然。我们对第二关键字的排序结果是可以直接通过在初始化时的`sa[]`数组直接算出的: ```cpp for(int k = 1;k<=n;k*=2){//倍增的开头,k就是长度 num = 0; //y[i]:记录第二关键字排序之后排第i位的对应x[]数组的下标是谁(有点拗口) for(int i = n-k;i<n;i++)y[num++] = i; //通过前几幅图的观察得知,数组中后k个数的y值都是0,肯定最小,所以排名肯定最高 for(int i = 0;i<n;i++)if(sa[i]>=k)y[num++] = sa[i]-k; //sa[i]<k的,不可能成为一个第二关键词。在之后-k,是因为对应x[]数组 ``` 接下来,对第一关键字的基数排序: ```cpp for(int i = 0;i<=m;i++)b[i] = 0; //初始化 for(int i = 0;i<n;i++)b[x[y[i]]]++; //因为y[]指向的是x[]下标,它就顺理成章地成为了这次基数排序时x的下标,整个基数排序的过程相当于把i换成了y[i] for(int i = 1;i<=m;i++)b[i]+=b[i-1]; for(int i = n-1;i>=0;i--)sa[--b[x[y[i]]]]=y[i],y[i]=0; //y[i]=0可以顺便对y[]进行初始化 ``` 那么是不是排完一遍序,倍增的一个循环就结束了呢?当然不是。因为我们并没有更新`x[]`的值(`y[]`的值已经提前求出),所以,接下来就可以利用更新完的`sa[]`来更新`x[]`数组: ```cpp swap(x,y);//这里看似是交换,其实是利用已经初始化的y[]来建一个原有x[]的副本 num = 0;//归零 x[sa[0]] = 0;//排第一的人的排名是第一(废话) for(int i = 1;i<n;i++)x[sa[i]] = (y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])?num:++num; //上面的for:如果他们的第一关键字和第二关键字都和上一名相同,他们本质上是同一排名。如果不相同,那么排名++ if(num==n)break;//num=n代表整个x数组内没有一个相同的排名,说明倍增可以停止了 m=num;//同时,整个数组的最大值就是num,不可能有更大的桶存在 ``` 好的!这就是求后缀数组的全部代码!接着,带上你的完整代码,去AC [P3809](https://www.luogu.com.cn/problem/P3809)吧!(注意数组范围,注意卡常) ```cpp #include<cstdio> #include<cstring> #include<algorithm> using namespace std; int n,m; char s[10001000]; //在定义数组的时候,有一个小细节,这里的y[]必须开两倍大小 int b[7501000],x[7501000],y[7501000],sa[7501000]; void SA(){ int num; for(int i = 0;i<n;i++)b[x[i]=s[i]]++; for(int i = 1;i<=m;i++)b[i]+=b[i-1]; for(int i = n-1;i>=0;i--)sa[--b[x[i]]]=i; for(int k = 1;k<n;k*=2){ num = 0; for(int i = n-k;i<n;i++)y[num++] = i; for(int i = 0;i<n;i++)if(sa[i]>=k)y[num++] = sa[i]-k; for(int i = 0;i<=m;i++)b[i] = 0; for(int i = 0;i<n;i++)b[x[y[i]]]++; for(int i = 1;i<=m;i++)b[i]+=b[i-1]; for(int i = n-1;i>=0;i--)sa[--b[x[y[i]]]]=y[i],y[i]=0; swap(x,y); num = 0; x[sa[0]] = 0; for(int i = 1;i<n;i++)x[sa[i]] = (y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])?num:++num; if(num==n)break; m=num; } return; } int main(){ gets(s); n = strlen(s); m = 128; SA(); for(int i = 0;i<n-1;i++){ printf("%d ",sa[i]+1); } printf("%d\n",sa[n-1]+1); return 0; } ``` 除了倍增算法,还有一种奇特的DC3算法,写起来很复杂,也快不了多少,个人认为OIer只要学倍增算法就够了。 ## 后缀数组的应用 既然我们已经生成了后缀数组,那么它到底可以用来干什么呢?它可以用来做哪些题目呢? ### LCP **所谓LCP,就是Longest Common Prefix,最长公共前缀。**~~话说叫LC的怎么那么多:LCT,LCA,LCM,LCP...~~比如说:字符串`abbaa`与`abaab`的lcp就是2.因为他们的前两个字符相同。之后的$LCP(i,j)$函数中的i与j,表示的是它们在后缀数组`sa[]`中的的下标,这也是为什么我们刚才要求后缀数组的原因。 通过求`sa[]`,我们可以求出它们两两之间的LCP,从而解决各种问题。那么这个LCP该如何求呢? 对此,我们可以证明几个小定理。(不需要可以跳过): #### 显然的 LCP交换律:$LCP(i,j)=LCP(j,i)$ 自己跟自己的lcp:$LCP(i,i)=len(i)=n-sa_i+1$ 通过上述两条,我们继续推出: (篇幅有限,对两条定理感兴趣的可以去[xminh的blog](https://xminh.github.io/2018/02/27/%E5%90%8E%E7%BC%80%E6%95%B0%E7%BB%84-%E6%9C%80%E8%AF%A6%E7%BB%86)阅读) #### LCP Lemma $LCP(i,k) = min (LCP(i,j),LCP(j,k)) (i \le j \le k)$ 这个可以很容易的用图示感性理解: ![](https://cdn.luogu.com.cn/upload/image_hosting/em1bfhwc.png) #### LCP Theorem $LCP(i,k)=min (LCP(j,j-1))$ ( $1 < i \leq j \leq k \le n$ ) #### 求出LCP的方法 知道了LCP Lemma和LCP Theorem了之后,其实是远远不够的。因为我们还是不知道求LCP的方法。如果使用暴力的话,那么求出所有lcp也需要 $O(n^3)$ ,这比求SA数组还要慢得多。所以不能这样,对此可以进行一些优化。 我们求LCP,其实并不需要求一个二位数组,而是使用一个数组`height[]`,来表示在`sa[]`中,相邻两个后缀的LCP。同时再建一个数组`h[]`作为辅助,`h[i] = height[rk[i]]` (写代码时并不需要建立`h[]`)。通过建这个数组,我们可以推一个最为重要的定理: $h_i \ge h_{i-1} -1$。这个就有必要证明一下了。 我们在`sa[]`数组里面找一个后缀,设它在原字符串的下标为`i-1`。在`sa[]`中的前面一个后缀,它在原字符串的下标为`k`。现在,把它们两个后缀的首字母都砍掉,它们就变成了`i`和`k+1`。 ![](https://cdn.luogu.com.cn/upload/image_hosting/g4aqb00f.png) 这张图我们也可以看出,当两者的首字母相同时,删除首字母后排名先后肯定也是不变的。而且,它们的LCP长度为`h[i-1]-1`。而根据LCP Theorem,我们可以知道,这个LCP长度是这个区间中最小的,因此 $h_i \ge h_{i-1} -1$ 那么当两者首字母不同呢?那就更简单了,首字母不同,它们的LCP一定是0,不可能有比他更小的了。综上所述,$h_i \ge h_{i-1} -1$。 应用这个定理,可以排除很多情况,直接将复杂度降到$O(n)$。 接下来就是代码的实现问题了,直接上代码吧! ```cpp void height(){ int k=0;//k可以看做当前的h[i-1] for (int i=0;i<n;++i) rk[sa[i]]=i;//这个在文章的开头就提到过 for (int i=0;i<n;++i) { if (rk[i]==0) continue;//height[0]肯定为0 if (k) k--;//h[i] >= h[i-1]-1,所以直接从h[i-1]-1开始枚举 int j=sa[rk[i]-1];//j是i相邻的一个后缀,求height while (j+k<=n && i+k<=n && s[i+k]==s[j+k]) k++;//枚举它们的LCP ht[rk[i]]=k;//k就是LCP的值 } } ``` 我们求出了height数组!那么如何利用height,来求LCP呢? 根据LCP Theorem,我们知道,这可以转化为一个**RMQ**问题,使用**st表**(Sparse-Table,稀疏表)来解决这个问题。感兴趣的可以移步关于st表的博客。 ### 例题 学会了如何写一个后缀数组以及LCP之后,我们就可以利用它们做几道题了。 #### P2408 [不同子串个数](https://www.luogu.com.cn/problem/P2408) >给你一个长为N的字符串,求不同的子串的个数。 这道可以说是一道SA最简单的裸题了。~~算法标签里面没标sa~~一般我们找不同子串,都是按照长度枚举之后暴力去重。但是我们运用后缀数组,就可以实现$O(n)$去重。 具体是这样的:我们知道,对于每一个后缀,它都能产生自身长度个前缀。而**所有后缀的所有前缀**,其实就是这个字符串的所有子串。然后怎么去重呢?这就要使用`height[]`数组了。我们知道,相邻两个后缀的LCP大小,其实就是这两个后缀的子串中,有几个是重复的。因此我们只要把所有子串的个数,减去`height[]`数组的每一项,就可以了。 而且,所有子串的个数,我们还可以使用公式$T_i = \frac{n(n+1)}{2}$来$O(1)$求得,那就更方便了! 核心代码如下: ```cpp /*抄是过不了的,要看清本题数据范围*/ long long ans=n*(n+1)/2; for(long long i = 0;i<n;i++){ ans-=ht[i]; } cout<<ans<<endl; ``` #### UVA11107[Life Forms](https://www.luogu.com.cn/problem/UVA11107) >给n个字符串,求长度最大字符串,要求在超过一半的字符串中出现。 这道题有很多的解法,先介绍一下在蓝书里面的后缀数组解法。 首先把所有字符串拼起来。将这个大字符串求后缀数组和`height[]`。然后,我们可以进行二分答案来判定这个“长度最大字符串”的长度`l`。每当碰到一个`height[]`中的元素小于这个所判定的长度,就给它分段。如图所示。 ![](https://cdn.luogu.com.cn/upload/image_hosting/7ajzp9nc.png) (绿色横线表示分段) 如果说都一段中,有超过n/2个原串,那么说明这个长度`l`是合法的。 但是万一有两个不同原串拼在一起变成了一个新串导致lcp错误怎么办?没有关系。我们可以在每两个原串中,放一个从来没有出现过的字符,这样子就能使两个不同原串强制分段,lcp=0.比如说有3个串:`abcd`,`acbd`,`cdba`,我们这样子拼起来:`abcd-acbd_cdbaJ`(最后一个字符也要加)。 ## 结语 好了,这就是关于后缀数组的全部内容了,可以在评论区留言。后缀数组的功能远不止这些,我也只是挑了2道较易理解的题。希望对大家有所帮助~祝大家在OI之路上顺利,各个吊打我!/cy