1. 程式人生 > 實用技巧 >字尾陣列(SA)

字尾陣列(SA)

SA 的用處(經典題型):

  • 求LCP,LCS,本質不同子串數

  • 出現 xxx 次子串,字串中不重疊地出現至少兩次的最長串長度

  • 最長AA式子串, 連續的若干相同子串(AABB式)


首先說一下,本篇部落格是本人在oi-wiki學習字尾陣列是寫下的筆記,更詳細的證明等見oi-wiki_SA

字尾陣列主要包含兩個陣列:\(sa[ ]\)\(rk[ ]\)

\(sa[i]\)表示所有後綴中字典序第i小的那個字尾。

\(rk[i]\)表示 \(i\) 字尾的字典序排名。

求字尾陣列

其中倍增求 \(SA\) 的程式碼如下(使用基數排序):

#include <iostream>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#define N 2000010
template<typename T> inline void read(T &x) {
    x = 0; char c = getchar(); bool flag = false;
    while (isdigit(c)) {if (c == '-') flag = true; c = getchar(); }
    while (isdigit(c)) {x = (x << 1) + (x << 3) + (c ^ 48); c = getchar(); }
    if (flag)   x = -x;
}
using namespace std;
char s[N];
int sa[N], rk[N], oldrk[N], id[N], p, n, w, limi;
int bin[N];
int main() {
    scanf("%s", s + 1);
    n = strlen(s + 1);
    for (register int i = 1; i <= n; ++i)   ++bin[rk[i] = s[i]];
    for (register int i = 1; i <= 300; ++i)   bin[i] += bin[i - 1];
    for (register int i = n; i > 0; --i)    sa[bin[rk[i]]--] = i;
    limi = 300;//注意是limi不是p,因為for一開始時不會執行limi = p
    for (w = 1; w < n; w <<= 1, limi = p) {
    	//處理id 
    	p = 0;						//注意p = 0 
    	for (register int i = n; i > n - w; --i)	id[++p] = i;//注意"i"
    	for (register int i = 1; i <= n; ++i) 
    		if (sa[i] > w)	id[++p] = sa[i] - w;//注意"sa[i] - w"   注意"sa[i]"
    	
    	//處理sa 
    	memset(bin, 0, sizeof(bin));
    	for (register int i = 1; i <= n; ++i)	++bin[rk[id[i]]];
    	for (register int i = 1; i <= limi; ++i)	bin[i] += bin[i - 1];
    	for (register int i = n; i > 0; --i) sa[bin[rk[id[i]]]--] = id[i];
    	
    	//處理rk(去重) 
    	memcpy(oldrk, rk, sizeof(rk));
    	p = 0;						//注意p = 0 
    	for (register int i = 1; i <= n; ++i)//注意"i - 1"  注意"oldrk" 
    		rk[sa[i]] = (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) ? p : ++p;
            //注意 + w
       	if (p == n)	break;//小優化:如果已經排好序了,就不用再排了。
    }
    
    for (register int i = 1; i <= n; ++i)   printf("%d ", sa[i]);
    return 0;
}

例題:

字元加密

字尾陣列的height陣列

\(height[i]\) 表示 \(sa[i]\) 字尾和 \(sa[i - 1]\) 字尾的 \(lcp\) (最長公共字首)。

求height陣列

我們發現一個規律:

\(height[rk[i]] >= height[rk[i - 1]] - 1\)

證明感性加玄學(詳見oi-wiki_SA):

假設i - 1的height為5,那麼

\(O(n)\) 程式碼如下:

for (register int i = 1, k = 0; i <= n; ++i) {
	if (k)	k--;//height[rk[i]] >= height[rk[i - 1]] - 1;
	while(s[i + k] == s[sa[rk[i] - 1] + k])	++k;
	height[rk[i]] = k;
}

至於為什麼是 \(O(n)\) 是因為可以證明 \(k--\) 的次數是有限的,最多 \(n\) 次,所以最多加 \(2n\) 次。

應用

求兩子串的最長公共字首

  • 題意簡述:

給定一小寫字母組成的字串,形如 \(abababcabcabc\),規定 \(s[l][r]\)\(l~r\)間的子串,如 \(s[2][5] = baba\)

多次詢問,每次給出兩組 \(l\),\(r\),求各自 \(lcp\)

\(len <= 100000, 1 <= l <= r <= len\)

  • 分析與解

首先要知道,\(lcp(sa[i], sa[j]) = min(height[i...j])\),感性證明一下,就是從 \(i\)\(j\) 都沒變,那就是他們的 \(lcp\) 了。

這樣,我們已知 \(lcp(i, j)\)\(lcp(l...r, l'..r')\) 就用子串長度和 \(lcp(i, j)\) 取最小者即可。

通過ST表,可以把複雜度降為 \(O(nlogn +q)\)

比較子串的字典序大小

求完字尾陣列後,我們能 \(O(1)\) 地比較兩字尾的大小,現在要比較子串大小,只需把 \(lcp(A,B) >= min(|A|, |B|)\) 的情況特判掉即可。

\(sa,rk,height\) 陣列後過於簡單,故不贅述。

求本質不同的子串數

我們按所有後綴以字典序從大到小的順序考慮。子串數就是字尾的字首數,這個好求,關鍵是求出多算的那些相同的子串數。

排序後第 \(i\) 名串與第 \(i - 1\) 名串的重疊部分長度為 \(lcp(sa[i], sa[i - 1])\),這裡多算了 \(lcp(sa[i], sa[i - 1])\) 個子串,把他們減去即可。

至於只考慮相鄰字尾的正確性,據說可以用 \(lcp(sa[i], sa[j]) = min(height[i...j])\) 來證明。

long long ans = (n * (n + 1)) >> 1;
for (register int i = 2; i <= n; ++i) {
	ans -= height[i];//height[i] = lcp(sa[i], sa[i - 1]);
}

求一個子串的字典序排名

例如,我們求 ABABABDBABA 的字典序排名。

我們可以先求出SA和height陣列,然後查詢 BABA 所在的字尾 BABABD。然後之前的字尾的字首字典序一定比它小。因此直接統計一下本質不同的子串數量(len - height),再加上B,BA,BAB 比它小,就好了。

不過有時候BABABD字尾的 height 可能比4大,這意味著前面所有 BABA... 的串的字典序都比 BABA 大。因此我們直接二分找到最靠下的第一個不包含 BABA 的字尾,這及之前的那些子串一定比 BABA 小,再加上 B,BA,BAB 即可。

求出現至少 \(k\) 次的最長子串長度

經典例題:牛奶模式

  • 題意簡述

給定一字串,求出現至少 \(k\) 次的最長子串長度。

\(len <= 20000\)

  • 分析與解

把子串轉換為字尾的字首,如果有幾個相同的子串,那麼在排序後的字尾中應該為相鄰的一部分,且它們的 \(lcp\) 一定大於等於子串長度。所以出現至少出現 \(k\) 次的最長子串長度一定是在排序後連續至少 \(k\) 個的 \(lcp\) 的最大值。然後就變成滑動視窗,用單調佇列維護即可

當然,既然求 SA 都到達了 \(O(nlogn)\),那麼二分答案的那個 \(log\) 也不算什麼了。

\(Code\): my record

字串中不重疊地出現至少兩次的最長串長度

注意到答案具有單調性,故考慮二分。

二分長度 \(len\),對於排序後的字尾,找到 \(lcp\) \(>=\) \(len\) 的連續的幾段,它們一定出現了至少兩次,且長度大於等於 \(len\)

現在要判斷有沒有不重疊的兩個子串。我們貪心地找出每段中最靠前的那個子串和最靠後的那個子串,如果這兩個都重疊,那就不可行,縮小 \(len\),否則擴大 \(len\)

加強版:不重疊出現至少k次的最長串長度

對於這種不重疊問題,較為常見的方法是根據子串在原串中的位置來判斷

同樣二分答案,將同一塊內所有子串的編號(位置)排個序,然後又知道了串長,可以知道子串的起始位置和終止位置。然後就相當於“選擇最多不交線段”的問題了,可以貪心解決。

