后缀数组
复习提纲:
(一)后缀排序的定义是什么?
(二)后缀排序如何利用倍增进行快速求解?
提示:双关键字都是什么?
(三)归并排序如何融入后缀排序之中?如何优化?
提示:有一维简单调整即可,无需排序。
(四)你能准确说出 \(h\) 数组的定义吗?
(五)记得 \(h\) 数组自身的优秀性质吗?如何求 \(h\) 数组?
(六)子串 LCP 问题怎么解?怎么转 RMQ?
(七)码?
upd 2026/6/13
后缀排序
后缀排序,指的是对于字符串的每个后缀按照字典序大小进行排序。
这里为了方便,每个后缀可以用后缀首字母下标代指。
定义后缀 \(i\) 的排名为 \(rk_i\),字符串排序结果的数组为 \(sa\),简称为后缀数组。
后缀排序基于倍增。
首先按照单字符排序,每个字符获得初始排名 \(rk\) 数组。
会进行 \(\log_n\) 轮排序,第 \(i\) 轮排序块长 \(k=2^{i-1}\),
每个位置第一关键字为 \(rk_i\),第二关键字为 \(rk_{i+k}\)(越界赋无穷小)。
排序时使用基数排序,先计数排序第二关键字,再计数排序第一关键字即可。
优化一:发现,由于第二关键字原本是有序的,只需要排序时把无穷小的放在数组头即可。
优化二:维护前缀和操作值域。
优化三:发现 \(rk\) 数组互异时直接退出。
#include<bits/stdc++.h>
using namespace std;
const int N=1000009,M=127;
int n,rk[N],sa[N],trk[N<<1],tsa[N],cnt[N];
char s[N];
int main(){
scanf("%s",s+1),n=strlen(s+1);
//预备排序
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>=1;--i) sa[cnt[s[i]]--]=i;
//正式排序
for(int w=1,cur,lim=M;w<n;w<<=1,lim=cur){
memset(cnt,0,sizeof cnt),cur=0;
//处理空位置第二关键字
for(int i=n-w+1;i<=n;++i) tsa[++cur]=i;
//将 sa 数组中有可能作为第二关键字的位置顺序找出
//这些位置实际对应 i-w
for(int i=1;i<=n;++i) if(sa[i]>w) tsa[++cur]=sa[i]-w;
//计数排序第一关键字
for(int i=1;i<=n;++i) cnt[rk[i]]++;
for(int i=1;i<=lim;++i) cnt[i]+=cnt[i-1];
//实际上在实现数组 tsa->sa 的转换
for(int i=n;i>=1;--i) sa[cnt[rk[tsa[i]]]--]=tsa[i];
memcpy(trk,rk,sizeof rk),cur=0;
//注意这里要实现并列情况 rk 相等
for(int i=1;i<=n;i++)
cur+=!(trk[sa[i]]==trk[sa[i-1]]&&trk[sa[i]+w]==trk[sa[i-1]+w]),rk[sa[i]]=cur;
if(cur==n) break;
}
for(int i=1;i<=n;i++) printf("%d ",sa[i]);
return 0;
}
后缀数组的直接利用
例一 「JSOI2007」字符加密
环上问题转换为链上问题,是为破环为链。
对于二倍字符串进行后缀排序即可。
例二「USACO07DEC」Best Cow Line
贪心选取,
发现队头队尾一样时需要一个“比较当前字串正倒序字典序”的操作。
直接做大概就是哈希正反字典串,二分哈希去相同。
后缀排序简单粗暴,字符串回文处理(\(\mathrm{abc\to abccba}\)),
排序后就实现了这个需求。
本题不需要使用分割字符。
例二「P5353」树上后缀排序
我们原来的操作是对于一条链排序。
想到几个思路,如下:
- 暴力合并:
完全二叉树卡到不低于平方,放弃。 - 树剖:
看起来困难,迫不得已再说。 - 扩展理解:
整体排序,按层倍增,依旧归并。
归并排序变成了四个关键字,而已。
求两个子串的最长公共前缀
后缀 \(i\) 和后缀 \(j\) 的最长公共前缀(长度)记作 \(lcp(i,j)\)。
定义 \(h_i=lcp(sa_i,sa_{i-1})\),特别的,\(h_1=0\)。
根据定理 \(h_{rk_i}\ge h_{rk_{i-1}}-1\),\(h\) 数组暴力去求即可。
理解
定义后缀 \(i\) 的前驱为所有字典序比他小的后缀,
直接前驱为前驱中字典序最大的一个。
这个定理的意思是,后缀 \(i\) 和其直接前驱的 LCP 最少为后缀 \(i-1\) 对应值减一。
这里不妨将后缀 \(i-1\) 记作 \(cS\),将后缀 \(i\) 记作 \(S\)。如果后缀 \(cS\) 直接前驱形如 \(cK\) ,那么 \(K\) 一定是 \(S\) 的前驱。
发现 \(K\) 和 \(S\) 的 LCP 就是 \(h_{i-1}-1\),而 \(S\) 与其直接前驱的 LCP 至少为该值。
否则 \(h_{i-1}=0\),自然有 \(h_i\ge h_{i-1}-1\)。综上,得证。
发现对于 \(rk_i<rk_j\),\(lcp(i,j)=\min\limits_{x=rk_i+1}^{rk_j} h_x\),进而转化成 RMQ 问题。
扩展到子串上,对于子串长度取最小值即可。
#include<bits/stdc++.h>
using namespace std;
const int N=1000009,M=127,K=21;
char s[N];
int cnt[N],rk[N],sa[N],trk[N<<1],tsa[N],h[N][K],lg[N],n,T;
int main(){
scanf("%d%s",&T,s+1),n=strlen(s+1);
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>=1;--i) sa[cnt[rk[i]]--]=i;
for(int w=1,cur,p=M;;w<<=1,p=cur){
memset(cnt,0,sizeof cnt),cur=0;
for(int i=n-w+1;i<=n;++i) tsa[++cur]=i;
for(int i=1;i<=n;++i) if(sa[i]>w) tsa[++cur]=sa[i]-w;
for(int i=1;i<=n;++i) ++cnt[rk[i]];
for(int i=1;i<=p;++i) cnt[i]+=cnt[i-1];
for(int i=n;i>=1;--i) sa[cnt[rk[tsa[i]]]--]=tsa[i];
memcpy(trk,rk,sizeof rk),cur=0;
for(int i=1;i<=n;i++)
cur+=(trk[sa[i]]!=trk[sa[i-1]]||trk[sa[i]+w]!=trk[sa[i-1]+w]),rk[sa[i]]=cur;
if(cur==n) break;
}
for(int i=1,j,k=0;i<=n;i++){
if(rk[i]==1) continue;
if(k) --k;
j=sa[rk[i]-1];
while(i+k<=n&&j+k<=n&&s[i+k]==s[j+k]) ++k;
h[rk[i]][0]=k;
}
for(int k=1;(1<<k)<=n;k++) for(int i=1;i+(1<<k)-1<=n;i++) h[i][k]=min(h[i][k-1],h[i+(1<<(k-1))][k-1]);
lg[1]=0; for(int i=2;i<=n;i++) lg[i]=lg[i>>1]+1;
for(int i=1,lu,lv,ru,rv,l,r,len,ans;i<=T;i++){
scanf("%d%d%d%d",&lu,&lv,&ru,&rv),l=rk[lu],r=rk[ru];
if(lu==ru){
printf("%d\n",min(rv,lv)-lu+1);
continue;
}
if(l>r) swap(l,r);
++l;
len=lg[r-l+1];
ans=min(h[l][len],h[r-(1<<len)+1][len]);
printf("%d\n",min(ans,min(lv-lu+1,rv-ru+1)));
}
return 0;
}
h 数组其他应用
- 判断子串大小关系
用 LCP 判定子串是否相同,
若不相同,则直接利用后缀排序求解即可。 - 本质不同子串数

浙公网安备 33010602011771号