1. 程式人生 > >五分鐘搞懂字尾陣列!字尾陣列解析以及應用(附詳解程式碼)

五分鐘搞懂字尾陣列!字尾陣列解析以及應用(附詳解程式碼)

為什麼學字尾陣列

字尾陣列是一個比較強大的處理字串的演算法,是有關字串的基礎演算法,所以必須掌握。
學會字尾自動機(SAM)就不用學字尾陣列(SA)了?,雖然SAM看起來更為強大和全面,但是有些SAM解決不了的問題能被SA解決,只掌握SAM是遠遠不夠的。
……

有什麼SAM做不了的例子?
比如果求一個串字尾的lcp方面的應用,這是SA可以很方便的用rmq來維護,但是SAM還要求lca,比較麻煩,還有就是字符集比較大的時候SA也有優勢。

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

S|,q100000
時限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是互逆運算)
好,來形象的理解一下
這就是Rank和SA
字尾陣列指的就是這個SA[i],有了它,我們就可以實現一些很強大的功能(如不相同子串個數、連續重複子串等)。如何快速的到它,便成為了這個演算法的關鍵

。而SARank是互逆的,只要求出任意一個,另一個就可以O(Len)得到。
現在比較主流的演算法有兩種,倍增DC3,在這裡,就主要講一下稍微慢一些,但比較好實現以及理解的倍增演算法(雖說慢,但也是O(Len logLen))的。

進入正題——倍增演算法

倍增演算法的主要思想 :對於一個字尾Suffix[i],如果想直接得到Rank比較困難,但是我們可以對每個字元開始的長度為2k的字串求出排名,k從0開始每次遞增1(每遞增1就成為一輪),當2k大於Len時,所得到的序列就是Rank,而SA也就知道了O(logLen)列舉k
這樣做有什麼好處呢?
設每一輪得到的序列為rank(注意r

小寫,最終字尾排名Rank大寫)。有一個很美妙的性質就出現了!第k輪的rank可由第k - 1輪的rank快速得來!
為什麼呢?為了方便描述,設SubStr(i, len)為從第i個字元開始,長度為len的字串我們可以把第k輪SubStr(i, 2k)看成是一個由SubStr(i, 2k1)SubStr(i + 2k1, 2k1)拼起來的東西。類似rmq演算法,這兩個長度而2k1的字串是上一輪遇到過的!當然上一輪的rank也知道!那麼吧每個這一輪的字串都轉化為這種形式,並且大家都知道字串的比較是從左往右,左邊和右邊的大小我們可以用上一輪的rank表示,那麼……這不就是一些兩位數(也可以視為第一關鍵字和第二關鍵字)比較大小嗎!再把這些兩位數重新排名就是這一輪的rank
我們用下面這張經典的圖理解一下:
就像一個兩位數的比較
相信只要理解字串的比較法則(跟實數差不多),理解起來並不難。#還有一個細節就是怎麼把這些兩位數排序?這種位數少的數進行排序毫無疑問的要用一個複雜度為長度*排序數的個數的優美演算法——基數排序(對於兩位數的數複雜度就是O(Len)的)。
基數排序原理 : 把數字依次按照由低位到高位依次排序,排序時只看當前位。對於每一位排序時,因為上一位已經是有序的,所以這一位相等或符合大小條件時就不用交換位置,如果不符合大小條件就交換,實現可以用”桶”來做。(敘說起來比較奇怪,看完下面的程式碼應該更好理解,也可以上網查有關資料)
好了SARank(大寫R)到此為止就處理好了。(下面有詳解程式碼!)。但我們發現,只有這兩樣東西好像沒什麼用,為了處理重複子串之類的問題,我們就要引入一個表示最長公共字首的新助手Height陣列!

構造最長公共字首——Height

同樣先是定義一些變數

Heigth[i] : 表示Suffix[SA[i]]和Suffix[SA[i - 1]]的最長公共字首,也就是排名相鄰的兩個字尾的最長公共字首
H[i] : 等於Height[Rank[i]],也就是字尾Suffix[i]和它前一名的字尾的最長公共字首
而兩個排名不相鄰的最長公共字首定義為排名在它們之間的Height的最小值
跟上面一樣,先形像的理解一下:
這就是Height

高效地得到Height陣列

如果一個一個數按SA中的順序比較的話複雜度是O(N2)級別的,想要快速的得到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)查詢。

Q2:一個串中可重疊的重複最長子串是多長?
A2:就是求任意兩個字尾的最長公共字首,而任意兩個字尾的最長公共字首都是Height 數組裡某一段的最小值,那最長的就是Height中的最大值

Q3:一個串種不可重疊的重複最長子串是多長?
A3:先二分答案轉化成判別式的問題比較好處理。假設當前需要判別長度為k是否符合要求,只需把排序後的字尾分成若干組,其中每組的字尾之間的Height 值都不小於k,再判斷其中有沒有不重複的字尾,具體就是看最大的SA值和最小的SA值相差超不超過k,有一組超過的話k就是合法答案。

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|,q100000
時限4s

簡單思路:首先可以把字串反過來就是求字尾的最長公共字首了,可以用SA求出height陣列,然後用rmq預處理之後就是求兩個位置間的最小值。然後對於一個區間,顯然只有在SA陣列中相鄰的兩個串可以貢獻答案。
對於區間詢問的問題可以用莫隊處理,然後考慮加入一個字尾應該怎麼處理,我們可以維護一個按SA陣列排序的連結串列。假設我們先把所有位置的SA全部加入,然後按順序刪除,重新按順序加入時就可以O(1)完成修改。那麼按照這個思路我們可以用固定左端點的並查集,做到只加入,不刪除,然後用O(nn+nlogn)的複雜度完成這道題。

*可能後面的處理方式比較麻煩,如果直接用splay維護區間中的字尾的話可以做到O(nnlogn),這個方法就比較直觀,而SAM在個問題上還是有點無力的。這題只是為了說明SA相比於SAM還是有他的獨到之處,特別是在處理字尾的lcp之類的問題上。

結束

以上就是我對字尾陣列的理解 ——YxuanwKeith