后缀数组是一个非常好的东西。一开始看不出来这个东西有什么用,但是它非常的有用。(以下 \(N\) 为字符串长度)
有了后缀数组,我们就可以在 \(O(N \log N)\) 的时间内:
- 得到所有后缀的字典序关系。(最基本的功能)
- 求出任意两个子串的最长公共前缀 (LCP)。
- 求出字符串中本质不同的子串个数(当然,SAM 也可以)。
用 DC3 算法可以达到 \(O(N)\) 的复杂度,但是常数较大。但是本文只介绍倍增的算法。
首先,我们需要知道:
基数排序
非常重要!给一堆字符串排序,我们可以想到先按第一个字符排序,再在有相同第一个字符的一堆字符串里,把它们按第二个字符排序,然后再在前两个字符都相同的一堆字符串里按第三个字符排序……直到排到最后一个字符。
感觉这样非常的麻烦,而且存下来前 \(?\) 个字符相同的所有串复杂度也是非常大的。众所周知,基数排序是稳定的排序,也就是说,排序前后两个相同关键字的元素的相对位置是不会改变的。那么如果我们先给某一个字符排序,再按前一个字符排序,这样排完的顺序就是很对的。至于长度不一样的字符串,只需要从最长的串的最后一位开始排,其它串的这一位看作 \(0\) 就好了。时间复杂度为 \(O(字符串的数量\times 最长的字符串长度)\)。
实现过程(非常抽象)
对某一位用桶记录各个字符出现的个数,然后求一个前缀和,按照上一次排名结束的顺序,给每个字符串标该字符串对应位置字符的这个数(指前缀和数组),然后这个数减一(因为要让某一位相同的串有不同的排名)。那么如何保证基数排序的稳定性呢,只需要倒着做就好了。(非常抽象)
板(给 n
个不同的字符串排序,输出最后的顺序):
#include<bits/stdc++.h>
using namespace std;
void write(int x)
{if(x>9)write(x/10);putchar(x%10+'0');
}
const int N=1e5+10;
int n;
char h[N][20];
int rk[N],num[1000],tmp[N],ans[N];
int main()
{scanf("%d",&n);for(int i=1;i<=n;i++)rk[i]=i;//最开始,每个串在它读入时的位置(无所谓)for(int i=1;i<=n;i++)scanf("%s",h[i]+1);for(int k=10;k>=1;k--){for(int i=1;i<=n;i++)tmp[i]=rk[i];//把上一次的顺序转移到另一个数组for(int i=0;i<=300;i++)num[i]=0;//清空桶for(int i=1;i<=n;i++)++num[h[i][k]];//桶for(int i=1;i<=300;i++)num[i]+=num[i-1];//前缀和for(int i=n;i>=1;i--)rk[num[h[tmp[i]][k]]--]=tmp[i];//按顺序放进排序数组}for(int i=1;i<=n;i++)write(rk[i]),putchar(' ');return 0;
}
求后缀数组需要用到基数排序。
倍增求后缀数组,我们用到 \(sa\) 和 \(rk\) 数组。(记从第 \(i\) 位开始的后缀为第 \(i\) 个后缀)
\(sa\) 数组
表示当前排在第 \(i\) 位的是哪一个后缀。
\(rk\)数组
表示当前第 \(i\) 个后缀排在第几位。
不难发现,这两个数组是互逆的。
\(height\) 数组
表示 \(sa_{i-1}\) 与 \(sa_i\) 的 LCP 长度。
实现过程
如果暴力把每个后缀存下来,再一般地排序,时间复杂度 \(O(N^2\log N)\)。
如果对每个后缀进行一般的基数排序,时间复杂度 \(O(N^2)\)。
发现这些后缀中会有很多相同的片段,考虑每次对所有长度为 \(k\) (或者向后 \(k\) 位超过 \(N\),超过的部分 \(rk\) 视为 \(0\))的子串进行排序,下一次排序把一个长度为 \(2k\) 的子串分成两个长度为 \(k\) 的串,以第一/二个串分别为第一/二关键字进行基数排序。每次排序的时间复杂度为 \(O(N)\),一共排 \(N \log N\)次,总共为 \(O(N\log N)\)。
伪代码
for k=1 -> k>=n k每次*2
{对起始下标 1~n 的后缀的第二关键字排序,从 sa 中拿出来,放进 oldsa 数组对第一关键字排序,从 oldsa 数组中拿出来,放进 sa 数组把 rk 数组复制到 oldrk 数组从头开始遍历,如果两个串第一、二关键字都相同,就给它们一样的排名,放进 rk 数组
}
(反正大概就是这么写的)
优化
常数非常大的代码。我们发现有一些事情是没有必要做的:
- 对第二关键字排序。因为上一次已经排好序了,所以只需要把第二关键字完全超出原串长度的部分放到最前面,剩下的按照原顺序排列就好了。
- 如果每一个后缀的排名都已经不一样,就没有必要继续排序了。
- 发现每次为 \(rk\) 重新赋值之后得到一个最大排名,也就是值域,也就是下一轮桶的使用范围。
优化后的伪代码:
for k=1 边界为 p<n k每次*2
{把第二关键字完全超出字符串长度的后缀放到最前面,其他的按照原顺序排列,放进 oldsa对第一关键字排序,从 oldsa 数组中拿出来,放进 sa 数组交换 oldrk 和 rk 数组的指针从头开始遍历,如果两个串第一、二关键字都相同,就给它们一样的排名,放进 rk 数组,用 p 记录排名
}
板:
#include<bits/stdc++.h>
using namespace std;
void write(int x)
{if(x>9)write(x/10);putchar(x%10+'0');
}
const int N=1e6+10;
char h[N];
int sa[N*2],oldsa[N],num[N],rk[N*2],oldrk[N];
int n,m=300,p;
int main()
{scanf("%s",h+1);n=strlen(h+1);for(int i=1;i<=n;i++)++num[rk[i]=h[i]];//基数排序这里也可以写成一个单独的函数for(int i=1;i<=m;i++)num[i]+=num[i-1];for(int i=n;i;i--)sa[num[rk[i]]--]=i;for(int k=1;p<n;k<<=1,m=p)//m 为值域{int now=0;for(int i=n-k+1;i<=n;i++)oldsa[++now]=i;//把超出的部分放到最前for(int i=1;i<=n;i++)if(sa[i]>k)oldsa[++now]=sa[i]-k;//其余原顺序放入memset(num,0,(m+1)*sizeof(int));for(int i=1;i<=n;i++)++num[rk[i]];for(int i=1;i<=m;i++)num[i]+=num[i-1];for(int i=n;i;i--)sa[num[rk[oldsa[i]]]--]=oldsa[i];//对第二关键字基数排序,注意这一行要倒序进行swap(rk,oldrk);//交换指针,相当于复制过去p=0;for(int i=1;i<=n;i++)//重新排序if(oldrk[sa[i]]==oldrk[sa[i-1]]&&oldrk[sa[i]+k]==oldrk[sa[i-1]+k])rk[sa[i]]=p;else rk[sa[i]]=++p;}for(int i=1;i<=n;i++)write(sa[i]),putchar(' ');return 0;
}
求 \(height\) 数组
有定理:\(height_{rk_{i}}\ge height_{rk_{i-1}}\),证明略。
时间复杂度为 \(O(N)\),证明也略。
板:
for(int i=1,k=0;i<=n;i++){if(k)--k;while(s[i+k]==s[sa[rk[i]-1]+k])++k;he[rk[i]]=k;}
一些其他功能
求任意两个后缀的 LCP
要求后缀 \(i\) 和 \(j\) 的 LCP(不妨设 \(rk_i < rk_j\)),只需求出 \(height\) 数组中下标在 \([rk_i,rk_j]\) 的最小值。用 st 表维护即可。
求字符串中本质不同的子串个数
设结果为 \(ans\),有结论:\(ans=\Sigma_{i=1}^N N-sa_i-height_i+1=N\times (N+1)-\Sigma_{i=1}^N height_i\)。