字串演算法

by 建國中學 賴昭勳

今天的內容

  • 字串匹配

  • Hashing

  • Z-Algorithm

  • KMP

  • Trie

  • Manacher

字串是什麼

一堆字元

反正字元要是什麼就可以是什麼

字母

數字

軟軟

上古漢語

所以字串是什麼

一堆字元用一維的順序合在一起

aka. 整數序列

跟字串有關的問題

字串匹配:問一些字元是否一樣的問題(數值不重要)

字典序問題:字元有定義大小順序

字串匹配 #0:

語法複習

C++ 裡面的 string

  • 據說不用#include string 就能用

  • 類似字元的vector,所有功能和vector 類似

string s;
cin >> s;
cin.ignore()
getline(cin, s);
s += 'a';
cout << s.size() << endl;
cout << s[0] << endl;
cout << s.substr(0, 5) << endl;
reverse(s.begin(), s.end());

要怎麼知道兩個字串是否相同?

阿不就一個一個比

int main() {
    string a, b;
    cin >> a >> b;
    int same = 1;
    for (int i = 0;i < min(b.size(), a.size());i++) {
        if (a[i] != b[i]) {
		    same = 0;
            break;
        }
    }
    if (same) {
    	cout << "JIZZ" << endl;
    } else {
    	cout << 7122 << endl;
    }
}

但是同一個字串跟很多東西比怎麼辦?

或是在一個字串中找某一個子字串?

字串匹配 #1:

Hash (雜湊)

哈希 (Hash)

(可用的)有點唬爛的方法

Hash: 資料壓縮

INFOR33rd->712233

IOIGold->tmw

 

原則上:

不可逆

多對一關係

H(x) = y

可以用的hash方法

      多項式表示法:

(s0pn1+s1pn2+...+sn2p1+sn1p0)%m(s_0*p^{n - 1} + s_1*p^{n - 2} + ... + s_{n - 2}*p^1 + s_{n - 1}*p^0) \% m

簡單來說就是把字串ss

當成一個 p \ p\ 進位的數字,再取模數

m, p 要怎麼選

通常 pp 需要是一個比字元數還多的質數

如小寫字母者可選p=29p = 29

ASCII 可使用p=257p = 257

 

mm是一個很大的質數

常見的有:109+7,99824435310^9 + 7, 998244353

還有1015+37,712271227,100069696910^{15} + 37, 712271227, 1000696969

這樣怎麼比對字串?

如果字串s==ts == t,可以先分別把他們兩個Hash,再看兩者的 Hash 值是否相同就好了!

Hash 一樣 Hash 不同
相同
不同 ? ?

機率盡量小!

各種切字串->前綴

一邊做一邊存

H(i1)=(s0pi1+s1pi2+...+si1p0)%m ...(1)H(i - 1) = (s_0*p^{i - 1} + s_1*p^{i - 2} + ... + s_{i - 1}*p^0) \% m \ ...(1)
H(i - 1) = (s_0*p^{i - 1} + s_1*p^{i - 2} + ... + s_{i - 1}*p^0) \% m \ ...(1)
H(i)=(s0pi + s1pi1+...+si1p1+sip0)%m=((1)p+sip0)%mH(i) = (s_0*p^i \ + \ s_1*p^{i - 1} + ... + s_{i - 1}*p^1 + s_i*p^0) \% m \\ = ((1) * p + s_i*p^0) \% m
H(i) = (s_0*p^i \ + \ s_1*p^{i - 1} + ... + s_{i - 1}*p^1 + s_i*p^0) \% m \\ = ((1) * p + s_i*p^0) \% m

O(n)O(n) 的時間建出所有前綴的Hash 值

各種切字串->子字串

假設要找 [l,r][l, r]的 Hash 值,要怎麼從前綴Hash h[]h[]得到?

區間和-> 前綴相減?

h[l1]prl+1h[l - 1] * p^{r - l + 1}
h[l - 1] * p^{r - l + 1}

