后缀数组学习笔记

后缀数组学习笔记

定义

所谓后缀,指的是对于一个字符串 s,如果它的下标从 1n,那么对于 s 的一个后缀 i=s[in]

所谓后缀数组 sa[],就是按照这些后缀的字典序排序后得到的数组。更具体的,后缀数组 sa[i] 中存储的是字符串 s 中排名为 i 的后缀的开头的下标。

一般来说,在处理后缀数组时,通常会附带一个排名数组 rk[],就是每一个后缀对应的名次。更具体的,排名数组 rk[i] 中存储的是字符串 s 的后缀 i 在后缀数组中的下标。

例如对于一个字符串 s= aabaaaab ,不同的后缀排序后应得到以下结果:

  1. aaaabs[4n]
  2. aaabs[5n]
  3. aabs[6n]
  4. aabaaaabs[1n]
  5. abs[7n]
  6. abaaaabs[2n]
  7. bs[8n]
  8. baaaabs[3n]

那么我们应得到两个数组为:

i 1 2 3 4 5 6 7 8
sa[i] 4 5 6 1 7 2 8 3
rk[i] 4 6 8 1 2 3 5 7

我们可以发现一个性质 sa[rk[i]] = rk[sa[i]] = i

实现

求解后缀数组,是后缀数组中最重要的步骤。有很多种求法都能够实现。

暴力枚举

依据定义,我们可以发现我们可以直接处理出字符串 sn 个后缀并存储在数组中,直接排序即可。因为 sort() 的复杂度是 O(nlogn) 的,再加上字符串比较是 O(n) 的,因此总时间复杂度是 O(n2logn) 的。

倍增

我们认为,暴力的复杂度之所以高,是因为它横向对比了每一个字符串的大小,即单独将字符串比对。那么我们转换一种思路,选择纵向比对,即观察所有字符串的第一个字符并且排序。虽然乍一看这样仍需要 nO(nlogn) 的排序,复杂度并没有变优。

现在考虑一个问题:对于两个长为 2n 的字符串 s,ts 的字典序小于 t 的字典序当且仅当:

  1. 字符串 s 的前 n 个字符的字典序小于字符串 t 的前 n 个字符的字典序。
  2. 字符串 s 的前 n 个字符的字典序等于字符串 t 的前 n 个字符的字典序,并且字符串 s 的后 n 个字符的字典序小于字符串 t 的后 n 个字符的字典序。

如果我们将一个长为 2n 的字符串 s 拆成一个二元组 (a,b),其中 a 代表在长度为 n 的子串中,字符串 s 的前 n 个字符的排名;b 代表在长度为 n 的子串中,字符串 s 的后 n 个字符的排名。那么对于两个字符串 s,ts 的字典序小于 t 的字典序当且仅当 s 的二元组小于 t 的二元组。显然,字符串 s 中的所有长度为 1 的子串的排名是好求的,可以利用桶排序快速求解。

如此,我们便可以通过排序快速的求解出所有字符串 s 的长度为 2k 的子串之间的排名。事实上,因为我们比较的是字符串的前缀,因此前缀小的字典序一定小,而 sa[i] 则实时记录着后缀 i 的前 2k 个字符组成的前缀的排名情况,因此我们可以是情况终止排序:

  1. 2k>n 时,也就是前缀超出了字符串的长度,这种情况下所有字符串的前缀都被认为是其本身,也就是已经得到了最后的排名,或者也可以理解为二元组后面的值均为 0,那么对答案有影响的只有第一维,那么无论怎么继续排名,对应的 sa[],rk[] 都不会发生变化,因此可以直接结束。当然这种情况也可以归为下一类。
  2. 当所有子串对应的二元组互不相同时,也就是每个后缀的前 2k1 个字符组成的字符串不相同时,那么显然所有的后缀的排名都已经确定了,因此可以直接结束。

