【算法與數據結構】—— 後綴數組

後綴數組



—— 摘要 ——



後綴數組是處理字符串的有力工具。後綴數組是後綴樹的一個非常精巧的替代品,它比後綴樹容易編程實現,能夠實現後綴樹的很多功能且時間複雜度也並不遜色,而且它比後綴樹所佔用的內存空間小很多。可以說,在信息學競賽中後綴數組比後綴樹要更爲實用。




—— 基本概念 ——



在對後綴數組進行定義之前,需要先了解以下幾個基礎名詞:

  1. 子串:字符串S的子串 str[i , j] ( i ≤ j )表示從字符串S中提取出S[i],S[i+1],……,S[j],並將這些單字符依次序連接形成的字符串。
  2. 後綴:後綴是指從某個位置i開始到整個字符串末尾結束的一個特殊子串。如果我們將某個字符串的後綴用Suffix(i)來表示,那麼Suffix(i)=str[i,strlen(S)],其中strlen(S)表示字符串S的長度。(在敘述部分,我們假設字符串的索引從1開始,下同)。
  3. 字符串大小比較:字符串的大小比較是一種基於字典序進行的比較。比如對於兩個字符串a,b,我們可以令i從1開始順次比較a[i]和b[i]的大小,如果a[i]=b[i],則令i加1,繼續往後比較;否則,一旦出現a[i]<b[i]則認爲a<b,反之則認爲a>b,結束比較。如果i>strlen(a)或i>strlen(b)都仍未比出結果,則認爲字符串長度更短的那個字符串更小。
    從上面字符串的大小比較來看,如果兩個字符串的長度不一致,那麼將這兩個字符串進行比較的結果一定是不等的,因爲不滿足a=b的必要條件:strlen(a)=stalen(b)。因此對於某個字符串S而言,S的兩個不同開頭位置的後綴必不相等。
  4. 後綴數組:後綴數組sa是一個一維數組,它保存的是1-n的某個全排列,並且保證Suffix(sa[i])<Suffix(sa[i+1]),1≤i≤n。也就是將S的n個後綴從小到大進行排序後,將排好序的後綴的開頭位置順次放進sa中。
  5. 名次數組:名次數組rank[i]保存的是Suffix(i)在所有後綴中按從小到大排列的“名次”。

簡單說來,後綴數組是“排第幾的是誰?”,名次數組是“你排第幾?”。容易看出,後綴數組和名次數組爲互逆運算。如下圖所示:
Alt
設字符串的長度爲n。爲了方便比較大小,可以在字符串的最後面再添加一個字符,要求這個字符沒有在前面的字符串中出現過,而且比前面的字符都要小。
任意兩個後綴如果直接比較大小,最多需要比較字符n次,也就是說最遲在比較第n個字符時一定能分出“勝負”。而在求出名次數組後,可以僅用0(1)的時間比較出任意兩個後綴的大小。但我們的程序在進行求解時並未直接求解rank數組,而是在對後綴數組sa進行求解。實際上,由於後綴數組和rank數組之間存在着互逆性,所以我們在求出後綴數組或名次數組中的其中一個以後,便可以用0(n)的時間求出另外一個。




—— 具體實現 ——



對於後綴數組的實現,主要有兩種算法:

  1. 倍增算法
  2. DC3算法

