1. 程式人生 > >學習總結-後綴數組

學習總結-後綴數組

困難 png log-n 感覺 上一個 我們 math 中一 void


(〇)前置知識

1.排序

最好會基數排序,實在不行可以快速排序 (倍增算法的時間復雜度會從\(\Theta (n\log n)~\to~\Theta (n\log^2 n)\))

2.字符串-後綴

這個大家應該都知道,比方說有一個字符串\(a~b~c~d~e~f\),那麽它的後綴就是:

後綴1: \(a~b~c~d~e~f\)

? 2: \(~~~b~c~d~e~f\)

? 3: \(~~~~~c~d~e~f\)

? 4: \(~~~~~~~~d~e~f\)

? 5: \(~~~~~~~~~~e~f\)

? 6: \(~~~~~~~~~~~~~~f\)

為了行文方便,以下簡稱 以第 \(i\)

位字符為開頭的後綴後綴 \(i\)

(一)何為後綴數組

所謂後綴數組,其實就是將字符串 \(S\) 的後綴按從小到大的順序(字典序)排好,存入一個數組 \(SA[~]\)。其中 \(SA[~i~]=j\) 表示排好序後排名第 \(i\) 的後綴是 後綴 \(j\)

此外還有一個 \(rank[~]\) 數組。\(rank[~j~]=i\) 表示後綴 \(j\) 排好序後的排名是 \(i\)

如圖1(後綴數組——處理字符串的有力工具 \(by\) 羅穗騫)

技術分享圖片

那麽,問題來了,如何快速求後綴數組?

(二)後綴數組之倍增法

倍增法的主要思想是:

每次將所有後綴按照前 \(2^{k}\) 個字符排序,最後得出所有後綴的排序。這樣問題就轉化為了:現在已知所有後綴關於前 \(2^{k-1}\)

個字符的排序,要對所有後綴排序按前 \(2^k\) 個字符排序。

顯然,可以將前 \(2^k\) 個字符分為兩部分,每一部分的長度都是 \(2^{k-1}\) ,並且這兩部分按照字典序排序後的名次是已知的。

這兩部分中,在前面的部分的排名是第一關鍵字( \(key1\) ),在後面的部分的排名就是第二關鍵字( \(key2\) )。然後再按照關鍵字進行排序,更新(\(update\)) \(rank[~]\) 數組,得到新的排名。

如圖,字符串“\(aabaaaab\)” 的後綴數組處理(後綴數組——處理字符串的有力工具 \(by\) 羅穗騫)。

技術分享圖片

因為字符串的每個後綴都是不同的(起碼長度不相同),所以最後得到的 \(rank[~]\)

數組裏的數必定是互不相同的。同樣,如果再做第 \(k\) 次倍增時,得到的 \(rank[~]\) 數組如果已經互不相同了,那麽就說明已經找到了最終的 \(rank[~]\) 數組,可以直接退出循環了。

以上就是 後綴數組-倍增法 的全部流程(個人認為還是很容易理解的,後綴數組的難點應該在基數排序)。下面是完成以上流程的兩種排序方法。

1.快速排序

時間復雜度:\(\Theta(n\log^2n)\)

快速排序實現的 後綴數組-倍增法 是很直觀、容易理解的,有助於初學者更好地理解 後綴數組-倍增法 的思路,編程難度也不是很大,值得一寫

放代碼之前,再來溫習一下幾個數組表示的意義:

\(sa[i]=j\):排名第 \(i\) 的後綴是後綴\(j\)

\(rank[j]=i\):後綴 \(j\) 在所有後綴中排名第 \(i\)

\(key1\):第一關鍵字(某個子串的前半部分)

\(key2\):第二關鍵字(某個子串的後半部分)

\(index\):表示後綴\(index\)

然後放代碼:

//快排
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e6+5;

struct node
{
    int key1, key2, index;
}data[maxn];

bool cmp(node x,node y)
{
    if(x.key1 < y.key1) return true;
    if(x.key1 > y.key1) return false;
    if(x.key2 < y.key2) return true;
    if(x.key2 > y.key2) return false;
    return x.index < y.index;
}

char str[maxn];

int n, rank[maxn], sa[maxn];

