后缀数组学习笔记
作用
对于一个字符串的后缀按照字典序进行排序
通常的做法是 \(O(nlogn)\) 的倍增做法
求法
首先要弄清楚几个数组的含义
\(sa[i]\):表示排名为 \(i\) 的后缀的标号
\(fir[i]\):表示标号为 \(i\) 的后缀的排名
\(sec[i]\) :表示第二关键字排名为 \(i\) 的后缀的标号
\(tax[i]\):基数排序时所使用的桶
暴力对后缀数组进行排序复杂度是 \(O(n^2logn)\) 的
因为我们每一次都要从低位到高位去比较每一个字符
但是观察后缀数组的性质,我们会发现
第 \(i\)个后缀的第二个字符,实际是第 \(i+1\)个后缀的第一个字符
也就是说,在对于第一个字符进行排序之后,我们其实也就知道了第二个字符的大小关系
同样地,如果我们知道了前 \(len\) 个字符的大小关系,那么我们也可以推出 \([len+1,2len]\) 的字符的大小关系
这样,只需要把前 \(len\) 个和后 \(len\) 个字符拼在一起排个序,我们就能由前 \(len\) 个字符的大小关系推出前 \(2len\) 个字符的大小关系
我们只要进行 \(logn\) 次这样的拼合就能得到最终的结果
现在主要的问题在于怎么 \(O(n)\)得到两部分拼在一起的结果
因为字典序是从低位到高位依次比较的,所以前半部分的优先级肯定要高于后半部分的优先级
因此可以把前半部分的结果看做第一关键字,把后半部分的结果看成第二关键字进行基数排序
这就是后缀排序的大体流程
其实说了这么多,主要还是为了求一个数组 \(height\)
\(lcp(x,y)\):字符串 \(x\) 与字符串 \(y\) 的最长公共前缀,在这里指 \(x\) 号后缀与 \(y\) 号后缀的最长公共前缀
\(height[i]\):\(lcp(sa[i],sa[i−1])\),即排名为 \(i\) 的后缀与排名为 \(i−1\) 的后缀的最长公共前缀
这个数组如果暴力去求复杂度就是 \(O(n^2)\) 的
但是 \(height\) 数组有一个很好的性质 \(height[fir[i]] \geq height[fir[i]-1]-1\)
这样复杂度就是 \(O(n)\) 的了
#define rg register
const int maxn=1e6+5;
int n,m,sa[maxn],fir[maxn],sec[maxn],tax[maxn],hei[maxn];
void Qsort(){//把前后两部分的结果拼合在一起
for(rg int i=0;i<=m;i++) tax[i]=0;//清空基排的桶
for(rg int i=1;i<=n;i++) tax[fir[i]]++;
for(rg int i=1;i<=m;i++) tax[i]+=tax[i-1];//前缀和
for(rg int i=n;i>=1;i--) sa[tax[fir[sec[i]]]--]=sec[i];//把前面的作为第一关键字,把后面的作为第二关键字
}
void getsa(){
m=10000;//m记录有多少个不同的后缀
for(rg int i=1;i<=n;i++) fir[i]=s[i],sec[i]=i;
Qsort();//先求出长度为1的排序结果
for(rg int len=1,p=0;p<n;m=p,len<<=1){
p=0;
for(rg int i=n-len+1;i<=n;i++) sec[++p]=i;//这一部分长度太短,没有后len个字符
for(rg int i=1;i<=n;i++) if(sa[i]>len) sec[++p]=sa[i]-len;//由前len个推后len个
Qsort();//拼起来
std::swap(fir,sec);//sec已经没用了,拿它当一个临时数组
fir[sa[1]]=p=1;//初始化
for(rg int i=2;i<=n;i++) fir[sa[i]]=(sec[sa[i]]==sec[sa[i-1]] && sec[sa[i]+len]==sec[sa[i-1]+len])?p:++p;//更新下一次排序的第一关键字
}
}
void getheight(){
rg int j,k=0;
for(rg int i=1;i<=n;i++){
if(k) k--;
j=sa[fir[i]-1];
while(s[i+k]==s[j+k]) k++;
hei[fir[i]]=k;
}
}
题型一:后缀排序
P4051 [JSOI2007]字符加密 P1368 【模板】最小表示法
给你一个长度为 \(n\) 的字符串,可以选择将最前面的字符移到最后,求字典序最小的方案
解决方法就是把原数组复制一遍,然后跑一下后缀排序
P6140 [USACO07NOV]Best Cow Line S P2870 [USACO07DEC]Best Cow Line G
需要把原串翻转接在最后,思想很巧妙
题型二:本质不同子串个数
P2408 不同子串个数 SP705 SUBST1 - New Distinct Substrings SP694 DISUBSTR - Distinct Substrings
用总的子串的个数 \(\frac{n(n+1)}{2}\) 减去重复的子串的个数 \(\sum_{i=1}^{n}height[i]\)
即 \(\sum_{i=1}^nn-sa[i]+1-height[i]\)
要稍稍做一下转化,把向结尾加字符转化成向前加字符
这样每次只会有一个新的后缀加入,我们只需要用一个 \(set\) 找该后缀的前驱后继计算答案即可
综合性比较强,利用 \(height\) 数组的性质可以求出某一个子串在所有子串中字典序的排名
题型三:利用\(height\)数组的性质计算
对于\(height\)数组,有如下的式子
\(height[i]=LCP(sa[i−1],sa[i])\)
\(LCP(j,k)=min_{l=j+1}^kheight[l]\)
这两道题都利用了\(height\)数组第二个取 \(min\) 的性质
对于 \(height\) 数组中的每一个值,记录一下它向左和向右能做的最远的贡献,可以用单调栈实现
将 \(height\) 数组从小到大排序后倒序枚举
用并查集维护联通块最大/最小值
每次把 \(height\) 数组所掌管的两个元素所在的集合合并
核心代码
sta[++tp]=1;
for(rg int i=2;i<=n;i++){
while(tp && heig[i]<=heig[sta[tp]]){
r[sta[tp]]=i;
tp--;
}
l[i]=sta[tp];
sta[++tp]=i;
}
while(tp){
r[sta[tp--]]=n+1;
}
ans=1LL*(n+1)*n*(n-1)/2;
for(rg int i=1;i<=n;i++){
ans-=2LL*(i-l[i])*(r[i]-i)*heig[i];
}
题型四:求不同串的最长的公共子串的长度
SP1811 LCS - Longest Common Substring SP10570 LONGCS - Longest Common Substring SP1812 LCS2 - Longest Common Substring II
把这些串连成一个长串,在串与串相接的地方插入一个没有出现过的特殊符号,防止出现重合的问题
然后求出整个串的 \(height\) 数组,并对于每一个\(height\) 数组染色,标记它属于原来的哪一个串
用双指针从前到后扫一遍,当记录到的不同串的个数等于总的串的个数时取一下最大值
对原字符串差分一下
核心代码
void xg(rg int now,rg int op){
if(col[now]==0) return;
if(cnt[col[now]]==0) js++;
cnt[col[now]]+=op;
if(cnt[col[now]]==0) js--;
}
int T;
int main(){
scanf("%d",&T);
while(T--){
memset(col,0,sizeof(col));
memset(l,0,sizeof(l));
memset(r,0,sizeof(r));
memset(cnt,0,sizeof(cnt));
ans=js=0;
scanf("%d",&t);
rg int len;
for(rg int i=1;i<=t;i++){
l[i]=n+1;
scanf("%s",s+n+1);
len=strlen(s+n+1);
n+=len;
r[i]=n;
s[++n]='A'+i;
}
if(t==1){
printf("0\n");
return 0;
}
getsa();
getheight();
for(rg int i=1;i<=t;i++){
for(rg int j=l[i];j<=r[i];j++){
col[fir[j]]=i;
}
}
rg int nl=1;
xg(1,1);
for(rg int nr=2;nr<=n;nr++){
while(head<=tail && hei[nr]<=hei[q[tail]]) tail--;
q[++tail]=nr;
xg(nr,1);
if(js==t){
while(js==t && nl<nr) xg(nl++,-1);
nl--;
xg(nl,1);
}
while(head<=tail && q[head]<=nl) head++;
if(js==t){
ans=std::max(ans,hei[q[head]]);
}
}
printf("%d\n",ans);
}
return 0;
}
题型五:求不同串的子串相同的方案数
利用上一个题型的方法把不同的串合并
利用单调队列求出每一个 \(height\) 数组能贡献的最左和最右的距离
最后再容斥一下,减去两个单独子串的上述贡献
题型六:求出现次数为 \(k\) 的子串的最长长度和长度为 \(k\) 的子串出现的最大次数
分别对应下面的两道题
P2852 [USACO06DEC]Milk Patterns G SP8222 NSUBSTR - Substrings
还是用单调栈维护当前的 \(height\) 能向右和向左扩展的最长的长度
然后 \(dp\) 转移即可
核心代码(第二道)
sta[++tp]=1;
for(rg int i=2;i<=n;i++){
while(tp && heig[i]<=heig[sta[tp]]){
r[sta[tp]]=i;
tp--;
}
l[i]=sta[tp];
sta[++tp]=i;
}
while(tp){
r[sta[tp--]]=n+1;
}
for(rg int i=1;i<=n;i++) f[i]=1;
for(rg int i=1;i<=n;i++){
f[heig[i]]=std::max(f[heig[i]],r[i]-l[i]);
}
for(rg int i=n;i>=1;i--){
f[i]=std::max(f[i],f[i+1]);
}
题型七:关键点的思想
主要考察怎么利用前缀和后缀的性质求类似于 \(AA\) 的子串的个数
考虑枚举一个 \(len\) ,然后对于每个点求出他是否是一个 \(2 \times len\) 的 \(AA\) 串的开头 / 结尾
我们每隔 \(len\) 放一个点,这样每一个 长度为 \(2 \times len\) 的 \(AA\) 串都至少会经过两个相邻的点
所以再转换为每两个相邻的点会对 \(a, b\) 产生多少贡献
先求出这对相邻点所代表的前缀的最长公共后缀 \(LCS\) 和 所代表的后缀的最长公共前缀 \(LCP\)
如果 \(LCP + LCS < Len\) 肯定不合法
否则给合法的区间整体加一
看到变化趋势就想到差分
差分之后实际上就是求类似于 \(ABA\) 的字符串的个数
和上一道题一样枚举 \(A\) 的长度,隔一段距离放一个关键点
代码实现
#include<cstdio>
#include<iostream>
#include<cstring>
#include<algorithm>
#define rg register
inline int read(){
rg int x=0,fh=1;
rg char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') fh=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
x=(x<<1)+(x<<3)+(ch^48);
ch=getchar();
}
return x*fh;
}
const int maxn=1e5+5;
struct SA{
int sa[maxn],fir[maxn],sec[maxn],tax[maxn],s[maxn],heig[maxn],mmin[maxn][20],lg[maxn],m,n;
void Qsort(){
for(rg int i=0;i<=m;i++) tax[i]=0;
for(rg int i=1;i<=n;i++) tax[fir[i]]++;
for(rg int i=1;i<=m;i++) tax[i]+=tax[i-1];
for(rg int i=n;i>=1;i--) sa[tax[fir[sec[i]]]--]=sec[i];
}
void getsa(){
for(rg int i=1;i<=n;i++) fir[i]=s[i],sec[i]=i;
Qsort();
for(rg int len=1,p=0;p<n;len<<=1,m=p){
p=0;
for(rg int i=n-len+1;i<=n;i++) sec[++p]=i;
for(rg int i=1;i<=n;i++) if(sa[i]>len) sec[++p]=sa[i]-len;
Qsort();
memcpy(sec,fir,sizeof(fir));
fir[sa[1]]=p=1;
for(rg int i=2;i<=n;i++) fir[sa[i]]=(sec[sa[i]]==sec[sa[i-1]] && sec[sa[i]+len]==sec[sa[i-1]+len])?p:++p;
}
}
void getheight(){
rg int j,k=0;
for(rg int i=1;i<=n;i++){
if(k) k--;
j=sa[fir[i]-1];
while(s[j+k]==s[i+k]) k++;
heig[fir[i]]=k;
}
}
void pre(){
getsa();
getheight();
for(rg int i=1;i<=n;i++) mmin[i][0]=heig[i];
for(rg int i=2;i<=n;i++) lg[i]=lg[i>>1]+1;
for(rg int j=1;j<=18;j++){
for(rg int i=1;i+(1<<j)-1<=n;i++){
mmin[i][j]=std::min(mmin[i][j-1],mmin[i+(1<<(j-1))][j-1]);
}
}
}
int getans(rg int l,rg int r){
l=fir[l],r=fir[r];
if(l>r) std::swap(l,r);
l++;
rg int k=lg[r-l+1];
return std::min(mmin[l][k],mmin[r-(1<<k)+1][k]);
}
}pre,suf;
int a[maxn],n,m,sta[maxn],tp,ans;
int main(){
n=read(),m=read();
for(rg int i=1;i<=n;i++) a[i]=read();
for(rg int i=1;i<=n;i++) a[i]=a[i+1]-a[i];
n--;
for(rg int i=1;i<=n;i++) sta[++tp]=a[i];
std::sort(sta+1,sta+1+tp);
tp=std::unique(sta+1,sta+1+tp)-sta-1;
for(rg int i=1;i<=n;i++) a[i]=std::lower_bound(sta+1,sta+1+tp,a[i])-sta;
pre.m=suf.m=tp,pre.n=suf.n=n;
for(rg int i=1;i<=n;i++) pre.s[i]=a[i],suf.s[i]=a[n-i+1];
pre.pre(),suf.pre();
rg int l,r,now1,now2;
for(rg int len=1;len<=n;len++){
for(rg int i=len;i+m+len<=n;i+=len){
l=i,r=i+m+len;
now1=pre.getans(l,r);
now2=suf.getans(n-l+1,n-r+1);
now1=std::min(now1,len),now2=std::min(now2,len);
if(now1+now2-1>=len){
ans+=now1+now2-len;
}
}
}
printf("%d\n",ans);
return 0;
}
题型八:lcp函数性质
主要利用 \(lcp\) 这个函数是单峰的,并且峰值在自己这里
首先把所有的串都拼在一起,中间插上特殊字符
对于每一个询问串,利用 \(lcp\) 单峰的性质,向左向右分别二分扩展边界
使得扩展出来的区间内的点所代表的后缀与询问串的 \(lcp\) 大于等于询问串的长度
这样一共会扩展出 \(q\) 个区间
只需要求出每一个区间内有多少种颜色和每一种颜色被多少区间覆盖
前者其实就是 \(HH\) 的项链,可以用莫队或者树状数组解决
每一种颜色只算最后一次出现的
后者也可以用树状数组维护
只把每一种颜色在某一个区间内第一次出现的位置算进答案就能达到去重
主席树+后缀数组+二分