后缀数组(SA)

后缀数组(SA)

感觉会有很多写错或写不清楚的地方,读者发现问题可以评论或右侧 QQ 联系我,感谢!

约定

“ 后缀 i ” 表示以第 i 个字符开头的后缀。

a[i] 表示字符串 a 的第 i 位,a[l,r] 表示 a 中位置 lr 的子串。

一个字符串从第 1 位开始,第 n 位结束。

两个字符串的大小关系定义如下:

  1. ab 为同一字符串,则 a=b

  2. ab 的真前缀,则 a<b

  3. 若不满足 1.2. 则一定存在最小的 i 满足 a[i]b[i]a,b 的大小关系由 a[i]b[i] 的大小关系确定。

定义

后缀数组主要求两个数组:sark

其中,sa[i] 表示将所有后缀排序后第 i 小的后缀编号,rk[i] 表示后缀 i 的排名,简单来说,sa[i] 表示排名 i 的是谁,rk[i] 表示后缀 i 排名第几。

显然,这两个数组满足 rk[sa[i]]=sa[rk[i]]=i。以下是一个 sark 的例子:

sa1.png

做法

O(n2logn) 做法

把所有后缀处理出来,用 sort 进行排序,每次比较为 O(n) 则总时间复杂度为 O(n2logn)

O(nlog2n) 做法

需要用到倍增思想。令 rkw[i] 表示 x[1,n],s[x,min(x+w1,n)]s[i,min(i+w1,n)] 的排名,则对于 rk2w[i] 我们可以通过以 rkw[i] 为第一关键字,rkw[i+w]i+w>n 则视为无穷小)为第二关键字,进行排序即可求出 rk2w[i] 的值。求出所有后缀排名会进行以上操作 O(logn) 次,若使用 sort 进行排序则每次排序 O(nlogn) ,则时间复杂度为 O(nlog2n)

以下为倍增示意图:

sa2.png

O(nlogn) 做法

发现瓶颈是排序,如果能做到 O(n) 排序,就能做到 O(nlogn) 了,考虑到排序的值域为 O(n),并且是一个双关键字的排序,所以我们可以使用基数排序将排序优化到 O(n)

这里介绍一下基数排序。

基数排序的原理是将待排序的元素拆分为 k 个关键字,以此以 k 关键字、k1 关键字、……、1 关键字进行排序,最后就能得到排序后的结果,以下是一个例子:

如果以计数排序为每次关键字排序的内层排序,则时间复杂度为 O(kn+i=1kwi) 其中,wi 为第 i 层关键字的值域。

对应到倍增做法中的排序,k=2w1,w2 为字符集大小,则排序复杂度则可看作 O(n),故最终后缀排序就可以做到 O(nlogn)

#include <bits/stdc++.h>
const int N = 1e6 + 10;
char s[N]; int n, m, k;
int cnt[N], sa[N], sec[N], rk[N];

inline void Csort()
{
	for (int i = 1; i <= m; ++ i)
		cnt[i] = 0;
	// 清零桶
	for (int i = 1; i <= n; ++ i)
		cnt[rk[i]] ++;
	// 将第一关键字压入桶中
	for (int i = 2; i <= m; ++ i)
		cnt[i] += cnt[i - 1];
	// 记个前缀和方便求排名
	for (int i = n; i >= 1; -- i)
		sa[cnt[rk[sec[i]]] --] = sec[i];
	// 对于第一关键字相同的数对来说
	// 其排名区间已经确定,我们只需通过第二关键字来确定其特定的排名
	// 我们从后到前枚举第二关键字的排名,则此时对应数对的排名就是对应排名区间的最后一个
	// 再将对应排名区间的右区间 -1 
	// 表示接下来枚举到的 第一关键字 相同的数对的排名区间改变了
}

