后缀数组详解
什么是后缀数组
后缀数组是处理字符串的有力工具 —罗穗骞
个人理解:后缀数组是让人蒙逼的有力工具!
就像上面那位大神所说的,后缀数组可以解决很多关于字符串的问题,
譬如这道题
注意:后缀数组并不是一种算法,而是一种思想。
实现它的方法主要有两种:倍增法$O(nlogn)$ 和 DC3法$O(n)$
其中倍增法除了仅仅在时间复杂度上不占优势之外,其他的方面例如编程难度,空间复杂度,常数等都秒杀DC3法
我的建议:深入理解倍增法,并能熟练运用(起码8分钟内写出来&&没有错误)。DC3法只做了解,吸取其中的精髓;
但是由于本人太辣鸡啦,所以本文只讨论倍增法
前置知识
后缀
这个大家应该都懂吧。。
比如说$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$,这个数组更神奇,,有空再讲吧。 已补充