后缀数组 SA

后缀数组 SA

前置约定

字符串下标从 1 开始。

“后缀 i” 指字符串 s[in]

定义

后缀数组(Suffix Array, SA)主要关系到两个数组:sark

其中 sa(i) 表示将所有后缀按照字典序排序后第 i 小的后缀的编号,rk(i) 则是编号为 i 的后缀的排名,二者有关系为 sa(rk(i))=rk(sa(i))=i

不知道为什么刚学的时候在字典序这块卡了好久。

什么是按字典序排序?设两个字符串为 A,B,就是两个原则:

  1. 对于两个字符串的公共长度部分,从下标 1 开始,若出现 A(i)<B(i),则 B(i) 大,反之 A(i) 大;
  2. 若两个字符串长度不同且无法通过步骤 1 比出大小,那么长度长的大。

例如:

  • aaaab<aab
  • ab<abaaaab

求后缀数组

朴素法

最暴力的方法:将字符串的所有后缀存下来然后排序。因为排序要比较 O(nlogn) 次字符串,每比较一次字符串需要比较 O(n) 个字符,总复杂度是 O(n2logn) 的。

倍增法

朴素法是对每两个字符串进行横向比对,现在我们换一种思路,选择纵向比对,也就是比对所有字符串的第一个字符。考虑对于两个长度为 2n 的字符串 A,BA<B 就转化成了前 n 个字符的字典序和后 n 个字符的字典序比较,也就是一个二元组的形式。可以倍增优化到 O(logn)。具体过程如下:

  1. 首先对字符串 s 的所有长度为 1 的子串(即每个字符)进行排序,得到排序后的编号数组 sa1 和排名数组 rk1
  2. 用两个长度为 1 的子串的排名,即 rk1(i)rk1(i+1),作为排序的第一和第二关键字进行排序,就可以对字符串 s 的所有长度为 2 的子串进行排序,得到 sa2rk2
  3. sa2rk2 进行类似上述操作,以此类推,直到倍增到 n

容易发现,这样做只比较了 O(logn) 次字符串。复杂度是 O(nlog2n) 的。

基数排序法

考虑倍增的复杂度依然不是最优的,因为一次 sort 仍然需要 O(nlogn) 的复杂度。我们上文已经提到过倍增把字符串比较优化成了二元组,那么就可以用基数排序优化到近似 O(n)

于是我们的复杂度来到了优秀的 O(nlogn)

但这依然不是最优的!下面展示关键的常数优化部分:

  • 第二关键字无须基数排序:考虑第二关键字排序的实质,就是把超出字符串范围的 sa(i) 放到字符串头部,剩下的不变,所以只需手动整一下就行;
  • 优化基数排序的值域:每次计算一个值域,在基数排序时将值域实时更新;
  • 若排名都不相同直接返回:考虑新的 rk 数组,如果排名分别为 1n,说明已经排好了,此时无须再进行排序。

最后的完整代码如下(P3809 【模板】后缀排序):

#include<bits/stdc++.h>
using namespace std;

constexpr int MAXN=1e6+5;
int n;
string s;
int sa[MAXN],rk[MAXN],rk2[MAXN],id[MAXN],cnt[MAXN];

// 背板!
void getsa(int m){
	for(int i=1;i<=n;i++) cnt[rk[i]=s[i]]++;
	for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
	for(int i=n;i;i--) sa[cnt[rk[i]]--]=i;
	for(int w=1,p,cur;;w<<=1,m=p){
		cur=0;
		for(int i=n-w+1;i<=n;i++) id[++cur]=i;
		for(int i=1;i<=n;i++) if(sa[i]>w) id[++cur]=sa[i]-w;
		memset(cnt,0,(m+1)<<2);
		for(int i=1;i<=n;i++) cnt[rk[i]]++;
		for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
		for(int i=n;i;i--) sa[cnt[rk[id[i]]]--]=id[i];
		p=0;
		memcpy(rk2,rk,(n+1)<<2);
		for(int i=1;i<=n;i++)
			rk[sa[i]]=rk2[sa[i]]==rk2[sa[i-1]]&&rk2[sa[i]+w]==rk2[sa[i-1]+w]?p:++p;
		if(p==n) break;
	}
}

int main(){
	cin.tie(nullptr)->sync_with_stdio(0);
	cin>>s;
	n=s.size();
	s=' '+s;
	getsa('z');
	for(int i=1;i<=n;i++) cout<<sa[i]<<' ';
	cout<<'\n';
	return 0;
}

科技法

什么 SA-IS、DC3,这些是 O(n) 求后缀数组的方法,并不在我们的讨论范围之内。

实际上,在大多数题目中,倍增求后缀数组是完全够用的,并且它很难成为瓶颈。