各種切字串->迴文?

迴文:正著念反著念都一樣

就正反Hash 一次!

切好子字串,對應到相同的index

h[l1]prl+1h[l - 1] * p^{r - l + 1}
h[l - 1] * p^{r - l + 1}

差常數倍

石唑

https://tioj.ck.tp.edu.tw/problems/1306

之後會一直寫這題的喔!

參考程式碼

#include <iostream>
#include <vector>
#define ll long long
using namespace std;
const ll P = 401, M = 998244353;
ll hashes[10005], modp[10005];
ll hashp(string s, bool saveval) {
    ll val = 0;
    int index = 0;
    for (char c:s) {
    	val = ((val * P) % M + c) % M;
    	if (saveval) hashes[index] = val;
    	//cout << val << endl;
    	++index;
    }
    return val;
}
void modpow(int base, int exp) {
	ll b = 1;
	modp[0] = 1;
	for (int i = 0; i < exp; i++) {
		b = (b * base) % M;
		modp[i + 1] = b;
	}
}
ll subseq (int l, int r) { //[l, r]
	if (l == 0) return hashes[r];
	return(((hashes[r] - hashes[l - 1] * modp[r - l + 1]) % M + M) % M);
}
int main() {
	ios_base::sync_with_stdio(false);
	cin.tie(0);
	int n;
	cin >> n;
	modpow(P, 10001);
	for (int i = 0; i < n; ++i) {
		string T;
		int q;
		cin >> T;
		ll useless = hashp(T, true);
		cin >> q;
		for (int j = 0; j < q; ++j) {
			string p;
			cin >> p;
			ll h = hashp(p, false);
			int cnt = 0;
			for (int k = 0; k < T.size() - p.size() + 1;++k) {
				//cout << h << " " << subseq(k, k + p.size() - 1) << endl;
				if (subseq(k, k+p.size() - 1) == h) ++cnt;
			}
			cout << cnt << "\n";
		}
	}
	return 0;
}

給一個字串SS,問有多少個環狀位移是迴文。S105|S| \leq 10^5

nn個字串,問有多少對(i,j)(i, j)使得Si,SjS_i, S_j合併起來是迴文。

n,S105n, \sum |S| \leq 10^5

補: 關於雜湊函數

  • 雜湊值完全取決於輸入
  • 雜湊函數用到所有輸入的資料
  • 雜湊函數「平均地」將輸入資料分攤在所有可能數值
  • 相近的輸入值通常會出現差很多的雜湊值

好的雜湊函數會有這些條件:

在此前提下,可以把它想成一種隨機分布

雜湊函數並不萬能

Birthday Attack

隨機挑選O(C)O(\sqrt C)個輸入就有高機率出現碰撞,其中CC是輸出範圍。

如果想要用 Hash 找 nn個字串是否有一組相同的字串,那就要注意此問題。

但是

「沒有什麼問題是Hash一次不能解決的。如果有,就Hash兩次。」

字串匹配 #2:

Z-Algorithm

Z-Algorithm

zzz...

  • 確定性的O(n+m)O(n + m)字串匹配

  • Code 少好做(?

要來建奇怪的陣列了

z[i]z[i]代表從sis_i開始的後綴與ss的最長共同前綴長度

A A B B A A B
X 1 0 0 3 1 0

ii

暴力法

直接做?O(n2)O(n^2)

while (i + cnt < ps && p[cnt] == p[i + cnt]) {
    cnt++;
}

觀察一下...

從左到右,紀錄最大出現過的右界rr

 

這同時代表著我們目前考慮到的最右邊

假設現在考慮 z[i]z[i]

 

如果i>ri > r,就直接暴力做

否則...

a d a d c a d a c b

l         rl \ \ \ \ \ \ \ \ \ r
l \ \ \ \ \ \ \ \ \ r
ii
i
ili - l
i - l

z[i]z[i] 至少有 min(z[il],ri+1)min(z[i - l], r - i + 1)那麼大!

假設現在考慮 z[i]z[i]

 

如果i>ri > r,就直接暴力做

否則令z[i]=min(ri+1,z[il])z[i] = min(r - i + 1, z[i - l]),繼續暴力做

a d a d c a d a c b

l         rl \ \ \ \ \ \ \ \ \ r
l \ \ \ \ \ \ \ \ \ r
ii
i
ili - l
i - l

實作細節

int n = s.size();
int l = 0, r = 0;
z[0] = 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;
	}
}

