后綴數組詳解


什么是后綴數組

后綴數組是處理字符串的有力工具 —羅穗騫

個人理解:后綴數組是讓人蒙逼的有力工具!

就像上面那位大神所說的,后綴數組可以解決很多關於字符串的問題,

譬如這道題

 

注意:后綴數組並不是一種算法,而是一種思想。

實現它的方法主要有兩種:倍增法$O(nlogn)$ 和 DC3法$O(n)$

其中倍增法除了僅僅在時間復雜度上不占優勢之外,其他的方面例如編程難度,空間復雜度,常數等都秒殺DC3法

 

我的建議:深入理解倍增法,並能熟練運用(起碼8分鍾內寫出來&&沒有錯誤)。DC3法只做了解,吸取其中的精髓;

 

但是由於本人太辣雞啦,所以本文只討論倍增法

 

前置知識

后綴

這個大家應該都懂吧。。

比如說$aabaaaab$

它的后綴為

基數排序

我下面會詳細講

現在,你可以簡單的理解為

基數排序在后綴數組中可以在$O(n)$的時間內對一個二元組$(p,q)$進行排序,其中$p$是第一關鍵字,$q$是第二關鍵字

比其他的排序算法都要優越

倍增法

首先定義一坨變量

$sa[i]$:排名為$i$的后綴的位置

$rak[i]$:從第$i$個位置開始的后綴的排名,下文為了敘述方便,把從第$i$個位置開始的后綴簡稱為后綴$i$

$tp[i]$:基數排序的第二關鍵字,意義與$sa$一樣,即第二關鍵字排名為$i$的后綴的位置

$tax[i]$:$i$號元素出現了多少次。輔助基數排序

$s$:字符串,$s[i]$表示字符串中第$i$個字符串

 

可能大家覺得$sa$和$rak$這兩個數組比較繞,沒關系,多琢磨一下就好

事實上,也正是因為這樣,才使得兩個數組可以在$O(n)$的時間內互相推出來

具體一點

$rak[sa[i]]=i$

$sa[rak[i]]=i$

 

那我們怎么對所有的后綴進行排序呢?

我們把每個后綴分開來看。

開始時,每個后綴的第一個字母的大小是能確定的,也就是他本身的$ascii$值

具體點?把第$i$個字母看做是$(s[i],i)$的二元組,對其進行基數排序。這樣我們可以保證$ascii$小的在前面,若$ascii$相同則先出現的在前面

 

這樣我們就得到了他們的在完成第一個字母的排序之后的相對位置關系

 

接下來呢?

不要忘了, 我們算法的名稱叫做“倍增法”,每次將排序長度*2,最多需要$log(n)$次便可以完成排序

因此我們現在需要對每個后綴的前兩個字母進行排序

 

此時第一個字母的相對關系我們已經知道了。

那第二個字母的大小呢?我們還需要一次排序么?

其實大可不必,因為我們忽略了一個非常重要的性質:第$i$個后綴的第二個字母,實際是第$i+1$個后綴的第一個字母

 

因此每個后綴的第二個字母的相對位置關系我們也是知道的。

我們用$tp$這個數組把他記錄出來,對$(rak,tp)$這個二元組進行基數排序

$tp[i]$表示的是第二關鍵字中排名為$i$的后綴的位置,$rak$表示的是上一輪中第$i$個后綴的排名。

對於一個長度為$w$的后綴,你可以形象的理解為:第一關鍵字針對前$\frac{w}{2}$個字符形成的字符串,第二關鍵字針對后$\frac{w}{2}$個字符形成的字符串

 

接下來我們需要對每個后綴的前四個字母組成的字符串進行排序

此時我們已經知道了每個后綴前兩個字母的排名,而第$i$個后綴的第$3,4$個字母恰好是第$i+2$個后綴的前兩個字母。

他們的相對位置我們又知道啦。

 

這樣不斷排下去,最后就可以完成排序啦

 

我相信大家看到這里肯定是一臉mengbi

下面我結合代碼和具體的排序過程給大家演示一下

 

過程詳解

按照上面說的,開始時$rak$為字符的ascii碼,第二關鍵字為它們的相對位置關系

這里的$a$數組是字符串數組

