字尾陣列(SA)
SA 的用處(經典題型):
-
求LCP,LCS,本質不同子串數
-
出現 xxx 次子串,字串中不重疊地出現至少兩次的最長串長度
-
最長AA式子串, 連續的若干相同子串(AABB式)
首先說一下,本篇部落格是本人在oi-wiki學習字尾陣列是寫下的筆記,更詳細的證明等見oi-wiki_SA
字尾陣列主要包含兩個陣列:\(sa[ ]\) 和 \(rk[ ]\)。
\(sa[i]\)表示所有後綴中字典序第i小的那個字尾。
\(rk[i]\)表示 \(i\) 字尾的字典序排名。
求字尾陣列
- 模板提交處:P3809 【模板】字尾排序 (SA)
其中倍增求 \(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]);
}
求一個子串的字典序排名
例如,我們求 ABABABD
中 BABA
的字典序排名。
我們可以先求出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)\) 的。
最長AA式子串
列舉len,每len個點放一個點,則重複子串一定跨越兩個點。對於相鄰點對,求lcp和lcs,判斷。
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(非負)。
結合各種資料結構
- 結合並查集
典型例題:品酒大會
不說了,滿眼都是淚。
\(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\)