补充

  • 上述代码中的这句话:memcpy(rk2,rk,(n+1)<<2),如果换成 swap(rk2,rk) 会慢很多,尽管很多人写的是后者。
  • 在多测的题目中,每次只需清空 cnt 数组即可。

height 数组

height 数组是后缀数组的重要辅助数组,很多后缀数组的题目都依赖于它完成。

定义

首先我们需要知道 LCP 指的是两个串的最长公共前缀,下文用 LCP(i,j) 表示后缀 i 和后缀 j 的 LCP。

于是,height(i)=LCP(sa(i),sa(i1))。特殊地,height(1)=0

求 height 数组

暴力 O(n3),正解是 O(n) 的,基于如下定理:

height(i)height(i1)1

口胡证明:

设后缀 k 是排在后缀 i1 前一名的后缀,即 rk(k)=rk(i1)1,它们的 LCP 是 height(i1)。都去掉第一个字符,就变成后缀 k+1 和后缀 i。此时,若 height(i1)[0,1],那么显然 height(i)=0。否则,height(i)height(i1)1,因为只去掉了第一个字符。

证毕。

然后就得到了 O(n) 代码:

// 背板!
void geth(){
	for(int i=1,k=0;i<=n;i++){
		if(!rk[i]) continue;
		if(k) k--;
		while(s[i+k]==s[sa[rk[i]-1]+k]) k++;
		h[rk[i]]=k;
	}
}

重要结论

求 height 数组的很大一部分原因就是这个推论:

LCP(sa(i),sa(j))=mini<kj{height(k)}

于是 LCP 问题就转化成了 RMQ 问题。RMQ 可以用 ST 表 O(1) 询问,于是 LCP 问题也变成 O(1) 了。


后缀数组的应用

是不是很有意思?有了这些,后缀数组的应用就变得广泛起来。

寻找最小的循环移动位置

典型例题:P4051 [JSOI2007] 字符加密

解法也很简单,把原字符串 S 拷一份变成 SS,然后跑 SA 即可。

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

典型例题:P2870 [USACO07DEC] Best Cow Line G

考虑到每次需要在原串后缀和反串后缀构成的集合里比较大小,可以将反串接在原串之后,中间加上一个奇怪字符 ~(目的是为了使得非法后缀不被计算,而 ~ 的 ASCII 码比所有字母都大所以最保险),对大串跑 SA,即可 O(1) 完成大小比较。

把所有串拼成一个大串,中间用奇怪字符分隔然后跑 SA 是常见套路。

最长公共前缀(LCP 问题)

求 height 数组就是干这事的。

最长重复子串(可重叠)

题意:若字符串 A 在字符串 B 中出现了两次及以上,则称 AB 的重复子串。现给定一个字符串 S,求 S 中出现的最长重复子串的长度。

有结论:最长重复子串的长度就是 height 数组中的最大值。因为 height 数组表示排名相邻的后缀的 LCP,显然这个 LCP 一定是重复子串,所以最长 LCP 就是最长重复子串。

最长重复子串(不可重叠)

题意:若字符串 A 在字符串 B 中出现了两次及以上(出现位置不能重叠),则称 AB 的重复子串。现给定一个字符串 S,求 S 中出现的最长重复子串的长度。

二分答案,设当前二分到 mid,我们按照 SA 数组的顺序把 height 大于等于 mid 的后缀分成一组,然后判断是否存在一组后缀,该组后缀里 sa 的最小值和最大值之差大于等于 mid 即可。因为 sa 存的是后缀的位置,那么两个相差大于等于 mid 意味着至少有 mid 个字符不重叠。

最长重复子串(至少重叠 k 次)

这是后缀数组的典型问题。例题:P2852 [USACO06DEC] Milk Patterns G

有结论:出现至少 k 次意味着跑完 SA 之后有至少连续 k 个后缀以这个子串作为公共前缀。

所以,求出每相邻 k1height(i) 的最小值,再取这些最小值的最大值就是答案。可以用单调队列 O(n) 解决,但最简洁的实现是 set。

不同子串数目

这也是后缀数组的典型问题。例题:P2408 不同子串个数 等多道题目

注意到子串就是后缀的前缀,所以考虑枚举每个后缀,计算前缀的总数,再减去重复

前缀的总数显然是 n(n+1)2

考虑怎么容斥掉重复的。如果按照 sa 的顺序枚举后缀,那每次新增的子串就是除了与上一个后缀的 LCP 剩下的前缀,即新增了 nsa(i)+1 个新的字符串,其中有 height(i) 个是和前一个后缀重复的,结合 height 数组的定义不难得知。

所以最后的答案就是:

n(n+1)2i=2nheight(i)

最长公共子串