複雜度 \(O(nlog^2n)\),但是第二個 \(log\) 非常小。

有個 \(O(nlogn)\) 的做法:我們把同一個塊內的所有子串在原字串中打上標記,然後在原串上掃描一遍,貪心選取每種串即可。這樣是穩定 \(O(nlogn)\) 的。

例題:4097. 【GDOI2015】簡訊加密

最長AA式子串

列舉len,每len個點放一個點,則重複子串一定跨越兩個點。對於相鄰點對,求lcp和lcs,判斷。

my record

AABB式的子串個數

看一下例題吧:優秀的拆分

題解的圖非常好:題解【P1117優秀的拆分】

這裡給出關鍵部分程式碼:

int array[N];//array[i]:從i開始向右延伸的"AA"個數 
int dearray[N];//dearray[i]:從i開始向左延伸的"AA"的個數
inline void calc(int pos, int len) {
	int lcp = A.get_lcp(pos + 1, pos + len + 1);
	if (lcp > len - 1)	lcp = len - 1;
	int lcs = B.get_lcp(n - (pos + len) + 1, n - pos + 1);
	if (lcs > len)	lcs = len;
	if (lcs + lcp < len)	return ;
	int chonghe = lcs + lcp - len;
	array[pos - lcs + 1]++;
	array[pos - lcs + 2 + chonghe]--;
	dearray[pos + len + lcp - chonghe]++;
	dearray[pos + len + lcp + 1]--;
}
...
for (register int i = 1; i <= (n >> 1); ++i) {
	for (register int j = i; j + i <= n; j += i) {
		calc(j, i);
	}
}

還是看圖理解吧。

AxxxA式(xxx的個數已知)的子串個數

照樣和前兩種模型的解決方法類似:列舉A串的長度 \(len\),每 \(len\) 個字元中放一個點。我們的任務是查詢第一個A(長度為len)包含某一個點的AxxxA式的串有多少個。

設當前點為\(l\),則令\(r = l + g + len\),其中 \(g\) 表示給定的 \(x\) 的個數,並求出其lcp,lcs(與len取min)然後就要自己動手畫圖了。推出該AxxxA串的左端點的區間為 \([l - lcs + 1, l + lcp - 1 - len + 1]\),即 \([l - lcs + 1, l + lcp - len]\),這樣的話合法的xxx的個數為 \(lcp + lcs - len\)。記得答案對0取max(非負)。

my record

結合各種資料結構

  • 結合並查集

典型例題:品酒大會

不說了,滿眼都是淚。

\(Code\): my record

  • 結合單調棧

通常用來求一個字串中相同子串對數。

例題:差異找相同字元

需要注意的地方

  • 注意什麼時候用 \(rk\),什麼時候用 \(s\)。把 \(rk\),\(sa\),\(s\) 分清楚,SA這塊的調錯就問題不大了。

  • 如果有多組資料,記得把陣列清空一下。需要清空的陣列有:\(rk,s,sa,height,id,oldrk,bin\);以及\(p,w,limi\)。總之都清空就問題不大了。

  • 注意求 \(height\) 時是 \(s[i + k] == s[sa[rk[i] - 1] + k]\),注意不要把-1搞到rk[]裡面!!注意是 \(sa[rk[i]-1]\) 而不是 * * * * !!!!

  • 求height的最後是height[rk[i]] = k!!

  • 注意 \(forforfor\) 中處理 \(rk\) 時是 \(oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]\),是 + !!!!

  • 利用SA求lcp,用st表時,一定要在height陣列上做St表,不能轉化到字元上!! 因為不是求字元間最小值,是求height陣列最小值,有些性質字元不符合的!!!不要耍小聰明!!!

小結

以下需要記憶

  • 求sa,求height

  • 在基數排序時,\(sa\) 一直是一對一的,但不夠準確;\(rk\) 是多對一的,所以有 \(bin[rk[id[i]]]++\),但它是越來越分散的,最後變成一對一。

  • \(height[rk[i]] <= height[rk[i - 1]] - 1\)

  • \(lcp(sa[i], sa[j]) = min(height[i...j])\)