int main()
{
    scanf("%s", str+1);
    n = strlen(str+1);
    for(int i=1; i<=n; i++) rank[i] = str[i]-‘0‘+1;//預處理長度為 1 的子串的 rank 值 
    
    for(int l=1; (1<<l) < n; l++)
    {
        int len = 1<<(l-1); 
        //長度為 len/2 的子串已經排好序,存到了rank[]中,現在對長度為 len 的子串排序 
        for(int i=1; i<=n; i++)
        {
            data[i].key1 = rank[i], data[i].index = i; 
            //將長度為 len 的子串分為兩部分,前一部分的關鍵詞是key1,用 index 記錄這是第幾個後綴 
            if(i+len <= n) data[i].key2 = rank[i+len];//如果該後綴有第二關鍵字,將其第二關鍵字存起來 
            else data[i].key2 = 0;//否則將其第二關鍵字的優先級賦為最高 
        }
        
        sort(data+1, data+n+1, cmp);//按照兩個關鍵字從小到大排序 
        
        int rk = 1;
        rank[data[1].index] = rk;//排在第一個的後綴 rank 值為 1 
        for(int i=2; i<=n; i++)
        {
            if(data[i].key1 == data[i-1].key1 and data[i].key2 == data[i-1].key2)//如果當前排名第 i 的後綴與排名為 i-1 的後綴的一、二關鍵字都相同 
                rank[data[i].index] = rk;//那麽它們的 rank 值也是相同的 
            else rank[data[i].index] = (++rk);//否則當前的排名第 i 的後綴 rank 值要比前一個高 1 
        }
        
        if(rk == n) break;//如果當前每個後綴的 rank 值已經不相同了,那麽可以直接退出,不必繼續循環了 
    }
    
    for(int i=1; i<=n; i++) sa[rank[i]] = i; //根據 rank[] 數組和 sa[] 數組的關系,可以得到 sa[rank[i]] = i 
    for(int i=1; i<=n; i++) printf("%d ", sa[i]);
    return 0;
}

當然,用快速排序來完成倍增法效率是比較低的,大概只能過模板題,遇到大部分的題目都會超時!

於是,基於基數排序的高效倍增法出現啦!!!

2.基數排序

時間復雜度:\(\Theta(n\log n)\)

\(ps\):個人感覺後綴數組的難點就在這裏了。

當然,其實可以直接將 "1.快速排序" 代碼中的 “\(sort\)” 直接替換為 "基數排序"。這樣子雖然直觀,但是常數比較大,在這裏不作介紹。

接下來要介紹的是基數排序的一種巧妙的寫法,不僅常數小,而且代碼短,容易編程實現。但是這種寫法理解起來比較困難,理解的時候必須要非常明確每個數組的意思,否則編程的時候很容易出錯。

先介紹一下基數排序:

基數排序(\(radix~sort\))屬於“分配式排序”(\(distribution~sort\)),又稱“桶子法”(\(bucket~sort\))或\(bin ~sort\)

顧名思義,它是透過鍵值的部份資訊,將要排序的元素分配至某些“桶”中,藉以達到排序的作用。

基數排序法是屬於穩定性的排序,其時間復雜度為\(\Theta(n\log_rm)\),其中r為所采取的基數,而m為堆數,在某些時候,基數排序法的效率高於其它的穩定性排序法。 ——\(by\) 百度百科

拿下面這組數據為例,演示一下基數排序:

\(73, 22, 93, 43, 55, 14, 28, 65, 39, 81\)

首先,從頭到尾掃一遍,按照個位數(第二關鍵字),將這些兩位數分配到\(10\)個"桶"裏面。如圖,

技術分享圖片

開一個\(sum[~]\)數組,\(sum[i]=j\) 表示個位數(第二關鍵字)是 \(i\) 的數有 \(j\) 個。

然後將\(sum[~]\)做一個前綴和(別著急,待會就知道\(sum\)數組的作用了)。如圖,

技術分享圖片

按照基數排序的思想,接下來我們將上圖中的序列按照個位數(第二關鍵字)的大小從小到大取出來(為保證基數排序的穩定性,若第二關鍵字相同,在數組中存儲原序列的下標小的在前),於是形成了如下序列:

$81, 22, 73, 93, 43, 14, 55, 65, 28, 39 $

那麽,如何將數據取出來呢?這時,\(sum[~]\) 數組便發揮作用了。

