後綴數組

後綴數組——處理字符串的有力工具

作者:羅穗騫

2009年1月

  【摘要】

  後綴數組是處理字符串的有力工具。後綴數組是後綴樹的一個非常精巧的替代品,它比後綴樹容易編程實現,能夠實現後綴樹的很多功能而時間複雜度也並不遜色,而且它比後綴樹所佔用的內存空間小很多。可以說,在信息學競賽中後綴數組比後綴樹要更爲實用。本文分兩部分。第一部分介紹兩種構造後綴數組的方法,重點介紹如何用簡潔高效的代碼實現,並對兩種算法進行了比較。第二部分介紹後綴數組在各種類型題目中的具體應用。

  【關鍵字】

  字符串,後綴,後綴數組,名次數組,基數排序,

  【正文】

一、後綴數組的實現

  本節主要介紹後綴數組的兩種實現方法:倍增算法(Doubling Algorithm)和DC3算法(Difference Cover),並對兩種算法進行了比較。可能有的讀者會認爲這兩種算法難以理解,即使理解了也難以用程序實現。本節針對這個問題,在介紹這兩種算法的基礎上,還給出了簡潔高效的代碼。其中倍增算法只有25行,DC3算法只有40行。

1.1、基本定義

  子串:字符串S的子串r[i..j],i≤j,表示r串中從i到j這一段,也就是順次排列r[i],r[i+1],...,r[j]形成的字符串。

  後綴:後綴是指從某個位置i開始到整個串末尾結束的一個特殊子串。字符串r的從第i個字符開始的後綴表示爲Suffix(i),也就是Suffix(i)=r[i..len(r)]。

  大小比較:關於字符串的大小比較,是指通常所說的“字典順序”比較,也就是對於兩個字符串u、v,令i從1開始順次比較u[i]和v[i],如果u[i]=v[i]則令i加1,否則若u[i]<v[i]則認爲u<v,u[i]>v[i]則認爲u&gt;v(也就是v<u),比較結束。如果i>len(u)或者 i>len(v)仍比較不出結果,那麼若len(u)<len(v)則認爲u<v,若 len(u)=len(v)則認爲u=v,若len(u)>len(v)則 u>v。

  從字符串的大小比較的定義來看,S的兩個開頭位置不同的後綴 u和v進行比較的結果不可能是相等,因爲 u=v的必要條件len(u)=len(v)在這裏不可能滿足。

  後綴數組:後綴數組SA是一個一維數組,它保存1..n的某個排列SA[1],SA[2],……,SA[n],並且保證 Suffix(SA[i])<Suffix(SA[i+1]),1≤i<n。也就是將S的n個後綴從小到大進行排序之後把排好序的後綴的開頭位置順次放入SA中。

  名次數組:名次數組Rank[i]保存的是Suffix(i)在所有後綴中從小到大排列的“名次”。

  簡單的說,後綴數組是“排第幾的是誰?”,名次數組是“你排第幾?”。容易看出,後綴數組和名次數組爲互逆運算。如圖1所示。

  設字符串的長度爲n。爲了方便比較大小,可以在字符串後面添加一個字符,這個字符沒有在前面的字符中出現過,而且比前面的字符都要小。在求出名次數組後,可以僅用O(1)的時間比較任意兩個後綴的大小。在求出後綴數組或名次數組中的其中一個以後,便可以用O(n)的時間求出另外一個。任意兩個後綴如果直接比較大小,最多需要比較字符n次,也就是說最遲在比較第n個字符時一定能分出“勝負”。