这更是后缀数组的典型问题。例题:P5546 [POI 2000] 公共串 等多道题目

目测这道题有很多种解法,最优的解法应该是 SA + 单调队列

首先套路地将给定的所有字符串连在一起串成一个大串,中间用奇怪字符隔开,记录大串上的每一个位置属于原本的第几个串。

求出 height 数组,则问题实际上转化为:在 height 数组上找连续的一段,使得这一段包含来自给定的每个字符串的至少一个后缀。设这个区间为 [l,r],则最后的答案就是 minl<irheight(i)

如果要计算有多少个这样的区间,就是一个双指针的典题,采用类似莫队的放缩手法,用一个 vis 数组记录第 i 个字符串的后缀出现了几次,然后更新即可。然后考虑计算答案,用滑动窗口解决即可。

这种做法除去预处理的时间复杂度是 O(n),总复杂度 O(nlogn),瓶颈在于 SA 预处理。

类似地,可以对 height 数组建立 ST 表,然后计算答案用 RMQ 计算。也可以采用二分,但二分没有单调队列快。

#include<bits/stdc++.h>
using namespace std;

constexpr int MAXN=1e6+50;
int n,m;
string s,s1;
int sa[MAXN],rk[MAXN],rk2[MAXN],id[MAXN],cnt[MAXN];
int h[MAXN];

void getsa(int m){
	for(int i=1;i<=n;i++) cnt[rk[i]=s[i]]++;
	for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
	for(int i=n;i;i--) sa[cnt[rk[i]]--]=i;
	for(int w=1,p,cur;;w<<=1,m=p){
		cur=0;
		for(int i=n-w+1;i<=n;i++) id[++cur]=i;
		for(int i=1;i<=n;i++) if(sa[i]>w) id[++cur]=sa[i]-w;
		memset(cnt,0,(m+1)<<2);
		for(int i=1;i<=n;i++) cnt[rk[i]]++;
		for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
		for(int i=n;i;i--) sa[cnt[rk[id[i]]]--]=id[i];
		p=0;
		memcpy(rk2,rk,(n+1)<<2);
		for(int i=1;i<=n;i++)
			rk[sa[i]]=rk2[sa[i]]==rk2[sa[i-1]]&&rk2[sa[i]+w]==rk2[sa[i-1]+w]?p:++p;
		if(p==n) break;
	}
}
void geth(){
	for(int i=1,k=0;i<=n;i++){
		if(!rk[i]) continue;
		if(k) k--;
		while(s[i+k]==s[sa[rk[i]-1]+k]) k++;
		h[rk[i]]=k;
	}
}

int col[MAXN],vis[MAXN],res;
list<int>q;
void add(int x){
	if(!col[x]) return;
	if(++vis[col[x]]==1) res++;
}
void del(int x){
	if(!col[x]) return;
	if(vis[col[x]]--==1) res--;
}

int main(){
	cin.tie(nullptr)->sync_with_stdio(0);
	cin>>m;
	for(int i=1;i<=m;i++){
		cin>>s1;
		s+=s1+'$';
	}
	s.pop_back();
	n=s.size();
	s=' '+s;
	getsa('z');
	geth();
	for(int i=1,c=1;i<=n;i++)
		if(s[i]=='$') c++;
		else col[rk[i]]=c;
	add(1);
	int ans=0;
	for(int r=2,l=1;r<=n;r++){
		while(!q.empty()&&h[q.back()]>=h[r]) q.pop_back();
		q.emplace_back(r);
		add(r);
		if(res==m){
			while(res==m&&l<r) del(l++);
			add(--l);
		}
		while(!q.empty()&&q.front()<=l) q.pop_front();
		if(!q.empty()&&res==m) ans=max(ans,h[q.front()]);
	}
	cout<<ans<<'\n';
	return 0;
}

另外,从这道题的运行结果上来看,字符串之间的分隔符只需要保证是特殊字符即可,不需要比所有字符的 ASCII 码大。

一些进阶题目

部分单独写了题解。

  • P2336 [SCOI2012] 喵星球上的点名

    SA + 莫队,用到了 height 数组的性质。

  • [BZOJ3230] 相似子串

    重点是找到排名对应子串的开头位置,转化到后缀上求解。跑一遍 SA,再结合二分找到两个子串分别最早出现在哪一个后缀,然后通过 RMQ 就能求出 a 的值,注意对两个子串的长度分别取 min。至于 b,在反串的 RMQ 上求解即可,注意我们不需要重新二分,因为我们已经找到了起始位置,所以也可以直接得到结束位置。

  • P2178 [NOI2015] 品酒大会

    实际上这道题和 P4248 [AHOI2013] 差异 是类似的,只不过多了一个求解区间最大乘积。

posted @   Laoshan_PLUS  阅读(7)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示