后缀数组学习笔记

后缀数组SA的学习笔记

1 一些基本定义

  • \(su_i\) 表示字符串从 \(i\) 开始的后置。简单记作 \(i\) 后缀。
  • \(rk_i\) 后缀 \(i\) 的排名,得到的是一个排名
  • \(sa_i\) 排名为 \(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(su_{sa[i]},su_{sa_[j]})\) ,也就是代表 排名为 i 和 j 的后缀的LCP。

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

然后我们会有一个非常强大的定理 \(h[rk[i]]\ge h[rk[i-1]]-1\)

考虑证明:

对于 \(h[rk[i-1]]\le 1\) 的情况,这是显然的。

考虑 \(h[rk[i-1]]>1\) 的情况。

那么设 \(sa[rk[i-1]]\)\(aAD\) ,那么 \(sa[rk[i-1]-1]\) 就是 \(aAB\) ,且 \(B<D\)

显然 \(sa[rk[i]]\) 就是 \(AD\) ,因为有 \(aAB\) ,那么还会有 \(AB\) ,因为 \(sa[rk[i]-1]\) 的排名只比 \(sa[rk[i]]\) 小1,所有会有 \(AB\le sa[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)\ge min(|A|,|B|)\) 的话,会有 \(A<B\)\(|A|<|B|\) 互相为充要条件。否则, \(A<B\)\(rk[a]<rk[c]\) 互为充要条件。非常的显然。
  • 不同子串的数目: 结论就是总数减去 \(\sum{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的数量,答案就是 \(\sum{a[i]b[i+1]}\) 。我们的目的就是求出这个 \(a,b\) 数组。

显然, \(n^2\) 的做法是很容易的,只需要一个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 。这样就是 \(n^2\) 的。考虑如何优化。

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

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

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

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

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

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

posted @ 2024-01-30 09:47  shAdomOvO  阅读(26)  评论(0)    收藏  举报