后缀数组学习笔记

后缀数组SA的学习笔记

1 一些基本定义

  • sui 表示字符串从 i 开始的后置。简单记作 i 后缀。
  • rki 后缀 i 的排名,得到的是一个排名
  • sai 排名为 i 的后缀是谁。(这就是我们要求的后缀数组SA)得到的是一个后缀
  • lcp ,两个字符串的最长公共前缀

2 算法流程

我们考虑使用倍增的做法,我们可以先求出长度为 1 的情况的下的排名,然后长度为 2 的字符串就是由前后两个长度为1的拼起来的,然后我们使用基数排序的思想,用两个桶,就可以求出合并后的排名了。然后更新一下下一轮的答案就好。

3 code

#include<bits/stdc++.h> using namespace std; const int N=1e6+10; char a[N]; int n; void init(){ cin>>(a+1); n=strlen(a+1); } int sa[N],c[N],y[N],m=122,x[N]; //sa表示的是排名为 i 的后缀的位置 //y表示第二关键字排名为 i 的第一关键字对应的位置是那个,其实也就是这个第二关键字的位置-k void solve(){ for(int i=1;i<=n;i++)c[x[i]=a[i]]++; for(int i=2;i<=m;i++)c[i]+=c[i-1]; //c数组是出现次数的前缀和,也就是对桶做一个前缀和 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; //i对应第一关键字对应的第二关键之的位置为 i+k for(int i=1;i<=n;i++) if(sa[i]>k)y[++num]=sa[i]-k; //sa[i] 代表的子串对应的第一关键字去做第二关键字,所以位置就是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); } m=num; if(num==n)break; } for(int i=1;i<=n;i++)cout<<sa[i]<<" "; } int main(){ init(); solve(); return 0; }

4 LCP

4.1 基础定义

虽然我们已经讲完了SA了,但是LCP是后缀数组的题目中经常要用上的东西,所以也是要讲的。

我们定义 LCP(i,j) 表示 LCP(susa[i],susa[j]) ,也就是代表 排名为 i 和 j 的后缀的LCP。

我们定义一个很重要的东西 h[i] 表示 lcp(sa[i],sa[i1])

然后我们会有一个非常强大的定理 h[rk[i]]h[rk[i1]]1

考虑证明:

对于 h[rk[i1]]1 的情况,这是显然的。

考虑 h[rk[i1]]>1 的情况。

那么设 sa[rk[i1]]aAD ,那么 sa[rk[i1]1] 就是 aAB ,且 B<D

显然 sa[rk[i]] 就是 AD ,因为有 aAB ,那么还会有 AB ,因为 sa[rk[i]1] 的排名只比 sa[rk[i]] 小1,所有会有 ABsa[rk[i]1]<AD 。所以 lcp(sa[rk[i]1],sa[rk[i]]) 至少也是 A 的。

4.2 O(n) 求 h 数组

有了上面这个定理,就可以暴力求这个式子了。

