字串學習總結(Hash & Manacher & KMP)

Vocanda發表於2020-07-21

前言

終於開始學習新的東西了,總結一下字串的一些知識。

NO.1 字串雜湊(Hash)

定義

即將一個字串轉化成一個整數,並保證字串不同,得到的雜湊值不同,這樣就可以用來判斷一個該字串是否重複出現過。

所以說\(Hash\)就是用來求字串是否相同或者包含的。(包含關係就可以列舉區間,但是通常用\(KMP\)不會真的有人用看臉的\(Hash\)做字串匹配吧,不會吧不會吧)。

實現

實現方式也是比較簡單的,其實就是把一個字串轉化為數字進行比較,到這裡可能有人就會說,直接比較長度和\(ASCII\)碼不就行了,也是轉化成數字啊(放屁)。這樣顯然是不行的,就好比說"ab"和“ba“,這兩個顯然不一樣,但是如果按上邊說的進行比較就是一樣的,這樣就錯了,所以我們要換一種方式:改變一下進位制。

如果是一個純字串的話,那麼我們應該把進位制調到大於\(131\),因為如果小於,就不能給每一種的字元一個值,那麼正確性也就無法保證了。所以取一個\(233\),合情合理,還很sao(逃。因為這個值至少能保證不會炸。我們求出來每個字串對應的數字,然後進行比較就好了。

對於雜湊而言,我們認為對一個數取模後一樣,那麼就是一樣的,所以可以偷點懶,也就是自然溢位,使用\(unsigned\ long\ long\),相當於自動對\(2^{64}\)取模,然後進行比較即可,當然,可以自己背一個\(10^{18}\)的質數進行取模(畢竟也是能卡的,也不知道哪個毒瘤會卡),各有優缺點。

程式碼

ull Hash(char s[]){//ull自然溢位
	ull res = 0;
	int len = strlen(s);
	for(int i=0;i<len;++i){//計算每一位,用自己定義的進位制base乘(也就是233 qwq)
		res = (res*base + (ull)s[i])%mod;//這裡我是取了個玄學mod
	}
	return res;
}

以上就是整個字串之間的對比。下邊說一說字串裡某個區間的對比

區間對比

意思就是直接給出你幾個字串,對比每個字串裡給定的區間\([l,r]\),這樣的話如果直接一個個的掃,肯定會慢好多,如果直接求整個串然後相減,那麼肯定是錯誤的,因為每一位都是要乘以一個進位制的,如果直接計算,那麼肯定就會亂掉,也就\(WA\)了。所以要用到之前說的東東:字首和。

我們記錄每一位的字首和,而記算的時候需要乘以當前位的進位制,這樣就會避免上邊說到的那種迷惑錯誤。記錄的時候就照常按照字首和記錄,只需要最後改一下判斷就行。

定義\(pw[len]\)為長度為\(len\)時的需要乘以的進位制,字首和就用\(sum\)來表示,求字首和就是這樣:

int main(){
	cin>>s;
	int len = strlen(s);
	sum[0] = (ull)s[0];
	for(int i=1;i<len;++i){
		sum[i] = sum[i-1]*base+(ull)a[i];//乘以進位制不能忘
	}
}

下邊是判斷是否合法:

	while(n--){
		int l,r,s,t,len;
		cin>>l>>r>>s>>t;
		len = r-l+1;//計算第幾位來乘以進位制,pw陣列提前可以快速冪處理好
		if(sum[r] - sum[l-1]*pw[len] == sum[t]-sum[s-1]*pw[len])printf("YES\n");//如果這樣計算出來值相等就合法
		else printf("NO\n");
	}

模板例題

字串雜湊

例題程式碼

#include<bits/stdc++.h>
using namespace std;
#define ull unsigned long long
const ull mod = 1926081719260817;
const int maxn = 1e4+10;
ull base = 233;
int a[maxn];
char s[maxn];
ull Hash(char s[]){
	ull res = 0;
	int len = strlen(s);
	for(int i=0;i<len;++i){
		res = (res*base + (ull)s[i])%mod;
	}
	return res;
}
int main(){
	int n;
	scanf("%d",&n);
	for(int i=1;i<=n;++i){
		cin>>s;
		a[i] = Hash(s);
	}
	int ans = 1;
	sort(a+1,a+n+1);
	for(int i=1;i<n;++i){
		if(a[i] != a[i+1])ans++;
	}
	printf("%d\n",ans);
}

NO.2 Manacher演算法

學長說很不常用,所以理解一個思想即可。

定義

\(1975\)年,\(Manacher\)發明了\(Manacher\)演算法(中文名:馬拉車演算法),是一個可以在\(O(n)\)的複雜度中返回字串\(s\)中最長迴文子串長度的演算法,十分巧妙。
例如這個字串:“abaca”,它可以處理每一位的迴文字串,以\(O(n)\)的效率處理最大值(當然還是有擴充套件的,只不過它不太常用,就只是分析一下演算法過程)

實現

因為迴文串分為奇迴文串和偶迴文串,處理起來比較麻煩,所以我們要用到一個小(sao)技(cao)巧(zuo),在每兩個字元之間插入一個不會出現的字元,但是要求插入的字元一樣,這樣才能保證不影響迴文串的長度。
舉個例子:“abbadcacda”這個字串,我們需要插入新的字元,這裡用’#',那麼就有了如下對應關係:

其中定義\(p[i]\)為以\(i\)為半徑的迴文半徑,也就是從中心向兩邊最長能擴充多少,而根據這個可以推出來以它為中心的真正的迴文串的長度。也就是\(p[i]-1\),根據這個就可以得到最長的迴文串的長度了。
但是複雜度為什麼是\(O(n)\)呢,那麼就涉及到了他的實現方法,我們定義一個迴文中心\(C\)和這個迴文的右側\(R\),也就是當前中心的最長迴文的右端點,如果列舉到的\(i\)大於\(R\),那麼直接更新就行,但是如果在裡邊,那麼會分出來三種情況:
\(1\)、列舉到的\(i\)關於\(C\)對稱到\(i'\),這時候\(i'\)的迴文區域在\([L,R]\),那麼\(i\)的迴文半徑就是\(i'\):
證明:因為此時的\([L,R]\)就是一個迴文區間,所以左右對稱過來是一樣的,所以得到\(i\)的迴文半徑。

\(2\)、列舉到\(i\),此時對稱點\(i'\)的迴文區域超出了\(L\),那麼\(i\)的迴文區域就一定是從\(i\)\(R\)
證明:借用一張圖片便於解釋:

(圖好醜……)首先我們設\(L\)點關於\(i'\)對稱的點為\(L'\)\(R\)點關於\(i\)點對稱的點為\(R'\)\(L\)的前一個字元為\(x\)\(L’\)的後一個字元為\(y\)\(k\)\(z\)同理,此時我們知道\(L - L'\)\(i'\)迴文區域內的一段迴文串,故可知\(R’ - R\)也是迴文串,因為\(L - R\)是一個大回文串。所以我們得到了一系列關係,\(x = y,y = k,x != z\),所以 \(k != z\)。這樣就可以驗證出\(i\)點的迴文半徑是\(i - R\)
\(3\)\(i'\) 的迴文區域左邊界恰好和\(L\)重合,此時\(i\)的迴文半徑最少是\(i\)\(R\),迴文區域從\(R\)繼續向外部匹配。
證明:因為 \(i'\) 的迴文左邊界和L重合,所以已知的\(i\)的迴文半徑就和\(i'\)的一樣了,我們設\(i\)的迴文區域右邊界的下一個字元是\(y\)\(i\)的迴文區域左邊界的上一個字元是\(x\),現在我們只需要從\(x\)\(y\)的位置開始暴力匹配,看是否能把\(i\)的迴文區域擴大即可。

小小總結一下,其實就是先進行暴力匹配,然後根據\(i'\)迴文區域和左邊界的關係進行查詢。

例題+程式碼

Manacher板子

#include<bits/stdc++.h>
using namespace std;
const int maxn = 11e6;
char s[maxn];
int Manacher(char s[]){
	int len = strlen(s);
	if(len == 0)return 0;//長度為0就return
	int len1 = len * 2 + 1;
	char *ch = new char[len1];//動態陣列
	int *par = new int[len1];
	int head = 0;
	for(int i=0;i<len1;++i){
		ch[i] = (i & 1) == 0 ? '#' : s[head++];//插入不一樣的字元
	}
	int C = -1;
	int R = -1;
	int Max = 0;
	par[0] = 1;
	for(int i=0;i<len1;++i){//列舉三種情況
		par[i] = (i < R)? min(par[C*2-i],R-i) : 1;//取最小的迴文半徑
		while(i + par[i] < len1 && i - par[i] > -1&& ch[i + par[i]] == ch[i - par[i]]){//暴力匹配
			par[i] ++ ;
		}
		if(i + par[i] > R){//如果超過右邊界就更新
		 	R = i + par[i];
			C = i;
		}
		Max = max(Max,par[i]);//更新最大半徑
	}
	delete[] ch;//清空動態陣列
	delete[] par;
	return Max - 1;//因為這個是添了字元的最大回文半徑,所以迴文串的最長是它-1
}
int main(){
	cin>>s;
	cout<<Manacher(s);
	return 0;
}

NO.3 KMP演算法

正常我們查詢字串是否為子串的時候,往往都是暴力列舉,效率為\(O(n^2)\),但是字串長了或者多了,肯定就是不行的了,所以有了\(KMP\)演算法。

定義

\(KMP\)演算法是一種改進的字串匹配演算法,由\(D.E.Knuth,J.H.Morris\)\(V.R.Pratt\)同時發現,因此人們稱它為克努特——莫里斯——普拉特操作(簡稱\(KMP\)演算法)。\(KMP\)演算法的關鍵是利用匹配失敗後的資訊,儘量減少模式串與主串的匹配次數以達到快速匹配的目的。具體實現就是實現一個\(next\)函式,函式本身包含了模式串的區域性匹配資訊。時間複雜度\(O(m+n)\)

通俗的來說就是在需要匹配的那個串上給每個位置一個失配指標\(fail[j]\),表示在當前位置\(j\)失配的時候需要返回到\(fail[j]\)位置繼續匹配,而這就是\(KMP\)演算法優秀複雜度的核心。

實現

失配陣列的匹配就是把需要查詢的那個字串進行一遍字首和字尾之間的匹配。我們舉個例子"ababa"這裡真字首分別為"a","ab","aba","abab",真字尾為"a","ba","aba","baba",找到他們的最大相同位置,就是\(fail\)指標,

我們設\(kmp[i]\) 用於記錄當匹配到模式串的第 \(i\) 位之後失配,該跳轉到模式串的哪個位置,那麼對於模式串的第一位和第二位而言,只能回跳到 \(1\),因為是 \(KMP\)是要將真字首跳躍到與它相同的真字尾上去(通常也可以反著理解),所以當 \(i=0\) 或者 \(i=1\) 時,相同的真字首只會是 \(str1(0)\)這一個字元,所以\(kmp[0]=kmp[1]=1\)

模板+程式碼

KMP字串匹配

#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e6+10;
char a[maxn],b[maxn];
int kmp[maxn];
int main(){
	cin>>a+1>>b+1;
	int lena = strlen(a+1);
	int lenb = strlen(b+1);
	int j = 0;
	for(int i=2;i<=lenb;++i){//自己跟自己匹配處理出kmp陣列
		while(j && b[i] != b[j+1]){
			j = kmp[j];
		}
		if(b[i] == b[j+1])j++;
		kmp[i] = j;
	}
	j = 0;
	for(int i=1;i<=lena;++i){
		while(j && a[i] != b[j+1]){
			j = kmp[j];
		}
		if(a[i] == b[j+1])j++;
		if(j == lenb){//匹配完了就輸出位置
			printf("%d\n",i-lenb+1);
			j = kmp[j];//返回失配位置
		}
	}
	for(int i=1;i<=lenb;++i){
		printf("%d ",kmp[i]);
	}
	return 0;
}


相關文章