1.2、倍增算法

  倍增算法的主要思路是:用倍增的方法對每個字符開始的長度爲2k的子字符串進行排序,求出排名,即rank值。k從0開始,每次加1,當2k大於n以後,每個字符開始的長度爲2k的子字符串便相當於所有的後綴。並且這些子字符串都一定已經比較出大小,即rank值中沒有相同的值,那麼此時的rank值就是最後的結果。每一次排序都利用上次長度爲2k-1的字符串的rank值,那麼長度爲2k的字符串就可以用兩個長度爲2k-1的字符串的排名作爲關鍵字表示,然後進行基數排序,便得出了長度爲2k的字符串的rank值。以字符串“aabaaaab”爲例,整個過程如圖2所示。其中x、y是表示長度爲2k的字符串的兩個關鍵字。

  具體實現:

    int wa[maxn],wb[maxn],wv[maxn],ws[maxn];

    int cmp(int *r,int a,int b,int l)
    {return r[a]==r[b]&&r[a+l]==r[b+l];}

    void da(int *r,int *sa,int n,int m)
    {
        int i,j,p,*x=wa,*y=wb,*t; 
        for(i=0;i<m;i++) ws[i]=0; 
        for(i=0;i<n;i++) ws[x[i]=r[i]]++; 
        for(i=1;i<m;i++) ws[i]+=ws[i-1]; 
        for(i=n-1;i>=0;i--) sa[--ws[x[i]]]=i; 
        for(j=1,p=1;p<n;j*=2,m=p) 
        { 
            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; 
            for(i=0;i<n;i++) wv[i]=x[y[i]]; 
            for(i=0;i<m;i++) ws[i]=0; 
            for(i=0;i<n;i++) ws[wv[i]]++; 
            for(i=1;i<m;i++) ws[i]+=ws[i-1]; 
            for(i=n-1;i>=0;i--) sa[--ws[wv[i]]]=y[i]; 
            for(t=x,x=y,y=t,p=1,x[sa[0]]=0,i=1;i<n;i++) 
                x[sa[i]]=cmp(y,sa[i-1],sa[i],j)?p-1:p++; 
        } 
        return; 
    }

  待排序的字符串放在r數組中,從r[0]到r[n-1],長度爲n,且最大值小於m。爲了函數操作的方便,約定除r[n-1]外所有的r[i]都大於0,r[n-1]=0。函數結束後,結果放在sa數組中,從sa[0]到sa[n-1]。

  函數的第一步,要對長度爲1的字符串進行排序。一般來說,在字符串的題目中,r的最大值不會很大,所以這裏使用了基數排序。如果r的最大值很大,那麼把這段代碼改成快速排序。代碼:

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

  這裏x數組保存的值相當於是rank值。下面的操作只是用x數組來比較字符的大小,所以沒有必要求出當前真實的rank值。

  接下來進行若干次基數排序,在實現的時候,這裏有一個小優化。基數排序要分兩次,第一次是對第二關鍵字排序,第二次是對第一關鍵字排序。對第二關鍵字排序的結果實際上可以利用上一次求得的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(i=0;i<n;i++) wv[i]=x[y[i]];
    for(i=0;i<m;i++) ws[i]=0;
    for(i=0;i<n;i++) ws[wv[i]]++;
    for(i=1;i<m;i++) ws[i]+=ws[i-1];
    for(i=n-1;i>=0;i--) sa[--ws[wv[i]]]=y[i];

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

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

  其中cmp函數的代碼是:

    int cmp(int *r,int a,int b,int l)
    {return r[a]==r[b]&&r[a+l]==r[b+l];}

  這裏可以看到規定r[n-1]=0的好處,如果r[a]=r[b],說明以r[a]或r[b]開頭的長度爲l的字符串肯定不包括字符r[n-1],所以調用變量r[a+l]和r[b+l]不會導致數組下標越界,這樣就不需要做特殊判斷。執行完上面的代碼後,rank值保存在x數組中,而變量p的結果實際上就是不同的字符串的個數。這裏可以加一個小優化,如果p等於n,那麼函數可以結束。因爲在當前長度的字符串中,已經沒有相同的字符串,接下來的排序不會改變rank值。例如圖1中的第四次排序,實際上是沒有必要的。對上面的兩段代碼,循環的初始賦值和終止條件可以這樣寫:

    for(j=1,p=1;p<n;j*=2,m=p) {…………}

  在第一次排序以後,rank數組中的最大值小於p,所以讓m=p。

  整個倍增算法基本寫好,代碼大約25行。

  算法分析:

  倍增算法的時間複雜度比較容易分析。每次基數排序的時間複雜度爲O(n),排序的次數決定於最長公共子串的長度,最壞情況下,排序次數爲logn次,所以總的時間複雜度爲O(nlogn)。

