字串演算法

Lecturer : Lemon

字串

我們在處理程式問題的時候常常會接觸到字串

因此發展了一些特定的演算法來解決問題

今天會教ㄉ:

  • Hash(雜湊)
  • KMP algorithm
  • Z algorithm
  • Trie

今天不會教以後也不會教ㄉ:

  • 自動機(比如AC自動機)

字串匹配問題

TIOJ 1306 (注意這題時限很緊)

比較簡單(?ㄉCSES

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]\) 
    1. 如果\(s[i] = s[j],\ p[i] = j + 1\)
    2. 否則讓\(j = p[j - 1]\)
    3. 重複以上操作至\(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