后缀数组 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\) 开始,若出现 \(A(i)<B(i)\),则 \(B(i)\) 大,反之 \(A(i)\) 大;
- 若两个字符串长度不同且无法通过步骤 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)\)。具体过程如下:
- 首先对字符串 \(s\) 的所有长度为 \(1\) 的子串(即每个字符)进行排序,得到排序后的编号数组 \(\text{sa}_1\) 和排名数组 \(\text{rk}_1\);
- 用两个长度为 \(1\) 的子串的排名,即 \(\text{rk}_1(i)\) 和 \(\text{rk}_1(i+1)\),作为排序的第一和第二关键字进行排序,就可以对字符串 \(s\) 的所有长度为 \(2\) 的子串进行排序,得到 \(\text{sa}_2\) 和 \(\text{rk}_2\);
- 用 \(\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)\) 的,基于如下定理:
口胡证明:
设后缀 \(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 数组的很大一部分原因就是这个推论:
于是 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 数组的定义不难得知。
所以最后的答案就是:
最长公共子串
这更是后缀数组的典型问题。例题: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 码大。
一些进阶题目
部分单独写了题解。
-
SA + 莫队,用到了 height 数组的性质。
-
重点是找到排名对应子串的开头位置,转化到后缀上求解。跑一遍 SA,再结合二分找到两个子串分别最早出现在哪一个后缀,然后通过 RMQ 就能求出 \(a\) 的值,注意对两个子串的长度分别取 \(\min\)。至于 \(b\),在反串的 RMQ 上求解即可,注意我们不需要重新二分,因为我们已经找到了起始位置,所以也可以直接得到结束位置。
-
实际上这道题和 P4248 [AHOI2013] 差异 是类似的,只不过多了一个求解区间最大乘积。

浙公网安备 33010602011771号