后缀数组 SA

后缀数组 SA

前置约定

字符串下标从 \(1\) 开始。

“后缀 \(i\)” 指字符串 \(s[i\dots n]\)

定义

后缀数组(Suffix Array, SA)主要关系到两个数组:\(\text{sa}\)\(\text{rk}\)

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

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

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

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

例如:

  • \(\texttt{aaaab}<\texttt{aab}\)
  • \(\texttt{ab}<\texttt{abaaaab}\)

求后缀数组

朴素法

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

倍增法

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

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

容易发现,这样做只比较了 \(O(\log n)\) 次字符串。复杂度是 \(O(n\log^2n)\) 的。

基数排序法

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

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

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

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

最后的完整代码如下(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 指的是两个串的最长公共前缀,下文用 \(\operatorname{LCP}(i,j)\) 表示后缀 \(i\) 和后缀 \(j\) 的 LCP。

于是,\(\text{height}(i)=\operatorname{LCP}(\text{sa}(i),\text{sa}(i-1))\)。特殊地,\(\text{height}(1)=0\)

求 height 数组

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

\[\text{height}(i)\ge\text{height}(i-1)-1 \]

口胡证明:

设后缀 \(k\) 是排在后缀 \(i-1\) 前一名的后缀,即 \(\text{rk}(k)=\text{rk}(i-1)-1\),它们的 LCP 是 \(\text{height}(i-1)\)。都去掉第一个字符,就变成后缀 \(k+1\) 和后缀 \(i\)。此时,若 \(\text{height}(i-1)\in[0,1]\),那么显然 \(\text{height}(i)=0\)。否则,\(\text{height}(i)\ge\text{height}(i-1)-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 数组的很大一部分原因就是这个推论:

\[\operatorname{LCP}(\text{sa}(i),\text{sa}(j))=\min_{i<k\le j}\{\text{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\) 中出现了两次及以上,则称 \(A\)\(B\) 的重复子串。现给定一个字符串 \(S\),求 \(S\) 中出现的最长重复子串的长度。

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

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

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

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

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

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

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

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

不同子串数目

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

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

前缀的总数显然是 \(\dfrac{n(n+1)}2\)

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

所以最后的答案就是:

\[\frac{n(n+1)}2-\sum_{i=2}^n\text{height}(i) \]

最长公共子串

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

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

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

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

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

这种做法除去预处理的时间复杂度是 \(O(n)\),总复杂度 \(O(n\log n)\),瓶颈在于 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 @ 2025-02-23 19:58  Laoshan_PLUS  阅读(31)  评论(4)    收藏  举报