DC3算法的代碼量稍微有點大,在競賽中遇到的時候幾乎不會用這算法(當然,更多的原因是這玩意兒有點難,我確實是搞不懂),因此下面僅對倍增算法進行介紹。
在對倍增算法進行學習前,要求讀者要懂得基數排序的基本思想,否則會很難理解這個算法。如果有不瞭解這個算法的同學,請先移步這篇博客——基數排序(後綴數組基礎)進行學習後再來。
倍增算法的主要思路是:用倍增的方法對每個字符開始的長度爲2k的子字符串進行排序,求出排名,即rank值。k從0開始,每次加1,當2k大於n以後,每個字符開始的長度爲2k的子字符串便相當於所有的後綴。並且這些子字符串一定都已比較出大小,即rank值中沒有相同的值,那麼此時的rank值就是最後的結果。每一次排序都利用上次長度爲2k-1的字符串的rank值,那麼長度爲2k的字符串就可以用兩個長度爲2k-1的字符串的排名作爲關鍵字表示,然後進行基數排序,最後便可得出長度爲2k的字符串的rank值。以字符串“aabaaaab”爲例,整個過程如下圖所示。其中x、y是表示長度爲2k的字符串的兩個關鍵字。
Alt
其具體的解題過程是:
(1)首先計算S[0],S[1],…,S[n-1]的排名(注意這個單個字符的排序)。比如上面,對於aabaaaab,排序後爲:1,1,2,1,1,1,1,2;
(2)計算子串S[0,1],S[1,2],S[2,3],…,S[n-2,n-1],S[n-1,null] 的排名(注意最後一個的第二個字符爲空),由於我們知道了單個字符的排名, 那麼每個子串可以用一個二元組來表示,比如S[0,1]={1,1},S[1,2]={1,2},S[2,3]={2,1},等等,也就是aa,ab,ba,aa,aa,aa,ab,bε(ε表示空)的排名,排序後爲:1,2,4,1,1,1,2,3
(3)計算子串S[0,1,2,3],S[1,2,3,4],S[2,3,4,5],……,S[n-4,n-3,n-2,n-1],S[n-3,n-2,n-1],S[n−2,n−1,],S[n−1,]的排名,方法與上面相同,也是用一個二元組來表示。
接下來的執行流程和(2)(3)類似:每次使用兩個2x-1長度的子串來計算2x長度的子串的排名,直到某一次排序後n個數字各不相同,最後就能將rank數組得到。
那在代碼中我們怎麼通過兩個2x-1長度的子串來計算2x長度的子串的排名呢?還是基數排序!我們通過基數排序來對具體關鍵字所在的索引進行排序,這裏面有個關鍵點是:對關鍵字所在索引進行排序。仔細想,這樣的操作最終不就會得到我們的後綴數組麼?確實如此。雖然上面圖示的流程給你的感覺是在對rank數組進行求解,但實際上rank數組在倍增法中只是作爲一個求後綴數組sa的跳板,我們最終的目的是爲了得到後綴數組。
紙上談兵也許會很空洞,下面我們結合具體的代碼進行分析:

const int N=15010;
class SuffixArray{
	private:
		static const int MAX=N;
		int wa[MAX],wb[MAX],wd[MAX],r[MAX];
		bool isSame(int *r,int a,int b,int len)
		{ return r[a]==r[b] && r[a+len]==r[b+len]; }
		void da(int n,int m)
		{
			//第一次桶排序,求出字符長度爲1時的sa數組 
			int *x=wa,*y=wb,*t;
			for(int i=0;i<m;i++) wd[i]=0;
			for(int i=0;i<n;i++) wd[x[i]=r[i]]++;
			for(int i=1;i<m;i++) wd[i]+=wd[i-1];
			for(int i=n-1;i>=0;i--) sa[--wd[x[i]]]=i;
			for(int j=1,p=1;p<n;j<<1,m=p){
				//對第二關鍵字排序
				p=0;
				for(int i=n-j;i<n;i++) y[p++]=i;
				for(int i=0;i<n;i++) if(sa[i]>=j) y[p++]=sa[i]-j;
				//對第一關鍵字排序
				for(int i=0;i<m;i++) wd[i]=0;
				for(int i=0;i<n;i++) wd[x[i]]++;
				for(int i=1;i<m;i++) wd[i]+=wd[i-1];
				for(int i=n-1;i>=0;i--) sa[--wd[x[y[i]]]]=y[i];
				//利用指針操作調換兩數組的內容 
				t=x,x=y,y=t;
				//更新x數組 
				p=1,x[sa[0]]=0;
				for(int i=1;i<n;i++) x[sa[i]]=isSame(y,sa[i-1],sa[i],j)?p-1:p++;
			}
		}
	public:
		int sa[MAX],n;					//sa表示後綴數組,n表示傳遞進來的字符串的長度 
		void calSuffixArray(char *s)	//計算後綴數組sa 
		{
			n=0;						//初始化n 
			while(*s){					//遍歷字符串 
				r[n++]=*s-'a'+1;		//初始化r數組:將字符型數組轉化爲數值型數組 
				s++;
			} 
			r[n]=0;						//最後在r數組的末尾再添加一個0 
			da(n+1,27);					//開始求sa數組 
		} 
};