然后我們對其進行排序,我們暫且先不管它是如何進行排序,因為排序的過程非常難理解,一會兒我重點講一下。

 

各個數組的大小

 

然后我們進行倍增。

 

這里再定義幾個變量

$M$:字符集的大小,基數排序時會用到。不理解也沒關系

$p$:排名的多少(有幾個不同的后綴)

注意在排序的過程中,各個后綴的排名可能是相同的。因為我們在倍增的過程中只是對其前幾個字符進行排名。

但是,對於每個后綴來說,最終的排名一定是不同的!畢竟每個后綴的長度都不相同

 

下面是倍增的過程

$w$表示倍增的長度,當各個排名都不相同時,我們便可以退出循環。

$M=p$是對基數排序的優化,因為字符集大小就是排名的個數

 

 

這兩句話是對第二關鍵字進行排序

假設我們現在需要得到的長度為$w$,那么$sa[i]$表示的實際是長度為$\frac{w}{2}$的后綴中排名為$i$的位置(也就是上一輪的結果)

我們需要得到的$tp[i]$表示的是:長度為$w$的后綴中,第二關鍵字排名為$i$的位置。

之所以能這樣更新,是因為$i$號后綴的前$\frac{w}{2}$個字符形成的字符串是$i - \frac{w}{2}$號后綴的后$\frac{w}{2}$個字符形成的字符串

算了直接上圖吧,。。

(注意此圖的邊界與代碼中有區別,原因是代碼中的$w$表示我們已經得到了長度為$w$的結果,現在正要去更新長度為$2w$的結果)

 

 

此時的$p$並不是統計排名的個數,只是一個簡單的計數器

注意:有一些后綴是沒有第二關鍵字的,他們的第二關鍵字排名排名應該在最前面。

 

此時第一二關鍵字都已經處理好了,我們進行排序

排完序之后,我們得到了一個新的$sa$數組

此時我們用$sa$數組來更新$rak$數組

 

我們前面說過$rak$數組是可能會重復的,所以我們此時用$p$來表示到底出現了幾個名次

還需要注意一個事情,在判斷是否重復的時候,我們需要用到上一輪的$rak$

而此時$tp$數組是沒有用的,所以我們直接交換$tp$和$rak$

當然你也可以寫為

 

 

在判斷重復的時候,我們實際上是對一個二元組進行比較。

 

當滿足判斷條件時,兩個后綴的名次一定是相同的(想一想,為什么?)

 

 然后愉快的輸出就可以啦!

 

放一下代碼

 

#include<cstdio>
#include<cstring>
#include<algorithm>
const int MAXN = 1e6 + 10;
using namespace std;
char s[MAXN];
int N, M, rak[MAXN], sa[MAXN], tax[MAXN], tp[MAXN];
void Debug() {
    printf("*****************\n");
    printf("下標"); for (int i = 1; i <= N; i++) printf("%d ", i);     printf("\n");
    printf("sa  "); for (int i = 1; i <= N; i++) printf("%d ", sa[i]); printf("\n");
    printf("rak "); for (int i = 1; i <= N; i++) printf("%d ", rak[i]); printf("\n");
    printf("tp  "); for (int i = 1; i <= N; i++) printf("%d ", tp[i]); printf("\n");
}
void Qsort() {
    for (int i = 0; i <= M; i++) tax[i] = 0;
    for (int i = 1; i <= N; i++) tax[rak[i]]++;
    for (int i = 1; i <= M; i++) tax[i] += tax[i - 1];
    for (int i = N; i >= 1; i--) sa[ tax[rak[tp[i]]]-- ] = tp[i];
    //這部分我的文章的末尾詳細的說明了
}
void SuffixSort() {
    M = 75;
    for (int i = 1; i <= N; i++) rak[i] = s[i] - '0' + 1, tp[i] = i;
    Qsort();
    Debug();
    for (int w = 1, p = 0; p < N; M = p, w <<= 1) {
        //w:當前倍增的長度,w = x表示已經求出了長度為x的后綴的排名,現在要更新長度為2x的后綴的排名
        //p表示不同的后綴的個數,很顯然原字符串的后綴都是不同的,因此p = N時可以退出循環
        p = 0;//這里的p僅僅是一個計數器000
        for (int i = 1; i <= w; i++) tp[++p] = N - w + i;
        for (int i = 1; i <= N; i++) if (sa[i] > w) tp[++p] = sa[i] - w; //這兩句是后綴數組的核心部分,我已經畫圖說明
        Qsort();//此時我們已經更新出了第二關鍵字,利用上一輪的rak更新本輪的sa
        std::swap(tp, rak);//這里原本tp已經沒有用了
        rak[sa[1]] = p = 1;
        for (int i = 2; i <= N; i++)
            rak[sa[i]] = (tp[sa[i - 1]] == tp[sa[i]] && tp[sa[i - 1] + w] == tp[sa[i] + w]) ? p : ++p;
        //這里當兩個后綴上一輪排名相同時本輪也相同,至於為什么大家可以思考一下
        Debug();
    }
    for (int i = 1; i <= N; i++)
        printf("%d ", sa[i]);
}
int main() {
    scanf("%s", s + 1);
    N = strlen(s + 1);
    SuffixSort();
    return 0;
}

 

 

 

 