for(int i=1,k=0;i<=n;i++){ if(k)k--;//h[rk[i]]>=h[rk[i-1]]-1 while(s[i+k]==s[sa[rk[i]-1]+k])k++; //h[rk[i]] 的定义就是 lcp(sa[rk[i]],sa[rk[i]-1]) ,而sa就是代表这个后缀的开始位置 h[rk[i]]=k; }

4.3 关于 h 的一些应用

  • lcp(sa[i],sa[j])=min{h[i+1..j]}
  • 比较两个子串的大小关系:有两个子串 A=S[a,b],B=S[c,d] ,若 lcp(a,c)min(|A|,|B|) 的话,会有 A<B|A|<|B| 互相为充要条件。否则, A<Brk[a]<rk[c] 互为充要条件。非常的显然。
  • 不同子串的数目: 结论就是总数减去 h[i] 。因为有 lcp(sa[i],sa[j])=min 。所以按SA的顺序去枚举这些后缀的话,他与前面的 lcp 都不会大于 h[i] ,所以 h[i] 位之后的都是不同的字符。

5 一些题目

P2852 [USACO06DEC] Milk Patterns G

出现至少 k 次的子串的最大长度:我们知道,后缀排序完后会有一个很好的性质,那就是前缀相同的一些后缀会在一段连续的区间里面(因为字符串比大小是从前往后比的,所以这一些在这一部分是完全相同的)。然后,我们发现,连续出现 k 次相同的子串,就是有这 k 个后缀的前缀相同, 因为这 k 个后缀显然会在一起,所以我们只要求连续 k 个后缀的最长公共前缀,就是 min{h[i]} ,所以用一个单调队列就好。

P1117 [NOI2016] 优秀的拆分

给你一个字符串,如果一个字符串可以被拆分成AABB的形式,那么这种拆分是优秀的,同一个字符串不同AB算是不同的拆法,问给出的字符串的所有的子串的优秀拆分数之和。

显然,AABB其实可以看成两个AA拼接起来,所以我们只需要求出AA的数量,为了不重复,我们需要求出以i开头的AA的数量和以i结尾的AA的数量,答案就是 a[i]b[i+1] 。我们的目的就是求出这个 a,b 数组。

显然, n2 的做法是很容易的,只需要一个hash就好。这居然有足足95pts,显然考场上剩下的5pts是没有拿的意义的。考虑如何优化。

我们枚举 A 的长度 len

然后我们每隔 len 的距离放置一个点,那么显然,AA 肯定会经过其中的两个点,并且只经过两个确定的点。所以我们考虑枚举相邻的两个点来求 AA 的数量。

总结:

感觉这个正解比较逆天,考场上不是很想得到。但是以后遇到类似于求 AA 这种东西的可以借用这道题的思路。再想了一下,还是逆天,太跳脱了,不过调这道题的时候注意到,因为各种奇怪的边界问题,所以在多测的时候,rk,x,y 都要多清空一些。可能会访问到 rk[n+1],x[n+k] 的 ,因为 l,r 可能为 n+1

#include<bits/stdc++.h> using namespace std; #define int long long const int N=6e5+10; int T,n; char a[N]; void init(){ cin>>(a+1); n=strlen(a+1); } struct node{ int sa[N],h[N],x[N],y[N],m=256,c[N],logn[N],f[N][25],rk[N],X[N]; void buildsa(){ m=256; memset(c,0,sizeof(c)); memset(sa,0,sizeof(sa)); memset(h,0,sizeof(h)); memset(rk,0,sizeof(rk)); memset(x,0,sizeof(x)); memset(y,0,sizeof(y)); for(int i=1;i<=n;i++)x[i]=X[i]; for(int i=1;i<=n;i++)c[x[i]]++; for(int i=1;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=1;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); x[sa[1]]=1;num=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; } m=num; if(num==n)break; } // for(int i=1;i<=n;i++)cout<<sa[i]<<" "; for(int i=1;i<=n;i++)rk[sa[i]]=i; for(int i=1,k=0;i<=n;i++){ if(k)k--; while(X[i+k]==X[sa[rk[i]-1]+k])k++; h[rk[i]]=k; } // for(int i=1;i<=n;i++)cout<<h[i]<<" "; } void buildst(){//构建一个取区间最小值的st表 //f[i][j] 记录的是 i~i+2^j-1 logn[1]=0; for(int i=2;i<=n;i++)logn[i]=logn[i/2]+1; for(int i=1;i<=n;i++)f[i][0]=h[i]; for(int j=1;j<=logn[n];j++){ for(int i=1;i+(1<<j)-1<=n;i++){ f[i][j]=min(f[i][j-1],f[i+(1<<(j-1))][j-1]); } } } int LCP(int l,int r){ l=rk[l],r=rk[r]; if(l>r)swap(l,r);l++; int s=logn[r-l+1]; return min(f[l][s],f[r-(1<<s)+1][s]); } // void clear(){ // for(int i=1;i<=n;i++) // } }SA[2];//SA[0] 表示正着的,SA[1] 表示倒着的 int cha[2][N];//g[0] 表示以i为开始的AA g[1] 表示以 i 为结束的 AA void solve(){ for(int i=1;i<=n;i++)SA[0].X[i]=a[i],SA[1].X[n-i+1]=a[i]; // for(int i=1;i<=n;i++)cha[0][i]=cha[1][i]=0; memset(cha,0,sizeof(cha)); SA[0].buildsa(); SA[1].buildsa(); SA[0].buildst(); SA[1].buildst(); for(int len=1;len<=n/2;len++){ for(int i=1;i+len<=n;i+=len){//直接枚举这些隔点 int l=i,r=i+len; int lcp=SA[0].LCP(l,r);lcp=min(lcp,len); int lcs=SA[1].LCP(n-(r-1)+1,n-(l-1)+1);lcs=min(lcs,len-1); if(lcs+lcp<len)continue; int k=lcs+lcp-len; cha[0][i-lcs]++; cha[0][i-lcs+k+1]--; // cha[1][i-lcs+2*len-1]++; // cha[1][i-lcs+2*len-1+k+1]--; cha[1][r+lcp-(lcp+lcs-len+1)]++; cha[1][r+lcp]--; } } for(int i=1;i<=n;i++)cha[0][i]+=cha[0][i-1],cha[1][i]+=cha[1][i-1]; // for(int i=1;i<=n;i++)cout<<cha[0][i]<<" "<<cha[1][i]<<"\n"; int ans=0; for(int i=1;i<=n-1;i++)ans+=cha[1][i]*cha[0][i+1]; cout<<ans<<"\n"; } signed main(){ cin>>T; while(T--){ init(); solve(); // SA[0].clear(); // SA[1].clear(); } }

P2178 [NOI2015] 品酒大会

这个问题分成两问,第一问求的是 i 相似的酒的对数。先考虑简单一点的,我们枚举这个想要的 r ,那么我们可以求出SA后用一个单调队列,如果这段区间的 h 数组的 min 大于 r 的话,这段区间就是可以选的,答案就加上当前的区间长度-1 。这样就是 n2 的。考虑如何优化。

还是考虑新加入一个后缀 i 对前面的贡献。显然,我们可以找到这样一个 k ,使得 minki 变成 hi ,而前面那一部分又没有改变。后面改变的这一部分很好算,一个线段树就好,我们每次新加入一个数最多会多出一中不同的状态,而我们可以当一个状态被后面修改的时候再计算他的贡献。

所以每次新加一个数,我们设他的值为 y ,那么这个值可以对应一个点 (x,y) ,直接暴力的扫前面的数是否比他大,如果大的话,前一个状态就会被去掉,我们计算这个被去掉的状态的贡献,然后把这个状态删掉,并把当前加入的这个状态的 x 改为被删掉的那个状态的 x 。最后没有可以删除的状态后,计算这一大块的贡献,就是 一开始的 x1 - 现在的 x2 乘上 y 。然后对这个状态打上一个标记,至于设置一个什么样的标记,我们记录他的 次数与 x 的总和。计算贡献就是对于 [1,y] 加上这个总 和 sum - 次数乘以当前状态的 x 。最后再加入这个状态就好。因为每个元素只会进出各一次,再加上线段树的复杂度,就是 (nlogn) 的 。

考虑怎么求第二问,感觉可以跟第一问一起求,每个状态再记录一下最大值与最小值。每次删除一个状态的时候用这个加入的状态乘起来就好。就是修改的标记可能要用 vector 来保存修改。

看了一下感觉这个就是正解,但是实在不好打,艹。

一种更加简单的写法,我们发现这个标记是会一直存在的,把它当成一并查集,每次合并相邻的两个状态,并把他们的标记合并。并更新一下他们的 x,y,还是前一个点的 x 和当前点的 y

具体来说,每个点记录他上一个跟他不在同一个并查集的点的位置,如果他的值小于他的上一个,就合并他们两个对应的并查集。并把当前点 i 对应的并查集的标记的贡献算出来。然后将当前点 i 的标记加到上一个点对应的并查集上。然后我们在写一个区间加和区间取 max 的树状数组就好。每个并查集的出事状态的标记就是当前 x 和次数 1 。


__EOF__

本文作者shAdom
本文链接https://www.cnblogs.com/shadom/p/17995831.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   shAdomOvO  阅读(6)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示