我們以該類中的函數調用順序來進行說明。
首先是public成員中的calSuffixArray函數,該函數作爲其類的唯一入口函數,實際上也就是用於求解後綴數組的唯一方法。該函數接受的參數是一個字符串,這個字符串即是需要被計算後綴數組的目標字符串。在calSuffixArray函數裏,首先是通過一個while循環來對字符串s進行遍歷,遍歷的目的有兩個:
① 得到字符串長度,並將其存進變量n中;
② 將字符串的每個字符單獨提取出,用相應的數字進行代替,並存進數組r中(比如上面代碼的意思是用1-26分別表示字母a-z,當然,本程序的處理對象僅僅是小寫字符串,如果含大寫字母你可以用*s-‘A’+1來替代,如果含特殊字符(如!、{、%、?、$等)可以用*s-‘A’+1來替代。具體的替代方案需根據你的處理對象並結合ASCII碼錶得出)。
通過上面的步驟,我們便得到了字符串s的等效替代數組r,這個數組在後面的da函數中才會發揮作用。緊接着,我們就直接調用da函數。這個函數有兩個參數:
① n表示你傳遞的等效替代數組r的長度。由於我們爲了方便後面的處理,在數組r的末尾添加了一個0,因此數組r的長度實際上是比字符串s多1的。所以我們在調用da函數時傳遞進的參數爲n+1。
② m指示了你在用基數排序時所用到的“桶”的數量(基數排序是基於桶排序的)。由於我給出的示例是處理僅含小寫字母的字符串,而小寫英文字母又只有26個,因此在calSuffixArray函數中傳遞進的參數爲27。如果你的處理對象含大小寫字母,那麼你可以將m改爲58;如果你的處理對象含大小寫字母和特殊字符,那麼你可以將m改爲94(具體數值可以根據你的處理對象並結合ASCII碼錶得出)。

接下來到了最關鍵的部分:da函數
進入函數首先是進行一個基數排序,代碼如下:

for(i=0;i<m;i++) wd[i]=0;
for(i=0;i<n;i++) wd[x[i]=r[i]]++;
for(i=1;i<m;i++) wd[i]+=wd[i-1];
for(i=n-1;i>=0;i--) sa[--wd[x[i]]]=i;

其作用是對長度爲1的字符串進行排序,比如對字符串”aabaaaab”進行排序,並將排序後的結果放進後綴數組sa中。而上面的x數組保存的值相當於就算rank值。這裏的工作可被視爲初始化工作。
接下來是若干次的基數排序,目的是對每個長度爲2k的字符串所形成的二元組進行排序。這裏的長度是以2的倍數形式遞增,所以我們可以用一個循環來實現,然後在每次循環中都對當前長度下的二元組進行排序處理。因此在這部分的排序可分爲兩次:第一次是對第二關鍵字排序,第二次是對第一關鍵字排序。對第二關鍵字排序的結果實際上可由上一次求得的sa直接算出,沒有必要再算一次。代碼如下:

for(p=0,i=n-j;i<n;i++) y[p++]=i;
for(i=0;i<n;i++)if(sa[i]>=j) y[p++]=sa[i]-j;

其中變量j表示當前字符串的長度,數組y保存的是對第二關鍵字排序的結果。然後要對第一關鍵字進行排序,代碼如下:

for(int i=0;i<m;i++) wd[i]=0;
for(int i=0;i<n;i++) wd[x[i]]++;
for(int i=1;i<m;i++) wd[i]+=wd[i-1];
for(int i=n-1;i>=0;i--) sa[--wd[x[y[i]]]]=y[i];

這樣便求出了新的sa值。在求出sa後,下一步是更新rank值。這裏要注意的是,可能有多個字符串的rank值是相同的,所以必須比較兩個字符串是否完全相同,y數組的值已經沒有必要保存,爲了節省空間,這裏用y數組保存rank值。這裏又有一個小優化,將x和y定義爲指針類型,複製整個數組的操作可以用交換指針的值代替,不必將數組中值一個一個的複製。代碼如下:

t=x,x=y,y=t;
p=1,x[sa[0]]=0;
for(int i=1;i<n;i++) x[sa[i]]=isSame(y,sa[i-1],sa[i],j)?p-1:p++;

其中isSame函數的代碼是:

bool isSame(int *r,int a,int b,int len)
{ return r[a]==r[b] && r[a+len]==r[b+len]; }

這裏可以看到規定r[n-1]=0的好處,如果r[a]=r[b],說明以r[a]或r[b],開頭的長度爲1的字符串肯定不包括字符r[n-1],所以調用變量r[a+1]和r[b+1]不會導致數組下標越界,這樣就不需要做特殊判斷。執行完上面的代碼後,rank值保存在x數組中,而變量p的結果實際上就是不同的字符串的個數。因此,如果p等於n,那麼函數可以結束。因爲在當前長度的字符串中,已經沒有相同的字符串,接下來的排序不會再改變rank數組,從而也就不會再改變後綴數組sa。
下面給出執行calSuffixArray函數的算法流程圖:
Alt
下面給出一個求解後綴數組sa的演示程序:

#include<iostream>
using namespace std;

const int N=15010;
class SuffixArray{
	private:
		static const int MAX=N;
		int wa[MAX],wb[MAX],wd[MAX],r[MAX];
		bool isSame(int *r,int a,int b,int len)
		{ return r[a]==r[b] && r[a+len]==r[b+len]; }
		void da(int n,int m)
		{
			//第一次桶排序,求出字符長度爲1時的sa數組 
			int *x=wa,*y=wb,*t;
			for(int i=0;i<m;i++) wd[i]=0;
			for(int i=0;i<n;i++) wd[x[i]=r[i]]++;
			for(int i=1;i<m;i++) wd[i]+=wd[i-1];
			for(int i=n-1;i>=0;i--) sa[--wd[x[i]]]=i;
			for(int j=1,p=1;p<n;j<<1,m=p){
				//對第二關鍵字排序
				p=0;
				for(int i=n-j;i<n;i++) y[p++]=i;
				for(int i=0;i<n;i++) if(sa[i]>=j) y[p++]=sa[i]-j;
				//對第一關鍵字排序
				for(int i=0;i<m;i++) wd[i]=0;
				for(int i=0;i<n;i++) wd[x[i]]++;
				for(int i=1;i<m;i++) wd[i]+=wd[i-1];
				for(int i=n-1;i>=0;i--) sa[--wd[x[y[i]]]]=y[i];
				//利用指針操作調換兩數組的內容 
				t=x,x=y,y=t;
				//更新x數組 
				p=1,x[sa[0]]=0;
				for(int i=1;i<n;i++) x[sa[i]]=isSame(y,sa[i-1],sa[i],j)?p-1:p++;
			}
		}
	public:
		int sa[MAX],n;					//sa表示後綴數組,n表示傳遞進來的字符串的長度 
		void calSuffixArray(char *s)	//計算後綴數組sa 
		{
			n=0;						//初始化n 
			while(*s){					//遍歷字符串 
				r[n++]=*s-'a'+1;		//初始化r數組:將字符型數組轉化爲數值型數組 
				s++;
			} 
			r[n]=0;						//最後在r數組的末尾再添加一個0 
			da(n+1,27);					//開始求sa數組 
		} 
};

int main()
{
	char chs[N];
	cin>>chs;
	SuffixArray suffixArray;
	suffixArray.calSuffixArray(chs);
	for(int i=1;i<=suffixArray.n;i++) cout<<suffixArray.sa[i]+1<<" ";
	cout<<endl; 
	return 0;
}

該程序的作用是對某個僅含小寫字母的字符串,求出其對應的後綴數組。比如輸入字符串”lljjllj”,效果如下:
Alt
數據結構的存在是爲了給算法奠基,而算法的意義則是爲解決一些實際問題。
下面我們來看一下後綴數組能用於處理些什麼問題:



—— 應用一:不同子串個數 ——



給定一個僅含小寫字母的字符串,問其有多少個不同的子串。(如:對於字符串abad而言,其不同子串有:a、b、d、ab、ba、ad、aba、bad、abad共9個)。
如果給的字符串長度在1000以內,我們是可以用暴力枚舉的方法來進行求解的,但是長度範圍一旦過大,暴力法就不奏效了。此時,我們的後綴數組就派上用場了。
在解釋用後綴數組求不同字串數量之前,我們需要先了解一個東西:公共前綴。
什麼是公共前綴?比如對於字符串”abcdef”和”abcxyz”而言,其公共前綴顯然是子串”abc”;而對於字符串”abcdef”和”uvwxyz”而言,其公共前綴則爲空。
我們定義height[i]=suffix(sa[i-1])和suffix(sa[i])的公共前綴長度。即:height數組爲排名相鄰的兩個後綴的公共前綴長度。比如對於字符串”aabaaaab”而言,我們不難得出其height數組的內容如下:
Alt
那麼對於任意的j和k(rank[j]<rank[k]),suffix(j)和suffix(k)的公共前綴長度爲:
min( { height[rank[j]+1],height[rank[j]+2], height[rank[j]+3],…,height[rank[k] } )
比如對於字符串”aabaaaab”而言,其後綴”abaaaab”和”aaab”的公共前綴長度爲:
min( { height[3],height[4],height[5],height[6] } ) = min( { 2,3,1,2 } ) = 1

到這裏有同學可能有點懵。我們不是來求不同子串個數麼?現在咋在整這什麼height數組哦。
實際上,對於任何一個子串而言,其都能被表達爲某個後綴的前綴。那麼求解某個字符串的不同字串個數實際上就等價於求所有後綴之間,不同前綴的個數。如果所有的後綴按照suffix(sa[1]),suffix(sa[2]),suffix(sa[3]),… , suffix(sa[n])的順序計算,不難發現,對於每一次新加進來的後綴suffix(sa[k]), 它將產生n-sa[k]+1個新的前綴。但是其中有height[k]個是和前面的字符串的前綴是相同的。所以suffix(sa[k])將“貢獻”出n-sa[k]+1-height[k]個不同的子串。累加後便是原問題的答案。這個做法的時間複雜度爲0(n)。

於是,現在我們的任務是先求出height數組。
如果按height[2],height[3], … height [n]的順序計算,最壞情況下時間複雜度爲0(n2)。這樣做並沒有利用字符串的性質。如果我們定義h[i]=height[rank[i]],也就是suffix(i)和在它前一名的後綴的公共前綴。那麼h數組具有性質:h[i]≥h[i-1]。
此時如果我們按照h[1], h[2], … h[n]的順序計算,並利用h數組的性質,則時間複雜度可降爲0(n)。
在具體實現時,其實沒有必要保存h數組,只須按照h[1], h[2], … h[n]的順序計算即可。代碼如下:

void calRank()						//計算名次數組rank 
{  for(int i=1;i<=n;i++) rank[sa[i]]=i;  }
void calHeight()					//計算相鄰的兩個後綴的最長公共前綴長度數組height 
{
	int j,k=0;
	calRank();
	for(int i=0;i<n;i++){
		if(k) k--;
		int j=sa[rank[i]-1];
		while(r[i+k]==r[j+k]) k++;
		height[rank[i]]=k;
	}
}

這樣就可以通過調用calHeight()函數將height數組求出。

在得到了height數組後,我們便可根據前面的分析,直接寫出求解某個字符串的不同子串的算法如下:

long long calSubstringNum(char *s)	//對於某個字符串str,計算其不同子串的個數 
{
	calSuffixArray(s);
	calHeight();
	long long ans=0;
	for(int i=1;i<=n;i++)
		ans += n - sa[i] -height[i];
	return ans;
}

下面給出整個過程的完整代碼:
#include<iostream>
using namespace std;

const int N=15010;
class SuffixArray{
	private:
		static const int MAX=N;
		int wa[MAX],wb[MAX],wd[MAX],r[MAX];
		bool isSame(int *r,int a,int b,int len)
		{ return r[a]==r[b] && r[a+len]==r[b+len]; }
		void da(int n,int m)						//n表示傳遞進的字符串的長度,m表示最大桶的數量 
		{
			int *x=wa,*y=wb,*t;
			for(int i=0;i<m;i++) wd[i]=0;
			for(int i=0;i<n;i++) wd[x[i]=r[i]]++;
			for(int i=1;i<m;i++) wd[i]+=wd[i-1];
			for(int i=n-1;i>=0;i--) sa[--wd[x[i]]]=i;
			for(int j=1,p=1;p<n;j<<1,m=p){
				//對第二關鍵字排序
				p=0;
				for(int i=n-j;i<n;i++) y[p++]=i;
				for(int i=0;i<n;i++) if(sa[i]>=j) y[p++]=sa[i]-j;
				//對第一關鍵字排序
				for(int i=0;i<m;i++) wd[i]=0;
				for(int i=0;i<n;i++) wd[x[i]]++;
				for(int i=1;i<m;i++) wd[i]+=wd[i-1];
				for(int i=n-1;i>=0;i--) sa[--wd[x[y[i]]]]=y[i];
				//利用指針操作調換兩數組的內容
				t=x,x=y,y=t;
				//更新x數組 
				p=1,x[sa[0]]=0;
				for(int i=1;i<n;i++) x[sa[i]]=isSame(y,sa[i-1],sa[i],j)?p-1:p++;
			}
		}
	public:
		int sa[MAX],rank[MAX],height[MAX],n;//分別表示後綴數組、名次數組、排名相鄰的兩個後綴的公共前綴長度數組、傳遞進來的初始字符串長度 
		void calSuffixArray(char *s)		//計算後綴數組sa 
		{
			n=0;							//初始化n 
			while(*s){						//遍歷字符串
				r[n++]=*s-'!'+1;			//初始化r數組:將字符型數組轉化爲數值型數組
				s++;
			}
			r[n]=0; 						//對於r數組而言,其還需要在最後加一個0方便處理 
			da(n+1,100);					//由於上面多加了個0,因此傳遞進da函數中的r數組長度需再加1 
		}
		void calRank()						//計算名次數組rank 
		{  
for(int i=1;i<=n;i++) 
rank[sa[i]]=i;  
}
		void calHeight()					//計算相鄰的兩個後綴的最長公共前綴長度數組height 
		{
			calRank();
			int k=0;
			for(int i=0;i<n;i++){
				if(k) k--;
				int j=sa[rank[i]-1];
				while(r[i+k]==r[j+k]) k++;
				height[rank[i]]=k;
			}
		}
		long long calSubStringNum(char *s)	//對於某個字符串str,計算其不同子串的個數 
		{
			calSuffixArray(s);
			calHeight();
			long long ans=0;
			for(int i=1;i<=n;i++)
				ans += n - sa[i] - height[i];
			return ans;
		}
};

int main()
{
	char chs[N];cin>>chs;
	SuffixArray suffixArray;
	cout<<"字符串:"<<chs<<"的不同字串個數爲:"<<suffixArray.calSubstringNum(chs)<<endl;
	for(int i=1;i<=suffixArray.n;i++) cout<<suffixArray.sa[i]<<" ";		//sa數組的索引從1開始,但是值從0開始 
	cout<<endl;
	for(int i=0;i<suffixArray.n;i++) cout<<suffixArray.rank[i]<<" ";	//rank數組的索引從0開始,但是值從1開始 
	cout<<endl;
	for(int i=1;i<=suffixArray.n;i++) cout<<suffixArray.height[i]<<" ";	//height數組的索引從1開始 
	cout<<endl; 
	return 0;
}

趁熱打鐵:藍橋杯 算法提高 着急的WYF(不同子串個數)



—— 應用二:最長公共子串 ——



我們先來了解下什麼是公共子串:如果字符串L同時出現在字符串A和字符串B中,則稱字符串L是字符串A和字符串B的公共子串。
現在如果給出兩個字符串A和B,讓你輸出其最長公共子串(Longest Common Substring ,簡稱LCS)。你要怎麼做?
當然,你是可以用暴力法的,但是那樣的時間複雜度達到了0(n3),在字符長度超過1000時就難以勝任了。這時,我們的後綴數組再次出場!

我們知道字符串的任何一個子串都可表達爲這個字符串的某個後綴的前綴,因此求A和B的最長公共子串等價於求A的後綴和B的後綴的最長公共前綴的最大值。如果枚舉A和B的所有的後綴,那麼這樣做顯然效率低下。由於要計算A的後綴和B的後綴的最長公共前綴,所以可先將第二個字符串寫在第一個字符串後面,中間用一個沒有出現過的字符隔開,再求這個新的字符串的後綴數組。觀察一下,看看能不能從這個新的字符串的後綴數組中找到一些規律。以A=”aaabaa”,B=”abaa”爲例,首先我們用一個特殊字符”$”來將這兩個字串連接起,得到新的字符串”aaaba$abaa”,然後我們可以得到其height數組的內容如下(其中屬於字符串A的後綴用藍色作爲背景、屬於字符串B的後綴用黃色作爲背景):

Alt
在上表中,容易看出height數組裏的最大值爲3,並且對於字符串A=”aaabaa”,B=”abaa”而言,其最長公共字串確實是”aba”,長度爲3。那是不是所有height值中的最大值就是答案呢?從上表的構建邏輯來看確實是成立的,但是這樣的成立建立在一個基礎之上:這兩個後綴不在同一個字符串中的!所以實際上,只有當suffix(sa[i-1])和suffix(sa[i])不是同一個字符串中的兩個後綴時,height[i]才滿足要求。而這個條件很容易滿足,用一個變量k存放特殊字符”$”所在位置,然後將每次欲更新最大值的那個height[i]所在的後綴用於和k進行一個比較即可。
若記字符串A和字符串B的長度分別爲len(A)和len(B)。則求新的字符串的後綴數組和height數組的時間是O(len(A)+ len(B)),然後求排名相鄰但原來不在同一個字符串中的兩個後綴的height值的最大值,時間也是O(len(A)+ len(B)),所以整個做法的時間複雜度爲O(len(A)+ len(B))。時間複雜度已經取到下限,由此看出,這是一個非常優秀的算法。

下面給出“求最長公共子串”的代碼:

bool check(int i,int k)							//判斷height[i]的兩個比較後綴是否不在同一個字符串中(即是否在k的兩邊)
{
	if( (sa[i-1]-k)*(sa[i]-k)<0 ) return true;	//結果小於0說明異號,異號說明sa[i-1]和sa[i]在k的兩邊(即要麼sa[i-1]<k && sa[i]>k;要麼sa[i]<k && sa[i-1]>k) 
	return false;
}

int calLongestCommonSubString(char *a,char *b,char *s)	//計算某兩個字符串a,b的最長公共子串s,並返回其長度
{
	int max=0;											//用於保存最長公共子串的長度 
	int pos=strlen(a);									//得到字符串a的長度(之後將用它標記最大height的位置)
	int flagPos=pos;									//記錄兩個字符中間插入的特殊字符的位置 
	a[pos++]='$';										//在字符串a的末尾插入特殊字符 
	strcpy(a+pos,b);									//將字符串b追加到字符串a的後面
	calSuffixArray(a);									//計算字符串a的後綴數組sa 
	calHeight();										//計算字符串a的rank數組和height數組 
	for(int i=2;i<=n;i++)								//尋找最大的height
		if(height[i]>max && check(i,flagPos)){
			pos=i;										//更新pos 
			max=height[i];								//更新max 
		}
	int x=0,maxPos=max+sa[pos];
	for(int i=sa[pos];i<maxPos;i++)
		s[x++] = a[i];
	return max;
}

最後給出整個過程的完整代碼(對於給定的某兩個字符串(僅含小寫字母),輸出這兩個字符串的最長公共子串):
#include<iostream>
#include<cstring>
using namespace std;

const int N=15010;
class SuffixArray{
	private:
		static const int MAX=15010;
		int wa[MAX],wb[MAX],wd[MAX],r[MAX];
		bool isSame(int *r,int a,int b,int len)
		{ return r[a]==r[b] && r[a+len]==r[b+len]; }
		bool check(int i,int k)
		{
			if( (sa[i-1]-k)*(sa[i]-k)<0 ) return true;	//結果小於0說明異號,異號說明sa[i-1]和sa[i]在k的兩邊 
			return false;
		} 
		void da(int n,int m)
		{
			int *x=wa,*y=wb,*t;
			for(int i=0;i<m;i++) wd[i]=0;
			for(int i=0;i<n;i++) wd[x[i]=r[i]]++;
			for(int i=1;i<m;i++) wd[i]+=wd[i-1];
			for(int i=n-1;i>=0;i--) sa[--wd[x[i]]]=i;
			for(int j=1,p=1;p<n;j<<2,m=p){
				p=0;
				for(int i=n-j;i<n;i++) y[p++]=i;
				for(int i=0;i<n;i++) if(sa[i]>=j) y[p++]=sa[i]-j;
				for(int i=0;i<m;i++) wd[i]=0;
				for(int i=0;i<n;i++) wd[x[i]]++;
				for(int i=1;i<m;i++) wd[i]+=wd[i-1];
				for(int i=n-1;i>=0;i--) sa[--wd[x[y[i]]]]=y[i];
				t=x,x=y,y=t;
				p=1,x[sa[0]]=0;
				for(int i=1;i<n;i++) x[sa[i]]=isSame(y,sa[i-1],sa[i],j)?p-1:p++;
			}
		}
	public:
		int sa[MAX],rank[MAX],height[MAX],n;
		void calSuffixArray(char *s)
		{
			n=0;
			while(*s){
				r[n++]=*s-'a'+1;
				s++;
			}
			r[n]=0;
			da(n+1,100);
		}
		void calRank()
		{
			for(int i=1;i<=n;i++)
				rank[sa[i]]=i;
		}
		void calHeight()
		{
			calRank();
			int k=0;
			for(int i=0;i<n;i++){
				if(k) k--;
				int j=sa[rank[i]-1];
				while(r[i+k]==r[j+k]) k++;
				height[rank[i]]=k;
			}
		}
		long long calSubStringNum(char *s)
		{
			calSuffixArray(s);
			calHeight();
			long long ans=0;
			for(int i=1;i<=n;i++)
				ans += n-sa[i]-height[i];
			return ans;
		}
		int calLongestCommonSubString(char *a,char *b,char *s)	//計算某兩個字符串a,b的最長公共子串s,並返回其長度 
		{
			int max=0;											//用於保存最長公共子串的長度 
			int pos=strlen(a);									//得到字符串a的長度(之後將用它標記最大height的位置)
			int flagPos=pos;									//記錄兩個字符中間插入的特殊字符的位置 
			a[pos++]='$';										//在字符串a的末尾插入特殊字符 
			strcpy(a+pos,b);									//將字符串b追加到字符串a的後面
			calSuffixArray(a);									//計算字符串a的後綴數組sa 
			calHeight();										//計算字符串a的rank數組和height數組 
			for(int i=2;i<=n;i++)								//尋找最大的height
				if(height[i]>max && check(i,flagPos)){
					pos=i;										//更新pos 
					max=height[i];								//更新max 
				}
			int x=0,maxPos=max+sa[pos];
			for(int i=sa[pos];i<maxPos;i++)
				s[x++] = a[i];
			return max;
		}
};

int main()
{
	char chs[N],chs_[N],ans[N];
	cin>>chs>>chs_;
	SuffixArray suffixArray;
	cout<<suffixArray.calLongestCommonSubString(chs,chs_,ans)<<endl;
	cout<<ans<<endl;
	return 0;
}


本文參考自:
《國家集訓隊論文》羅穗騫
《後綴數組:倍增法和DC3的簡單理解》Only the Strong Survive 的博客
感謝他們在文章中精彩的推論和分析,使我能在巨人的肩膀上學習。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章