字尾陣列詳解

自為風月馬前卒發表於2018-02-06

什麼是字尾陣列

字尾陣列是處理字串的有力工具 —羅穗騫

個人理解:字尾陣列是讓人蒙逼的有力工具!

就像上面那位大神所說的,字尾陣列可以解決很多關於字串的問題,

譬如這道題

 

注意:字尾陣列並不是一種演算法,而是一種思想。

實現它的方法主要有兩種:倍增法$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$,這個陣列更神奇,,有空再講吧。 已補充

 

相關文章