1.3、DC3算法

  DC3算法分3步:

  (1)、先將後綴分成兩部分,然後對第一部分的後綴排序。

  將後綴分成兩部分,第一部分是後綴k(k模3不等於0),第二部分是後綴k(k模3等於0)。先對所有起始位置模3不等於0的後綴進行排序,即對suffix(1),suffix(2),suffix(4),suffix(5),suffix(7)……進行排序。做法是將suffix(1)和suffix(2)連接,如果這兩個後綴的長度不是3的倍數,那先各自在末尾添0使得長度都變成3的倍數。然後每3個字符爲一組,進行基數排序,將每組字符“合併”成一個新的字符。然後用遞歸的方法求這個新的字符串的後綴數組。如圖3所示。在得到新的字符串的sa後,便可以計算出原字符串所有起始位置模3不等於0的後綴的sa。要注意的是,原字符串必須以一個最小的且前面沒有出現過的字符結尾,這樣才能保證結果正確(請讀者思考爲什麼)。

  (2)、利用(1)的結果,對第二部分的後綴排序。

  剩下的後綴是起始位置模3等於0的後綴,而這些後綴都可以看成是一個字符加上一個在(1)中已經求出 rank的後綴,所以只要一次基數排序便可以求出剩下的後綴的sa。

  (3)、將(1)和(2)的結果合併,即完成對所有後綴排序。

  這個合併操作跟合併排序中的合併操作一樣。每次需要比較兩個後綴的大小。分兩種情況考慮,第一種情況是suffix(3*i)和suffix(3*j+1)的比較,可以把suffix(3*i)和suffix(3*j+1)表示成:

suffix(3*i)   = r[3*i]   + suffix(3*i+1)
suffix(3*j+1) = r[3*j+1] + suffix(3*j+2)

  其中 suffix(3*i+1)和 suffix(3*j+2)的比較可以利用(2)的結果快速得到。第二種情況是 suffix(3*i)和 suffix(3*j+2)的比較,可以把 suffix(3*i)和suffix(3*j+2)表示成:

suffix(3*i)   = r[3*i]   + r[3*i+1] + suffix(3*i+2)  
suffix(3*j+2) = r[3*j+2] + r[3*j+3] + suffix(3*(j+1)+1)

  同樣的道理,suffix(3*i+2)和 suffix(3*(j+1)+1)的比較可以利用(2)的結果快速得到。所以每次的比較都可以高效的完成,這也是之前要每 3個字符合並,而不是每 2個字符合並的原因。

  具體實現:

    #define F(x) ((x)/3+((x)%3==1?0:tb))
    #define G(x) ((x)<tb?(x)*3+1:((x)-tb)*3+2) 
    int wa[maxn],wb[maxn],wv[maxn],ws[maxn]; 
    int c0(int *r,int a,int b) 
    {return r[a]==r[b]&&r[a+1]==r[b+1]&&r[a+2]==r[b+2];} 
    int c12(int k,int *r,int a,int b) 
    {if(k==2) return r[a]<r[b]||r[a]==r[b]&&c12(1,r,a+1,b+1); 
    else return r[a]<r[b]||r[a]==r[b]&&wv[a+1]<wv[b+1];} 
    void sort(int *r,int *a,int *b,int n,int m) 
    { 
        int i; 
        for(i=0;i<n;i++) wv[i]=r[a[i]]; 
        for(i=0;i<m;i++) ws[i]=0; 
        for(i=0;i<n;i++) ws[wv[i]]++; 
        for(i=1;i<m;i++) ws[i]+=ws[i-1]; 
        for(i=n-1;i>=0;i--) b[--ws[wv[i]]]=a[i]; 
        return; 
    }
    void dc3(int *r,int *sa,int n,int m)
    {
        int i,j,*rn=r+n,*san=sa+n,ta=0,tb=(n+1)/3,tbc=0,p; 
        r[n]=r[n+1]=0; 
        for(i=0;i<n;i++) if(i%3!=0) wa[tbc++]=i; 
        sort(r+2,wa,wb,tbc,m); 
        sort(r+1,wb,wa,tbc,m); 
        sort(r,wa,wb,tbc,m); 
        for(p=1,rn[F(wb[0])]=0,i=1;i<tbc;i++) 
        rn[F(wb[i])]=c0(r,wb[i-1],wb[i])?p-1:p++; 
        if(p<tbc) dc3(rn,san,tbc,p); 
        else for(i=0;i<tbc;i++) san[rn[i]]=i;
        for(i=0;i<tbc;i++) if(san[i]<tb) wb[ta++]=san[i]*3; 
        if(n%3==1) wb[ta++]=n-1; 
        sort(r,wb,wa,ta,m); 
        for(i=0;i<tbc;i++) wv[wb[i]=G(san[i])]=i; 
        for(i=0,j=0,p=0;i<ta && j<tbc;p++) 
        sa[p]=c12(wb[j]%3,r,wa[i],wb[j])?wa[i++]:wb[j++]; 
        for(;i<ta;p++) sa[p]=wa[i++]; 
        for(;j<tbc;p++) sa[p]=wb[j++]; 
        return; 
    }

  各個參數的作用和前面的倍增算法一樣,不同的地方是r數組和sa數組的大小都要是3*n,這爲了方便下面的遞歸處理,不用每次都申請新的內存空間。函數中用到的變量:

    int i,j,*rn=r+n,*san=sa+n,ta=0,tb=(n+1)/3,tbc=0,p;

  rn數組保存的是(1)中要遞歸處理的新字符串,san數組是新字符串的sa。變量ta表示起始位置模3爲0的後綴個數,變量tb表示起始位置模3爲1的後綴個數,已經直接算出。變量tbc表示起始位置模3爲1或2的後綴個數。先按(1)中所說的用基數排序把3個字符“合併 ”成一個新的字符。爲了方便操作,先將r[n]和r[n+1]賦值爲0。

  代碼:

    r[n]=r[n+1]=0;
    for(i=0;i<n;i++) if(i%3!=0) wa[tbc++]=i;
    sort(r+2,wa,wb,tbc,m);
    sort(r+1,wb,wa,tbc,m);
    sort(r,wa,wb,tbc,m);

  其中sort函數的作用是進行基數排序。代碼:

    void sort(int *r,int *a,int *b,int n,int m)
    { 
        int i; 
        for(i=0;i<n;i++) wv[i]=r[a[i]]; 
        for(i=0;i<m;i++) ws[i]=0; 
        for(i=0;i<n;i++) ws[wv[i]]++; 
        for(i=1;i<m;i++) ws[i]+=ws[i-1]; 
        for(i=n-1;i>=0;i--) b[--ws[wv[i]]]=a[i]; 
        return; 
    }

  基數排序結束後,新的字符的排名保存在 wb數組中。

  跟倍增算法一樣,在基數排序以後,求新的字符串時要判斷兩個字符組是否完全相同。代碼:

    for(p=1,rn[F(wb[0])]=0,i=1; i<tbc;i++)
    rn[F(wb[i])]=c0(r,wb[i-1],wb[i])?p-1:p++;

  其中 F(x)是計算出原字符串的 suffix(x)在新的字符串中的起始位置,c0函數是比較是否完全相同,在開頭加一段代碼:

    #define F(x) ((x)/3+((x)%3==1?0:tb))
    inline int c0(int *r,int a,int b)
    {return r[a]==r[b]&&r[a+1]==r[b+1]&&r[a+2]==r[b+2];}

  接下來是遞歸處理新的字符串,這裏和倍增算法一樣,可以加一個小優化,如果p等於tbc,那麼說明在新的字符串中沒有相同的字符,這樣可以直接求出san數組,並不用遞歸處理。代碼:

    if(p<tbc) dc3(rn,san,tbc,p);
    else for(i=0;i<tbc;i++) san[rn[i]]=i;

  然後是第(2)步,將所有起始位置模3等於0的後綴進行排序。其中對第二關鍵字的排序結果可以由新字符串的sa直接計算得到,沒有必要再排一次。代碼:

    for(i=0;i<tbc;i++) if(san[i]<tb) wb[ta++]=san[i]*3;
    if(n%3==1) wb[ta++]=n-1;
    sort(r,wb,wa,ta,m);
    for(i=0;i<tbc;i++) wv[wb[i]=G(san[i])]=i;

  要注意的是,如果n%3==1,要特殊處理suffix(n-1),因爲在san數組裏並沒有suffix(n)。G(x)是計算新字符串的suffix(x)在原字符串中的位置,和F(x)爲互逆運算。在開頭加一段:

    #define G(x) ((x)<tb?(x)*3+1:((x)-tb)*3+2)。

  最後是第(3)步,合併所有後綴的排序結果,保存在sa數組中。代碼:

    for(i=0,j=0,p=0;i<ta && j<tbc;p++) 
    sa[p]=c12(wb[j]%3,r,wa[i],wb[j])?wa[i++]:wb[j++]; 
    for(;i<ta;p++) sa[p]=wa[i++]; 
    for(;j<tbc;p++) sa[p]=wb[j++];
    
  其中c12函數是按(3)中所說的比較後綴大小的函數,k=1是第一種情況,k=2是第二種情況。代碼:

    int c12(int k,int *r,int a,int b)
    {if(k==2) return r[a]<r[b]||r[a]==r[b]&&c12(1,r,a+1,b+1);
    else return r[a]<r[b]||r[a]==r[b]&&wv[a+1]<wv[b+1];}

  整個DC3算法基本寫好,代碼大約40行。

  算法分析:

  假設這個算法的時間複雜度爲f(n)。容易看出第(1)步排序的時間爲O(n)(一般來說,m比較小,這裏忽略不計),新的字符串的長度不超過2n/3,求新字符串的 sa的時間爲f(2n/3),第(2)和第(3)步的時間都是 O(n)。所以

