String Algorithm
Gino
以下所有演算法都是講師這兩個禮拜才學會
可能有概念不是很清楚
所以等下應該會出現學員打爆講師的情況
麻煩鞭小力一點 ; w;
DISCLAIMER
由於字串這個主題暑假培訓沒教
所以這堂課的重心會在講解演算法原理
題目不會帶太多因為我也沒寫過多少字串題
夠電的人可以直接下滑戳題單或寫自己的題目
或是看臺上的人雜耍順便嘴砲
DISCLAIMER
簡報上沒有的
由一堆字元排列成的序列
字元可以是字母、數字、任意符號
$$ S = "StarBurstStream" $$
$$ T = "48763" $$
$$ U = "1011111001111011" $$
\(S = "Star{\color{cyan} Burst}Stream"\)
\(S = "Star{\color{cyan} Burst}Stream"\)
\( \uparrow \)
\( l \)
\( \uparrow \)
\( r \)
\(S = "Star{\color{cyan} Burst}Stream"\)
\( \uparrow \)
\( l \)
\( \uparrow \)
\( r \)
\(S[l, r] = "{\color{cyan} Burst}"\)
\(S = "{\color{cyan} StarBu}rstStream"\)
\(S = "StarBurs{\color{cyan} tStream}"\)
\(S = "aab {\color{cyan}b}"\)
\(T = "aab{\color{cyan}c}"\)
\(S < T\)
\(S = "aab"\)
\(T = "aab{\color{cyan}c}"\)
\(S < T\)
(空字元最小)
由左到右比對,比到第一個不一樣的字元
用剛才的比較方式排序字串
\( a \)
\( aaab \)
\( aab \)
\( abb \)
\( baab \)
Matching
"Matching" \(\Rightarrow\) 配對,檢查是否相同
給定字串 \(S, P\)
求 \(P\) 在 \(S\) 中出現的次數及位置
字串 \(P\) 稱為模式字串 (Pattern)
\(S = "abaaabbaaab"\)
\( P = "ab" \)
\(S = "{\color{cyan}ab}aa{\color{cyan}ab}baa{\color{cyan}ab}"\)
\( S[0, 1] = P \)
\( S[4, 5] = P \)
\( S[9, 10] = P \)
\(\Rightarrow P\) 出現了 \(3\) 次
枚舉開始位置,然後慢慢往下比對
S | A A B A A B A A B B B
P | A A B A A B B B
枚舉開始位置,然後慢慢往下比對
S | A A B A A B A A B B B
P | A A B A A B B B
枚舉開始位置,然後慢慢往下比對
S | A A B A A B A A B B B
P | A A B A A B B B
P | A A B A A B B B
枚舉開始位置,然後慢慢往下比對
S | A A B A A B A A B B B
P | A A B A A B B B
P | A A B A A B B B
P | A A B A A B B B
枚舉開始位置,然後慢慢往下比對
S | A A B A A B A A B B B
P | A A B A A B B B
P | A A B A A B B B
P | A A B A A B B B
P | A A B A A B B B Matched!
假設字串 \(S\) 是隨機給定
考慮現在從 \(S\) 某個位置開始匹配
\(P\) 的第 \(1\) 個字元被比對的機率:\(1\)
\(P\) 的第 \(2\) 個字元被比對的機率:\(1 \over |C|\)
...
\(P\) 的第 \(k\) 個字元被比對的機率:\(1 \over {|C|^{k-1}}\)
期望比對次數
$$\sum_{k=1}^{|P|} {{1} \over {|C|^{k-1}}} < {{|C|}\over{|C|-1}} \leq 2$$
期望比對次數
$$\sum_{k=1}^{|P|} {{1} \over {|C|^k-1}} < {{|C|}\over{|C|-1}} \leq 2$$
總共匹配 \(|S|-|P|\) 次
期望複雜度 \(O(2 \times (|S|-|P|)) \)
期望比對次數
$$\sum_{k=1}^{|P|} {{1} \over {|C|^k-1}} < {{|C|}\over{|C|-1}} \leq 2$$
總共匹配 \(|S|-|P|\) 次
期望複雜度 \(O(2 \times (|S|-|P|)) \)
\( O(|S|+|P|) \) !
\(S = "aaaaaaaaaaaaaaaa"\)
\( P = "aaaaa" \)
\( O(|S||P|) \)
喔其實有啦
打過 YTP 就知道
Rolling Hash
唬爛美學
雜湊 (Hash)
把龐雜的資訊壓縮、精簡
讓檢索、比對物件變得更簡單、快速
雜湊 (Hash)
把龐雜的資訊壓縮、精簡
讓檢索、比對物件變得更簡單、快速
代價:丟失一些訊息,比對正確率降低
雜湊函式 (Hash Function)
一個好的雜湊函式 \(f\) 具備以下條件:
雜湊函式 (Hash Function)
一個好的雜湊函式 \(f\) 具備以下條件:
雜湊碰撞
字串的雜湊 - Rolling Hash
選定數字 \(p, M\)
字串 \(S\) 的雜湊函數:
\( f(S) = (S_0 \cdot p^{|S|-1} + S_1 \cdot p^{|S|-2} + ... + S_{|S|-1} \cdot p^0) \mod M\)
把 \(S\) 想成是一個 \(p\) 進位的數字
再把這個數字模 \(M\) (壓縮到值域 \([0, M-1]\))
怎麼選 \(p, M\)
通常 \(p\) 是一個略 \(> |C|\) 的質數
Ex. 小寫字母字串 \((|C|=26)\) \(\Rightarrow p 取 29\)
\(M\) 要一個夠大的質數
Ex. \( 10^9 + 7, 10^8 + 7, 998244353 \)
但盡量不要超過 \( 3\times10^9 \)
不然乘法的時候 long long 會溢位
把 Hash 想成倒三角形
ll Hash(string& s) {
ll ret = 0;
for (auto& i : s) {
ret *= p;
ret += (i-'a'+1);
ret %= MOD;
}
return ret;
}
把 Hash 想成倒三角形
ll Hash(string& s) {
ll ret = 0;
for (auto& i : s) {
ret *= p;
ret += (i-'a'+1);
ret %= MOD;
}
return ret;
}
把 Hash 想成倒三角形
ll Hash(string& s) {
ll ret = 0;
for (auto& i : s) {
ret *= p;
ret += (i-'a'+1);
ret %= MOD;
}
return ret;
}
把 Hash 想成倒三角形
ll Hash(string& s) {
ll ret = 0;
for (auto& i : s) {
ret *= p;
ret += (i-'a'+1);
ret %= MOD;
}
return ret;
}
把 Hash 想成倒三角形
ll Hash(string& s) {
ll ret = 0;
for (auto& i : s) {
ret *= p;
ret += (i-'a'+1);
ret %= MOD;
}
return ret;
}
把 Hash 想成倒三角形
ll Hash(string& s) {
ll ret = 0;
for (auto& i : s) {
ret *= p;
ret += (i-'a'+1);
ret %= MOD;
}
return ret;
}
把 Hash 想成倒三角形
ll Hash(string& s) {
ll ret = 0;
for (auto& i : s) {
ret *= p;
ret += (i-'a'+1);
ret %= MOD;
}
return ret;
}
把 Hash 想成倒三角形
ll Hash(string& s) {
ll ret = 0;
for (auto& i : s) {
ret *= p;
ret += (i-'a'+1);
ret %= MOD;
}
return ret;
}
前綴 Hash
vector<ll> rh;
void build() {
rh[0] = s[0]-'a'+1;
for (int i = 1; i < (int)s.size(); i++) {
rh[i] = rh[i-1]*p + (s[i]-'a'+1);
rh[i] %= MOD;
}
}
區間 Hash
ll query(int l, int r) {
ll ret = rh[r] - (l ? rh[l-1] * pe[r-l+1] : 0);
ret = (ret % MOD + MOD) % MOD;
return ret;
}
\(l\)
\(r\)
區間 Hash
ll query(int l, int r) {
ll ret = rh[r] - (l ? rh[l-1] * pe[r-l+1] : 0);
ret = (ret % MOD + MOD) % MOD;
return ret;
}
\(rh(l-1)\)
\(rh(r)\)
\( f(S[l, r]) = rh(r) - rh(l-1) \times p^{r-l+1} \)
區間 Hash
ll query(int l, int r) {
ll ret = rh[r] - (l ? rh[l-1] * pe[r-l+1] : 0);
ret = (ret % MOD + MOD) % MOD;
return ret;
}
\(rh(l-1) \times p^{r-l+1} \)
\(rh(l-1)\)
\(rh(r)\)
\( f(S[l, r]) = rh(r) - rh(l-1) \times p^{r-l+1} \)
區間 Hash
ll query(int l, int r) {
ll ret = rh[r] - (l ? rh[l-1] * pe[r-l+1] : 0);
ret = (ret % MOD + MOD) % MOD;
return ret;
}
\( f(S[l, r]) = rh(r) - rh(l-1) \times p^{r-l+1} \)
\(rh(l-1)\)
\(rh(r)\)
假設選的模數是 \(10^9+7\)
如果對 \(10^5\) (約\(> \sqrt{10^9+7}\)) 個字串進行 Hash
碰撞機率 \(> 99\%\)
假設選的模數是 \(10^9+7\)
如果對 \(10^5\) (約\(> \sqrt{10^9+7}\)) 個字串進行 Hash
碰撞機率 \(> 99\%\)
怎麼辦QQ?
一個 Mod 不夠
一個 Mod 不夠
那就開兩個
一個 Mod 不夠
那就開兩個
兩個 Mod 不夠 那就開三個
...
48762 個 Mod 不夠 那就開 48763 個
字串匹配裸題
測試匹配演算法用
輸入一個字串 \(S\)
求最長迴文子字串
\(|S| \leq 10^6 \)
好像可以來點 Hash + Binary Se...
提示(Touch Me):
將 \(S\) 的開頭字元移到結尾稱作一次旋轉
求將 \(S\) 經過數次旋轉後
能得到字典序最小的字串為何
\( |S| \leq 10^6 \)
練習
或者你可以用 hash 揍後面的題目
接下來要進入玄學了(?
會有一堆指針跟變數跳來跳去
要集中精神ㄛ
因為一不小心恍神可能就會跟不上
REMINDER
KMP Algorithm
從失敗中吸取經驗
KMP 改良了暴力匹配的過程
省去一些不必要的匹配過程
而且複雜度意外的優美
KMP = Knuth-Morris-Pratt
觀察一下暴力比對過程
S | A A B A A B A A B B B
P | A A B A A B B B
觀察一下暴力比對過程
S | A A B A A B A A B B B
P | A A B A A B B B
觀察一下暴力比對過程
S | A A B A A B A A B B B
P | A A B A A B B B
匹配失敗了QQ
但真的只能把 P 位移一格然後重做嗎?
繼續 try 之前先停住,考慮接下來的匹配點
S |
P |
P |
P |
P |
P |
P |
...
把紅色位置想成門檻
S |
P |
P |
P |
P |
P |
P |
...
接下來如果要能匹配成功
必定要先跨過這道檻
S |
P |
P |
P |
P |
P |
P |
...
那能夠跨過這道門檻的匹配點有什麼性質?
S |
P |
P |
P |
P |
P |
P |
...
那能夠跨過這道門檻的匹配點有什麼性質?
S |
P |
P |
P |
P |
P |
P |
...
那能夠跨過這道門檻的匹配點有什麼性質?
到門檻之前都要長得跟 S 一模一樣!
S |
P |
P |
P |
P |
P |
P |
...
我們先把能走到門檻面前的匹配點稱為候選點
只有他們才有機會匹配成功
S |
P |
P |
P |
P |
P |
P |
...
正式地說
從 P 開頭取一段前綴,再從 S[\(i\)] 之前取一段等長的後綴
如果取出來的前綴跟後綴是一樣的 \(\Rightarrow\) 候選點
\(i\)
S |
P |
P |
P |
P |
P |
P |
...
並且我們要從第一個候選點開始繼續匹配
\(\Rightarrow\) 選最長的共同前後綴!
\(i\)
S |
P |
P |
P |
P |
P |
P |
...
「從 P 開頭取一段前綴,再從 S[\(i\)] 之前取一段等長的後綴」
注意到其實也可以從 P[\(j\)] 取
\(j\)
S |
P |
P |
P |
P |
P |
P |
...
「從 P 開頭取一段前綴,再從 S[\(i\)] 之前取一段等長的後綴」
注意到其實也可以從 P[\(j\)] 取
\(j\)
蛤,為什麼突然這麼說
如果都是在 P 上面做事
而且不需要先知道 S 長怎樣...
如果都是在 P 上面做事
而且不需要先知道 S 長怎樣...
對於長度為 \(n\) 的字串 \(P=p_0\ p_1\ ...\ p_{n-1}\)
定義 \(P\) 的失配函數 \(F\) 如下:
\[F(i)=\begin{cases}max\{k:{\color{yellow} P[0,k-1]=P[i-k+1,i]},\ k < i+1\}&\text{if \(i \neq 0\) and \(k\) exists.}\\ 0&\text{otherwise.}\end{cases}\]
\(F(i)\) 其實就是位置 \(i\) 的最長共同前後綴長度。
稱為失配函數 \(\Rightarrow\) 匹配失敗的時候會用到。
Failure Function/定義
假設我們已經預先算好了每個 \(F(i)\)
假設我們已經預先算好了每個 \(F(i)\)
可以來匹配字串了!
S |
P |
S |
P |
S |
P |
S |
P |
匹配失敗,找下一個候選點
\(j\)
\(i\)
S |
P |
\(j\)
\(i\)
S |
P |
\(j\)
\(i\)
P |
\(F(j-1)\)
S |
P |
\(j\)
\(i\)
P |
\(F(j-1)\)
\(j \rightarrow F(j-1)\)
繼續跟 \(S[i]\) 匹配
S |
P |
\(j\)
\(i\)
P |
啊如果又匹配失敗
\(F(j-1)\)
S |
P |
\(j\)
\(i\)
P |
啊如果又匹配失敗
那就再跳一次
\(F(j-1)\)
S |
P |
\(j\)
\(i\)
P |
P |
\(F(F(j-1)-1)\)
\(F(j-1)\)
S |
P |
\(j\)
\(i\)
P |
\(F(j)\)
P |
\(F(j-1) \rightarrow F(F(j-1)-1)\)
\(F(F(j-1)-1)\)
S |
\(i\)
...
\(j'\)
反正就一直跳,跳到能繼續下去為止
P |
S |
...
繼續匹配zzz...
P |
S |
...
P.S. 如果跳到不能再跳,但還是匹配失敗的話
P |
\(j'\)
\(i\)
S |
...
P.S. 如果跳到不能再跳,但還是匹配失敗的話
那就直接往下一格從頭做
P |
\(j'\)
\(i\)
P |
\(j'+1\)
複雜度證明
假設匹配到 \(S[i], P[j]\)
則接下來會有兩種情況
複雜度證明
情況 1 只會發生 \(|S|-1\) 次
而因為 \(0 \leq F(j-1) \leq |P| \)
且情況 2 發生次數 \(\leq\) 情況 1(有加才有減)
\(O(|S|)\)!
KMP Algorithm/Code
pi:字串 p 的指針
si:字串 s 的指針
for (int si = 0; si < n; si++) {
while (匹配失敗 && pi 還可以跳) 跳到下一個候選點;
if (s[si] == p[pi]) pi++;
if (匹配成功) {
cout << "Matched!\n";
pi = f[pi-1]; // 匹配成功 -> 跳到下一個候選點
}
}
int pi = 0;
for (int si = 0; si < n; si++) {
while (pi && s[si] != p[pi]) pi = f[pi-1];
if (s[si] == p[pi]) pi++;
if (pi == m) {
cout << "Matched!\n";
pi = f[pi-1];
}
}
那要怎麼快速計算 \(F(i)\)
假設我們已經算完 \(F(1)\sim F(i)\)
現在計算 \(F(i+1)\)
abaabaaba?
\(i+1\)
箭頭長 \(= F(i)\)
#Case 1
藍色箭頭可以延伸
abaabaabaa
\(i+1\)
\(F(i+1) := F(i) + 1\)
#Case 2
藍色箭頭不能延伸
abaabaabab
\(i+1\)
#Case 2
藍色箭頭不能延伸
abaabaabab
\(i+1\)
找到最長的箭頭
像藍色一樣也符合共同前後綴的條件
#Case 2
藍色箭頭不能延伸
abaabaabab
\(i+1\)
找到最長的箭頭
像藍色一樣也符合共同前後綴的條件
\(F(F(i)-1)\)
#Case 2
藍色箭頭不能延伸
abaabaabab
\(i+1\)
找到最長的箭頭
像藍色一樣也符合共同前後綴的條件
\(F(F(i)-1)\)
有發現了嗎
這跟剛剛匹配失敗要「跳」是一樣的!
#Case 2
abaabaabab
\(i+1\)
繼續檢查箭頭能不能延伸
如果不行,就一直跳
#Case 2
abaabaabab
\(i+1\)
繼續檢查箭頭能不能延伸
如果不行,就一直跳
#Case 2
abaabaabab
\(i+1\)
繼續檢查箭頭能不能延伸
如果不行,就一直跳
#Case 2
abaabaabab
\(i+1\)
繼續檢查箭頭能不能延伸
如果不行,就一直跳
#Case 2
abaabaabab
\(i+1\)
繼續檢查箭頭能不能延伸
如果不行,就一直跳
\(F(i+1)=2\)
複雜度證明
性質:\(F\) 每次最多增加 1
且 \(0 \leq F(i) \leq n\)
\(\Rightarrow F\) 的變動量不超過 \(2n\)(一樣,有加才有減)
每次變動只比較字元一次
\(O(|P|)\)!
Failure Function/Code
ptr:箭頭
i:現在在蓋 f[i]
vector<int> f;
void build() {
f.clear(); f.resize(m, 0);
int ptr = 0;
for (int i = 1; i < m; i++) {
while (箭頭不能延伸 && 可以跳) 找下一個箭頭;
if 可以延伸) ptr++;
f[i] = ptr;
}
}
vector<int> f;
void build() {
f.clear(); f.resize(m, 0);
int ptr = 0;
for (int i = 1; i < m; i++) {
while (ptr && p[i] != p[ptr]) ptr = f[ptr-1];
if (p[i] == p[ptr]) ptr++;
f[i] = ptr;
}
}
Failure Function/Code
vector<int> f;
void build() {
f.clear(); f.resize(m, 0);
int ptr = 0;
for (int i = 1; i < m; i++) {
while (ptr && p[i] != p[ptr]) ptr = f[ptr-1];
if (p[i] == p[ptr]) ptr++;
f[i] = ptr;
}
}
跟剛剛字串匹配的 Code 長得差不多
其實可以把蓋 \(F\) 的過程想成 \(P\) 跟自己匹配!
Gusfield's Algorithm
俗稱 Z Algorithm
z
z
z
z
Z Algorithm
複雜度也是 \(O(|S|+|P|)\)
比剛才的 KMP 還要直觀、好想
實用性也更高
對於長度為 \(n\) 的字串 \(P=p_0\ p_1\ ...\ p_{n-1}\)
定義 \(P\) 的 \(Z\) 函數如下:
\[Z(i)=\begin{cases}max\{k:{\color{yellow} P[0,k-1]=P[i,i+k-1]}\}&\text{if \(k\) exists.}\\ 0&\text{otherwise.}\end{cases}\]
\(Z(i)\) 其實就是從 \(i\) 開始的後綴跟 \(P\) 的最長共同前綴長度。
Z Value/定義
\(i\)
\(i+Z(i)-1\)
\(Z(i)-1\)
\(0\)
怎麼蓋 Z?
暴力枚舉 \(O(n^2)\)
這顯然不是我們要的複雜度
Intuition
假設現在要算 \(Z(i)\)
我們試著利用已經算過的東西
來估算一下 \(Z(i)\) 至少有多大
不要無腦地從 \(0\) 開始
把先前算過的 \(Z(1) ... Z(i-1)\)
看成一堆區間
\([1, Z(1)]\)
\([2, Z(2)]\)
...
\([i-1, Z(i-1)]\)
這些是我們「已經算過的」資訊
接下來要好好利用它們
把先前算過的 \(Z(1) ... Z(i-1)\)
看成一堆區間
\([1, Z(1)]\)
\([2, Z(2)]\)
...
\([i-1, Z(i-1)]\)
這些是我們「已經算過的」資訊
接下來要好好利用它們
P.S. 不考慮 \(Z(0)\)
\(Z(0)\) 不用算,自動等於原字串長
從那坨區間當中
抓右界最大的出來
令該區間為 \([l, r]\)
\(l\)
\(r\)
#Case 1
\(i > r\)
\(i\)
\(l\)
\(r\)
#Case 1
\(i > r\)
進入了全新、未知的領域
只能從頭開始做
\(i\)
\(l\)
\(r\)
#Case 2
\(i \leq r\)
\(i\)
\(l\)
\(r\)
#Case 2
\(i \leq r\)
\(i\)
\(l\)
\(r\)
#Case 2
\(i \leq r\)
\(i\)
\(l\)
\(r\)
\(i-l\)
#Case 2
\(i \leq r\)
\(i\)
\(l\)
\(r\)
\(i-l\)
\(Z(i-l)\)
\(Z(i-l)\)
#Case 2
\(i \leq r\)
\(i\)
\(l\)
\(r\)
\(i-l\)
\(Z(i-l)\)
\(Z(i-l)\)
\(Z(i-l)\)
#Case 2
\(i \leq r\)
\(i\)
\(l\)
\(r\)
\(i-l\)
\(Z(i-l)\)
\(min(Z(i-l), r-i+1)\)
\(Z(i-l)\)
不能超過\(r\)
我們不知道\(r\) 以後的字元長怎樣
#Case 2
\(i \leq r\)
\(Z(i)\) 至少有 \(min(Z(i-l), r-i+1)\) 那麼大
\(i\)
\(l\)
\(r\)
\(i-l\)
\(Z(i-l)\)
\(min(Z(i-l), r-i+1)\)
\(Z(i-l)\)
#Case 2
\(i \leq r\)
\(Z(i)\) 至少有 \(min(Z(i-l), r-i+1)\) 那麼大
\(i\)
\(l\)
\(r\)
\(i-l\)
\(Z(i-l)\)
\(min(Z(i-l), r-i+1)\)
\(Z(i-l)\)
這是我們能知道最大的下界
所以接下來就繼續暴力看 \(Z(i)\) 能不能變更大!
Gusfield's Algorithm/Code
vector<int> z;
void build_z() {
z.clear(); z.resize(n, 0);
int l = 0, r = 0;
for (int i = 1; i < n; i++) {
if (i <= r) z[i] = min(z[i-l], r-i+1);
while (i+z[i] < n && s[z[i]] == s[i+z[i]]) z[i]++;
if (i+z[i]-1 > r) l = i, r = i+z[i]-1;
}
}
void build_z() {
int l = 0, r = 0;
for (int i = 1; i < n; i++) {
if (i <= r) 估算 z[i] 下界
while (z[i] 能變更大) 那就讓它更大;
更新 l, r;
}
}
複雜度證明
注意到 \(r\) 只會遞增
且最多增加 \(n-1\) 次
接下來一樣分各種情況來討論
複雜度證明
每次計算 \(Z(i)\) 的時候
2. 不會影響 \(r\),且所花時間 \(O(1)\)
1. 3. 由於 \(r\) 最多只會增加 \(n-1\) 次,均攤 \(O(n)\)
總複雜度:\(O(n)\)!
怎麼拿 Z 做字串匹配
把 P 跟 S 黏在一起
T = P + "$" + S
"$" 是不在 P, S 內的字元
蓋字串 T 的 Z 陣列
看 "$" 以後的 \(Z(i)\) 有沒有 \(=|P|\)
Suffix Array
後綴數組
把一個字串所有的後綴丟到一個陣列
接著對裡面所有後綴排序
得到的陣列叫做 Suffix Array
把一個字串所有的後綴丟到一個陣列
接著對裡面所有後綴排序
得到的陣列叫做 Suffix Array
排序後的後綴陣列有一些神奇的性質可以解決題目
但總之先來學怎麼蓋 Suffix Array 吧
Suffix Array/建構
Naive 做法
\(O(n \log n)\) 排序演算法
每次 compare \(O(n)\) 暴力匹配
總複雜度 \(O(n^2 \log n)\)
Suffix Array/建構
Naive 做法
\(O(n \log n)\) 排序演算法
每次 compare \(O(n)\) 暴力匹配
總複雜度 \(O(n^2 \log n)\)
能不能更快?
Suffix Array/建構
rolling hash!
\(O(n \log n)\) 排序演算法
每次 compare 二分搜 \(\Rightarrow O(\log n)\)
總複雜度 \(O(n \log^2 n)\)
Suffix Array/倍增法
還可以怎麼做/更快的方法
倍增!
Suffix Array/倍增法
還可以怎麼做/更快的方法
倍增!
Suffix Array/倍增法
一些名詞
後綴 \(i\):從 index \(i\) 開始的後綴,也就是 \(S_{i...|S|-1}\)
\(sa(r)\):排序後的 Suffix Array,第 \(r\) 個後綴
\(rk(i)\):後綴 \(i\) 在 \(sa\) 中的排名
\(sa\) 跟 \(rk\) 互為反函數
Suffix Array/倍增法
一些名詞
後綴 \(i\):從 index \(i\) 開始的後綴,也就是 \(S_{i...|S|-1}\)
\(sa(r)\):排序後的 Suffix Array,第 \(r\) 個後綴
\(rk(i)\):後綴 \(i\) 在 \(sa\) 中的排名
\(sa\) 跟 \(rk\) 互為反函數
\(sa(r)\):第 \(r\) 小的後綴是誰
\(rk(i)\):後綴 \(i\) 是第幾小
Suffix Array/倍增法
S = "aababbb"
為了實作方便
我們在 S 後面加上一個字元 "$"
這個 "$" 比 S 裡面的字元都還要小
Suffix Array/倍增法
S = "aababbb$"
$
aababbb$
ababbb$
abbb$
b$
babbb$
bb$
bbb$
7
0
1
3
6
2
5
4
rk(7) = 0
rk(0) = 1
rk(1) = 2
rk(3) = 3
rk(6) = 4
rk(2) = 5
rk(5) = 6
rk(4) = 7
Suffix Array/倍增法
S = "aababbb$"
$
aababbb$
ababbb$
abbb$
b$
babbb$
bb$
bbb$
7
0
1
3
6
2
5
4
rk(7) = 0
rk(0) = 1
rk(1) = 2
rk(3) = 3
rk(6) = 4
rk(2) = 5
rk(5) = 6
rk(4) = 7
Suffix Array
Suffix Array/倍增法
方便起見,先想像 S 是無限循環的
$aababbb...
aababbb$...
ababbb$a...
abbb$aab...
b$aababb...
babbb$aa...
bb$aabab...
bbb$aaba...
7
0
1
3
6
2
5
4
rk(7) = 0
rk(0) = 1
rk(1) = 2
rk(3) = 3
rk(6) = 4
rk(2) = 5
rk(5) = 6
rk(4) = 7
Suffix Array
Suffix Array/倍增法
Intuition
為了方便理解
以 \(S[i \rightarrow k]\)
代表從 \(S_i\) 開始往後延伸長度 \(k\) 的字串
\([i \rightarrow k]\)
\(S\)
Suffix Array/倍增法
Intuition
如果已經排序完所有 \(S[i \rightarrow k]\)
接下來排序
\(S[i \rightarrow 2k]\)
\(S\)
\(S\)
Suffix Array/倍增法
比較兩個長度 \(2k\) 的字串...
\(S[i\rightarrow 2k]\)
\(S[j\rightarrow 2k]\)
Suffix Array/倍增法
從中間砍半...
\(S[i\rightarrow 2k]\)
\(S[j\rightarrow 2k]\)
Suffix Array/倍增法
先比較左半邊
\(S[i\rightarrow 2k]\)
\(S[j\rightarrow 2k]\)
如果左半邊一樣大
那就比較右半邊
Suffix Array/倍增法
\(S[i\rightarrow 2k]\)
\(S[j\rightarrow 2k]\)
左右兩半都是長度 \(k\) 的字串
由於已經排序完所有 \(S[i\rightarrow k]\)
\(\Rightarrow\) \(O(1)\) 完成大小比較!
先比較左半邊
如果左半邊一樣大
那就比較右半邊
Suffix Array/倍增法
Step 1/Base Case:排序 \(S[i \rightarrow 1]\)
Step 2/
利用 \(S[i \rightarrow 2^0]\) 的結果,排序 \(S[i \rightarrow 2^1]\)
利用 \(S[i \rightarrow 2^1]\) 的結果,排序 \(S[i \rightarrow 2^2]\)
利用 \(S[i \rightarrow 2^2]\) 的結果,排序 \(S[i \rightarrow 2^3]\)
...
Suffix Array/倍增法
Step 1/Base Case:排序 \(S[i \rightarrow 1]\)
Step 2/
利用 \(S[i \rightarrow 2^0]\) 的結果,排序 \(S[i \rightarrow 2^1]\)
利用 \(S[i \rightarrow 2^1]\) 的結果,排序 \(S[i \rightarrow 2^2]\)
利用 \(S[i \rightarrow 2^2]\) 的結果,排序 \(S[i \rightarrow 2^3]\)
...
重複直到 \(S[i \rightarrow 2^k]\),其中 \(2^k\) 已經 \(\geq\) \(S\) 原本的長度!
Suffix Array/倍增法
右邊是我們要排序的 Suffix Array
aababbb$
ababbb$a
babbb$aa
abbb$aab
bbb$aaba
bb$aabab
b$aababb
$aababbb
0
1
2
3
4
5
6
7
S = "aababbb$"
Suffix Array/倍增法
Step 1/Base Case:排序 \(S[i \rightarrow 1]\)
aababbb$
ababbb$a
babbb$aa
abbb$aab
bbb$aaba
bb$aabab
b$aababb
$aababbb
S = "aababbb$"
0
1
2
3
4
5
6
7
Suffix Array/倍增法
Step 1/Base Case:排序 \(S[i \rightarrow 1]\)
排序完成!
$aababbb
aababbb$
ababbb$a
abbb$aab
babbb$aa
bbb$aaba
bb$aabab
b$aababb
S = "aababbb$"
7
0
1
3
2
4
5
6
Suffix Array/倍增法
Step 2/
利用 \(S[i \rightarrow 2^0]\) 的結果,排序 \(S[i \rightarrow 2^1]\)
$aababbb
aababbb$
ababbb$a
abbb$aab
babbb$aa
bbb$aaba
bb$aabab
b$aababb
S = "aababbb$"
7
0
1
3
2
4
5
6
Suffix Array/倍增法
Step 2/
利用 \(S[i \rightarrow 2^0]\) 的結果,排序 \(S[i \rightarrow 2^1]\)
排序完成!
$aababbb
aababbb$
ababbb$a
abbb$aab
b$aababb
babbb$aa
bbb$aaba
bb$aabab
S = "aababbb$"
7
0
1
3
6
2
4
5
Suffix Array/倍增法
Step 2/
利用 \(S[i \rightarrow 2^1]\) 的結果,排序 \(S[i \rightarrow 2^2]\)
$aababbb
aababbb$
ababbb$a
abbb$aab
b$aababb
babbb$aa
bbb$aaba
bb$aabab
S = "aababbb$"
7
0
1
3
6
2
4
5
Suffix Array/倍增法
Step 2/
利用 \(S[i \rightarrow 2^1]\) 的結果,排序 \(S[i \rightarrow 2^2]\)
排序完成!
$aababbb
aababbb$
ababbb$a
abbb$aab
b$aababb
babbb$aa
bb$aabab
bbb$aaba
S = "aababbb$"
7
0
1
3
6
2
5
4
Suffix Array/倍增法
Step 2/
利用 \(S[i \rightarrow 2^2]\) 的結果,排序 \(S[i \rightarrow 2^3]\)
$aababbb
aababbb$
ababbb$a
abbb$aab
b$aababb
babbb$aa
bb$aabab
bbb$aaba
S = "aababbb$"
7
0
1
3
6
2
5
4
Suffix Array/倍增法
Step 2/
利用 \(S[i \rightarrow 2^2]\) 的結果,排序 \(S[i \rightarrow 2^3]\)
排序完成!
$aababbb
aababbb$
ababbb$a
abbb$aab
b$aababb
babbb$aa
bb$aabab
bbb$aaba
S = "aababbb$"
7
0
1
3
6
2
5
4
Suffix Array/倍增法
Step 2/
\(2^3\) 已經 \(\geq\) 原本長度
終止排序!
$aababbb
aababbb$
ababbb$a
abbb$aab
b$aababb
babbb$aa
bb$aabab
bbb$aaba
S = "aababbb$"
7
0
1
3
6
2
5
4
Suffix Array/倍增法
倍增法的複雜度取決於排序時使用的演算法
如果使用 std::sort \(\Rightarrow O(n \log ^2 n) \)
Suffix Array/倍增法
倍增法的複雜度取決於排序時使用的演算法
如果使用 std::sort \(\Rightarrow O(n \log ^2 n) \)
但因為 Suffix Array 值域是 \([0, n-1]\)
所以可以用 Radix Sort
複雜度降為 \(O(n \log n)\)!
Suffix Array/模板請享用
struct suffixarray {
string s;
int n; // 字串長度
vector<int> suf, rk;
// suf: Suffix Array
// rk: 後綴 i 的排名
// radix sort 要用的東西
vector<int> cnt, pos;
vector<pair<pii, int> > buc[2];
void init(string& ss) {
s = ss + (char)0; // 加特殊字元到結尾
n = (int)s.size();
suf.resize(n);
rk.resize(n);
cnt.resize(n);
pos.resize(n);
Each(i, buc) i.resize(n);
}
// radix sort
inline void radix_sort() {
// 一開始 pair 被放在 buc[0]
// 先排序 pair.second,放到 buc[1]
// 接著排序 pair.first,放回 buc[0]
REP(t, 2) {
fill(cnt.begin(), cnt.end(), 0);
Each(i, buc[t]) cnt[(t?i.F.F:i.F.S)]++;
REP(i, n) pos[i] = i ? pos[i-1]+cnt[i-1] : 0;
Each(i, buc[t]) buc[t^1][pos[(t?i.F.F:i.F.S)]++] = i;
}
}
// 蓋 suf, rk
inline bool fill_suf() {
// buc[0]:已經排序好的
REP(i, n) suf[i] = buc[0][i].S;
// 蓋 rk
rk[suf[0]] = 0;
bool end = true;
FOR(i, 1, n, 1) {
bool dif = buc[0][i].F != buc[0][i-1].F;
end &= dif;
rk[suf[i]] = rk[suf[i-1]] + dif;
}
return end;
// end:剪枝
// 如果每個東西都長的不一樣
// 那就不需要繼續倍增了
}
// 倍增蓋 Suffix Array
void buildSA() {
// 邊界條件:排序 [i->1]
REP(i, n) buc[0][i] = mp(mp(s[i], s[i]), i);
sort(buc[0].begin(), buc[0].end());
if (fill_suf()) return;
// 利用 [i->2^k] 的結果排序 [i->2^(k+1)]
for (int k = 0; (1<<k) < n; k++) {
// 把 [i->2^(k+1)] 以 pair(左半rank, 右半rank) 表示
REP(i, n) buc[0][i] = mp(mp(rk[i], rk[(i+(1<<k))%n]), i);
radix_sort();
if (fill_suf()) return;
}
}
};
suffixarray sa;
填鴨教學繼續
除了 SA 之外
我們還要加蓋另一個奇怪的陣列
對於長度兩個字串 \(S, T\)
定義 \(lcp(S, T)\) 為兩字串的最長共同前綴長度。
\(S\)
\(T\)
\(lcp(S, T)\)
LCP Array/定義
$
aababbb$
ababbb$
abbb$
b$
babbb$
bb$
bbb$
7
0
1
3
6
2
5
4
LCP Array/定義
接下來
我們要在 Suffix Array 上蓋 LCP Array
\(lcp[i]=\begin{cases}lcp(\text{後綴}sa[i], \text{後綴}sa[i-1])&\text{if i>0}\\ 0&\text{otherwise.}\end{cases}\)
接下來
我們要在 Suffix Array 上蓋 LCP Array
\(lcp[i]=\begin{cases}lcp(\text{後綴}sa[i], \text{後綴}sa[i-1])&\text{if i>0}\\ 0&\text{otherwise.}\end{cases}\)
$
aababbb$
ababbb$
abbb$
b$
babbb$
bb$
bbb$
7
0
1
3
6
2
5
4
LCP Array/定義
0
0
1
2
0
1
1
2
LCP Array
學怎麼蓋 LCP Array 之前
有一個重要的性質要先理解
Lemma.
對於 \(i < j\),
\(lcp(sa(i), sa(j)) = min\{lcp(sa(k-1), sa(k))\ |\ i < k \leq j\}\)
\(sa(j)\)
\(sa(i)\)
\(\vdots\)
Lemma.
對於 \(i < j\),
\(lcp(sa(i), sa(j)) = min\{lcp[i+1], ..., lcp[j]\}\)
\(sa(j)\)
\(sa(i)\)
\(\vdots\)
\(lcp[i+1]\)
\(\vdots\)
\(lcp[j]\)
這一段區間
取最小值
Proof.
設 \(lcp(i, j) = k, i<j\),並設 \(min\{lcp[i+1], ..., lcp[j]\} = m\)。
由於 Suffix Array 已經排序好 \(\Longrightarrow sa(i), sa(i+1), ..., s(j)\) 前 \(k\) 個字元必定相同。
從而有 \(m \geq k\)。
而如果 \(m > k\),則代表 \(lcp(i, j) > k\),矛盾。
故 \(m = k\),即 \(lcp(sa(i), sa(j)) = min\{lcp[i+1], ..., lcp[j]\}\)。
接下來介紹 \(O(n)\) 蓋 LCP Array 的方法
"Kasai-Arimura-Arikawa-Lee-Park Algorithm"
LCP Array/建構
重要觀念
我們是在 SA 上面蓋表
但建表的順序要按照後綴原本的 index
主要是因為要利用以下這個性質:
LCP Array/建構
右邊是 Suffix Array 局部樣貌
設後綴 \(i, j\) 在 SA 中相鄰且 \(j\) 在 \(i\) 上方
令 \(lcp(i, j) = k, k>0\)
LCP Array/建構
\(i\)
\(j\)
\(k\)
LCP Array/建構
\(i\)
\(j\)
\(i+1\)
\(j+1\)
右邊是 Suffix Array 局部樣貌
設後綴 \(i, j\) 在 SA 中相鄰且 \(j\) 在 \(i\) 上方
令 \(lcp(i, j) = k, k>0\)
觀察到後綴 \(i+1\) 其實只是後綴 \(i\) 去掉第一個字
後綴 \(j+1\) 亦然
\(\Longrightarrow lcp(i+1, j+1) = k-1\)
\(k\)
\(k-1\)
\(k-1\)
\(\vdots\)
LCP Array/建構
\(i\)
\(j\)
\(i+1\)
\(j+1\)
右邊是 Suffix Array 局部樣貌
設後綴 \(i, j\) 在 SA 中相鄰且 \(j\) 在 \(i\) 上方
令 \(lcp(i, j) = k, k>0\)
觀察到後綴 \(i+1\) 其實只是後綴 \(i\) 去掉第一個字
後綴 \(j+1\) 亦然
\(\Longrightarrow lcp(i+1, j+1) = k-1\)
再由前面的 Lemma.
\(\Longrightarrow lcp[i+1]\) 至少 \(\geq k-1\)!
\(k\)
\(k-1\)
\(k-1\)
\(\vdots\)
LCP Array/建構
void buildLCP() {
lcp[0] = 0;
REP(i, n) {
// building lcp[rk[i]]
if (rk[i] == 0) continue;
if (i) lcp[rk[i]] = max(0LL, lcp[rk[i-1]]-1);
int j = suf[rk[i]-1];
while (i+lcp[rk[i]] < n && j+lcp[rk[i]] < n &&
s[i+lcp[rk[i]]] == s[j+lcp[rk[i]]]) lcp[rk[i]]++;
}
}
複雜度分析跟 KMP 很像
我懶得寫證明反正是 \(O(n)\)