1. 程式人生 > >字尾陣列的學習筆記與例題

字尾陣列的學習筆記與例題

【前言】

字尾陣列真神奇。

【字尾】

對於一個字串,定義suffix(i)為以i起點至結尾的子串。例如ababc,suffix(3)=bc

【字尾樹】

字尾樹容易理解,就是把一個字串的所有後綴串插入到一個字典樹上(字典樹不再累贅)。如圖為"ababc"的字尾樹:

建造這棵樹的代價是O(n*n)的空間複雜度,沒什麼實用性,只是為了更好的理解字尾陣列。注意,對於每個分支,我已按從左到右的順序排列,即最左端的suffix(0)就是所有後綴串中字典序最小的。

而我們想要的所謂的字尾陣列就是 sa={ 0, 2, 1, 3, 4 } ,可以理解為一張成績單,成績是字典序從小到大。

【字尾陣列】

字尾樹中已提到sa陣列是一張成績單,我們有了這個成績單,就厲害了。比如我們想知道某個字串是否出現在原字串中,就可以在成績單上二分查詢,由於比較函式strcmp線性複雜度,因此總時間複雜度為O(n*longn)。

【字尾陣列的求解】

最直白的方法就是所有後綴串直接sort,但是代價是O(n*n)。下面介紹O(n*logn)的演算法。

注:本文所有下標從0開始

sa[] :將所有後綴串按字典序從小到大的一張成績單,如sa[0]表示第0名字尾串是suffix(sa[0]);

c[] :  c[k]記錄字元k出現的次數

temp1[], temp2[]:臨時儲存空間,借給*x和*y。

倍增

加上基數排序的思想。

基數排序:比如對{45, 23,81 }一個數列進行排序,我們可以先按個位數排好{81, 23,45 },再按十位數排好{ 23,45,81 },這就是基數排序。實際上我們是把十位數看做第一關鍵字,各位數看做第二關鍵字

倍增:初始時我們把每個字元看做長度為1的串,然後它和它的後一個串組合為一個二元組,按照基數排序對所有二元組排序,排完後,所有長度為2的串在成績單上有序了(注意suffix(n-1)沒有第二元,視為0,即最小)。同理再把長度2倍增為4,思考一下。

關於這個倍增+基數排序的過程難以用語言描述,在程式碼中以註釋講解。

int get_sa(char *s,int n) //n是字串s長度
{
    int p,i,m=MAX,*x=temp1,*y=temp2; //x[i]始終為串對i離散化的大小
    for(i=0;i<m;i++) c[i]=0;
    for(i=0;i<n;i++) c[ x[i]=s[i] ]++;
    for(i=1;i<m;i++) c[i]+=c[i-1];
    for(i=n-1;i>=0;i--) sa[--c[x[i]]]=i; //把i串對放到成績單中
    for(int k=1;k<=n;k<<=1) //倍增
    {
        p=0; //串對長度翻倍,y[p]存新成績單,僅按第二關鍵字排序
        //按第二關鍵字
        for(i=n-k;i<n;i++) y[p++]=i; //第二關鍵字為空,故放在前k名即可
        for(i=0;i<n;i++)if(sa[i]>=k)y[p++]=sa[i]-k;//遍歷翻倍前的成績單,作為二關依次排
        //成績單y[i]按第一關鍵字
        for(i=0;i<m;i++) c[i]=0;
        for(i=0;i<n;i++) c[x[y[i]]]++;
        for(i=1;i<m;i++) c[i]+=c[i-1];
        for(i=n-1;i>=0;i--) sa[--c[x[y[i]]]]=y[i];
        //計算新的離散化成績x,y陣列充當臨時陣列
        swap(x,y);  //這裡不要多想,只是借用一下y,把x的值扔給y,然後計算新的x
        p=0; x[sa[0]]=p++;
        for(i=1;i<n;i++)
        {
            int sec1=(sa[i-1]+k>=n ? -1 : y[sa[i-1]+k]); //取第二關鍵字,沒有的-1代替,勿用0
            int sec2=(sa[i]+k>=n ? -1 : y[sa[i]+k]);
            x[sa[i]]=(y[sa[i-1]]==y[sa[i]] && sec1==sec2)?p-1:p++; //兩個關鍵字均相等
        }
        if(p>=n)break; //已完全離散化
        m=p;
    }
}

【rank和height】

僅僅有了sa陣列,我們還是做不了大事。

height[]:height[i]表示成績單上第i名與其前一名的最長公共字首,很明顯第0名必為0;

rank[]:  sa的反函式,記錄suffix(i)是第幾名。

這兩個陣列好求:

void get_height(char *s,int n)
{
    for(int i=0;i<n;i++)ranks[sa[i]]=i;
    int k=0;
    for(int i=0;i<n;i++)
    {
        if(k>0)k--; //前一個匹配到了k個,這次至少配到k-1個
        if(ranks[i]==0) //第0名沒有height
        {
            height[0]=0;
            continue;
        }
        int j=sa[ranks[i]-1]; //j是i的前一名
        while(s[i+k]==s[j+k])k++;
        height[ranks[i]]=k;
    }
}

有了height陣列,這個功能比較強大,我們可以求任意兩個字尾的最長公共字首,即LCP,求法:用RMQ維護height陣列的區間最小值,若要訪問suffix(i)與suffix(j)的LCP,即為{ height[ rank[i] + 1] , height[ rank[i] +2] ......height[ rank[j] ] }中的最小值。如果不好理解,在後綴樹模擬一下就明白了。

【多餘的解釋】

博文中與劉汝佳那本訓練指南上講的基本一致,但經過實踐我發現了若干錯誤。博文中有劉汝佳原始碼。

build函式倒數第四行:

for (int i=1;i<n;i++) t[sa[i]]=(t2[sa[i-1]]==t2[sa[i]] && t2[sa[i-1]+k]==t2[sa[i]+k])?p-1:p++;

這裡相當於對排好序的成績單做一個離散化,t2[sa[i-1]]==t2[sa[i]] 用來判斷第一關鍵字,沒有問題。 t2[sa[i-1]+k]==t2[sa[i]+k])用來判斷第二關鍵字,這裡有問題!首先陣列越界是必然的,越界也沒關係,陣列開兩倍,後面越界了都是0。但是,成績單是從第0名開始!!故x陣列必有一項為0!!比如sa[i-1]+k取的那一項恰好t2[sa[i-1]+k]==0,而sa[i]+k>=n,必然t2[sa[i]+k]==0,顯然一定滿足相等,而實際上這裡的第二關鍵字是不相等的(前者為0,後者不存在,故不能認為相等)!

避免此bug的方法:1.排名從1開始就可以。2. 單獨判斷第二關鍵字,不要為了少些程式碼,通過越界省事。3.。。。。。

宣告:我並沒有否定劉汝佳前輩的意思,思路完美++,只是這個bug困擾了我一個上午。

求一個串中不同子串的個數。任意子串必為某一字尾串的字首,某一子串有重複只能是與字典序最接近的子串。

故重複串必出現在每兩個字典序相鄰的字尾串的最長公共字首,即LCP。

任意字尾串所包含的子串總個數 = n - sa[i] ,注意下標i是這個字尾串的排名

去掉重複的就是 :n - sa[i] - height[i] ;

求總和。

題意:給出一個字串,兩個整數a,b,求有多少個子串滿足出現次數在[a,b]之間。

分析:先考慮如何求 >=x 的子串個數怎麼求,然後分別求出>=a和>=b+1的個數,相減即可。

求出現次數>=x的子串個數:看著字尾樹會好理解些。在成績單sa上,任選一個起點 i,既然要求出現次數>=x,那麼從 i 到 i+x-1 的LCP(最長公共字首)的出現次數一定>=x,而且LCP的字首也都出現>=x次。故從成績單上取出所有的連續x個的LCP,但是會有重複,怎麼去重?當我查詢到 i -> i+x-1 的LCP累計到答案時,與i-1 -> i-1+x-1 重複了多少個呢?重複了 i-1 -> i+x-1 的LCP個,結合字尾樹仔細思考一下。

題意:給出n個數的序列,求出現次數至少為k的最長連續子序列長度。

分析:焦作網路賽那個H題求的是出現至少k次的個數,這裡求的最長長度,注意區分。在成績單sa上列舉起點 i ,終點為i+k-1,取一下LCP,最終的LCP最大值即為答案。

題意:給出n個數的序列,求一個最長連續子序列,滿足:出現至少兩次,長度至少5次,不可以重疊,相似視為同一個。兩個序列相似定義為 兩個序列的間隔相等,例如1,2,3,5,6與4,5,6,8,9相似。

分析:求出原序列的n-1個間隔構成的序列,求其後綴陣列。然後二分答案,對於答案len,判斷其是否可行:在height陣列找到一段連續的區間滿足 區間長度>=len 且 其中對應的sa最大值與最小值只差>len(為了產生不重疊);