最长公共子串(SA和SAM求法)

  • Post author:
  • Post category:其他


这里主要介绍两种求多个字符串最长公共子串的方法。

这里我们以

[POI2000]公共串

为例题来讲一下这两种不同的方法的区别。

前置知识:

后缀数组



后缀自动机


首先先讲一下SA的求法:

我们可以很容易的想明白,排名越靠近的两个字符串,他们的最长公共前缀,也就是LCP会越大,然后我们考虑将这些字符串连接起来,中间用不同的特殊字符分隔开,去求这个大的字符串的后缀数组。

例如:我们要去求下面三个字符串的最长公共子串

1.

abca


2.

abc


3.

bcde


那么通过我们操作后形成的大字符串就是

abca1abc2bcde3


然后我们求出这个大字符串的后缀数组,然后我们来思考一下,如何去计算答案。

很显然,因为经过排序后越相似的子串距离越近,那么我们就可以用类似滑动窗口的那种感觉去选择一段区间,保证选择的这段区间要满足至少包含每一个字符串的一个子串,这样我们求这一段区间的LCP,就代表公共子串的长度,我们求出所有的这些区间,然后将这些长度取



m

a

x

max






ma


x





,就可以轻松的得到答案了。

时间复杂度



O

(

字符串总长度

)

O(字符串总长度)






O


(


字符串总长度


)






下面的代码里会有细节的解释

