天天看點

字尾數組解析以及應用

點我

為什麼學字尾數組

字尾數組是一個比較強大的處理字元串的算法,是有關字元串的基礎算法,是以必須掌握。

學會字尾自動機(SAM)就不用學字尾數組(SA)了?不,雖然SAM看起來更為強大和全面,但是有些SAM解決不了的問題能被SA解決,隻掌握SAM是遠遠不夠的。

……

有什麼SAM做不了的例子?

比如果求一個串字尾的lcp方面的應用,這是SA可以很友善的用rmq來維護,但是SAM還要求lca,比較麻煩,還有就是字元集比較大的時候SA也有優勢。

現在這裡放道題,看完這個blog可能就會做了!:

你可想想這道題:你有一個01串S,然後定義一個字首最右邊的位置就是這個字首的結束位置。現在有q多個詢問,每個詢問結束位置在l~r中不同字首的最長公共字尾是多長?

|S|,q≤100000” role=”presentation”>|S|,q≤100000|S|,q≤100000

時限4s

而下面是我對字尾數組的一些了解

構造字尾數組——SA

先定義一些變量的含義

Str :需要處理的字元串(長度為Len)

Suffix[i] :Str下标為i ~ Len的連續子串(即字尾)

Rank[i] : Suffix[i]在所有字尾中的排名

SA[i] : 滿足Suffix[SA[1]] < Suffix[SA[2]] …… < Suffix[SA[Len]],即排名為i的字尾為Suffix[SA[i]] (與Rank是互逆運算)

好,來形象的了解一下

字尾數組解析以及應用

字尾數組指的就是這個SA[i],有了它,我們就可以實作一些很強大的功能(如不相同子串個數、連續重複子串等)。如何快速的到它,便成為了這個算法的關鍵。而SA和Rank是互逆的,隻要求出任意一個,另一個就可以O(Len)得到。

現在比較主流的算法有兩種,倍增和DC3,在這裡,就主要講一下稍微慢一些,但比較好實作以及了解的倍增算法(雖說慢,但也是O(Len logLen))的。

進入正題——倍增算法

倍增算法的主要思想 :對于一個字尾Suffix[i],如果想直接得到Rank比較困難,但是我們可以對每個字元開始的長度為2k” role=”presentation”>2k2k大于Len時,所得到的序列就是Rank,而SA也就知道了。O(logLen)枚舉k

這樣做有什麼好處呢?

設每一輪得到的序列為rank(注意r是小寫,最終字尾排名Rank是大寫)。有一個很美妙的性質就出現了!第k輪的rank可由第k - 1輪的rank快速得來!

