后缀数组 SA
后缀数组 SA
前置约定
字符串下标从
“后缀
定义
后缀数组(Suffix Array, SA)主要关系到两个数组:
其中
不知道为什么刚学的时候在字典序这块卡了好久。
什么是按字典序排序?设两个字符串为
,就是两个原则:
- 对于两个字符串的公共长度部分,从下标
开始,若出现 ,则 大,反之 大; - 若两个字符串长度不同且无法通过步骤 1 比出大小,那么长度长的大。
例如:
求后缀数组
朴素法
最暴力的方法:将字符串的所有后缀存下来然后排序。因为排序要比较
倍增法
朴素法是对每两个字符串进行横向比对,现在我们换一种思路,选择纵向比对,也就是比对所有字符串的第一个字符。考虑对于两个长度为
- 首先对字符串
的所有长度为 的子串(即每个字符)进行排序,得到排序后的编号数组 和排名数组 ; - 用两个长度为
的子串的排名,即 和 ,作为排序的第一和第二关键字进行排序,就可以对字符串 的所有长度为 的子串进行排序,得到 和 ; - 用
和 进行类似上述操作,以此类推,直到倍增到 。
容易发现,这样做只比较了
基数排序法
考虑倍增的复杂度依然不是最优的,因为一次 sort 仍然需要
于是我们的复杂度来到了优秀的
但这依然不是最优的!下面展示关键的常数优化部分:
- 第二关键字无须基数排序:考虑第二关键字排序的实质,就是把超出字符串范围的
放到字符串头部,剩下的不变,所以只需手动整一下就行; - 优化基数排序的值域:每次计算一个值域,在基数排序时将值域实时更新;
- 若排名都不相同直接返回:考虑新的
数组,如果排名分别为 到 ,说明已经排好了,此时无须再进行排序。
最后的完整代码如下(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,这些是
实际上,在大多数题目中,倍增求后缀数组是完全够用的,并且它很难成为瓶颈。
补充
- 上述代码中的这句话:
memcpy(rk2,rk,(n+1)<<2)
,如果换成swap(rk2,rk)
会慢很多,尽管很多人写的是后者。 - 在多测的题目中,每次只需清空
cnt
数组即可。
height 数组
height 数组是后缀数组的重要辅助数组,很多后缀数组的题目都依赖于它完成。
定义
首先我们需要知道 LCP 指的是两个串的最长公共前缀,下文用
于是,
求 height 数组
暴力
口胡证明:
设后缀
是排在后缀 前一名的后缀,即 ,它们的 LCP 是 。都去掉第一个字符,就变成后缀 和后缀 。此时,若 ,那么显然 。否则, ,因为只去掉了第一个字符。 证毕。
然后就得到了
// 背板!
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 问题就转化成了 RMQ 问题。RMQ 可以用 ST 表
后缀数组的应用
是不是很有意思?有了这些,后缀数组的应用就变得广泛起来。
寻找最小的循环移动位置
典型例题:P4051 [JSOI2007] 字符加密。
解法也很简单,把原字符串
从字符串首尾取字符最小化字典序
典型例题:P2870 [USACO07DEC] Best Cow Line G。
考虑到每次需要在原串后缀和反串后缀构成的集合里比较大小,可以将反串接在原串之后,中间加上一个奇怪字符 ~
(目的是为了使得非法后缀不被计算,而 ~
的 ASCII 码比所有字母都大所以最保险),对大串跑 SA,即可
把所有串拼成一个大串,中间用奇怪字符分隔然后跑 SA 是常见套路。
最长公共前缀(LCP 问题)
求 height 数组就是干这事的。
最长重复子串(可重叠)
题意:若字符串
在字符串 中出现了两次及以上,则称 为 的重复子串。现给定一个字符串 ,求 中出现的最长重复子串的长度。
有结论:最长重复子串的长度就是 height 数组中的最大值。因为 height 数组表示排名相邻的后缀的 LCP,显然这个 LCP 一定是重复子串,所以最长 LCP 就是最长重复子串。
最长重复子串(不可重叠)
题意:若字符串
在字符串 中出现了两次及以上(出现位置不能重叠),则称 为 的重复子串。现给定一个字符串 ,求 中出现的最长重复子串的长度。
二分答案,设当前二分到
最长重复子串(至少重叠 k 次)
这是后缀数组的典型问题。例题:P2852 [USACO06DEC] Milk Patterns G。
有结论:出现至少
所以,求出每相邻
不同子串数目
这也是后缀数组的典型问题。例题:P2408 不同子串个数 等多道题目。
注意到子串就是后缀的前缀,所以考虑枚举每个后缀,计算前缀的总数,再减去重复。
前缀的总数显然是
考虑怎么容斥掉重复的。如果按照
所以最后的答案就是:
最长公共子串
这更是后缀数组的典型问题。例题:P5546 [POI 2000] 公共串 等多道题目。
目测这道题有很多种解法,最优的解法应该是 SA + 单调队列。
首先套路地将给定的所有字符串连在一起串成一个大串,中间用奇怪字符隔开,记录大串上的每一个位置属于原本的第几个串。
求出 height 数组,则问题实际上转化为:在 height 数组上找连续的一段,使得这一段包含来自给定的每个字符串的至少一个后缀。设这个区间为
如果要计算有多少个这样的区间,就是一个双指针的典题,采用类似莫队的放缩手法,用一个
这种做法除去预处理的时间复杂度是
类似地,可以对 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 码大。
一些进阶题目
部分单独写了题解。
-
SA + 莫队,用到了 height 数组的性质。
-
重点是找到排名对应子串的开头位置,转化到后缀上求解。跑一遍 SA,再结合二分找到两个子串分别最早出现在哪一个后缀,然后通过 RMQ 就能求出
的值,注意对两个子串的长度分别取 。至于 ,在反串的 RMQ 上求解即可,注意我们不需要重新二分,因为我们已经找到了起始位置,所以也可以直接得到结束位置。 -
实际上这道题和 P4248 [AHOI2013] 差异 是类似的,只不过多了一个求解区间最大乘积。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】