f(n) = O(n) + f(2n/3)
f(n) ≤ c×n + f(2n/3)
f(n) ≤ c×n + c×(2n/3) + c×(4n/9) + c×(8n/27) + …… ≤ 3c×n
所以 f(n) = O(n)

  由此看出,DC3算法是一個優秀的線性算法。

1.4、倍增算法與DC3算法的比較

  從時間複雜度、空間複雜度、編程複雜度和實際效率等方面對倍增算法與DC3算法進行比較。

  時間複雜度:

  倍增算法的時間複雜度爲O(nlogn),DC3算法的時間複雜度爲O(n)。從常數上看,DC3算法的常數要比倍增算法大。

  空間複雜度:

  倍增算法和DC3算法的空間複雜度都是O(n)。按前面所講的實現方法,倍增算法所需數組總大小爲6n,DC3算法所需數組總大小爲10n。

  編程複雜度:

  倍增算法的源程序長度爲 25行,DC3算法的源程序長度爲 40行。

  實際效率:

  測試環境:NOI-linux Pentium(R) 4 CPU 2.80GHz

  
  (不包括讀入和輸出的時間,單位:ms)

  從表中可以看出,DC3算法在實際效率上還是有一定優勢的。倍增算法容易實現,DC3算法效率比較高,但是實現起來比倍增算法複雜一些。對於不同的題目,應當根據數據規模的大小決定使用哪個算法。

二、後綴數組的應用

  本節主要介紹後綴數組在各種類型的字符串問題中的應用。各題的原題請見附件二,參考代碼請見附件三。

2.1、最長公共前綴

  這裏先介紹後綴數組的一些性質。

  height數組:定義height[i]=suffix(sa[i-1])和suffix(sa[i])的最長公共前綴,也就是排名相鄰的兩個後綴的最長公共前綴。那麼對於j和k,不妨設rank[j]<rank[k],則有以下性質:

  suffix(j)和suffix(k)的最長公共前綴爲height[rank[j]+1],height[rank[j]+2],height[rank[j]+3],……,height[rank[k]]中的最小值。

  例如,字符串爲“aabaaaab”,求後綴“abaaaab”和後綴“aaab”的最長公共前綴,如圖4所示:

  那麼應該如何高效的求出height值呢?

  如果按height[2],height[3],……,height[n]的順序計算,最壞情況下時間複雜度爲O(n2)。這樣做並沒有利用字符串的性質。定義h[i]=height[rank[i]],也就是suffix(i)和在它前一名的後綴的最長公共前綴。

  h數組有以下性質:

h[i]≥h[i-1]-1

  證明:

  設suffix(k)是排在suffix(i-1)前一名的後綴,則它們的最長公共前綴是h[i-1]。那麼suffix(k+1)將排在suffix(i)的前面(這裏要求h[i-1]>1,如果h[i-1]≤1,原式顯然成立)並且suffix(k+1)和suffix(i)的最長公共前綴是h[i-1]-1,所以suffix(i)和在它前一名的後綴的最長公共前綴至少是h[i-1]-1。按照h[1],h[2],……,h[n]的順序計算,並利用h數組的性質,時間複雜度可以降爲O(n)。

  具體實現:

  實現的時候其實沒有必要保存h數組,只須按照h[1],h[2],……,h[n]的順序計算即可。代碼:

    int rank[maxn],height[maxn];
    void calheight(int *r,int *sa,int n)
    {
        int i,j,k=0; 
        for(i=1;i<=n;i++) rank[sa[i]]=i; 
        for(i=0;i<n;height[rank[i++]]=k) 
        for(k?k--:0,j=sa[rank[i]-1];r[i+k]==r[j+k];k++); 
        return; 
    }

  例1:最長公共前綴

  給定一個字符串,詢問某兩個後綴的最長公共前綴。

  算法分析:

  按照上面所說的做法,求兩個後綴的最長公共前綴可以轉化爲求某個區間上的最小值。對於這個RMQ問題(如果對RMQ(Range Minimum Query)問題不熟悉,請閱讀其他相關資料),可以用O(nlogn)的時間先預處理,以後每次回答詢問的時間爲O(1)。所以對於本問題,預處理時間爲O(nlogn),每次回答詢問的時間爲O(1)。如果RMQ問題用O(n)的時間預處理,那麼本問題預處理的時間可以做到O(n)。

2.2、單個字符串的相關問題

  這類問題的一個常用做法是先求後綴數組和 height數組,然後利用 height數組進行求解。

2.2.1、重複子串

  重複子串:字符串R在字符串L中至少出現兩次,則稱R是L的重複子串。

  例2:可重疊最長重複子串

  給定一個字符串,求最長重複子串,這兩個子串可以重疊。

  算法分析:

  這道題是後綴數組的一個簡單應用。做法比較簡單,只需要求height數組裏的最大值即可。首先求最長重複子串,等價於求兩個後綴的最長公共前綴的最大值。因爲任意兩個後綴的最長公共前綴都是height數組裏某一段的最小值,那麼這個值一定不大於height數組裏的最大值。所以最長重複子串的長度就是height數組裏的最大值。這個做法的時間複雜度爲O(n)。

  例3:不可重疊最長重複子串(pku1743)

  給定一個字符串,求最長重複子串,這兩個子串不能重疊。

  算法分析:

  這題比上一題稍複雜一點。先二分答案,把題目變成判定性問題:判斷是否存在兩個長度爲k的子串是相同的,且不重疊。解決這個問題的關鍵還是利用height數組。把排序後的後綴分成若干組,其中每組的後綴之間的height值都不小於k。例如,字符串爲“aabaaaab”,當 k=2時,後綴分成了 4組,如圖5所示。

  容易看出,有希望成爲最長公共前綴不小於k的兩個後綴一定在同一組。然後對於每組後綴,只須判斷每個後綴的sa值的最大值和最小值之差是否不小於k。如果有一組滿足,則說明存在,否則不存在。整個做法的時間複雜度爲O(nlogn)。本題中利用 height值對後綴進行分組的方法很常用,請讀者認真體會。

  例4:可重疊的 k次最長重複子串(pku3261)

  給定一個字符串,求至少出現k次的最長重複子串,這k個子串可以重疊。

  算法分析:

  這題的做法和上一題差不多,也是先二分答案,然後將後綴分成若干組。不同的是,這裏要判斷的是有沒有一個組的後綴個數不小於k。如果有,那麼存在k個相同的子串滿足條件,否則不存在。這個做法的時間複雜度爲 O(nlogn)。