再補一下調試結果

 

基數排序

如果你對上面的主體過程有了大致的了解,那么基數排序的過程就不難理解了

在閱讀下面內容之前,我希望大家能初步了解一下基數排序

https://baike.baidu.com/item/%E5%9F%BA%E6%95%B0%E6%8E%92%E5%BA%8F/7875498?fr=aladdin

大致看一下它給出的例子和c++代碼就好

 

 

先來大致看一下,代碼就$4$行

 

 

$M$:字符集的大小,一共需要多少個桶

$tax$:元素出現的次數,在這里就是名次出現的次數

 

第一行:把桶清零

第二行:統計每個名詞出現的次數

第三行:做個前綴和(啪,廢話)

可能大家會疑惑前綴和有什么用?

利用前綴和可以快速的定位出每個位置應有的排名

具體的來說,前綴和可以統計比當前名次小的后綴有多少個。

第四行:@#¥%……&*

我知道大家肯定看暈了,我們先來回顧一下這幾個數組的定義

這里我們假設已經得到了$w$長度的排名,要更新$2w$長度的排名

$sa[i]$:長度為$w$的后綴中,排名為$i$的后綴的位置

$rak[i]$:長度為$w$的后綴中,從第$i$個位置開始的后綴的排名

$tp[i]$:長度為$2w$的后綴中,第二關鍵字排名為$i$的后綴的位置

我們考慮如果把串長為$w$擴展為$2w$會有哪些變化

首先第一關鍵字的相對位置是不會改變的,唯一有變化的是$rak$值相同的那些后綴,我們需要根據$tp$的值來確定他們的相對位置

煮個栗子,$rak$相同,$tp[1] = 2,tp[2] = 4$,那么從$4$開始的后綴排名比從$2$開始的后綴排名靠后

再回來看這句話應該就好明白了

首先我們倒着枚舉$i$,

那么$sa[tax[rak[tp[i]]]--]$的意思就是說:

我從大到小枚舉第二關鍵字,再用$rak[i]$定位到第一關鍵字的大小

那么$tax[rak[tp[i]]]$就表示當第一關鍵字相同時,第二關鍵字較大的這個后綴的排名是啥

得到了排名,我們也就能更新$sa$了

 

height數組

個人感覺,上面說的一大堆,都是為$height$數組做鋪墊的,$height$數組才是后綴數組的精髓、

先說定義

$i$號后綴:從$i$開始的后綴

$lcp(x,y)$:字符串$x$與字符串$y$的最長公共前綴,在這里指$x$號后綴與與$y$號后綴的最長公共前綴

$height[i]$:$lcp(sa[i], sa[i - 1])$,即排名為$i$的后綴與排名為$i - 1$的后綴的最長公共前綴

$H[i]$:$height[rak[i]]$,即$i$號后綴與它前一名的后綴的最長公共前綴

 

性質:$H[i] \geqslant H[i - 1] - 1$

證明引自遠航之曲大佬

 

update in 2019.3.28

在復習的時候我發現這里的證明有一個跳點,包括論文中的證明也有一點不嚴謹的地方