為什麼呢?為了友善描述,設SubStr(i, len)為從第i個字元開始,長度為len的字元串我們可以把第k輪SubStr(i, 2k” role=”presentation”>2k2k的字元串是上一輪遇到過的!當然上一輪的rank也知道!那麼吧每個這一輪的字元串都轉化為這種形式,并且大家都知道字元串的比較是從左往右,左邊和右邊的大小我們可以用上一輪的rank表示,那麼……這不就是一些兩位數(也可以視為第一關鍵字和第二關鍵字)比較大小嗎!再把這些兩位數重新排名就是這一輪的rank。

我們用下面這張經典的圖了解一下:

字尾數組解析以及應用

相信隻要了解字元串的比較法則(跟實數差不多),了解起來并不難。#還有一個細節就是怎麼把這些兩位數排序?這種位數少的數進行排序毫無疑問的要用一個複雜度為長度*排序數的個數的優美算法——基數排序(對于兩位數的數複雜度就是O(Len)的)。

基數排序原理 : 把數字依次按照由低位到高位依次排序,排序時隻看目前位。對于每一位排序時,因為上一位已經是有序的,是以這一位相等或符合大小條件時就不用交換位置,如果不符合大小條件就交換,實作可以用”桶”來做。(叙說起來比較奇怪,看完下面的代碼應該更好了解,也可以上網查有關資料)

好了SA和Rank(大寫R)到此為止就處理好了。(下面有詳解代碼!)。但我們發現,隻有這兩樣東西好像沒什麼用,為了處理重複子串之類的問題,我們就要引入一個表示最長公共字首的新助手Height數組!

構造最長公共字首——Height

同樣先是定義一些變量

Heigth[i] : 表示Suffix[SA[i]]和Suffix[SA[i - 1]]的最長公共字首,也就是排名相鄰的兩個字尾的最長公共字首

H[i] : 等于Height[Rank[i]],也就是字尾Suffix[i]和它前一名的字尾的最長公共字首

而兩個排名不相鄰的最長公共字首定義為排名在它們之間的Height的最小值。

跟上面一樣,先形像的了解一下:

字尾數組解析以及應用

高效地得到Height數組

如果一個一個數按SA中的順序比較的話複雜度是O(N2” role=”presentation”>N2N2)級别的,想要快速的得到Height就需要用到一個關于H數組的性質。

H[i] ≥ H[i - 1] - 1!

如果上面這個性質是對的,那我們可以按照H[1]、H[2]……H[Len]的順序進行計算,那麼複雜度就降為O(N)了!

讓我們嘗試一下證明這個性質 : 設Suffix[k]是排在Suffix[i - 1]前一名的字尾,則它們的最長公共字首是H[i - 1]。都去掉第一個字元,就變成Suffix[k + 1]和Suffix[i]。如果H[i - 1] = 0或1,那麼H[i] ≥ 0顯然成立。否則,H[i] ≥ H[i - 1] - 1(去掉了原來的第一個,其他字首一樣相等),是以Suffix[i]和在它前一名的字尾的最長公共字首至少是H[i - 1] - 1。

仔細想想還是比較好了解的。H求出來,那Height就相應的求出來了,這樣結合SA,Rank和Height我們就可以做很多關于字元串的題了!

代碼——Code

建議複制到自己的程式設計軟體上看

/*
    Problem: JZOJ1598(詢問一個字元串中有多少至少出現兩次的子串)
    Content: SA's Code and Explanation
    Author : YxuanwKeith
*/

#include <cstdio>
#include <cstring>
#include <algorithm>

using namespace std;

const int MAXN = 100005;

char ch[MAXN], All[MAXN];
int SA[MAXN], rank[MAXN], Height[MAXN], tax[MAXN], tp[MAXN], a[MAXN], n, m; 
char str[MAXN];
//rank[i] 第i個字尾的排名; SA[i] 排名為i的字尾位置; Height[i] 排名為i的字尾與排名為(i-1)的字尾的LCP
//tax[i] 計數排序輔助數組; tp[i] rank的輔助數組(計數排序中的第二關鍵字),與SA意義一樣。
//a為原串
void RSort() {
    //rank第一關鍵字,tp第二關鍵字。
    for (int i = 0; i <= m; i ++) tax[i] = 0;
    for (int i = 1; i <= n; i ++) tax[rank[tp[i]]] ++;
    for (int i = 1; i <= m; i ++) tax[i] += tax[i-1];
    for (int i = n; i >= 1; i --) SA[tax[rank[tp[i]]] --] = tp[i]; //確定滿足第一關鍵字的同時,再滿足第二關鍵字的要求
} //計數排序,把新的二進制組排序。

int cmp(int *f, int x, int y, int w) { return f[x] == f[y] && f[x + w] == f[y + w]; } 
//通過二進制組兩個下标的比較,确定兩個子串是否相同

void Suffix() {
    //SA
    for (int i = 1; i <= n; i ++) rank[i] = a[i], tp[i] = i;
    m = 127 ,RSort(); //一開始是以單個字元為機關,是以(m = 127)

    for (int w = 1, p = 1, i; p < n; w += w, m = p) { //把子串長度翻倍,更新rank

        //w 目前一個子串的長度; m 目前離散後的排名種類數
        //目前的tp(第二關鍵字)可直接由上一次的SA的得到
        for (p = 0, i = n - w + 1; i <= n; i ++) tp[++ p] = i; //長度越界,第二關鍵字為0
        for (i = 1; i <= n; i ++) if (SA[i] > w) tp[++ p] = SA[i] - w;

        //更新SA值,并用tp暫時存下上一輪的rank(用于cmp比較)
        RSort(), swap(rank, tp), rank[SA[1]] = p = 1;

        //用已經完成的SA來更新與它互逆的rank,并離散rank
        for (i = 2; i <= n; i ++) rank[SA[i]] = cmp(tp, SA[i], SA[i - 1], w) ? p : ++ p;
    }
    //離散:把相等的字元串的rank設為相同。
    //LCP
    int j, k = 0;
    for(int i = 1; i <= n; Height[rank[i ++]] = k) 
        for( k = k ? k - 1 : k, j = SA[rank[i] - 1]; a[i + k] == a[j + k]; ++ k);
    //這個知道原理後就比較好了解程式
}

void Init() {
    scanf("%s", str);
    n = strlen(str);
    for (int i = 0; i < n; i ++) a[i + 1] = str[i];
}

int main() {
    Init();
    Suffix();

    int ans = Height[2];
    for (int i = 3; i <= n; i ++) ans += max(Height[i] - Height[i - 1], 0);
    printf("%d\n", ans);    
}      

4個比較基礎的應用

Q1:一個串中兩個串的最大公共字首是多少?

A1:這不就是Height嗎?用rmq預處理,再O(1)查詢。

” role=”presentation”>

Q2:一個串中可重疊的重複最長子串是多長?

A2:就是求任意兩個字尾的最長公共字首,而任意兩個字尾的最長公共字首都是Height 數組裡某一段的最小值,那最長的就是Height中的最大值。

” role=”presentation”>

Q3:一個串種不可重疊的重複最長子串是多長?

A3:先二分答案,轉化成判别式的問題比較好處理。假設目前需要判别長度為k是否符合要求,隻需把排序後的字尾分成若幹組,其中每組的字尾之間的Height 值都不小于k,再判斷其中有沒有不重複的字尾,具體就是看最大的SA值和最小的SA值相差超不超過k,有一組超過的話k就是合法答案。

” role=”presentation”>

A4:一個字元串不相等的子串的個數是多少?

Q4:每個子串一定是某個字尾的字首,那麼原問題等價于求所有字尾之間的不相同的字首的個數。而且可以發現每一個字尾Suffix[SA[i]]的貢獻是Len - SA[i] + 1,但是有子串算重複,重複的就是Heigh[i]個與前面相同的字首,那麼減去就可以了。最後,一個字尾Suffix[SA[i]]的貢獻就是Len - SA[k] + 1 - Height[k]。

對于字尾數組更多的應用這裡就不詳細闡述,經過思考後每個人都會發現它的一些不同的用途,它的功能也許比你想象中的更強大!

最開始的那道題

先搬下來。。。

你可想想這道題:你有一個01串S,然後定義一個字首最右邊的位置就是這個字首的結束位置。現在有很多個詢問,每q個詢問結束位置在l~r中不同字首的最長公共字尾是多長?

|S|,q&#x2264;100000” role=”presentation”>|S|,q≤100000|S|,q≤100000

時限4s

簡單思路:首先可以把字元串反過來就是求字尾的最長公共字首了,可以用SA求出height數組,然後用rmq預處理之後就是求兩個位置間的最小值。然後對于一個區間,顯然隻有在SA數組中相鄰的兩個串可以貢獻答案。

對于區間詢問的問題可以用莫隊處理,然後考慮加入一個字尾應該怎麼處理,我們可以維護一個按SA數組排序的連結清單。假設我們先把所有位置的SA全部加入,然後按順序删除,重新按順序加入時就可以O(1)完成修改。那麼按照這個思路我們可以用固定左端點的并查集,做到隻加入,不删除,然後用O(nn+nlogn)” role=”presentation”>O(nn−−√+nlogn)O(nn+nlogn)的複雜度完成這道題。

*可能後面的處理方式比較麻煩,如果直接用splay維護區間中的字尾的話可以做到O(nnlogn)” role=”presentation”>O(nn−−√logn)O(nnlogn),這個方法就比較直覺,而SAM在個問題上還是有點無力的。這題隻是為了說明SA相比于SAM還是有他的獨到之處,特别是在處理字尾的lcp之類的問題上。

結束

以上就是我對字尾數組的了解 ——YxuanwKeith

<link rel="stylesheet" href="https://csdnimg.cn/release/phoenix/template/css/markdown_views-ea0013b516.css">
            </div>      

為什麼學字尾數組

字尾數組是一個比較強大的處理字元串的算法,是有關字元串的基礎算法,是以必須掌握。

學會字尾自動機(SAM)就不用學字尾數組(SA)了?不,雖然SAM看起來更為強大和全面,但是有些SAM解決不了的問題能被SA解決,隻掌握SAM是遠遠不夠的。

……

有什麼SAM做不了的例子?

比如果求一個串字尾的lcp方面的應用,這是SA可以很友善的用rmq來維護,但是SAM還要求lca,比較麻煩,還有就是字元集比較大的時候SA也有優勢。

現在這裡放道題,看完這個blog可能就會做了!:

你可想想這道題:你有一個01串S,然後定義一個字首最右邊的位置就是這個字首的結束位置。現在有q多個詢問,每個詢問結束位置在l~r中不同字首的最長公共字尾是多長?

|S|,q&#x2264;100000” role=”presentation”>|S|,q≤100000|S|,q≤100000

時限4s

而下面是我對字尾數組的一些了解

構造字尾數組——SA

先定義一些變量的含義

Str :需要處理的字元串(長度為Len)

Suffix[i] :Str下标為i ~ Len的連續子串(即字尾)

Rank[i] : Suffix[i]在所有字尾中的排名

SA[i] : 滿足Suffix[SA[1]] < Suffix[SA[2]] …… < Suffix[SA[Len]],即排名為i的字尾為Suffix[SA[i]] (與Rank是互逆運算)

好,來形象的了解一下

字尾數組解析以及應用

字尾數組指的就是這個SA[i],有了它,我們就可以實作一些很強大的功能(如不相同子串個數、連續重複子串等)。如何快速的到它,便成為了這個算法的關鍵。而SA和Rank是互逆的,隻要求出任意一個,另一個就可以O(Len)得到。

現在比較主流的算法有兩種,倍增和DC3,在這裡,就主要講一下稍微慢一些,但比較好實作以及了解的倍增算法(雖說慢,但也是O(Len logLen))的。

進入正題——倍增算法

倍增算法的主要思想 :對于一個字尾Suffix[i],如果想直接得到Rank比較困難,但是我們可以對每個字元開始的長度為2k” role=”presentation”>2k2k大于Len時,所得到的序列就是Rank,而SA也就知道了。O(logLen)枚舉k

這樣做有什麼好處呢?

設每一輪得到的序列為rank(注意r是小寫,最終字尾排名Rank是大寫)。有一個很美妙的性質就出現了!第k輪的rank可由第k - 1輪的rank快速得來!

為什麼呢?為了友善描述,設SubStr(i, len)為從第i個字元開始,長度為len的字元串我們可以把第k輪SubStr(i, 2k” role=”presentation”>2k2k的字元串是上一輪遇到過的!當然上一輪的rank也知道!那麼吧每個這一輪的字元串都轉化為這種形式,并且大家都知道字元串的比較是從左往右,左邊和右邊的大小我們可以用上一輪的rank表示,那麼……這不就是一些兩位數(也可以視為第一關鍵字和第二關鍵字)比較大小嗎!再把這些兩位數重新排名就是這一輪的rank。

我們用下面這張經典的圖了解一下:

字尾數組解析以及應用

相信隻要了解字元串的比較法則(跟實數差不多),了解起來并不難。#還有一個細節就是怎麼把這些兩位數排序?這種位數少的數進行排序毫無疑問的要用一個複雜度為長度*排序數的個數的優美算法——基數排序(對于兩位數的數複雜度就是O(Len)的)。

基數排序原理 : 把數字依次按照由低位到高位依次排序,排序時隻看目前位。對于每一位排序時,因為上一位已經是有序的,是以這一位相等或符合大小條件時就不用交換位置,如果不符合大小條件就交換,實作可以用”桶”來做。(叙說起來比較奇怪,看完下面的代碼應該更好了解,也可以上網查有關資料)

好了SA和Rank(大寫R)到此為止就處理好了。(下面有詳解代碼!)。但我們發現,隻有這兩樣東西好像沒什麼用,為了處理重複子串之類的問題,我們就要引入一個表示最長公共字首的新助手Height數組!

構造最長公共字首——Height

同樣先是定義一些變量

Heigth[i] : 表示Suffix[SA[i]]和Suffix[SA[i - 1]]的最長公共字首,也就是排名相鄰的兩個字尾的最長公共字首

H[i] : 等于Height[Rank[i]],也就是字尾Suffix[i]和它前一名的字尾的最長公共字首

而兩個排名不相鄰的最長公共字首定義為排名在它們之間的Height的最小值。

跟上面一樣,先形像的了解一下:

字尾數組解析以及應用

高效地得到Height數組

如果一個一個數按SA中的順序比較的話複雜度是O(N2” role=”presentation”>N2N2)級别的,想要快速的得到Height就需要用到一個關于H數組的性質。

H[i] ≥ H[i - 1] - 1!

如果上面這個性質是對的,那我們可以按照H[1]、H[2]……H[Len]的順序進行計算,那麼複雜度就降為O(N)了!

讓我們嘗試一下證明這個性質 : 設Suffix[k]是排在Suffix[i - 1]前一名的字尾,則它們的最長公共字首是H[i - 1]。都去掉第一個字元,就變成Suffix[k + 1]和Suffix[i]。如果H[i - 1] = 0或1,那麼H[i] ≥ 0顯然成立。否則,H[i] ≥ H[i - 1] - 1(去掉了原來的第一個,其他字首一樣相等),是以Suffix[i]和在它前一名的字尾的最長公共字首至少是H[i - 1] - 1。

仔細想想還是比較好了解的。H求出來,那Height就相應的求出來了,這樣結合SA,Rank和Height我們就可以做很多關于字元串的題了!

代碼——Code

建議複制到自己的程式設計軟體上看

/*
    Problem: JZOJ1598(詢問一個字元串中有多少至少出現兩次的子串)
    Content: SA's Code and Explanation
    Author : YxuanwKeith
*/

#include <cstdio>
#include <cstring>
#include <algorithm>

using namespace std;

const int MAXN = 100005;

char ch[MAXN], All[MAXN];
int SA[MAXN], rank[MAXN], Height[MAXN], tax[MAXN], tp[MAXN], a[MAXN], n, m; 
char str[MAXN];
//rank[i] 第i個字尾的排名; SA[i] 排名為i的字尾位置; Height[i] 排名為i的字尾與排名為(i-1)的字尾的LCP
//tax[i] 計數排序輔助數組; tp[i] rank的輔助數組(計數排序中的第二關鍵字),與SA意義一樣。
//a為原串
void RSort() {
    //rank第一關鍵字,tp第二關鍵字。
    for (int i = 0; i <= m; i ++) tax[i] = 0;
    for (int i = 1; i <= n; i ++) tax[rank[tp[i]]] ++;
    for (int i = 1; i <= m; i ++) tax[i] += tax[i-1];
    for (int i = n; i >= 1; i --) SA[tax[rank[tp[i]]] --] = tp[i]; //確定滿足第一關鍵字的同時,再滿足第二關鍵字的要求
} //計數排序,把新的二進制組排序。

int cmp(int *f, int x, int y, int w) { return f[x] == f[y] && f[x + w] == f[y + w]; } 
//通過二進制組兩個下标的比較,确定兩個子串是否相同

void Suffix() {
    //SA
    for (int i = 1; i <= n; i ++) rank[i] = a[i], tp[i] = i;
    m = 127 ,RSort(); //一開始是以單個字元為機關,是以(m = 127)

    for (int w = 1, p = 1, i; p < n; w += w, m = p) { //把子串長度翻倍,更新rank

        //w 目前一個子串的長度; m 目前離散後的排名種類數
        //目前的tp(第二關鍵字)可直接由上一次的SA的得到
        for (p = 0, i = n - w + 1; i <= n; i ++) tp[++ p] = i; //長度越界,第二關鍵字為0
        for (i = 1; i <= n; i ++) if (SA[i] > w) tp[++ p] = SA[i] - w;

        //更新SA值,并用tp暫時存下上一輪的rank(用于cmp比較)
        RSort(), swap(rank, tp), rank[SA[1]] = p = 1;

        //用已經完成的SA來更新與它互逆的rank,并離散rank
        for (i = 2; i <= n; i ++) rank[SA[i]] = cmp(tp, SA[i], SA[i - 1], w) ? p : ++ p;
    }
    //離散:把相等的字元串的rank設為相同。
    //LCP
    int j, k = 0;
    for(int i = 1; i <= n; Height[rank[i ++]] = k) 
        for( k = k ? k - 1 : k, j = SA[rank[i] - 1]; a[i + k] == a[j + k]; ++ k);
    //這個知道原理後就比較好了解程式
}

void Init() {
    scanf("%s", str);
    n = strlen(str);
    for (int i = 0; i < n; i ++) a[i + 1] = str[i];
}

int main() {
    Init();
    Suffix();

    int ans = Height[2];
    for (int i = 3; i <= n; i ++) ans += max(Height[i] - Height[i - 1], 0);
    printf("%d\n", ans);    
}      

4個比較基礎的應用

Q1:一個串中兩個串的最大公共字首是多少?

A1:這不就是Height嗎?用rmq預處理,再O(1)查詢。

” role=”presentation”>

Q2:一個串中可重疊的重複最長子串是多長?

A2:就是求任意兩個字尾的最長公共字首,而任意兩個字尾的最長公共字首都是Height 數組裡某一段的最小值,那最長的就是Height中的最大值。

” role=”presentation”>

Q3:一個串種不可重疊的重複最長子串是多長?

A3:先二分答案,轉化成判别式的問題比較好處理。假設目前需要判别長度為k是否符合要求,隻需把排序後的字尾分成若幹組,其中每組的字尾之間的Height 值都不小于k,再判斷其中有沒有不重複的字尾,具體就是看最大的SA值和最小的SA值相差超不超過k,有一組超過的話k就是合法答案。

” role=”presentation”>

A4:一個字元串不相等的子串的個數是多少?

Q4:每個子串一定是某個字尾的字首,那麼原問題等價于求所有字尾之間的不相同的字首的個數。而且可以發現每一個字尾Suffix[SA[i]]的貢獻是Len - SA[i] + 1,但是有子串算重複,重複的就是Heigh[i]個與前面相同的字首,那麼減去就可以了。最後,一個字尾Suffix[SA[i]]的貢獻就是Len - SA[k] + 1 - Height[k]。

對于字尾數組更多的應用這裡就不詳細闡述,經過思考後每個人都會發現它的一些不同的用途,它的功能也許比你想象中的更強大!

最開始的那道題

先搬下來。。。

你可想想這道題:你有一個01串S,然後定義一個字首最右邊的位置就是這個字首的結束位置。現在有很多個詢問,每q個詢問結束位置在l~r中不同字首的最長公共字尾是多長?

|S|,q&#x2264;100000” role=”presentation”>|S|,q≤100000|S|,q≤100000

時限4s

簡單思路:首先可以把字元串反過來就是求字尾的最長公共字首了,可以用SA求出height數組,然後用rmq預處理之後就是求兩個位置間的最小值。然後對于一個區間,顯然隻有在SA數組中相鄰的兩個串可以貢獻答案。

對于區間詢問的問題可以用莫隊處理,然後考慮加入一個字尾應該怎麼處理,我們可以維護一個按SA數組排序的連結清單。假設我們先把所有位置的SA全部加入,然後按順序删除,重新按順序加入時就可以O(1)完成修改。那麼按照這個思路我們可以用固定左端點的并查集,做到隻加入,不删除,然後用O(nn+nlogn)” role=”presentation”>O(nn−−√+nlogn)O(nn+nlogn)的複雜度完成這道題。

*可能後面的處理方式比較麻煩,如果直接用splay維護區間中的字尾的話可以做到O(nnlogn)” role=”presentation”>O(nn−−√logn)O(nnlogn),這個方法就比較直覺,而SAM在個問題上還是有點無力的。這題隻是為了說明SA相比于SAM還是有他的獨到之處,特别是在處理字尾的lcp之類的問題上。

結束

以上就是我對字尾數組的了解 ——YxuanwKeith

<link rel="stylesheet" href="https://csdnimg.cn/release/phoenix/template/css/markdown_views-ea0013b516.css">
            </div>