字串演算法
Lecturer : Lemon
字串
我們在處理程式問題的時候常常會接觸到字串
因此發展了一些特定的演算法來解決問題
今天會教ㄉ:
- Hash(雜湊)
- KMP algorithm
- Z algorithm
- Trie
今天不會教以後也不會教ㄉ:
- 自動機(比如AC自動機)
字串匹配問題
e.g.
\(T\) = "bananacanada"
\(P_1\)= "na"
則\(answer\) = 3
Hash
先來講一個偏數學的方法
雖然聽起來很唬爛
我們希望有一個函式\(h(x)\)使得
當\(h(x) = h(y)\)時,\(x = y\)
那麼只要我們能夠快速地計算出\(h(x)\)
我們便能快速地確認兩者是不是一樣的
也可以叫哈希(? or 雜湊
Hash
這跟字串又有什麼關係呢(?
\(T =\) "bananacanada"
\(P_1 = \) "na"
其中\(h("ba") \neq h("an") \neq h("na")\)
也就是說我們只要順著掃過所有長度為\(|P_1|\)的子字串
檢查他們的雜湊值是否一樣
就可以確定他們是不是一樣ㄉㄌ
Hash funtion
那這個函數應該要是什麼呢(?
\((\sum_{i = 1}^{|S|} S_i \times p^{i-1})\ mod\ m\)
其中p是一個大於字元數的質數
e.g. p = 29(小寫字母) or 257(ASCII)
m是一個夠大的質數
e.g. m = 1e9 + 7 or 712271227 or 998244353
Naive
然後你會發現求這個函數的時間複雜度是\(O(N)\)
最後總時間複雜度依然是\(O(N ^ 2)\)
難道真的是唬爛ㄉㄇ
我們在\(T\)中順著掃過所有長度為\(|P_i|\)的子字串
然後開心的求ㄌhash function的值
前綴
然後對每個\(P_j\)求整個字串的hash值\(k\)
我們對\(T\)的每個前綴求hash function
將其長度為\(i\)的記為\(h[i] = (\sum_{j = 1}^{i} S_j \times p^{j - 1})\ mod\ m\)
我們知道當
\(h[r] - h[l - 1] \equiv k \times p^{l - 1}\ (mod\ m),\ r - l + 1 = |S|\)
則\(T_{[l, r]} = P_{j}\)
CODE
#include <iostream>
#pragma GCC optimize("Ofast")
using namespace std;
const int MOD = 1e9 + 7, PRIME = 257, MAXN = 10005;
int preh[MAXN]; // 1base
int ppow[MAXN];
int mabs(int a) {
return (a + MOD) % MOD;
}
int madd(int a, int b) {
return (a + b) % MOD;
}
int mmul(int a, int b) {
return (1ll * a * b) % MOD;
}
void init() {
ppow[0] = 1;
for(int i = 1; i < MAXN; ++i) {
ppow[i] = mmul(ppow[i - 1], PRIME);
}
}
void solve() {
string T;
cin >> T;
for(int i = 0; i < T.size(); ++i) {
preh[i + 1] = madd(preh[i], mmul(T[i], ppow[i]));
}
//for(int i = 0; i <= T.size(); ++i) {
// cerr << preh[i] << ' ';
//}
//cerr << '\n';
int Q;
cin >> Q;
for(int q = 0; q < Q; ++q) {
string P;
cin >> P;
int hash = 0;
for(int i = 0; i < P.size(); ++i) {
hash = madd(hash, mmul(P[i], ppow[i]));
}
//cerr << "Hash: " << hash << '\n';
int cnt = 0;
for(int i = 1; i + P.size() - 1 <= T.size(); ++i) {
int j = i + P.size() - 1;
if(mmul(hash, ppow[i - 1]) == mabs(preh[j] - preh[i - 1])) ++cnt;
}
cout << cnt << '\n';
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int Cases;
cin >> Cases;
init();
for(int i = 0; i < Cases; ++i) {
solve();
}
}
差點TLE w
複雜度
對於兩個字串長度\(N, M\)
求前綴的時間\(O(N)\),空間\(O(N)\)
另一個字串求hash的時間\(O(M)\),空間\(O(1)\)
掃過字串並比對的複雜度\(O(N) \times O(1) \in O(N)\)
總時間複雜度\(O(N + M)\)
總空間複雜度\(O(N)\)
Z algorithm
本名Gusfield's algorithm
Z是一個function的名字
Z function :
最長共同前綴
L | E | M | L | E | L | E | M | O | N |
---|---|---|---|---|---|---|---|---|---|
X | 0 | 0 | 2 | 0 | 3 | 0 | 0 | 0 | 0 |
Z algorithm
當我們有了Z值
=> 把字串串起來
e.g.
\(T\) = "bananacanada"
\(P_1\)= "na"
比對用的字串\(S =\) "na#bananacanada"
中間可以隨便插入一個不會用到的字元
當Z值 = \(|P|\)則以他當開頭的子字串就是答案!
Naive
string s = "lemonilemon";
for(int i = 1; i < s.size(); ++i) {
int len = 0;
while(i + len < s.size() && s[len] == s[i + len]) ++len;
z[i] = len;
}
當然是直接枚舉起點暴搜找前綴R
每個Z value要花\(O(N)\)的時間
總共\(O(N) \times O(N) \in O(N ^ 2)\)
輕鬆TLE
映射
該如何好好利用之前得到的資訊呢(?
考慮目前\(l + Z[l] - 1\)有最大值\(r\)
(其實就是目前跑到的最右界)
L | E | M | L | E | M | L | E | E | M |
---|---|---|---|---|---|---|---|---|---|
X | 0 | 0 | 5 | 0 | 0 |
映射
L | E | M | L | E | M | L | E | E | M |
---|---|---|---|---|---|---|---|---|---|
X | 0 | 0 | 5 | 0 | 0 |
\(Z[i] \geq min(Z[i-l], r - i + 1)\)
映射
\(Z[i] \geq min(Z[i-l], r - i + 1)\)
對於所有\(i \leq r\)的情況我們都有上面ㄉ柿子
否則就直接暴搜
string s = "lemonilemon";
int l = 0, r = 0;
for(int i = 1; i < s.size(); ++i) {
int len = 0
if(i <= r) len = min(z[i - l], r - l + 1);
while(i + len < s.size() && s[len] == s[i + len]) ++len;
z[i] = len;
if(i + len - 1 > r) {
l = i;
r = i + len - 1;
}
}
CODE
#include <iostream>
#include <string>
using namespace std;
const int MAXN = 10005;
int z[MAXN << 1];
void solve() {
string T;
cin >> T;
int Q;
cin >> Q;
for(int q = 0; q < Q; ++q) {
string P;
cin >> P;
string str = P + "#" + T;
int l = 0, r = 0;
int cnt = 0;
for(int i = 1; i < str.size(); ++i) {
int len;
if(i > r) len = 0;
else len = min(r - i + 1, z[i - l]);
while(i + len < str.size() && str[len] == str[i + len]) ++len;
//cerr << i << ' ' << len << '\n';
z[i] = len;
if(i + len - 1 > r) {
l = i;
r = i + len - 1;
}
if(len == P.size()) ++cnt;
}
//cerr << str << '\n';
//for(int i = 1; i < str.size(); ++i) {
// cerr << z[i] << " \n"[i == str.size() - 1];
//}
cout << cnt << '\n';
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int Cases;
cin >> Cases;
for(int t = 0; t < Cases; ++t) {
solve();
}
}
差點TLE w
均攤複雜度
為什麼這樣的複雜度會是好的(?
怎麼看都沒有好到哪裡去R
- 很Trivial,\(r\)是遞增的
- 如果算Z時沒有改變\(r\),則查詢是\(O(1)\)的
所以若字串長度為\(N\)
\(r\)最多改變\(O(N)\)次
即時間複雜度\(O(N) + O(N) \times O(1) \in O(N)\)
空間複雜度\(O(N)\)
複雜度
由於字串匹配時需要將兩字串串接
故時間複雜度\(O(N + M)\)
空間複雜度\(O(N + M)\)
Z algorithm的好處大概是比較好寫(?
KMP algorithm
Knuth-Morris-Pratt algorithm
這都是人名la
又要維護怪怪的數列ㄌ
次長共同前後綴
(因為最長的就是全部)
L | E | M | L | E | L | E | M | O | N |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 1 | 2 | 1 | 2 | 3 | 0 | 0 |
failure function
e | e | e | e | d |
---|---|---|---|---|
0 | 1 | 2 | 3 | 0 |
好好運用已經有的資訊
當發現已經無法配對
那就回到上一個可以配對的狀態ㄅ
e.g.
Yeeeeeedrag
KMP algorithm
透過這樣的過程
我們就能以\(O(N + M)\)的複雜度找到所有相同的子字串ㄌ
Failure function
該怎麼找到failure function的序列ㄋ(?
難道又暴搜(?
我們想要找到位置\(i\)前綴ㄉ次長共同前後綴\(p[i]\)
- \(p[0] = 0\)
- 先設\(j = p[i - 1]\)
- 如果\(s[i] = s[j],\ p[i] = j + 1\)
- 否則讓\(j = p[j - 1]\)
- 重複以上操作至\(s[i] = s[j]\),或若\(j = 0\)則使\(p[i] = 0\)
不是加一就是變小複雜度\(O(N)\)
CODE
#include <iostream>
using namespace std;
const int MAXN = 20005;
int fail[MAXN];
void getfail(string s) {
fail[0] = 0;
for(int i = 1; i < s.size(); ++i) {
fail[i] = fail[i - 1];
while(s[i] != s[fail[i]]) {
if(!fail[i]) {
fail[i] = -1;
break;
}
fail[i] = fail[fail[i] - 1];
}
++fail[i];
}
}
void solve() {
string T;
cin >> T;
int Q;
cin >> Q;
for(int q = 0; q < Q; ++q) {
string P;
cin >> P;
getfail(P);
//for(int i = 0; i < P.size(); ++i) {
//cerr << fail[i] << " \n"[i == P.size() - 1];
//}
int cnt = 0;
int j = 0;
for(int i = 0; i < T.size();) {
if(T[i] == P[j]) {
++i;
++j;
if(j == P.size()) {
++cnt;
j = fail[j - 1];
}
}
else {
if(!j) {
++i;
continue;
}
j = fail[j - 1];
}
}
cout << cnt << '\n';
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int t;
cin >> t;
for(int i = 0; i < t; ++i) {
solve();
}
}
這個就不會TLEㄌ
複雜度
我們首先對要配對的字串算Failure function
時間:\(O(M)\)
空間:\(O(M)\)
然後我們要做配對
時間:\(O(N + M)\)
空間:\(O(1)\)
總時間複雜度:\(O(N + M)\)
總空間複雜度:\(O(M)\)
題目
Trie
字典樹
對它就是一棵樹
題目
性質
它是一棵樹(圖論)
每條邊代表一個字元
所有經過的邊加起來就是該節點的字串
性質
樹上有哪些字呢(?
DFS !
怎麼知道那格的字真的存在呢(?
在節點上存一個布林值
true代表該格的確是個字;false反之
複雜度
若我們有\(N\)個長度為\(M\)的字串\(S_i\)
建立trie的時間複雜度為\(O(NM)\)
查詢的時間複雜度為\(O(M)\)
空間複雜度\(O(NM)\)
上述是最差情況
會受到使用的字元數影響(重複的節點不用建立)
這4上雞考前最後一堂小社ㄛ
\大家加油/
順帶一提 段考結束那個禮拜的周末會有模擬賽ㄛ
都來打la
字串演算法
By lemonilemon
字串演算法
- 308