后缀数组学习笔记
定义
记一个长度为 的字符串 ,以 中第 个下标开始到结尾的子串被称为 的第 个后缀。显然,一个长度为 的字符串有 个后缀。
下面介绍一种倍增算法实现 对后缀按字典序进行排序。
倍增算法
记 表示排名第 位的后缀, 表示第 个后缀的排名。 表示 与 的最长公共前缀。
以字符串 aababb 为例,它的六个后缀分别为 aababb,ababb,babb,abb,bb,b。
第一次排序时只关注每个后缀的第一个字符,如果第一个字符相同,则不改变原来的相对顺序。得到 aababb,ababb,abb,babb,bb,b。对于此操作,可以用基数排序做到 复杂度。
假设当前已经按照前 位排序(若后缀不足 位则补上空字符)。接下来将每个后缀的前 个字符当成第一关键字,将 个字符作为第二关键字进行排序。每一轮结束后将所有后缀的第一关键字离散化。 那么就可以使用用基数排序,同时每次排序的字符数会扩大两倍,那么就得到了一个 的算法。
大体的思路就是这样,接下来讨论一下基数排序。
基数排序的复杂度与排序的值域相关。例如 ,统计每个数字的出现次数后,利用前缀和求出 ,表示小于等于 的数的个数。从后往前遍历原数组,此时遍历到的 就是该数字排序后所在的位置,此时再将 ,这样就可以保证排序后相同数字的相对顺序不变。
其余的一些实现细节可以看代码。接下来讨论 height 数组。
记 表示后缀 和 的最长公共子串。显然有 ,不妨设 。
对于所有的 ,都有 。证明很简单,在每一个字符串前取 ,对于排序后的字符串,显然有 ,且 ,那么自然就有 了(这里的 代表取的子串)。
根据上面提到的性质,就有:
。
而 恰好就是 。
定义 h 数组,。表示第 个后缀和它排序后前一个后缀的 。可以得到一个性质:。这个性质的证明比较复杂,这里以后缀 小于后缀 为例。
假设排序后排在 前面一位为后缀 ,排在 前面一位为后缀 。
若 ,那么显然 。
若 ,那么两个后缀的第一个字符相同,而对于这两个后缀从第二个字符开始的后缀,也就是后缀 和 ,显然就有 , (就是减去开头的那个字符)。这里假设 ,对于小于的情况同理。根据 。就有 ,即 。
利用这个性质,在求 时就可以直接从第 位开始枚举。也就可以将求 数组的复杂度降到 。
模板题代码:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=1e6+10;
int n,m;char s[N];
int sa[N],height[N],rk[N];//sa[i]:排名为i的后缀
int x[N],y[N],cnt[N];//x[i]表示第i个后缀离散化后对应的数字,y[i]表示按第二关键字排序后的排名为i的后缀
void get_sa()
{
for(int i=1;i<=n;i++) cnt[x[i]=s[i]]++;
for(int i=2;i<=m;i++) cnt[i]+=cnt[i-1];
for(int i=n;i>=1;i--) sa[cnt[x[i]]--]=i; //基数排序
for(int k=1;k<=n;k<<=1)
{
int num=0;
for(int i=n-k+1;i<=n;i++) y[++num]=i;
//对于最后k个后缀,它们的第二关键字为空,那么显然比前面的都小,按照第二关键字排序自然就在最后面,顺序遍历是不改变相对关系
//可以发现第i个后缀的第二关键字就是第i+k个字符的第一关键字,下面的j就是在枚举i+k
for(int j=1;j<=n;j++) if(sa[j]>k) y[++num]=sa[j]-k;
//对于原串的前k个后缀是无法作为其他后缀的第二关键字,而对于剩下的后缀,第二关键字排序就是i+k的第一关键字
//第二关键字排序结束
for(int i=1;i<=m;i++) cnt[i]=0;
for(int i=1;i<=n;i++) cnt[x[i]]++;
for(int i=2;i<=m;i++) cnt[i]+=cnt[i-1];
for(int i=n;i>=1;i--) sa[cnt[x[y[i]]]--]=y[i],y[i]=0;
//本处其实就是双关键字排序,由于需要保证当第一关键字相同后第二关键字有序,最里面的就是y[i]
swap(x,y); num=1;x[sa[1]]=1;
for(int i=2;i<=n;i++) x[sa[i]]=(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])?num:++num;
//离散化操作,如果第一和第二关键字都相同就默认二者相同
if(num==n) break;//如果没有相同的,即排序完毕
m=num;
}
}
void get_height()
{
for(int i=1;i<=n;i++) rk[sa[i]]=i;//排名为i的后缀排名为i
for(int i=1,k=0;i<=n;i++)
{
if(rk[i]==1) continue;//排名第一的后缀
if(k) k--;//即 h[i]>=h[i-1]-1,注意是h而不是height,因为下面的j!=i-1
int j=sa[rk[i]-1];//即排在i前面的后缀
while(i+k<=n&&j+k<=n&&s[i+k]==s[j+k]) k++;
height[rk[i]]=k;
}
}
int main()
{
scanf("%s",s+1);n=strlen(s+1);m=122;//'z'=122
get_sa();get_height();
for(int i=1;i<=n;i++) printf("%d ",sa[i]);puts("");
for(int i=1;i<=n;i++) printf("%d ",height[i]);puts("");
return 0;
}
【模板】品酒大会
一年一度的“幻影阁夏日品酒大会”隆重开幕了。
大会包含品尝和趣味挑战两个环节,分别向优胜者颁发“首席品酒家”和“首席猎手”两个奖项,吸引了众多品酒师参加。
在大会的晚餐上,调酒师 Rainbow 调制了 杯鸡尾酒。
这 杯鸡尾酒排成一行,其中第 杯酒 () 被贴上了一个标签 ,每个标签都是 个小写英文字母之一。
设 表示第 杯酒到第 杯酒的 个标签顺次连接构成的字符串。
若 ,其中 , , , ,则称第 杯酒与第 杯酒是“ 相似” 的。
当然两杯“ 相似”的酒同时也是“ 相似”、“ 相似”、……、“ 相似”的。
特别地,对于任意的 ,,,第 杯酒和第 杯酒都是“ 相似”的。
在品尝环节上,品酒师 Freda 轻松地评定了每一杯酒的美味度,凭借其专业的水准和经验成功夺取了“首席品酒家”的称号,其中第 杯酒 的美味度为 。
现在 Rainbow 公布了挑战环节的问题:本次大会调制的鸡尾酒有一个特点,如果把第 杯酒与第 杯酒调兑在一起,将得到一杯美味度为 的酒。
现在请各位品酒师分别对于 ,,,, ,统计出有多少种方法可以选出 杯“ 相似”的酒,并回答选择 杯“ 相似”的酒调兑可以得到的美味度的最大值。
数据范围
思路
先对原串的所有后缀进行排序,可以发现任意两个后缀的 一定小于等于它们的最长公共前缀,根据之前提到的 数组的性质,对于排序后的后缀,如果一段区间内的 最小值大于等于 ,那么这个区间内的所有后缀必然是 相似的。反之,如果有 ,那么对于 满足 , 和 不可能是 相似的。
这提示我们可以在排序后的后缀序列中满足 切一刀,将原序列划分成若干个区间。每个区间内的任意两个后缀就必然是 相似的。接下来讨论如何记录区间内的美味度最大值。
1.两个后缀的美味度都为正数,那么必然就是最大值和次大值相乘得到的最大。
2.两个后缀的美味度都为负数,那么必然就是最小值和次小值相乘得到的最大。
3.两个后缀的美味度为一正一负,出现这种情况只可能是区间只有两个数时,那么也就是区间的最大值和次大值相乘。
综上,只需要维护区间内的最大值、次大值、最小值和最大值即可求出美味度的最大值。
由于区间分裂时的区间最值不好维护,可以想到按照 从大到小来枚举,这样就只会合并区间,就可以用到并查集维护所有的信息。
code:
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>
using namespace std;
#define LL long long
const int N=3e5+10;
const LL INF=1e9+10;
const LL INFF=2e18;
int fa[N],siz[N],n,m,c[N],x[N],y[N],height[N],sa[N],rk[N];char s[N];
vector<int>hs[N];
LL ans[N][2],max1[N],max2[N],min1[N],min2[N],w[N];
LL get(LL x){return x*(x-1)/2ll;}
int find(int x){return fa[x]==x?x:fa[x]=find(fa[x]);}
void get_sa()
{
for(int i=1;i<=n;i++) c[x[i]=s[i]]++;
for(int i=2;i<=m;i++) c[i]+=c[i-1];
for(int i=n;i>=1;i--) sa[c[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
int num=0;
for(int i=n-k+1;i<=n;i++) y[++num]=i;
for(int i=1;i<=n;i++) if(sa[i]>k) y[++num]=sa[i]-k;
for(int i=1;i<=m;i++) c[i]=0;
for(int i=1;i<=n;i++) c[x[i]]++;
for(int i=2;i<=m;i++) c[i]+=c[i-1];
for(int i=n;i>=1;i--) sa[c[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y);num=1,x[sa[1]]=1;
for(int i=2;i<=n;i++) x[sa[i]]=(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k]?num:++num);
if(num==n) break;m=num;
}
}
void get_height()
{
for(int i=1;i<=n;i++) rk[sa[i]]=i;
for(int i=1,k=0;i<=n;i++)
{
if(rk[i]==1) continue;
if(k) k--;
int j=sa[rk[i]-1];
while(i+k<=n&&j+k<=n&&s[i+k]==s[j+k]) k++;
height[rk[i]]=k;
}
}
int main()
{
scanf("%d",&n);m=122;scanf("%s",s+1);
get_sa(),get_height();
for(int i=1;i<=n;i++) scanf("%lld",&w[i]);
for(int i=2;i<=n;i++) hs[height[i]].push_back(i);
LL res=-INFF,cnt=0;
for(int i=1;i<=n;i++)
{
fa[i]=i;siz[i]=1;
max1[i]=min1[i]=w[sa[i]];//记住,这里是排序后的后缀
max2[i]=-INF;min2[i]=INF;
}
for(int r=n-1;r>=0;r--)
{
for(int i=0;i<hs[r].size();i++)
{
int x=find(hs[r][i]),y=find(hs[r][i]-1);
cnt-=get(siz[x])+get(siz[y]);siz[x]+=siz[y];cnt+=get(siz[x]);
fa[y]=x;
if(max1[x]>=max1[y]) max2[x]=max(max2[x],max1[y]);
else max2[x]=max(max1[x],max2[y]),max1[x]=max1[y];
if(min1[x]<=min1[y]) min2[x]=min(min2[x],min1[y]);
else min2[x]=min(min1[x],min2[y]),min1[x]=min1[y];
res=max(res,max(max1[x]*max2[x],min1[x]*min2[x]));
}
ans[r][0]=cnt,ans[r][1]=(res==-INFF?0ll:res);
}
for(int i=0;i<n;i++) printf("%lld %lld\n",ans[i][0],ans[i][1]);
return 0;
}
【例题】生成魔咒
魔咒串由许多魔咒字符组成,魔咒字符可以用数字表示。
例如可以将魔咒字符 、 拼凑起来形成一个魔咒串 。
一个魔咒串 的非空字串被称为魔咒串 的生成魔咒。
例如 时,它的生成魔咒有 、、、、 五种。
时,它的生成魔咒有 、、 三种。
最初 为空串。
共进行 次操作,每次操作是在 的结尾加入一个魔咒字符。
每次操作后都需要求出,当前的魔咒串 共有多少种生成魔咒。
数据范围
,
用来表示魔咒字符的数字 满足 。
思路
首先来考虑一个静态的问题,即长度为 的字符串中有多少个不同的子串。
注意到所有后缀的前缀集合就是的子串集合。考虑先将所有的后缀排序,此时相邻的两个后缀 和 中,相同的前缀数就是 。对于前两个后缀,正确性显然。对于后面的所有后缀,根据前面提到的夹逼定理,,必然有 。等价于 和前面所有后缀的最长公共前缀就是和前一个后缀的最长公共前缀。
记 表示排序后第 个后缀的长度。那么总的不同子串数就是 。
由于每次往后添加数字会改变所有后缀,考虑转化一下题意,即每次向序列开头加入一个字符。那么每次更新只会增加一个后缀。和上一道题思路类似,再转化为每次从开头删去一个字符。那么就是要在后缀序列中删除一个后缀,那么就可以用链表来维护。根据 。 数组是可以 维护的。
最后,由于字符的范围比较大,在进行基数排序前记得离散化。
code:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=1e5+10;
#define LL long long
int num[N],a[N],x[N],y[N],c[N],height[N],sa[N],rk[N],n,m,lef[N],rig[N];
LL ans[N],res;
void get_sa()
{
for(int i=1;i<=n;i++) c[x[i]=a[i]]++;
for(int i=2;i<=m;i++) c[i]+=c[i-1];
for(int i=n;i>=1;i--) sa[c[x[i]]--]=i;
for(int k=1;k<=n;k<<=1)
{
int num=0;
for(int i=n-k+1;i<=n;i++) y[++num]=i;
for(int i=1;i<=n;i++) if(sa[i]>k) y[++num]=sa[i]-k;
for(int i=1;i<=m;i++) c[i]=0;
for(int i=1;i<=n;i++) c[x[i]]++;
for(int i=2;i<=m;i++) c[i]+=c[i-1];
for(int i=n;i>=1;i--) sa[c[x[y[i]]]--]=y[i],y[i]=0;
swap(x,y),num=1,x[sa[1]]=1;
for(int i=2;i<=n;i++) x[sa[i]]=(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])?num:++num;
if(num==n) break;m=num;
}
}
void get_height()
{
for(int i=1;i<=n;i++) rk[sa[i]]=i;
for(int i=1,k=0;i<=n;i++)
{
if(rk[i]==1) continue;
if(k) k--;
int j=sa[rk[i]-1];
while(i+k<=n&&j+k<=n&&a[i+k]==a[j+k]) k++;
height[rk[i]]=k;
}
}
int main()
{
scanf("%d",&n);for(int i=n;i>=1;i--) scanf("%d",&a[i]),num[i]=a[i];
sort(num+1,num+n+1);m=unique(num+1,num+n+1)-num-1;
for(int i=1;i<=n;i++) a[i]=lower_bound(num+1,num+m+1,a[i])-num;
get_sa();get_height();
for(int i=1;i<=n;i++)
{
res+=n-sa[i]+1-height[i];
lef[i]=i-1,rig[i]=i+1;
}
rig[0]=1,lef[n+1]=n;
for(int i=1;i<=n;i++)
{
ans[i]=res;int k=rk[i],j=rig[k];
res-=n-sa[j]+1-height[j];
res-=n-sa[k]+1-height[k];
height[j]=min(height[j],height[k]);
res+=n-sa[j]+1-height[j];
lef[rig[k]]=lef[k];
rig[lef[k]]=rig[k];
}
for(int i=n;i>=1;i--) printf("%lld\n",ans[i]);
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律