int main()
{
	scanf("%s", s + 1);
	n = strlen(s + 1), m = 'z';
	// n, m 分别代表 s 长度以及字符集大小

	for (int i = 1; i <= n; ++ i)
		sec[i] = i, rk[i] = s[i];
	// sec[i] 表示第二关键字排名为 i 的编号
	// 初始时并无第二关键字,所以按照顺序排序
	// rk[i] 表示第一关键字中编号为 i 的排名
	// 初始时即为 s[i] 的字符大小
	// sa[i] 表示排名为 i 的编号

	// 需要注意,rk[i] 中相同串的排名会相同
	// sa[i] 中相同的串排名不会相同,会根据位置来具体排名(越靠前排名越前)
	Csort();

	for (int w = 1; w <= n; w *= 2)
	{
		// w 为当前倍增到的长度
		int num = 0;
		for (int i = n - w + 1; i <= n; ++ i)
			sec[++ num] = i; 
		// [n - w + 1, n] 无第二关键字,故令其关键字排名靠前
		for (int i = 1; i <= n; ++ i)
			if (sa[i] > w) sec[++ num] = sa[i] - w;
		// 从小到大枚举排名,判断此时这个串是否能作为其他串的第二关键字
		// 若能作为其他串的第二关键字,则对应编号为 sa[i] - w
		Csort(); std::swap(sec, rk); num = 0;
		// 第一第二关键字都已经求出,进行基数排序即可
		// 基数排序之后,sa 数组已经求出,我们需要用其更新 rk 数组
		// sec 数组已经没用,rk 数组需要更新,暂时用 sec 储存 rk 数组
		for (int i = 1; i <= n; ++ i)
			rk[sa[i]] = (sec[sa[i]] == sec[sa[i - 1]] 
				&& sec[sa[i] + w] == sec[sa[i - 1] + w]) ? num : ++ num;
		// 按照第一关键字排名从小到大枚举
		// 依次更新 rk 数组
		m = num;
		// 更新关键字值域大小
	}
	for (int i = 1; i <= n; ++ i)
		printf("%d ", sa[i]);
	return 0;
}

O(n) 做法

有 SA-IS 和 DC3 做法,这里暂不介绍(一般用不到)。

应用

循环同构问题

循环同构:当字符串 s 中可以选定一个位置 i 满足:s[i,n]+s[1,i1]=t ,则称 st 循环同构,例如 abcdd,与这个字符串循环同构的串有 abcddbcddacddabddabcdabcd

给出一个字符串,我们需要知道与其字符串循环同构的串的排名。Link

将字符串 s 复制一份成 ss,求其后缀数组即可。

在字符串 s 中找子串 t(在线)

在线的含义是 s 给出,t 只在询问时给出。

s 的后缀数组求出,在其后缀数组中二分寻找 t(是否存在),q 组询问,时间复杂度 O(|s|log|s|+q×|t|log|s|),当然,想找出 ts 中所有出现的位置也是可以做的,但是 KMP 显然可以做到更优。

从字符串首尾取字符最小化字典序

给你一个字符串,每次从首或尾取一个字符组成字符串,问所有能够组成的字符串中字典序最小的一个。Link

一个简单的想法是暴力 O(n) 判断选首还是选尾,这样最差是 O(n2) 的,我们考虑将反串拼接在原串后,中间加个从未出现过的字符,这样,就可以 O(1) 判断选首还是选尾,就能 O(nlogn) 解决此题。

height 数组

LCP:两个字符串 s,t 的 LCP 表示最长相同前缀。

下文的 lcp(l,r) 表示 s[l,n]s[r,n] 的 LCP。

height 数组定义如下:

height[x]={lcp(sa[x],sa[x1])x>10x=1

height 数组可以 O(n) 求出,需要一个引理:height[rk[i]]height[rk[i1]]1

证明如下:

  1. height[rk[i1]]1,上述式子显然成立,因为 height[rk[i1]]10
  2. height[rk[i1]]>1,令后缀 i1aBCB 长度为 height[rk[i1]]1,后面会用到),则后缀 iBC,后缀 sa[rk[i1]1]aBD(D<C),我们可以推出后缀 sa[rk[i1]1]+1=BD,则后缀 sa[rk[i1]1]+1 一定排在 i 之前,故 lcp(i,sa[rk[i]1])|B|=height[rk[i1]]1height[rk[i]]height[rk[i1]]1
