后缀数组(SA)学习笔记(倍增算法)

后缀数组是一个非常好的东西。一开始看不出来这个东西有什么用,但是它非常的有用。(以下 \(N\) 为字符串长度)
有了后缀数组,我们就可以在 \(O(N \log N)\) 的时间内:

  1. 得到所有后缀的字典序关系。(最基本的功能)
  2. 求出任意两个子串的最长公共前缀 (LCP)。
  3. 求出字符串中本质不同的子串个数(当然,SAM 也可以)。

用 DC3 算法可以达到 \(O(N)\) 的复杂度,但是常数较大。但是本文只介绍倍增的算法。


首先,我们需要知道:

基数排序

非常重要!给一堆字符串排序,我们可以想到先按第一个字符排序,再在有相同第一个字符的一堆字符串里,把它们按第二个字符排序,然后再在前两个字符都相同的一堆字符串里按第三个字符排序……直到排到最后一个字符。

感觉这样非常的麻烦,而且存下来前 \(?\) 个字符相同的所有串复杂度也是非常大的。众所周知,基数排序是稳定的排序,也就是说,排序前后两个相同关键字的元素的相对位置是不会改变的。那么如果我们先给某一个字符排序,再按前一个字符排序,这样排完的顺序就是很对的。至于长度不一样的字符串,只需要从最长的串的最后一位开始排,其它串的这一位看作 \(0\) 就好了。时间复杂度为 \(O(字符串的数量\times 最长的字符串长度)\)
image

实现过程(非常抽象)

对某一位用桶记录各个字符出现的个数,然后求一个前缀和,按照上一次排名结束的顺序,给每个字符串标该字符串对应位置字符的这个数(指前缀和数组),然后这个数减一(因为要让某一位相同的串有不同的排名)。那么如何保证基数排序的稳定性呢,只需要倒着做就好了。(非常抽象)

板(给 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 数组
}

(反正大概就是这么写的)

优化

常数非常大的代码。我们发现有一些事情是没有必要做的:

  1. 对第二关键字排序。因为上一次已经排好序了,所以只需要把第二关键字完全超出原串长度的部分放到最前面,剩下的按照原顺序排列就好了。
  2. 如果每一个后缀的排名都已经不一样,就没有必要继续排序了。
  3. 发现每次为 \(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}}-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\)

posted @ 2025-03-15 11:43  baiguifan  阅读(52)  评论(0)    收藏  举报