2.2.2、子串的個數

  例5:不相同的子串的個數(spoj694,spoj705)

  給定一個字符串,求不相同的子串的個數。

  算法分析:

  每個子串一定是某個後綴的前綴,那麼原問題等價於求所有後綴之間的不相同的前綴的個數。如果所有的後綴按照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]個不同的子串。累加後便是原問題的答案。這個做法的時間複雜度爲O(n)。

2.2.3、迴文子串

  迴文子串:如果將字符串L的某個子字符串R反過來寫後和原來的字符串R一樣,則稱字符串R是字符串L的迴文子串。

  例6:最長迴文子串(ural1297)

  給定一個字符串,求最長迴文子串。

  算法分析:

  窮舉每一位,然後計算以這個字符爲中心的最長迴文子串。注意這裏要分兩種情況,一是迴文子串的長度爲奇數,二是長度爲偶數。兩種情況都可以轉化爲求一個後綴和一個反過來寫的後綴的最長公共前綴。具體的做法是:將整個字符串反過來寫在原字符串後面,中間用一個特殊的字符隔開。這樣就把問題變爲了求這個新的字符串的某兩個後綴的最長公共前綴。如圖6所示。

  這個做法的時間複雜度爲O(nlogn)。如果RMQ問題用時間爲O(n)的方法預處理,那麼本題的時間複雜度可以降爲O(n)。

2.2.4、連續重複子串

  連續重複串:如果一個字符串L是由某個字符串S重複R次而得到的,則稱L是一個連續重複串。R是這個字符串的重複次數。

  例7:連續重複子串(pku2406)

  給定一個字符串L,已知這個字符串是由某個字符串S重複R次而得到的,求R的最大值。

  算法分析:

  做法比較簡單,窮舉字符串S的長 k,然後判斷是否滿足。判斷的時候,先看字符串L的長度能否被k整除,再看suffix(1)和suffix(k+1)的最長公共前綴是否等於n-k。在詢問最長公共前綴的時候,suffix(1)是固定的,所以RMQ問題沒有必要做所有的預處理,只需求出height數組中的每一個數到height[rank[1]]之間的最小值即可。整個做法的時間複雜度爲O(n)。

  例8:重複次數最多的連續重複子串(spoj687,pku3693)

  給定一個字符串,求重複次數最多的連續重複子串。

  算法分析:

  先窮舉長度L,然後求長度爲L的子串最多能連續出現幾次。首先連續出現1次是肯定可以的,所以這裏只考慮至少2次的情況。假設在原字符串中連續出現2次,記這個子字符串爲S,那麼S肯定包括了字符r[0],r[L],r[L*2],r[L*3],……中的某相鄰的兩個。所以只須看字符r[L*i]和r[L*(i+1)]往前和往後各能匹配到多遠,記這個總長度爲K,那麼這裏連續出現了K/L+1次。最後看最大值是多少。如圖7所示。

  窮舉長度L的時間是n,每次計算的時間是n/L。所以整個做法的時間複雜度是O(n/1+n/2+n/3+……+n/n)=O(nlogn)。

2.3、兩個字符串的相關問題

  這類問題的一個常用做法是,先連接這兩個字符串,然後求後綴數組和height數組,再利用height數組進行求解。