先前我們已經對 \(sum[~]\) 數組做了一次前綴和了,所以此時 \(sum[i]=j\) 的意義就變為了"個位數(第二關鍵字)小於等於 \(i\) 的數有 \(j\)"。

此時,對於每個數,我們都知道“小於等於它的有多少個數”,這個數的位置也就確定了。

為了使排序具有穩定性,我們需要從後往前掃一遍序列,每遇到一個數(假設其第二關鍵字\(i\)),就將其放入新序列的第 \(sum[i]\) 個位置,然後將 \(sum[i]-1\) (因為該數已經找到了在新系列中的位置,若接下來還有第二關鍵字\(i\) 的數,其下表肯定會小於當前的數,所以在新序列中的位置是當前數的前一位)。

這樣就可以高效率地求出按照第二關鍵字排序後得到的新序列了,時間復雜度:\(\Theta(n)\)。上面所舉的例子具體演示見基數排序.pptx。

接下來再對新序列按照上文方法對第一關鍵字(十位數)排序,這樣就會得到一個從小到大的序列。

現在再回過頭來看後綴數組,其實是對兩個關鍵字排序,按照上述方法排序即可。

有一點不同的是,因為倍增的時候已經將長度為 \(len\) 的關鍵字排名算出來了,所以第二關鍵字不需要按照如上方法排序,可以直接利用上一步的排序結果做出來

因為我們知道,對於每一個後綴,它們都不一定有第二關鍵字。假設兩個後綴的第一關鍵字相同,如果其中一個子串沒有第二關鍵字,那麽該子串的字典序要小於另一個子串。

所以,沒有第二關鍵字的後綴按照第二關鍵字排序一定會在新序列的最前面。若第二關鍵字的長度為 \(len\) ,則後綴 $n-len+1 $ 到後綴 \(n\)沒有第二關鍵字。直接按照下標的順序依次排在新序列的最前面即可。

然後,根據上一次算出的長度為 \(len\) 的子串的排名,依次放入新序列即可。註意!!!:第二關鍵字長度為 \(len\) ,則子串 \(1...len,~~2...(len+1),~~3..(len+2),~~...,~~(len-1)...(len+len-2)\) 絕對不是第二關鍵字。因為若一個後綴有完整的(長度為\(len\))的第二關鍵字,則其不可能有不完成的(長度小於\(len\))的第一關鍵字。前面列舉的幾個子串如果為第二關鍵字的話,其所在的後綴的第一關鍵字必然是不完整的

就這樣,不用排序,我們直接利用已知的排名求出了按照第二關鍵字排序的新序列。然後再用上文提到過的基數排序的方法,排序出第一關鍵字,即可得到 \(rank[~]\) 數組。

註意:為了代碼簡潔,效率高一些,代碼中排序出來的序列中存儲的都是下標不是數值,所以理解起來可能會有點繞,一定要明確每個數組表示的意義

再次明確一下每個數組的定義:

\(sa[i] = j\):排名第 \(i\) 的後綴是 後綴\(j\)

\(rank[j]=i\):(上一步)後綴 \(j\) 的排名是 \(i\)

\(newRank[j]=i\):當前後綴\(j\) 的排名是\(i\)(與\(rank[~]\)數組是叠代的關系,可以理解為滾動數組)

\(sum[i]=j\):第一關鍵字小於等於\(i\) 的有 \(j\)

\(key2[i]=j:\)第二關鍵字排名為\(i\) 的是後綴\(j\)

下面直接上代碼(有註釋):

#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e6+5;
int sa[maxn], rank[maxn], newRank[maxn], sum[maxn], key2[maxn];
int n, m;
char  str[maxn];

bool cmp(int a, int b, int l)
{
    if(rank[a] != rank[b]) return false;//如果第一關鍵字不相同,直接返回不相同 
    if( (a+l > n and b+l <= n) or (a+l <= n and b+l > n) ) return false;//如果第一關鍵字相同,但是其中只有一個有第二關鍵字,直接返回不相同 
    if(a+l > n and b+l > n) return true;//如果第一關鍵字相同,且都沒有第二關鍵字,返回相同 
    return rank[a+l] == rank[b+l];//返回第二關鍵字相不相同 

}