int LstHeight = 0;
for (int i = 1; i <= n; ++ i)
{ 
    // 按照 rk 从小到大求
	if (LstHeight) LstHeight --;
    // 从上一个状态继承下来
	while (s[sa[rk[i]] + LstHeight] == s[sa[rk[i] - 1] + LstHeight])
		LstHeight ++; // 求出对应的 height
	height[rk[i]] = LstHeight;
}

height 数组的应用

两子串最长公共前缀

给出一个定理:

lcp(sa[l],sa[r])=mini=l+1rheight[i]

要证明这个定理,我们首先证明一个引理:

​ 对于任意的 1l<k<rnlcp(sa[l],sa[r])=min{lcp(sa[l],sa[k]),lcp(sa[k],sa[r])}

证明如下:

  1. p=min{lcp(sa[l],sa[k]),lcp(sa[k],sa[r])},则有 lcp(sa[l],sa[k])p,lcp(sa[k],sa[r])p s[sa[l],sa[l]+p1]= s[sa[k],sa[k]+p1]=s[sa[r],sa[r]+p1],可以得到结论 lcp(sa[l],sa[r])p
  2. 再设 lcp(sa[l],sa[r])=q>p,则有 s[sa[l],sa[l]+q1]=s[sa[r],sa[r]+q1],由于 p=min{lcp(sa[l],sa[k]),lcp (sa[k],sa[r])},故 s[sa[l]+p1+1] s[sa[k]+p1+1]s[sa[r]+p1+1]s[sa[k]+p1+1],令 x=s[sa[l]+p1+1],y=s[sa[k]+p1+1],z=s[sa[r]+p1+1],因为 l<k<r 所以 xyz,又考虑到 q>px=zx=y=z 与上文矛盾,故 q>p 不成立,可以得到结论 lcp(sa[l],sa[r])p

由 1. 2. 可得:对于任意的 1l<k<rnlcp(sa[l],sa[r])=min{lcp(sa[l],sa[k]),lcp(sa[k],sa[r])}

有了这个引理,证明上文的定理就很容易了,证明如下:

  1. rl=1rl=2 时,显然成立。
  2. rl=m 时成立,当 rl=m+1 时,由引理可知 lcp(sa[l],sa[r])=min{lcp(sa[l],sa[l+1]),lcp(sa[l+1],sa[r])} 考虑到 r(l+1)=mm 所以有 lcp(sa[l],sa[r])=min{lcp(sa[l],sa[l+1]),mini=l+2rheight[i])}=mini=l+1rheight[i],根据数学归纳法,上述定理成立。

有了这个定理,求任意子串的 LCP 就可以转化为 RMQ 问题。

比较一个字符串的两个子串大小关系

需要比较的是 s[a,b]s[c,d] 的大小关系。

  1. lcp(a,c)min{ba+1,dc+1},则两者的大小关系由长度关系得到,即 ba+1<dc+1s[a,b]<s[c,d]
  2. 否则,两者的大小关系由 rk 数组确定,即 rk[a]<rk[c]s[a,b]<s[c,d]

不同子串数量

考虑容斥,子串我们看作后缀的前缀(非常常见的套路),总子串个数为 n×(n1)2,如果按照后缀排序的顺序枚举后缀,每次新增的子串就是除了与上一个后缀的 LCP 剩下的前缀,也就是说,容斥要减去的就是当前的 height 数组。

所以,不同子串数量为:

n×(n1)2i=2nheight[i]

出现至少 k 次的子串的最大长度

出现至少 k 也就代表着后缀排序之后,至少连续 k 个后缀的 LCP 含有这个子串,所以,求出 height 数组之后,找出每相邻的 k1 个 height 数组的最小值的最大值,单调队列 O(n) 即可(瓶颈在求后缀数组 O(nlogn),故用线段树啥的也行)。

参考文献

  1. oi-wiki
  2. [2004]后缀数组 by. 徐智磊
  3. [2009]后缀数组——处理字符串的有力工具 by. 罗穗骞
posted @   chzhc  阅读(226)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
levels of contents
点击右上角即可分享
微信分享提示