后缀数组及其应用

这里简单介绍一下倍增算法((O(nlogn)))求后缀数组
 


 
 
 

定义

定义(S)是长度为(n)的字符串,后缀(suf[i])(S[isim n])

(S)的所有后缀按字典序排序:

  • (rk[i](rank[i]))(suf[i])的排名;
  • (sa[i])指第(i)名的后缀是(suf[sa[i]])
  • (height[i])指第(i)名和第(i-1)名后缀的最长公共前缀((lcp))

 
 

算法原理

sa数组求法

求得(sa)数组也就求得了(rk)

首先我们可以很容易想到一个快排实现的算法,如果基于逐字符比较的算法时间是(O(n^2logn))

我们也可以想到一个优化,用(Hash+)二分代替逐字符比较,这样时间是(O(nlog^2n))

另外的想法:

可以把每个后缀看成由(ASCII)码构成的数列,用基数排序来实现,这样时间是(O(n^2))的;

想办法优化:

因为待排序的字符串都是(S)上面的后缀,它们是有相同部分的;

这样我们可以引入这样的一个算法:

1.对于每个后缀的第一个字符进行一次排序,得到它们的排名作为每一个后缀的第一关键字;

2.对于每个后缀的前两(1+1)个字符进行一次排序,(将(suf[i])的前两个字符(S[i],S[i+1]),看做一个整体进行排序,由于第一步已经的到了长度为1的子串的排名,这里就等价于将它们的排名进行合并后再排序),再得到它们的排名作为每一个后缀的第一关键字;

3.对于每个后缀的前三个(1+2)个字符进行依次排序,再得到它们的排名作为每一个后缀的第一关键字;

4.对于每个后缀的前五个(1+4)个字符进行依次排序,

······

直到所有排名都互不相同;

 
看图片更好理解,就是一个不停排序合并再排序的过程,每次合并时都能保证是上一步已经解决的问题,且对于每一个后缀都能不重不漏地包含所有字符,因此这样的排序效果上是等价于暴力基数排序的;

又由于合并长度是倍增实现的,所以最多进行(log_2n)次排序,而每次排序都使用位数为(2)的基数排序,就能使算法时间复杂度维持在(O(nlogn))级别;

这部分的的代码

#include<bits/stdc++.h>
#define ll long long 
#define mp make_pair
using namespace std;

const int N=100005;
char c[N];
int n,m=127;//m是值域的初始大小  
int tub[N];//桶 
int fk[N],psk[N];//注意!!!fk(first key)是某个位置的一关键字 
//而psk(pos of second key)是 排名为i的二关键字所在的位置 
int sa[N],height[N],rk[N];
//ll ans;

inline int read()
{
	int x=0,fl=1;char st=getchar();
	while(st<'0'||st>'9'){ if(st=='-')fl=-1; st=getchar();}
	while(st>='0'&&st<='9') x=x*10+st-'0',st=getchar();
	return x*fl;
}

inline void rsort()//基数排序 
{
	for(int i=1;i<=m;i++) tub[i]=0;//清空桶 
	for(int i=1;i<=n;i++) ++tub[fk[i]];//将第一关键字加入桶中 
	for(int i=1;i<=m;i++) tub[i]+=tub[i-1];//求桶的前缀和,得到一关键字的排名 
	for(int i=n;i>=1;i--) sa[tub[fk[psk[i]]]--]=psk[i];//按照从后到前的顺序,依据二关键字的排名得到目前的sa 
}

inline void SA()
{
	for(int i=1;i<=n;i++) fk[i]=c[i],psk[i]=i; //初始 
	rsort();//第一次排序 
	for(int k=1;k<=n;k<<=1)//合并的长度 
	{
		int cnt=0;
		for(int i=n-k+1;i<=n;i++) psk[++cnt]=i;//这一部分位置是没有能与其合并的 ,其二关键字应是最小的 
		for(int i=1;i<=n;i++)
			if(sa[i]>k) psk[++cnt]=sa[i]-k; //这里的sa[i]会被某个位置合并,这个位置是sa[i]-k
		//这一部分的二关键字是按照顺序加入的 
		rsort();
		swap(fk,psk);//将fp放到psk转存,方便得到下一次的一关键字 
		fk[sa[1]]=1;
		cnt=1;
		for(int i=2;i<=n;i++) //如果两个位置的一关键字和二关键字都相同,则排名不同,需要+1  
			fk[sa[i]]=(psk[sa[i]]==psk[sa[i-1]]&&psk[sa[i]+k]==psk[sa[i-1]+k])?cnt:++cnt;
		if(cnt==n) break;//如果已经所有排名不同了就可以退出 
		m=cnt;//更新值域 
	}
}

int main()
{
	n=read();
	scanf("%s",c+1);
	SA();
//	get_height();
//	ans=(ll) n*(n+1)/2;
//	for(int i=1;i<=n;i++)
//		ans-=height[rk[i]];
//	printf("%lld",ans);
	return 0;
}

height数组的求法

 
主要依靠一个性质:

(height[rk[i]] ≥ height[rk[i-1]]-1)

感性证明:

假设排在(suf[i-1])前面的那个串是(suf[k])它们的最长公共前缀长度为(height[rk[i-1]])

(suf[i])(suf[i-1])少了它的第一个字符,可得(suf[i])(suf[k+1])的最长公共前缀是(height[rk[i-1]]-1)

(suf[i])(suf[k+1])在排序后不一定在一起(即(height[rk[i]])不一定是它们的最长公共前缀),

可是就算不在一起,在排好序的后缀中(suf[k+1])(suf[i])之间的后缀都一定满足,与(suf[i])的最长公共前缀至少是(height[rk[i-1]]-1)

那么一定有(height[rk[i]] ≥ height[rk[i-1]]-1)

这样就可以得到一个(O(n))的代码

inline void get_height()
{
    int k=0;
    for(int i=1;i<=n;++i) rk[sa[i]]=i;
    for(int i=1;i<=n;++i)  //i是位置不是排名 
    {
        if(k)--k;//-1
        int j=sa[rk[i]-1];//找到排名前一个的位置 
        while(j+k<=n&&i+k<=n&&c[i+k]==c[j+k])++k;//继续匹配 
        height[rk[i]]=k;
    }
}

 
 
 


 
 

一些应用

询问任意两个后缀的lcp

可以得知(suf[i])(suf[j])(rk[i]<rk[j]))的(lcp)长度就是(minlimits_{k=rk[i]+1}^{rk[j]}height[k])

这就变成一个区间最小值的问题了;

 
 

本质不同的子串

(S)的所有子串个数是(n*(n+1)/2)

(S)的所有子串也可以由,所有后缀的前缀得来;

其中相同的有(sum height[i])个;

所以有:

[ans=dfrac{n imes (n+1)}{2}-sum height[i] ]

 
 

两个串的最长公共子序列

(Dp)会超时的那种;

一个简单的做法是:

将两个串合并成一个串,并且中间用一个不会出现的字符隔开(如%),求出新串的后缀数组;

求出一个最大的(height[i]),满足(sa[i])(sa[i-1])在不同的串里面;

这个做法可以推广到多个串的最长公共子序列,但时间也会增加;

 
 
 

出现次数大于等于k的最长子串

找出,长度为(k-1)的序列的(height[])的最小值,的最大值;

原文地址:https://www.cnblogs.com/yudes/p/SA.html