int main()
{
    scanf("%s", str+1);
    n = strlen(str+1);
    for(int i=1; i<=n; i++) sum[rank[i] = str[i]]++; //按照字典序初始化排名 
    m = max(n, 256);
    for(int i=1; i<=m; i++) sum[i]+=sum[i-1];//前綴和,此時sum[i]=j表示的是小於等於i的關鍵字有j個(此時只有一個關鍵字) 
    for(int i=n; i>=1; i--) sa[sum[rank[i]]--] = i; //將分好類的原序列放入新序列中 
    
    for(int l=1; l<n; l<<=1)//倍增 
    {
        //l表示已知所有後綴按照前l個字符的排名,求所有後綴按照前l*2個字符的排名 
        int k = 0;
        for(int i=n-l+1; i<=n; i++) key2[++k] = i;//如果沒有第二關鍵字,直接放在序列的最前面 
        for(int i=1; i<=n; i++) if(sa[i] > l) key2[++k] = sa[i]-l;//跳過不合法的第二關鍵字
        //註意,因為存儲的是後綴j,所以存的是它的第一個字符的位置,並不是第二關鍵字的開頭位置,因此sa[i]需要減l 
        for(int i=1; i<=m; i++) sum[i] = 0;//初始化sum數組 
        for(int i=1; i<=n; i++) sum[rank[i]]++;//分類 
        for(int i=1; i<=m; i++) sum[i]+=sum[i-1];//前綴和 
        for(int i=n; i>=1; i--)
        {
            int j = key2[i];//key2中,後綴已經排好序了,但實際上在rank數組中,存儲的還是上一個位置的排名信息,因此要用到更新後的key2數組的信息 
            sa[sum[rank[j]]--] = j;//從後往前,按照第一關鍵字分好的類別依次放入新序列中,同時保證了第二關鍵字小的較前 
        }
        
        int rk = 1;
        newRank[sa[1]] = rk;//計算排名,排序後的第一名的排名為1 
        for(int i=2; i<=n; i++)
        { 
            if(cmp(sa[i-1], sa[i], l)) newRank[sa[i]] = rk;//如果當前這一名第一和第二關鍵字與前一名相同,那麽它們的排名也是相同的 
            else newRank[sa[i]] = ++rk;//否則當前排名是前一個排名的後一位 
        }
        for(int i=1; i<=n; i++) rank[i] = newRank[i];//將當前排名覆蓋以前的排名 
        
        if(rk == n) break;//剪枝 
    }
    
    for(int i=1; i<=n; i++) printf("%d ",sa[i]);
    return 0;
}

終於搞掂了,寫到整個人都要虛脫了......我...盡力了。

(三)求height數組

說實話,後綴數組在實際應用中並沒有什麽卵用,真正的利器\(hight\) 數組。

感覺前面講那麽多都是為了這一章做鋪墊,然而這一章因為我too lazy,並不想寫那麽多......

首先講一下\(height\)數組的定義:

\(height[i]=j:\)表示排名為\(i\) 的後綴和排名為\(i-1\) 的後綴的最長公共前綴的長度為\(j\)

\(H[i]=j:\)表示後綴\(i\)與其比它排名前一名的後綴的最長公共前綴長度為\(j\)

\(H\)數組有一個極其極其重要的性質:

\(H[i]\ge H[i-1]-1\)

註意:\(H[i]\ge0\)

證明略。

然後按照後綴1 到 後綴\(n\)的順序,根據以上性質,縮小枚舉範圍(本來是1...?,現在變為H[i-1]-1...?),得到\(height\)數組。

直接上代碼了:

void getHeight()
{
    int k = 0;
    for(int i=1; i<=n; i++)
    {
        if(rank[i] == 1) continue;
        int j = sa[rank[i]-1];
        while(str[j+k] == str[i+k] and j+k<=n and i+k<=n) k++;
        height[rank[i]] = k;
        if(k != 0) k--;
    }
}

(四)總結

終於寫完了雖然倒數第2章有些敷衍

總之後綴數組在字符串處理中還是非常有用的。後綴數組是字符串處理中非常優秀的數據結構,是一種處理字符串的有力工具,在不同類型的字符串問題中有廣泛的應用。我們應該掌握好後綴數組這種數據結構,並且能在不同類型的題目中靈活、高效的運用。

學習總結-後綴數組