那么最多进行 logn 次排序,每次排序的复杂度都是 O(nlogn),那么此时的复杂度是 O(nlog2n)

这个复杂度并不是最优,考虑到每一个排序的对象都只是一个二元组,那么我们可以采用桶排序中的一种:基数排序。

基数排序

基数排序属于桶排序的一种,更具体来说,基数排序是一种针对多维排序的算法。这种排序方式针对每一位从低到高,每一维都用桶排序的方式进行排序。

例如针对 11,32,39,50,103,9 的排序,排序过程如下:

首先按照个位数的不同完成排序:

0 1 2 3 4 5 6 7 8 9
50 11 32 103 39,9

接着按照十位数的不同完成排序(注意插入顺序按照上方排序后的结果插入):

0 1 2 3 4 5 6 7 8 9
103,9 11 32,39 50

最后按照百位数的不同完成排序:

0 1 2 3 4 5 6 7 8 9
9,11,32,39,50 103

最后按顺序取出即可得到最终的排序结果:9,11,32,39,50,103

这样做的正确性在于,当开始按照一个维度排序时,所有比这一维度更低的维度已经完成了排序,这意味着,插入的顺序一定满足如果只看后面几维,那么大小一定是从小到大的,那么在同一个桶内,因为最高位相同,需要比较的就是后面维度的大小。又因为对于后面维度大小的顺序是既定的,因此,一个桶内的大小顺序是从小到大的。接着对于不同的桶,前面的桶中的数一定小于后面的桶中的数,因此整体来看,所有数的排序顺序是从小到大的。

我们都知道,对于一次桶排序,复杂度是 O(n+a) 的,其中 a 指的是值域的范围,即每一个维度的最大跨度。对于一个有 m 个维度的序列的排序,显然复杂度是 O(mn+a)。但是对于求解后缀数组来说,首先 m=2,因为需要排序的是一个二元组,其次,每一个维度的值域跨度都是 n,因为每一个位置代表的都是当前子串在 n 个子串中的排名,所以代入得复杂度为 O(2n+2n)=O(4n)=O(n)

由此便将复杂度 O(nlogn) 的排序转为了复杂度 O(n) 的排序,因此新的复杂度是 O(nlogn),这显然非常优秀。

代码实现

说了这么多,是时候应该操练一下了。下面就以【#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(最长公共前缀)

事实上,利用后缀数组求解的 LCP 也算的上十足的局限了,毕竟后缀数组中的所有字符串都是原字符串 s 的后缀。所以事实上,SA+LCP 的组合本质上也就是求解一个字符串的不同后缀的最长公共前缀而以了。

不过既来之则安之,我们考虑如何快速求出一个字符串的 n 个后缀两两之间的 LCP

暴力枚举

首先将一个肯定错误的方法,枚举每两个后缀,然后从前往后一直找到第一个不同的字符,那么枚举是 O(n2) 的,处理是 O(n) 的,由此得到了一个 O(n3) 的复杂度代码,显然不对。

后缀数组

首先规定一个说法:令 LCP(i,j) 表示在 sa[] 中,下标分别为 i,j 的两个字符串的 LCP。那么我们有以下结论:

  1. LCP(i,j)=LCP(j,i)
  2. LCP(i,i)=leni=nsai
  3. ijk,LCP(i,j)LCP(i,k)
  4. kji,LCP(j,i)LCP(k,i)
  5. LCP 引理:LCP(i,j)=min(LCP(i,k),LCP(k,j))(ikj)
  6. LCP 定理:LCP(i,j)=mink=i+1j(LCP(k1,k))

前两条是显然的,接下来给出剩余结论的解释。

关于第 3

因为字典序从小到大的缘故,那么每一个字符串都包括两个部分:前一个字符串的前缀和自己本身的部分。写作 si=A(si1)+B(si)。那么我们可以得知,i<j,sj=mink=ij1(A(sk))+B(sj)。对此,显然有 LCP(i,j)=mink=ij1(A(sk))。所以有 ijk,LCP(i,j)=minp=ij1(A(sp))minp=ik1(A(sp))=LCP(i,k)