#include<bits/stdc++.h>
#define endl "\n"
#define x first
#define y second
#define Endl endl
typedef long long ll;
const int INF=0x7fffffff;
const double PI=3.14159265359;
using namespace std;
const int N=1e6+10;
string str;
int L[N],R[N],col[N],cnt[N];
int sum=0;
struct SAIS{//这是个SAIS的板子,O(n)求SA  
	int sa[N],rk[N],s[N<<1],op[N<<1],pos[N<<1],c1[N],c[N];
	int ht[N];
    #define re register
	#define L(x) sa[c[s[x]]--]=x
	#define R(x) sa[c[s[x]]++]=x
	inline void sa_sort(int *S,int n,int m,int *s,int *op,int tn)
	{
	    for(re int i=1;i<=n;i++) sa[i]=0;
	    for(re int i=1;i<=m;i++) c1[i]=0;
	    for(re int i=1;i<=n;i++) c1[s[i]]++;
	    for(re int i=2;i<=m;i++) c1[i]+=c1[i-1];
	    for(re int i=1;i<=m;i++) c[i]=c1[i];
	    for(re int i=tn;i;i--) L(S[i]);
	    for(re int i=1;i<=m+1;i++) c[i]=c1[i-1]+1;
	    for(re int i=1;i<=n;i++)
	    if(sa[i]>1 && op[sa[i]-1]) R(sa[i]-1);
	    for(re int i=1;i<=m;i++) c[i]=c1[i];
	    for(re int i=n;i;i--)
	    if(sa[i]>1 && !op[sa[i]-1]) L(sa[i]-1);
	}
	void SA_IS(int n,int m,int *s,int *op,int *pos)//m代表字符的范围 
	{
	    int tot=0,cnt=0;int *S=s+n;
	    op[n]=0;
	    for(re int i=n-1;i;i--) op[i]=(s[i]!=s[i+1])?s[i]>s[i+1]:op[i+1];
	    rk[1]=0;
	    for(re int i=2;i<=n;i++)
	    if(op[i-1]==1 && op[i]==0) pos[++tot]=i,rk[i]=tot;
	    else rk[i]=0;
	    sa_sort(pos,n,m,s,op,tot);
	    int u=0,p=0;
	    for(re int i=1;i<=n;i++)
	    if(rk[sa[i]])
	    {
	        u=rk[sa[i]];
	        if(cnt<=1 || pos[u+1]-pos[u]!=pos[p+1]-pos[p]) ++cnt;
	        else
	        {
	            for(re int j=0;j<=pos[u+1]-pos[u];j++)
	            if(s[pos[u]+j]!=s[pos[p]+j] || op[pos[u]+j]!=op[pos[p]+j]){++cnt;break;}
	        }
	        S[u]=cnt;
	        p=u;
	    }
	    if(tot!=cnt) SA_IS(tot,cnt,S,op+n,pos+n);
	    else for(re int i=1;i<=tot;i++) sa[S[i]]=i;
	    for(re int i=1;i<=tot;i++) S[i]=pos[sa[i]];
	    sa_sort(S,n,m,s,op,tot);
	}
	void get_ht(int n)
	{
		for(re int i=1;i<=n;i++) rk[sa[i]=sa[i+1]]=i;
		for(re int i=1,p=0;i<=n;ht[rk[i]]=p,i++)
		if(rk[i]!=1) for(p=p-!!p;sa[rk[i]-1]+p<=n && i+p<=n && s[i+p]==s[sa[rk[i]-1]+p];p++);
	}
	void Get_SA(int n){
		for(int i=1;i<=n;i++) s[i]=str[i];
		s[++n]=1;
		SA_IS(n--,122,s,op,pos);//122为字符串的ASCII码范围 
		get_ht(n);
	}
}sa;
void add(int x){//类似滑动窗口的感觉,或者说是莫队? 
	if(!col[x]) return;
	cnt[col[x]]++;
	if(cnt[col[x]]==1) sum++;
}
void del(int x){
	if(!col[x]) return;
	cnt[col[x]]--;
	if(cnt[col[x]]==0) sum--;
}
void solve(){
	int ans=0;
	int n;
	cin>>n;
	for(int i=1;i<=n;i++){//L[i]和R[i]表示这个字符串所在的范围 
		L[i]=R[i-1]+1;
		string temp;
		cin>>temp;
		str+=temp;
		str+=i+'0';//通过不同的分割符,将这些字符串分隔开 
		R[i]=L[i]+temp.size();
	}
	int len=str.size();
	str=" "+str;
	sa.Get_SA(len);
	for(int i=1;i<=n;i++){
		for(int j=L[i];j<R[i];j++) col[sa.rk[j]]=i;//将他们的排名染色,便于之后统计区间是否合法 
	}
	deque<int>q;//单调队列 求区间最值 
	int l=1;
	add(1);
	for(int r=2;r<=len;r++){
		while(q.size()&&sa.ht[q.back()]>=sa.ht[r]) q.pop_back();
		q.push_back(r);
		add(r);
		if(sum==n){
			while(sum==n&&l<r) del(l),l++;
			l--,add(l);
		}
		while(q.size()&&q.front()<=l) q.pop_front();//这里取等于的原因是求LCP的时候 是从l+1到r的ht取min得到的 
		if(sum==n) ans=max(ans,sa.ht[q.front()]);
	 }
 	cout<<ans<<endl;
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int T;
//	cin>>T;
	T=1;
	while(T--){
		solve();
	}

}





接下来讲一下SAM的做法

如果使用SAM去做这道题的话,我们只需要用其中的一个串建立SAM,然后用其他的串在它上面跑匹配就可以了,匹配时我们可以设置两个变量



p

p






p





(当前状态),



l

e

n

len






l


e


n





(当前匹配长度)。

很明显的一点是,如果当前我们可以匹配上的话,我们就让



l

e

n

+

+

len++






l


e


n




+








+





,否则我们就让



p

p






p





跳到



f

a

[

p

]

fa[p]






f


a


[


p


]





,并且让



l

e

n

len






l


e


n





更新为跳



f

a

fa






f


a





后的l



e

n

[

p

]

en[p]






e


n


[


p


]





(这里可以直接更新



l

e

n

len






l


e


n





的原因是因为我们已经匹配到了



p

p






p





,所以当前状态的所有后缀我们也都匹配到了,所以就可以直接更新了)。