2.3.1、公共子串

  公共子串:如果字符串L同時出現在字符串A和字符串B中,則稱字符串L是字符串A和字符串B的公共子串。

  例9:最長公共子串(pku2774,ural1517)

  給定兩個字符串A和B,求最長公共子串。

  算法分析:

  字符串的任何一個子串都是這個字符串的某個後綴的前綴。求A和B的最長公共子串等價於求A的後綴和B的後綴的最長公共前綴的最大值。如果枚舉A和B的所有的後綴,那麼這樣做顯然效率低下。由於要計算A的後綴和B的後綴的最長公共前綴,所以先將第二個字符串寫在第一個字符串後面,中間用一個沒有出現過的字符隔開,再求這個新的字符串的後綴數組。觀察一下,看看能不能從這個新的字符串的後綴數組中找到一些規律。以A=“aaaba”,B=“abaa”爲例,如圖8所示。

  那麼是不是所有的height值中的最大值就是答案呢?不一定!有可能這兩個後綴是在同一個字符串中的,所以實際上只有當suffix(sa[i-1])和suffix(sa[i])不是同一個字符串中的兩個後綴時,height[i]纔是滿足條件的。而這其中的最大值就是答案。記字符串A和字符串B的長度分別爲|A|和|B|。求新的字符串的後綴數組和height數組的時間是O(|A|+|B|),然後求排名相鄰但原來不在同一個字符串中的兩個後綴的height值的最大值,時間也是O(|A|+|B|),所以整個做法的時間複雜度爲O(|A|+|B|)。時間複雜度已經取到下限,由此看出,這是一個非常優秀的算法。

2.3.2、子串的個數

  例10:長度不小於k的公共子串的個數(pku3415)

  給定兩個字符串A和B,求長度不小於k的公共子串的個數(可以相同)。

  樣例1:

  A=“xx”,B=“xx”,k=1,長度不小於k的公共子串的個數是5。

  樣例2:

  A =“aababaa”,B =“abaabaa”,k=2,長度不小於k的公共子串的個數是22。

  算法分析:

  基本思路是計算A的所有後綴和B的所有後綴之間的最長公共前綴的長度,把最長公共前綴長度不小於k的部分全部加起來。先將兩個字符串連起來,中間用一個沒有出現過的字符隔開。按height值分組後,接下來的工作便是快速的統計每組中後綴之間的最長公共前綴之和。掃描一遍,每遇到一個B的後綴就統計與前面的A的後綴能產生多少個長度不小於k的公共子串,這裏A的後綴需要用一個單調的棧來高效的維護。然後對A也這樣做一次。具體的細節留給讀者思考。

2.4、多個字符串的相關問題

  這類問題的一個常用做法是,先將所有的字符串連接起來,然後求後綴數組和height數組,再利用height數組進行求解。這中間可能需要二分答案。

  例11:不小於k個字符串中的最長子串(pku3294)

  給定n個字符串,求出現在不小於k個字符串中的最長子串。

  算法分析:

  將n個字符串連起來,中間用不相同的且沒有出現在字符串中的字符隔開,求後綴數組。然後二分答案,用和例3同樣的方法將後綴分成若干組,判斷每組的後綴是否出現在不小於k個的原串中。這個做法的時間複雜度爲O(nlogn)。

  例12:每個字符串至少出現兩次且不重疊的最長子串(spoj220)

  給定n個字符串,求在每個字符串中至少出現兩次且不重疊的最長子串。

  算法分析:

  做法和上題大同小異,也是先將n個字符串連起來,中間用不相同的且沒有出現在字符串中的字符隔開,求後綴數組。然後二分答案,再將後綴分組。判斷的時候,要看是否有一組後綴在每個原來的字符串中至少出現兩次,並且在每個原來的字符串中,後綴的起始位置的最大值與最小值之差是否不小於當前答案(判斷能否做到不重疊,如果題目中沒有不重疊的要求,那麼不用做此判斷)。這個做法的時間複雜度爲 O(nlogn)。

  例13:出現或反轉後出現在每個字符串中的最長子串(PKU3294)

  給定n個字符串,求出現或反轉後出現在每個字符串中的最長子串。

  算法分析:

  這題不同的地方在於要判斷是否在反轉後的字符串中出現。其實這並沒有加大題目的難度。只需要先將每個字符串都反過來寫一遍,中間用一個互不相同的且沒有出現在字符串中的字符隔開,再將n個字符串全部連起來,中間也是用一個互不相同的且沒有出現在字符串中的字符隔開,求後綴數組。然後二分答案,再將後綴分組。判斷的時候,要看是否有一組後綴在每個原來的字符串或反轉後的字符串中出現。這個做法的時間複雜度爲O(nlogn)。

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