下面兩處畫紅線的地方均沒有證明"suffix(k+1)"與"i前一名的后綴之間的關系",實際上這兩者之間的關系是:他們的lcp至少為h[i - 1] - 1。可以用反證法證明,在此不再贅述

 

能夠線性計算height[]的值的關鍵在於h[](height[rank[]])的性質,即h[i]>=h[i-1]-1,下面具體分析一下這個不等式的由來。

我們先把要證什么放在這:對於第i個后綴,設j=sa[rank[i] – 1],也就是說j是i的按排名來的上一個字符串,按定義來i和j的最長公共前綴就是height[rank[i]],我們現在就是想知道height[rank[i]]至少是多少,而我們要證明的就是至少是height[rank[i-1]]-1。

好啦,現在開始證吧。

首先我們不妨設第i-1個字符串(這里以及后面指的“第?個字符串”不是按字典序排名來的,是按照首字符在字符串中的位置來的)按字典序排名來的前面的那個字符串是第k個字符串,注意k不一定是i-2,因為第k個字符串是按字典序排名來的i-1前面那個,並不是指在原字符串中位置在i-1前面的那個第i-2個字符串。

這時,依據height[]的定義,第k個字符串和第i-1個字符串的公共前綴自然是height[rank[i-1]],現在先討論一下第k+1個字符串和第i個字符串的關系。

第一種情況,第k個字符串和第i-1個字符串的首字符不同,那么第k+1個字符串的排名既可能在i的前面,也可能在i的后面,但沒有關系,因為height[rank[i-1]]就是0了呀,那么無論height[rank[i]]是多少都會有height[rank[i]]>=height[rank[i-1]]-1,也就是h[i]>=h[i-1]-1。

第二種情況,第k個字符串和第i-1個字符串的首字符相同,那么由於第k+1個字符串就是第k個字符串去掉首字符得到的,第i個字符串也是第i-1個字符串去掉首字符得到的,那么顯然第k+1個字符串要排在第i個字符串前面,要么就產生矛盾了。同時,第k個字符串和第i-1個字符串的最長公共前綴是height[rank[i-1]],那么自然第k+1個字符串和第i個字符串的最長公共前綴就是height[rank[i-1]]-1。

到此為止,第二種情況的證明還沒有完,我們可以試想一下,對於比第i個字符串的字典序排名更靠前的那些字符串,誰和第i個字符串的相似度最高(這里說的相似度是指最長公共前綴的長度)?顯然是排名緊鄰第i個字符串的那個字符串了呀,即sa[rank[i]-1]。也就是說sa[rank[i]]和sa[rank[i]-1]的最長公共前綴至少是height[rank[i-1]]-1,那么就有height[rank[i]]>=height[rank[i-1]]-1,也即h[i]>=h[i-1]-1。

 

 

代碼

void GetHeight() {
    int j, k = 0;
    for(int i = 1; i <= N; i++) {
        if(k) k--;
        int j = sa[rak[i] - 1];
        while(s[i + k] == s[j + k]) k++;
        Height[rak[i]] = k;
        printf("%d\n", k);
    }
}

 

 

經典應用

兩個后綴的最大公共前綴

$lcp(x, y) = min(heigh[x-y])$, 用rmq維護,O(1)查詢

可重疊最長重復子串

Height數組里的最大值

不可重疊最長重復子串 POJ1743

首先二分答案$x$,對height數組進行分組,保證每一組的$min height$都$>=x$

依次枚舉每一組,記錄下最大和最小長度,多$sa[mx] - sa[mi] >= x$那么可以更新答案

本質不同的子串的數量

枚舉每一個后綴,第$i$個后綴對答案的貢獻為$len - sa[i] + 1 - height[i]$

后記

本蒟蒻也是第一次看這么難的東西。

第一次見這種東西應該是去年夏天吧,那時我記得自己在機房里瞅着這幾行代碼看了一晚上也沒看出啥來。

現在再來看也是死磕了一天多才看懂。

不過我還是比較好奇。

這種東西是誰發明的啊啊啊啊啊腦洞也太大了吧啊啊啊啊啊啊

哦對了,后綴數組還有一個非常有用的數組叫做$height$,這個數組更神奇,,有空再講吧。 已補充

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM