1. 程式人生 > >字尾陣列:原理和實現

字尾陣列:原理和實現

字尾陣列(Suffix Array)是某一字串的所有後綴按照字典序的一個排列。本文陣列的索引從0開始。稱s[j..len(s)-1]為字尾j。sa[i] = j,表示原串的所有後綴按字典序排列,排在第i個的是字尾j。一個字串的字尾陣列是唯一的。

sa根據排名查字尾編號,與之對應的是rank陣列,根據字尾編號查排名。sa[i] = j <=> rank[j] = i。

給字尾排序有什麼作用呢?一個字串的所有子串,都可以表示為它某個字尾的字首。對於一個有序的序列,我們可以二分查詢。設文字串的長度為n,模板串的長度為m。直接二分,可以做到O(n lg n)+O(m lg n)的時間複雜度。用height陣列進行優化,則可以做到O(n lg n)+O(m+lg n)。height是字尾陣列中相鄰兩個字尾的最長公共字首。

字尾陣列的構造

從上一段可以看出,我們能用O(n lg n)的時間構造一個字串的字尾陣列——倍增演算法。存在O(n)的演算法,在OI中似乎不常用。

給數排序有很多方法。基於比較的排序,最快可做到O(n lg n),理論上不能進一步優化。在我們的模型中,數之間的比較是O(1)的,然而,字串的比較是O(n)的。所以,我們得另闢蹊徑。

除了基於比較的排序,我們還有計數排序、基數排序等。字串的集合太大,計數排序不可行。但是字符集往往很小,我們應該試試基於計數排序的基數排序。每次計數排序用時O(n),進行n輪計數排序,總共是O(n^2),仍然不理想。

字典序有什麼特性?把s和t都拆成[0..i],[i+1..len-1]兩截,則s < t <=> (s[0..i] < t[0..i]) 或 (s[0..i] = t[0..i] 且 s[i+1..len(s)-1] < t[i+1..len(t)-1])。從中看出兩點:一,我們可以遞迴或者遞推地比較兩個字串;二,這和二元組之間的比較很像!

設sa_k為只取每個字尾的字首k得到的“字尾陣列”,rank_k類似。我們能根據它們計算出sa_2k和rank_2k。圖示如下:

字尾陣列

取二元組(rank_k[i], rank_k[i+k])為字尾i的關鍵字排序,就得到了sa_2k。相當於rank_k能幫助我們快速地比較兩個字尾。再根據sa_2k計算出rank_2k。如果i+k越界怎麼辦?我採用的方法是令它的rank等於-1。後面,我們將看到,置rank[n]=-1即可。

如果用快速排序,至此,我們已經得到O(n lg^2 n)構造字尾陣列的演算法。

rank的取值範圍是0~n-1,統計一下每個rank對應多少個字尾是可接受的,能不能用先前提到的基數排序呢?基數排序從低位到高位迴圈,每一趟以這一位為關鍵字採用某種穩定排序演算法(如計數排序)排序,最後就得到了有序的序列。以上面banana例子中k=4時的二元組為例:

字尾陣列

Pretty cool !

一種簡單的實現方法是,以rank_k[i+k]為關鍵字,對所有後綴計數排序,得到pre。再以rank_k[i]為關鍵字,對pre計數排序,得到sa_2k。

有沒有可以優化的地方?

sa_k是什麼?以rank_k[i]為關鍵字,對所有後綴排序的結果。現在取rank_k[i+k]為關鍵字,對於那些滿足i+k < n的字尾,它們的先後順序保持不變,而對於那些i+k >= n的字尾,根據上面的約定,其第二關鍵字等於-1,應該排在pre的最前面。所以,我們可以直接從sa_k得到pre。

字尾陣列

開始寫程式碼吧。以下程式碼僅作說明之用,為了敘述方便打亂了順序。