关于第 4

如果倒序来看,依旧有 si=A(si+1)+B(si),按照相同思路即可得证。

关于 LCP 引理

因为 LCP(i,j)=minp=ij1(A(sp))=min(minp=ik1(A(sp)),minp=kj1(A(sp)))=min(LCP(i,k),LCP(k,j)),所以得证。

关于 LCP 定理

采用数学归纳法,由 LCP 引理可知,LCP(i,i+1)=min(LCP(i,i),LCP(i,i+1)),假设我们已经证明了对于 i<j,LCP(i,j)=mink=i+1j(LCP(k1,k)),那么可以推得 LCP(i,j+1)=min(LCP(i,j),LCP(j,j+1))=mink=i+1j+1LCP(k1,k)。得证。

但是,事实上,光知道以上结论是远远不够求解 LCP 的,因为光得到这些结论,我们依旧只能依靠 LCP 定理 O(n3) 求解,或者利用 LCP 引理 O(n2) 求解。显然不够优,那么我们考虑插入辅助数组进行处理。令 ht[] 表示 sa[] 中两个相邻后缀的 LCP。同时利用 h[] 数组进行辅助,其中 h[i]=ht[rk[i]](代码中并不需要建立 h[] 数组)。通过这个数组,我们可以得到一个重要的定理:h[i] >= h[i-1]-1

至于如何证明,我们首先在 sa[] 中找到一个后缀,它的初始位置为 i1,在它排名前一个的后缀的初始位置为 k。那么如果我们将两个后缀的首字母均删去,那么得到的两个后缀即为 i,k+1。当两个后缀的首字母相同时,删去后两者的先后顺序不变,也就是他们中间可能有一些其他的字符串,这就导致了 h[i] >= LCP(i,k+1) = LCP(i-1,k)-1 = h[i-1]-1 ,此时得证。当两个后缀的首字母不同时,两者的 LCP=0,因此 h[i] > -1 = h[i-1]-1。综上,定理得证。

有了这个定理,我们在回退指针时只需要回退一次,而指针最多前进 2n 次,回退共 n 次,复杂度就变成了 O(2n+n)=O(3n)=O(n) 的。

代码
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 ;
}

应用

不同子串个数

我们考虑对于每一个后缀,在统计的时候如果每一个后缀的所有前缀都记入答案中,那么统计出的便是原字符串的所有子串。但是子串可能是有重复的,具体哪一些算重复了,显然是和排在它前面的后缀的公共的前缀部分,因为这些部分在前面已经算过一次了,因此我们需要剪掉 LCP(i1,i) 的长度,因此不同子串个数就是用总的串数减去重复的子串也就是 n(n+1)2i=1nLCP(i1,i)。求出 ht[] 后就是 O(n) 的了。

最长 k 重复子串

这是后缀数组的经典做法:求解至少重复了 k 次的可重复子串的最大长度。

我们利用 LCP 定理,LCP(i,j)=mink=i+1jLCP(k1,k),和 LCP(i,j)LCP(i,k)(ijk)。于是我们发现,如果 LCP(i,j)x,那么 ikj,LCP(i,k)LCP(i,j)x,也就是区间 [i,j] 内的所有后缀都和第 i 个后缀有一个长度不小于 x 的公共前缀,也就是说,后缀 i 的长度为 LCP(i,j) 的前缀,区间 [i,j] 的每一个后缀都一定共同拥有这个前缀,对应到原字符串上,这意味着这个子串出现了 ji+1 次。

同时还有一个性质,如果存在长度为 x 的子串符合条件,那么一定存在长度小于 x 的子串符合条件。因此我们可以考虑二分。接着考虑 check 函数。我们根据上面的思路,找到连续的 ht[] 不小于 x 的区间,设区间的起始点为 i,结束点为 j,长度为 p=ji+1。那么这说明区间 [i1,j] 中的所有后缀都有一个长度不小于 x 的共同前缀,因此只需要判断 p+1 是否比 k 更大即可。

