后缀数组学习笔记
后缀数组学习笔记
定义
所谓后缀,指的是对于一个字符串
所谓后缀数组 sa[]
,就是按照这些后缀的字典序排序后得到的数组。更具体的,后缀数组 sa[i]
中存储的是字符串
一般来说,在处理后缀数组时,通常会附带一个排名数组 rk[]
,就是每一个后缀对应的名次。更具体的,排名数组 rk[i]
中存储的是字符串
例如对于一个字符串 aabaaaab
,不同的后缀排序后应得到以下结果:
aaaab
( )aaab
( )aab
( )aabaaaab
( )ab
( )abaaaab
( )b
( )baaaab
( )
那么我们应得到两个数组为:
sa[i] |
||||||||
rk[i] |
我们可以发现一个性质 sa[rk[i]] = rk[sa[i]] = i
。
实现
求解后缀数组,是后缀数组中最重要的步骤。有很多种求法都能够实现。
暴力枚举
依据定义,我们可以发现我们可以直接处理出字符串 sort()
的复杂度是
倍增
我们认为,暴力的复杂度之所以高,是因为它横向对比了每一个字符串的大小,即单独将字符串比对。那么我们转换一种思路,选择纵向比对,即观察所有字符串的第一个字符并且排序。虽然乍一看这样仍需要
现在考虑一个问题:对于两个长为
- 字符串
的前 个字符的字典序小于字符串 的前 个字符的字典序。 - 字符串
的前 个字符的字典序等于字符串 的前 个字符的字典序,并且字符串 的后 个字符的字典序小于字符串 的后 个字符的字典序。
如果我们将一个长为
如此,我们便可以通过排序快速的求解出所有字符串 sa[i]
则实时记录着后缀
- 当
时,也就是前缀超出了字符串的长度,这种情况下所有字符串的前缀都被认为是其本身,也就是已经得到了最后的排名,或者也可以理解为二元组后面的值均为 ,那么对答案有影响的只有第一维,那么无论怎么继续排名,对应的sa[],rk[]
都不会发生变化,因此可以直接结束。当然这种情况也可以归为下一类。 - 当所有子串对应的二元组互不相同时,也就是每个后缀的前
个字符组成的字符串不相同时,那么显然所有的后缀的排名都已经确定了,因此可以直接结束。
那么最多进行
这个复杂度并不是最优,考虑到每一个排序的对象都只是一个二元组,那么我们可以采用桶排序中的一种:基数排序。
基数排序
基数排序属于桶排序的一种,更具体来说,基数排序是一种针对多维排序的算法。这种排序方式针对每一位从低到高,每一维都用桶排序的方式进行排序。
例如针对
首先按照个位数的不同完成排序:
接着按照十位数的不同完成排序(注意插入顺序按照上方排序后的结果插入):
最后按照百位数的不同完成排序:
最后按顺序取出即可得到最终的排序结果:
这样做的正确性在于,当开始按照一个维度排序时,所有比这一维度更低的维度已经完成了排序,这意味着,插入的顺序一定满足如果只看后面几维,那么大小一定是从小到大的,那么在同一个桶内,因为最高位相同,需要比较的就是后面维度的大小。又因为对于后面维度大小的顺序是既定的,因此,一个桶内的大小顺序是从小到大的。接着对于不同的桶,前面的桶中的数一定小于后面的桶中的数,因此整体来看,所有数的排序顺序是从小到大的。
我们都知道,对于一次桶排序,复杂度是
由此便将复杂度
代码实现
说了这么多,是时候应该操练一下了。下面就以【#100. [luogu3809]后缀排序模板】为例,展示求解后缀数组的标准代码。
#include <bits/stdc++.h>
using namespace std;
const int N=1e6;
int n,m;
int sa[N+5],t[N+5],x[N+5],y[N+5];
char s[N+5];
void SA(){
//进行长度为 1 时的桶排序
for(int i=0;i<n;i++)t[x[i]=s[i]]++;//x[] 存储上一次的处理中,第 i 个位置开始的子串的排名,即 rk[]
for(int i=1;i<=m;i++)t[i]+=t[i-1];//利用前缀和可以得知每一个桶中的最大排名
for(int i=n-1;i>=0;i--)sa[--t[x[i]]]=i;//求解对应长度的 sa[]
for(int k=1;k<=n;k<<=1){//倍增处理长度为 2k 的子串的排名
int num=0;
//在这里,我们不选择进行两遍基数排序,而是根据一些已知的信息推断出最终排序的顺序
//y[] 表示在第一次基数排序后排名为 i 的子串的起始位置
for(int i=n-k;i<n;i++)y[num++]=i;//后面 k 个位置开头的子串,因为后一半的子串一定为空串,故而第二维为 0,此时按插入顺序下标小的靠前
for(int i=0;i<n;i++){
if(sa[i]>=k)y[num++]=sa[i]-k;//子串的位置 <k,不可能成为长度为 2k 的子串的后半段,因此不放进桶中。剩下符合条件的子串 -k 求出起始位置
}
//进行对于第一维的桶排序
for(int i=0;i<=m;i++)t[i]=0;//清空桶
for(int i=0;i<n;i++)t[x[y[i]]]++;//按照插入顺序将第一维放入桶中
for(int i=1;i<=m;i++)t[i]+=t[i-1];
for(int i=n-1;i>=0;i--)sa[--t[x[y[i]]]]=y[i];
swap(x,y);//因为新的 x[] 需要用到旧的 x[] 去更新,这一行只相当于用 y[] 暂时存储了 x[]
num=0;
x[sa[0]]=0;//由 sa[] 和 rk[] 的性质可得。
//在下方 for 循环不采用是上述性质当且仅当排名以字典序为第一维度,下标为第二维度时可行,即排名没有重复。而为了判断是否存在重复的二元组,我们的排名会有重复。
for(int i=1;i<n;i++)x[sa[i]]=(sa[i-1]+k<n&&y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])?num:++num;
//因为基数排序后二元组已经是从小到大的,因此如果两个相邻的二元组完全相同,那么它们两个对应的排名应当是相同的。不然,后者的排名应比前者的排名大 1。
//对于字符串下标从 0 开始,或者更准确的说,是排名从 0 到 n-1 的写法,sa[i-1]+k<n 的判断是正确且必须的。
//当前一个的子串的后半段为空串时,如果枚举到的子串的后半段也是空串,那么当前子串的开始位置,一定比前一个靠前,不然则不满足二元组从小到大排列
//因此当前子串的前半段的长度一定比前一个子串的前半段长度更长,则排名更大。
//如果枚举到的子串的后半段不为空,那么显然当前子串的字典序更大,因此排名更大。综上可得,sa[i-1]+k<n 是排名不变的必要条件,正确性得证。
//而必要性我们可以考虑特殊情况,如某一位的排名刚好为 0,刚好用去和一位越界数组的值作比较,因为未赋值的位置默认为 0,因此刚好得到了排名不变的错误。
//因此,这条判断可以有效避免这个问题。即必要性得证。
//并且,这条判断可以不需要让 y[] 开到二倍,其原因在于避免了可能存在前半段相同,但后半段存在越界数组的情况。
//如果是前一个子串越界,那么我们能够直接判断出来;如果是当前子串越界,那么后半段当前子串一定比前一个子串小,因此前半段两者一定不一样,也能够直接跳过。
if(num>=n-1)break;//如果每个二元组都互不相同,那么可以直接结束
m=num;//每一维度的最大值变为 num
}
return ;
}
int main(){
scanf("%s",s);
m=122;//z 的 ASCII 码值为 122
n=strlen(s);
SA();
for(int i=0;i<n;i++)printf("%d ",sa[i]+1);
return 0;
}
应用
说了这么多,后缀数组是否只能求解后缀相关内容呢?并不是,很神奇的有一个关于前缀的算法能够利用后缀数组实现。
LCP(最长公共前缀)
事实上,利用后缀数组求解的
不过既来之则安之,我们考虑如何快速求出一个字符串的
暴力枚举
首先将一个肯定错误的方法,枚举每两个后缀,然后从前往后一直找到第一个不同的字符,那么枚举是
后缀数组
首先规定一个说法:令 sa[]
中,下标分别为
引理: 定理:
前两条是显然的,接下来给出剩余结论的解释。
关于第 条
因为字典序从小到大的缘故,那么每一个字符串都包括两个部分:前一个字符串的前缀和自己本身的部分。写作
关于第 条
如果倒序来看,依旧有
关于 引理
因为
关于 定理
采用数学归纳法,由
但是,事实上,光知道以上结论是远远不够求解 ht[]
表示 sa[]
中两个相邻后缀的 h[]
数组进行辅助,其中 h[i]=ht[rk[i]]
(代码中并不需要建立 h[]
数组)。通过这个数组,我们可以得到一个重要的定理:h[i] >= h[i-1]-1
。
至于如何证明,我们首先在 sa[]
中找到一个后缀,它的初始位置为 h[i] >= LCP(i,k+1) = LCP(i-1,k)-1 = h[i-1]-1
,此时得证。当两个后缀的首字母不同时,两者的 h[i] > -1 = h[i-1]-1
。综上,定理得证。
有了这个定理,我们在回退指针时只需要回退一次,而指针最多前进
代码
void Height(){
int k=0;//可以认为 k 相当于 ht[i-1]
for(int i=0;i<n;i++)rk[sa[i]]=i;//rk[] 和 sa[] 的性质
for(int i=0;i<n;i++){
if(rk[i]==0)continue;//ht[0]=0
if(k)k--;//因为 h[i] >= h[i-1]-1,因此只用回退一次
int j=sa[rk[i]-1];//前一个后缀的初始位置
while(i+k<n&&j+k<n&&s[i+k]==s[j+k])k++;//暴力跳转求解 LCP
ht[rk[i]]=k;//ht[] 的定义
}
return ;
}
应用
不同子串个数
我们考虑对于每一个后缀,在统计的时候如果每一个后缀的所有前缀都记入答案中,那么统计出的便是原字符串的所有子串。但是子串可能是有重复的,具体哪一些算重复了,显然是和排在它前面的后缀的公共的前缀部分,因为这些部分在前面已经算过一次了,因此我们需要剪掉 ht[]
后就是
最长 重复子串
这是后缀数组的经典做法:求解至少重复了
我们利用
同时还有一个性质,如果存在长度为 ht[]
不小于
最长公共子串
这种类型的题需要求解在
我们发现,如果每一个给出的字符串中都含有某一个子串,那么说明这个子串至少出现了
为什么要用不同的符号?因为如果都用相同的符号,有可能被判定为所有的答案子串加上后面的符号为公共子串(当然你也可以最后一个字符串不加符号),用不同的符号就没有这个问题。接着利用后缀数组+二分求解。我们还需要需要小改一下标准格式中的
普遍的,题目中的
关于清空操作的复杂度,因为只会清空存储在集合里面的,因此加入了多少个就会清空多少个,因为最多加入
最长回文子串
其实正常是利用
考虑要让子串是回文的,这说明在将原字符串颠倒后,一定能找到和当前子串对应位置相同的一模一样的子串,而子串又可以表示为后缀的前缀。因此先复制一份颠倒方向的字符串并复制在,考虑两个后缀的最长公共前缀即可。我们发现,以为两个后缀的最长公共前缀
现在考虑一个问题,两个满足对应关系的后缀的最长公共前缀一定是符合要求的吗?显然不一定,比如字符串 ccbc
复制后成为 ccbccbcc
,我们发现最长重复子串是 cbcc
,但是由于最后一个 c
是复制得到的,我们不能将其统计进答案中,因此我们需要优化,我们为了避免这个错误,不妨在复制前和复制后的字符串之间添加一个比所有字符都大的字符,如 {
,如此处理后的字符串便变成了 ccbc{cbcc
,在匹配时,因为复制后的字符串的末尾没有字符,因此最长的公共前缀一定不包含 {
,所以答案是正确的。
现在解释为什么需要让字符比所有字符都大,如果字符比一些原有字符要小,那么在排序的时候,原字符串的一些后缀会凭借新加入的字符插在复制字符串的后缀和对应的回文串之间,导致相邻的两个后缀不符合要求从而漏解。所以我们要让新加入的字符不干扰原字符串中内容的排序,所以添加一个比所有字符都大的字符是正确的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?