#include <cstring>
#include <algorithm>
void build_SA(char s[], int n)
{
    memset(b, 0, sizeof(int)*SIGMA_SIZE);
    for (int i = 0; i < n; ++i)
        ++b[s[i]-'a'];
    for (int i = 1; i < SIGMA_SIZE; ++i)
        b[i] += b[i-1];
    for (int i = n-1; i >= 0; --i)
        sa[--b[s[i]-'a']] = i;
    int m = rank[sa[0]] = 0;
    for (int i = 1; i < n; ++i)
        rank[sa[i]] = m += s[sa[i]] != s[sa[i-1]];
    ++m;

這一段求出sa_1和rank_1。假定SIGMA_SIZE > MAX_N。如果字符集比較大,std::sort即可,不影響漸近的執行時間。m是不同rank的種數。

    int *&pre = t;
    for (int k = 1; m < n; k *= 2, ++m) {
        for (int i = 0, p = k; i < n; ++i)
            if (sa[i] >= k)
                pre[p++] = sa[i]-k;
        for (int i = 0; i < k; ++i)
            pre[i] = n-k+i;

這一段求出pret是一個指標,定義為全域性變數。

int b[MAX_N], buff[2][MAX_N+1], *t = buff[0];

接下來根據第一關鍵字計數排序。

        memset(b, 0, sizeof(int)*m);
        for (int i = 0; i < n; ++i)
            ++b[rank[i]];
        for (int i = 1; i < m; ++i)
            b[i] += b[i-1];
        for (int i = n-1; i >= 0; --i)
            sa[--b[rank[pre[i]]]] = pre[i];

至此,已求出新的sa。接著,我們來計算rank

        m = r[sa[0]] = 0;
        for (int i = 1; i < n; ++i)
            r[sa[i]] = m += rank[sa[i]] != rank[sa[i-1]] || rank[sa[i]+k] != rank[sa[i-1]+k];
        swap(r, rank);
    }

r是一箇中間“陣列”。這時pre陣列已經沒用了,所以它們可以共用一塊記憶體。rank也定義為指標。

    int *&pre = t, *&r = t;

現在可以看到定義陣列指標的用意。無須copy,無須memcpyswap即可。DP裡搞滾動陣列也可以借鑑這個技巧。

int b[MAX_N], sa[MAX_N], buff[2][MAX_N+1], *t = buff[0], *rank = buff[1];

別忘了在for k外面加上這句!

    rank[n] = r[n] = -1;

兩個字尾i、j,設i < j。若j+k <= n,則rank[i+k]和rank[j+k]有定義。若j+k > n,則必有rank[j] < rank[i],根據短路法則,無須再比較rank[i+k]和rank[j+k]。

最後,還有:

}

最長公共字首

設字尾sa[i]和sa[j]的最長公共字首為LCP(i, j),有定理:LCP(i, j) = min{height[k] | min{i, j} < k <= max{i, j}}。

一開始,劉汝佳告訴我這很顯然,我是拒絕的。然而現在我也想建議大家使用顯然法……

還是證一下吧。先證一個引理,設i < k <= j,則LCP(i, j) = min{LCP(i, k), LCP(k, j)}。當k = j時顯然成立。以下說明k < j時的情形。

設LCP(i, k)=a,LCP(j, k)=b,LCP(i, j)=c,sa[i]=x,sa[j]=y,sa[k]=z,不妨設a <= b。那麼,s[x..x+a-1] = s[z..z+a-1],s[z..z+b-1] = s[y..y+b-1],由傳遞性,s[x..x+a-1] = s[y+a-1..y+a-1],故LCP(i, j) >= a。由字典序的定義,字尾陣列中i~k項字尾的前c個字元相等,故a >= LCP(i, j)。證畢。

結合這個引理和數學歸納法,上述定理成立。

所以說height陣列很重要啦。有了它,再求求RMQ,任意兩字尾的最長公共字首就搞定了。

上面這個定理有個推論,幫我們線性時間求height:height[rank[i]] >= height[rank[i-1]]-1。字尾i和i-1,拿掉i-1的第一個字元,二者就相等了。考慮sa[rank[i-1]-1]=p-1,則字尾p-1<字尾i-1。如果它倆的第一個字元相等,那麼,有後綴p < 字尾i,由定理,有height[rank[i-1]]-1 = LCP(sa[p], sa[i]) <= height[rank[i]]。如果它倆的第一個字元不等,則height[rank[i-1]] = 0,顯然成立。推論得證。

height[rank[i]]相對於前一項只能增加或減1,最多累計減少(n-1)個1,且height[rank[i]] <= n。於是,按照height[rank[0]], height[rank[1]], height[rank[2]], …的順序愉快地遞推吧!

寫在最後

字尾陣列最初是從兄弟學校的MYJ學長那裡學的。研究了一下劉汝佳老師的程式碼,然後默寫了一遍……UOJ #35始終是0分,CodeVS的模板題過了。那時我還沒發現UOJ可以看一部分資料啊……我自己也不太清楚自己在寫什麼,這就是照著別人程式碼打的壞處。但是自己寫不出來啊。於是這個資料結構和莫隊演算法等等在今年1~4月於匆忙中囫圇吞棗的知識一樣,屬於不可用的。

這次是自己重新寫的,但心底裡對於以前的程式碼還是有個模糊的印象。根據劉汝佳老師的程式碼修改的。有兩個地方比我更優:一是他通過共用prer(原始碼變數名不一樣),只用4n的空間;二是引入m變數。

本文參考了劉汝佳、陳鋒編著的《演算法競賽入門經典訓練指南》和國家集訓隊2004年許智磊前輩的《字尾陣列》一文。

如有不當之處,歡迎指正~