最长公共子串

这种类型的题需要求解在 n 个字符串中共同出现的最长子串。但是我们可以将此题转化成上面的类型。

我们发现,如果每一个给出的字符串中都含有某一个子串,那么说明这个子串至少出现了 n 次,即我们需要求解在所有字符串中至少出现了 n 次的子串的最长长度。首先,我们考虑字符串有多个的问题,我们不妨将所有字符串合并成一个字符串,中间用不同的符号分割开,这样就转换成了在一个字符串中至少出现了 n 次的子串的最长长度。我们可以利用后缀数组求解。

为什么要用不同的符号?因为如果都用相同的符号,有可能被判定为所有的答案子串加上后面的符号为公共子串(当然你也可以最后一个字符串不加符号),用不同的符号就没有这个问题。接着利用后缀数组+二分求解。我们还需要需要小改一下标准格式中的 check 函数。因为要求是每一个字符串中都有一个,光是至少有 n 个显然不够。我们可以考虑每一个子串分别属于那一个字符串,当所有字符串都有的时候就说明可以。

普遍的,题目中的 n 都较大,因此直接遍历加二分的复杂度是 O((ns)logs)。考虑到我们判断是否位于某一个字符串靠的是找到第一个结束位置大于后缀位置的字符串,因此我们可以用到二分。至于存储不能通过二进制压缩的问题则可以选择集合。当集合内元素达到 n 个时则说明有可行子串。

关于清空操作的复杂度,因为只会清空存储在集合里面的,因此加入了多少个就会清空多少个,因为最多加入 s 个元素,所以复杂度是 O(s) 的。关于加入操作的复杂度,因为每一次通过二分查找 n 个字符串中的正确字符串,因此每一个查找都是 O(logn) 的,最多查找 s 次,因此总复杂度是 O(slogn) 的。加上二分的复杂度是 O(slogs+slognlogs)

最长回文子串

其实正常是利用 manacher O(n) 求解的,但是波波将某道题放在了后缀数组的题单里,因此我们利用后缀数组 O(nlogn) 求解。

考虑要让子串是回文的,这说明在将原字符串颠倒后,一定能找到和当前子串对应位置相同的一模一样的子串,而子串又可以表示为后缀的前缀。因此先复制一份颠倒方向的字符串并复制在,考虑两个后缀的最长公共前缀即可。我们发现,以为两个后缀的最长公共前缀 LCP(i,j)=mink=i+1jLCP(k1,k) 本质上是在求解区间最小值,而区间最小值一定比区间的最大值小,因此我们不如之选择整个区间的最大值作为最优答案。

现在考虑一个问题,两个满足对应关系的后缀的最长公共前缀一定是符合要求的吗?显然不一定,比如字符串 ccbc 复制后成为 ccbccbcc,我们发现最长重复子串是 cbcc,但是由于最后一个 c 是复制得到的,我们不能将其统计进答案中,因此我们需要优化,我们为了避免这个错误,不妨在复制前和复制后的字符串之间添加一个比所有字符都大的字符,如 {,如此处理后的字符串便变成了 ccbc{cbcc,在匹配时,因为复制后的字符串的末尾没有字符,因此最长的公共前缀一定不包含 {,所以答案是正确的。

现在解释为什么需要让字符比所有字符都大,如果字符比一些原有字符要小,那么在排序的时候,原字符串的一些后缀会凭借新加入的字符插在复制字符串的后缀和对应的回文串之间,导致相邻的两个后缀不符合要求从而漏解。所以我们要让新加入的字符不干扰原字符串中内容的排序,所以添加一个比所有字符都大的字符是正确的。

posted @   DycIsMyName  阅读(25)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?
点击右上角即可分享
微信分享提示