SA 模板(除錯用)

inline void build() {
    scanf("%s", s + 1);
    n = strlen(s + 1);
    for (register int i = 1; i <= n; ++i)   ++bin[rk[i] = s[i]];
    for (register int i = 1; i <= 300; ++i)   bin[i] += bin[i - 1];
    for (register int i = n; i > 0; --i)    sa[bin[rk[i]]--] = i;
    limi = 300;//注意是limi不是p,因為for一開始時不會執行limi = p
    for (w = 1; w < n; w <<= 1, limi = p) {
        //處理id 
        p = 0;                      //注意p = 0 
        for (register int i = n; i > n - w; --i)    id[++p] = i;//注意"i"
        for (register int i = 1; i <= n; ++i) 
            if (sa[i] > w)  id[++p] = sa[i] - w;//注意"sa[i] - w"   注意"sa[i]"

        //處理sa 
        memset(bin, 0, sizeof(bin));
        for (register int i = 1; i <= n; ++i)   ++bin[rk[id[i]]];
        for (register int i = 1; i <= limi; ++i)    bin[i] += bin[i - 1];
        for (register int i = n; i > 0; --i) sa[bin[rk[id[i]]]--] = id[i];

        //處理rk(去重) 
        memcpy(oldrk, rk, sizeof(rk));
        p = 0;                      //注意p = 0 
        for (register int i = 1; i <= n; ++i)//注意"i - 1"  注意"oldrk" 
            rk[sa[i]] = (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) ? p : ++p;
            //注意 + w
    }
	for (register int i = 1, k = 0; i <= n; ++i) {
	    if (k)  k--;//height[rk[i]] >= height[rk[i - 1]] - 1;
	    while(s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
	    height[rk[i]] = k;
        //注意rk
	}
}

st表的構建與lcp的查詢 模板(除錯用)

inline void build_st() {
  for (register int i = 1; i <= n; ++i)	f[i][0] = height[i];
  for (register int j = 1; j <= 18; ++j) {
      for (register int i = 1; i <= n; ++i) {
          if (i + (1 << (j - 1)) <= n)
              f[i][j] = min(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]);
          else	f[i][j] = 0;
      }
   }
}
inline int query(int l, int r) {
	l = rk[l], r = rk[r];
	if (l > r)	swap(l, r);
	++l;
	int jibie = Log2[r - l + 1];
	return min(f[l][jibie], f[r - (1 << jibie) + 1][jibie]);
}

記憶方法

  • rk 問位置返回排名, sa 問排名返回子串位置!!

  • rk 一開始不是 1~n,到最後才是 1~n!而 sa 無論何時都是 1~n 的!!

  • 每次基排都是以rk為關鍵字

  • for (register int i = 1; i <= n; ++i) if (sa[i] > w) id[++p] = sa[i] - w; (基排第一步) 因為每次是根據後面排前面,所以要按照後面排名的次序列舉,然後把前面加入到id數組裡面!!

  • rk[sa[i]] = (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) ? p : ++p; (去重部分,易錯點) ①當然是根據 \(sa\) 來確定 \(rk\),總不能單用字元位置就確定 \(rk\) 吧。 ②一定要和前一名比較!不然就會出現第0名的情況。 ③ 要根據 oldrk 來比較!!不然前面就白乾了。不然不覺得 \(memcpy()\) 那一步沒用嗎?不覺得整條語句太短了嗎?

  • while(s[i + k] == s[sa[rk[i] - 1] + k]) ++k; 牢牢記住 \(s[sa[rk[i] - 1] + k]\) !即前一名往後k位的字元!!有了這個不難知道我們是在以下標(位置)的次序求 height!!因此最後為:height[rk[i]] = k; 另外,當 \(k = 0\) 的時候,我們其實在比較第1位。因此不用質疑那一個 \(while()\),也不要最後寫成 k - 1!此外,雖然 \(height[1] = 0\) 是肯定的,但是我們是按照位置的次序求的,因此一開始不要初始化 i 為 2!要初始化為 1

  • 最後再記一下 w < n


\(Updated\) \(on\) \(Dec.9th\)