另外我们需要记录一下每一个点被匹配到时最长长度是多少,等全部匹配完之后我们要去更新答案,答案长度是所以字符串匹配到此节点时长度的最小值,等所有的字符串都匹配完后,我们就可以计算答案了。

时间复杂度



O

(

字符串总长度

)

O(字符串总长度)






O


(


字符串总长度


)






下面的代码会给出细节的解释

#include<bits/stdc++.h>
#define endl "\n"
#define x first
#define y second
#define Endl endl
typedef long long ll;
const int INF=0x7fffffff;
const double PI=3.14159265359;
using namespace std;
const int N=1e6+10;
string s[N];
int anslen[N*2],tlen[N*2],col[N*2],cnt[N*2];
int ans=0,n;
struct SAM
{
	int idx,last,len[N*2],link[N*2],nex[N*2][26];
    int c[N*2],a[N*2];
	int sz[N*2];
	int v[N*2];
	SAM()
	{
		idx=0;last=0;
		len[0]=0;link[0]=-1;
	}
	void insert(int c)
	{
		int cur=++idx;
		anslen[cur]=len[cur]=len[last]+1;
		int p=last;
		while(p!=-1 && !nex[p][c]) nex[p][c]=cur,p=link[p];
		if(p==-1) link[cur]=0;
		else
		{
			int q=nex[p][c];
			if(len[p]+1==len[q]) link[cur]=q;
			else
			{
				int clone=++idx;
				anslen[clone]=len[clone]=len[p]+1;
				link[clone]=link[q];
				for(int i=0;i<26;i++) nex[clone][i]=nex[q][i];
				while(p!=-1 && nex[p][c]==q) nex[p][c]=clone,p=link[p];
				link[q]=link[cur]=clone;
			}
		}
		last=cur;
	}
	void work(int id,int x){
		int top=0;
		int p=0,sum=0;//p代表现在的状态  sum代表现在的匹配长度 
		for(int i=0;i<s[id].size();i++){
			while(p!=-1&&nex[p][s[id][i]-'a']==0){
				p=link[p];
                if(p==-1) break;
				sum=len[p];
			}
			if(p==-1){
				p=0;
				sum=0;
			}
			else{
				sum++;
				p=nex[p][s[id][i]-'a'];
			}
			int k=p;
			do{
				v[++top]=k;
				tlen[k]=max(sum,tlen[k]);//记录这个节点被匹配到时的最长长度 
                col[k]=x;
                if(cnt[k]==x-1) cnt[k]=x;//如果该字符串在其它字符串中均出现,让它的出现次数++ 
				k=link[k];
			}while(k!=0&&col[k]!=x);
		}
		for(int j=top;j>=1;j--){
			int temp=v[j];//答案长度取min 
			anslen[temp]=min(anslen[temp],tlen[temp]);
		}
        for(int j=top;j>=1;j--){
            int temp=v[j];
            tlen[temp]=0;
        }
	}
	void getans(){
		for(int i=1;i<=idx;i++){
			if(cnt[i]==n){
				ans=max(ans,anslen[i]);
			}
		}
	}
}sam;
void solve(){
	cin>>n;
	int minnid=0,minnlen=INF;
	for(int i=1;i<=n;i++){//将长度最小的字符串建立SAM
		cin>>s[i];
		int len=s[i].size();
		if(minnlen>len){
			minnlen=len,minnid=i;
		}
	}
	for(int i=0;i<minnlen;i++){
		sam.insert(s[minnid][i]-'a');
	}
	for(int i=1;i<=n;i++){//将所有字符串在SAM上匹配一遍 
		sam.work(i,i);
	}
	sam.getans();
	cout<<ans<<endl;
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int T;
//	cin>>T;
	T=1;
	while(T--){
		solve();
	}

}

下面说一下两种方法的区别:

首先SA的优点就是方便好写并且容易理解

SAM的优点就是可以求得很大数量级的字符串的公共子串。



版权声明:本文为r11111se原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。