字串演算法

String Algorithm

Gino

以下所有演算法都是講師這兩個禮拜才學會

可能有概念不是很清楚

所以等下應該會出現學員打爆講師的情況

 

麻煩鞭小力一點 ; w;

DISCLAIMER

由於字串這個主題暑假培訓沒教

所以這堂課的重心會在講解演算法原理

題目不會帶太多因為我也沒寫過多少字串題

 

夠電的人可以直接下滑戳題單或寫自己的題目

或是看臺上的人雜耍順便嘴砲

DISCLAIMER

內容大綱

  • 名詞定義
  • 字串匹配
  • Rolling Hash
  • KMP
  • Z-Value
  • Suffix Array
  • LCP Array

簡報上沒有的

  • Trie
  • Aho-Corasick Automation
  • Suffix Automation
  • Manacher's Algorithm

名詞定義

字串

由一堆字元排列成的序列

字元可以是字母、數字、任意符號

 

$$ S = "StarBurstStream" $$

$$ T = "48763" $$

$$ U = "1011111001111011" $$

符號表示

  • 第 \(i\) 個字元:\(S_i\)
  • 長度:\(|S|\)
  • 串接:\(S + T\)
  • 字元集:\(C\)
  • 字元集大小:\(|C|\)

子字串

\(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\) 具備以下條件:

  1. 複雜度:比對 \(f(x), f(y)\) < 比對 \(x, y\)
  2. \(x = y \Longrightarrow f(x) = f(y) \)
  3. \(f(x) = f(y)\) 但 \(x \neq y\) 的機率盡量低

雜湊函式 (Hash Function)

一個好的雜湊函式 \(f\) 具備以下條件:

  1. 複雜度:比對 \(f(x), f(y)\) < 比對 \(x, y\)
  2. \(x = y \Longrightarrow f(x) = f(y) \)
  3. \(f(x) = f(y)\) 但 \(x \neq y\) 的機率盡量低

雜湊碰撞

字串的雜湊 - 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[i]=P[j]\):\(i, j\) 都會加 \(1\)
  2. \(S[i]\neq P[j]\):重複讓 \(j\) 變成 \(F(j-1)\) 直到此情況不成立(讓 \(j\) 減少)

複雜度證明

情況 1 只會發生 \(|S|-1\) 次

而因為 \(0 \leq F(j-1) \leq |P| \)

且情況 2 發生次數 \(\leq\) 情況 1(有加才有減)

 

\(O(|S|)\)!

  1. \(S[i]=P[j]\):\(i, j\) 都會加 \(1\)
  2. \(S[i]\neq P[j]\):重複讓 \(j\) 變成 \(F(j-1)\) 直到此情況不成立(讓 \(j\) 減少)

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)\) 的時候

  1. \(i>r\): \(r\) 增加
  2. \(i<r, i+Z(i-l)-1 \leq r\):則 \(Z(i)\) 不可能變更大,\(Z(i)\) 計算完畢
  3. \(i<r, i+Z(i-l)-1 > r\):\(r\) 可能增加

 

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)\)

字串演算法

By Gino

字串演算法

師大附中 2021 校隊培訓簡報

  • 358