這樣複雜度是好的喔?

4

Z-Algorithm 的複雜度分析

觀察在計算的時候,rr遞增

每次計算z[i]z[i]時,有以下情況​

1. i>ri > r,則 rr增加,均攤O(n)O(n)

2. ir,i+z[il]<ri \leq r, i + z[i - l] < r,則不會再往後

搜尋,O(1)O(1)

3. ir,i+z[il]>=ri \leq r, i + z[i - l] >= r,則rr增加,均攤O(n)O(n)

整體複雜度:O(n)O(n)

那要怎麼做字串匹配

把要配對的單字(word) 和文句(paragraph)串在一起做 Z 陣列!

a b # a c a a b d

z[i]z[i]為單字的長度

題解 by baluteshih

#include <iostream>
#include <algorithm>
#include <string>
#define ll long long
#define maxn 20005
using namespace std;
int z[maxn];
int main() {
	ios_base::sync_with_stdio(0);cin.tie(0);
	int t;
	cin >> t;
	while (t--) {
		string s;
		cin >> s;
		int q;
		cin >> q;
		while (q--) {
			string p;
			cin >> p;
			int ans = 0;
			int l = 0, r = 0, ps = p.size(), ss = s.size();
			z[0] = 0;
			int cnt;
			for (int i = 1;i < ps;i++) {
				if (i > r) cnt = 0;
				else cnt = min(r - i + 1, z[i - l]);
				while (i + cnt < ps && p[cnt] == p[i + cnt]) cnt++;
				z[i] = cnt;
				cnt--;
				if (i + cnt > r) l = i, r = i + cnt;
			}
			z[ps] = 0;
			for (int i = 0;i < ss - ps + 1;i++) {
				if (i + ps + 1 > r) cnt = 0;
				else cnt = min(r - int(ps + i), z[ps + i + 1 - l]);
				while (i + cnt < ss && cnt < ps && p[cnt] == s[i + cnt]) cnt++;
				z[ps + i + 1] = cnt;
				if (cnt == ps) ans++;
				cnt--;
				if (i + cnt > r) l = ps + 1 + i, r = ps + 1 + i + cnt;
			}
			cout << ans << "\n";
		}
	}
	return 0;
}

然後這題丟上去會TLE

字串匹配 #3:

KMP

KMPlayer(x

很難看懂的東西

Failure Function

次長共同前後綴

ababccab 

 

babbabba

Failure Function Table FFT(x)

定義 p[i]p[i]為字串第ii 個前綴(s0...i - 1)的次長共同前後綴

ex. j i z j i

j

j i

j i z

j i z j

j i z j i

對應到的數值為

0

0

0

1 -> j

2 -> ji

怎麼找?

爆搜:O(n3)O(n^3)
每個前綴都要比較nn次字串,每次O(n)O(n)

 

484 有點太爛了?

觀察性質 #1

p[i+1]p[i]+1p[i + 1] \leq p[i] + 1

babbabbab?

^這個不可能再往左邊

i+1i + 1
i + 1

觀察性質 #2

判斷的時候只要看一個字元

babbabbab?

i+1i + 1
i + 1

如果相同,p[i+1]=p[i]+1p[i + 1] = p[i] + 1

那麼不相同呢?

觀察性質 #2-2

找到最大的 jj,使得p[i+1]=jp[i + 1] = j

(符合共同前後綴條件)

babbabbab?

i+1i + 1
i + 1

j=p[p[i]1]j = p[p[i] - 1] 是可能會對的數值中最大的!

從綠色再檢查一次

演算法總結

  1. 存一個陣列p[i]p[i],代表字串 s\ s i\ i個前綴的次長共同前後綴

  2. 邊界條件:p[0]=0p[0] = 0

  3. 若已經做完 p[0]...p[i]\ p[0]...p[i],令j=p[i]j = p[i]

  •  s[i+1]=s[j]\ s[i + 1] = s[j],則 p[i+1]=j+1\ p[i + 1] = j + 1

  • 否則,更新 j=p[j]\ j = p[j],重複以上迴圈直到上述條件成立

  • 若最後 j=0\ j = 0,則 p[i+1]=0\ p[i + 1] = 0

複雜度證明

由性質#1得知,p[i]p[i] 的值不會超過nn,且每次最多加一,因此p[i]p[i]的變動量不會超過2n=O(n)2*n = O(n) (也就是加了多少才能扣多少),且每次改變時皆為O(1)O(1)字元比較,故得證

那要怎麼找相同子字串呢?

簡單做法:跟Z-Algorithm 一樣,把要匹配的兩個字串串起來做一次Failure Function

 

如:要在 TTFTFFT 裡面找 FFT 的話

就對 FFT#TTFTFFT 做Failure Function

如果在後面 p[i]=len(s)p[i] = len(s)那就是有找到

 

但是這樣484太慢了?

真正的KMP

每次遇到不能匹配時,快速回到下一個可以的位置。

  • 要在 S 中尋找W 的出現

  • 紀錄兩個變數:mm -> W目前第一個字元的位置,ii ->比到W的第幾個字元

此處 m=4,i=6m = 4, i = 6

性質 #1

黑線畫的AB是下一個可能匹配到的開頭。

即m須更新為:大於m的數字中,最小可能匹配到的開頭。

 

可能匹配到的開頭  -> 剩下的是 W的前綴

性質 #2

當我更新完 W的值之後,AB不用再重複跑一遍。因為已和 S 配對且是 W 的前綴

因此,只要從原本的位置繼續找就好了。

演算法簡介

1. 對單字WW做Failure Function

2. 紀錄WWSS中起始的位置為mm

    當前比較到W[i]W[i]S[i+m]S[i + m]

3. 增加ii直到W[i]S[i+m]W[i] \neq S[i + m],如

     果iW.size()i \geq W.size()那就配對到了

4. 令m=m+ip[i1],i=p[i1]m' = m + i - p[i - 1], i = p[i - 1]

    ,重複步驟 3直到 m>S.size()W.size()m > S.size() -W.size()

複雜度證明

1. 注意到由於ip[i1]0i - p[i - 1] \geq 0mm必然遞增

2. m+i=(m+ip[i1])+(p[i1])m + i = (m + i - p[i - 1]) + (p[i - 1]) 在更新時呈非嚴格遞增

因此均攤複雜度O(n)O(n)

#include <iostream>
#include <algorithm>
#include <string>
#define ll long long
#define maxn 20005
using namespace std;
int kmp[maxn];
int main() {
	ios_base::sync_with_stdio(0);cin.tie(0);
	int t;
	cin >> t;
	while (t--) {
		string s;
		cin >> s;
		int q;
		cin >> q;
		while (q--) {
			string p;
			cin >> p;
			int ps = p.size(), ss = s.size(), ans = 0;
			kmp[0] = 0;
			for (int i = 1;i < ps;i++) {
				kmp[i] = kmp[i - 1];
				while (p[i] != p[kmp[i]]) {
					if (kmp[i] == 0) {
						kmp[i] = -1;
						break;
					}
					kmp[i] = kmp[kmp[i] - 1];
				}
				kmp[i]++;
			}
			int ind = 0;
			for (int m = 0;m < ss - ps + 1;) {
				while (ind < ps && p[ind] == s[m + ind]) ind++;
				ind--;
				if (ind == ps - 1) ans++;
				if (ind == -1) {
					m++;
					ind = 0;
				} else {
					m = m + ind - kmp[ind] + 1;
					ind = kmp[ind];
				}
			}
			cout << ans << "\n";
		}
	}
	return 0;
}

KMP 的應用

找到字串中有多少的獨特的子字串,複雜度O(n2)O(n^2)

每次增加一個字元更新答案

從反的做回來,KMP開下去。

新的子字串->前綴!

a b a c c a b a

0 0 1 0 0 1 2 3

max(p[i])max(p[i])個字串絕對有重複過!

答案增加 lenmax(p[i])len - max(p[i])

自動機觀點

資訊科學的自動機(Automaton)是一個有向圖。有一個節點為起點,每一條有向邊上有某個輸入(字元)。

TIOJ 1531

TIOJ 1091

字串與圖論:

TRIE~

 

aka 字典樹

存取字串之間的相似關係!

把字串問題用圖論觀點解釋!

每個節點是一個狀態,每條邊有一個字元,由父節點到葉節點是字串。重複的字元會合併。

例: 給定nn個字串S1,...,SnS_1, ..., S_n,每次詢問一個字串TT,輸出有幾個字串有TT是他的前綴。

n105,S106,T106n \leq 10^5, |S| \leq 10^6, \sum |T| \leq 10^6

CHE

CHEESE

CHEIS

CCC

OR

c

c

c

o

r

h

e

e

e

i

s

s

DFS!

Manacher's Algorithm

看看怪問題

輸入一個字串,輸出最長的回文子字串長度。

 

S106|S| \leq 10^6

暴力做: 對每個回文的中心找最長回文。S2|S|^2

奇數/偶數回文長度?

在每兩個原本字串的字元間放一個特殊字元"*"

abbacbc-> *a*b*b*a*c*b*c*

 

偶數回文的中心會在特殊字元。

因此只要考慮以每個字元為中心就好!

怪陣列

f[i]f[i]代表以第ii個字元為中心時延伸最長的回文長度 (也就是說[if[i],i+f[i]][i - f[i], i + f[i]]是回文)

另外維護r,midr, mid代表目前看到的i+f[i]i + f[i]最大值以及對應的ii

假設當前的i>ri > r,就暴力匹配。

否則...?

從左做到右考慮回文中心...

跟Z Algorithm 一樣,考慮某個非嚴格遞增的量!

Z: 目前匹配到最右邊的字串與左界

Manacher: 目前找到最右邊的回文與中心

*b*a*b*b*a*a*

f[i]f[i]至少是min(ri,f[mid(imid)])min(r - i, f[mid - (i - mid)])

然後再暴力匹配!

midmid
mid
ii
i
mid(imid)mid - (i - mid)
mid - (i - mid)
//Challenge: Accepted
#include <iostream>
#include <algorithm>
#include <vector>
#include <utility>
#define maxn 2000005
#define ll long long
using namespace std;
int p[maxn];
string s;

int main() {
	ios_base::sync_with_stdio(0);cin.tie(0);
	int n;
	cin >> n;
	string in;
	cin >> in;
	s += '.';
	for (int i = 0;i < n;i++) {
		s += in[i];
		s += '.';
	}
	//cout << s << endl;
	n = 2 * n + 1;
	int l = -1, r = -1, ans = 0;
	for (int i = 0;i < n;i++) {
		int len = min(i, n - 1 - i);
		if (i <= r) {
			p[i] = min(p[2 * l - i], r - i);
		}
		for (int j = p[i] + 1;j <= len;j++) {
			if (s[i + j] != s[i - j]) break;
			p[i]++;
		}
		ans = max(ans, p[i]);
		if (i + p[i] > r) {
			r = i + p[i];
			l = i;
		}
		//cout << p[i] << " " << l << " " << r << endl;
	}
	cout << ans << "\n";
}
Made with Slides.com