1. 程式人生 > 其它 >字串專題-學習筆記:KMP

字串專題-學習筆記:KMP

目錄

1.概述

KMP 演算法是一種字串演算法,具體解決的問題為字串匹配問題:

給出一個模式串 \(t\),文字串 \(s\),請問 \(t\) 是否為 \(s\) 的字串 / \(t\)\(s\) 中出現了幾次等等問題。

後文中無特殊說明,\(n\) 為文字串 \(s\) 的長度,\(m\) 為模式串 \(t\) 的長度。

2.例題

link

我們要求兩個東西:

  1. 模式串 \(t\) 在文字串 \(s\) 中出現的位置。
  2. 模式串 \(t\)\(border\) 長度。

我們暫且先不管這個 \(border\),考慮第 1 個問題。

顯然有一種暴力匹配的方法:直接從最前面的位置開始,在文字串 \(s\) 中截出長度為 \(m\) 的字串,\(O(m)\) 暴力匹配。

然而,這樣做會發現最壞理論複雜度到了 \(O(nm)\)!我們不能忍受。

於是 KMP 就出現了。

比如面對這樣的兩個字串:

文字串 s = a a a b a c a b a c d
模式串 t =   a b a

規定起始點為 \(i\)

如上所示,假設我們現在匹配到了 \(i = 2\) 的位置。我們發現不匹配(失配),那麼如果是暴力做法,那麼在 \(i = 3\) 的位置就會重新匹配一次。

但是我們完全可以不用這麼做,因為 模式串 \(t\) 的前面兩個字元跟文字串 \(s\) 的前面兩個字元相同,可以直接從第 3 位開始匹配。

而 KMP 的任務就是儘可能多的實現上面這句話。

那麼我們又怎麼判斷要跳到哪裡呢?

還記得 \(border\) 嗎?\(border\) 可以幫助我們判斷要跳到哪裡。

比如說我們現在在文字串第 \(i\) 個位置開始匹配,結果失配了,假設失配位置為 \(j\),那麼我們直接從 \(border_j\) 開始匹配。

下文記 \(Next_i\)\(border_i\)

想想為什麼?\(border\) 的定義是:在一個字串 \(s\) 中,如果一個串 \(str\)

滿足其既是 \(s\) 的字首,又是 \(s\) 的字尾,那麼 \(str\) 就是 \(s\) 的一個 \(border\),而一個串的 \(border\) 指他的所有 \(border\) 的最長長度。

那麼如果模式串 \(t\) 失配,我們可以跳到 \(Next_t\) 這個位置繼續匹配,而不需要直接從頭匹配。

那麼如何求 \(Next\) 陣列呢?

2.1 自匹配操作

\(Next\) 陣列在 KMP 中稱為自匹配操作。

比如對於模式串 \(\text{t = a b a b a c a b a}\),我們要對其進行自匹配操作。

注意:\(Next_i=i\) 是沒有意義的!

首先顯然的,\(Next_1=0\)

那麼 \(Next_2\) 呢?還是等於 0。

\(Next_3\)?等於 1。

但是我們是怎麼知道 \(Next_3\) 等於 1 的呢?上圖!

比如我們要求 \(Next_i\),而此時我們已經保證 \(Next_{1...i-1}\) 已經求好。

上圖中紅色部分表示這個串的 \(border\)

\(Next_{i-1}=j\),那麼我們假設 \(j\) 在這個位置:

由於上圖中兩個藍色部分完全相同,那麼我們首先判斷一下 \(t_{j +1}\)\(t_i\) 是否相同,如果相同 \(Next_i\) 就求出來了。

但是不相同呢?或許有的人會說了:那不是還要暴力查詢嗎?

不需要!因為 \(Next_{i-1}=j\),此時如果我們再取 \(k=Next_j\)(為了方便擦去了紅色部分):

圖很醜(確信

那麼首先在 \([1,j]\) 內兩段綠色字串相等,而由於 \(Next_{i-1}=j\),根據傳遞性,\([1,k]\) 就會跟 \([i-k,i-1]\) (也就是最後這段綠色的)相同,此時我們只需要判斷 \(t_{k+1}\) 是否等於 \(t_i\) 就可以了。相同就結束,不相同?繼續這麼做唄!

所以我們會發現,實質上 KMP 充分利用了 \(border\) 的性質,以 \(Next\) 陣列為媒介,減少了轉移次數,從而降低時間複雜度。

不過需要注意:當 \(j\) 跳到 0 時,如果 \(t_1 \ne t_i\),此時 \(Next_i = 0\);否則其餘所有情況,\(Next_i = j + 1\)

那麼在 \(\text{t = a b a b a c a b a}\) 中,\(Next_3=1\) 也就不難想了吧!

對於這個文字串 \(t\)\(Next=\{0,0,1,2,3,0,1,2,3\}\)

於是自匹配操作漂亮解決。

程式碼:

int j = 0;//初始化為 0
for (int i = 2; i <= m; ++i)
{
	while (j && s2[j + 1] != s2[i]) j = Next[j];//不斷往前找
	if (s2[j + 1] == s2[i]) ++j;//注意 +1
	Next[i] = j;
}

其實此時你會發現,題目要求的 \(border\) 長度就是我們的 \(Next\) 陣列。

這裡說句閒話:在幾年之前 luogu 的模板題是說:

直接輸出 \(Next\) 陣列。
如果你不知道什麼是 \(Next\) 陣列,自行學習 KMP 演算法。

不過或許是因為一些原因(比如並不是所有人的 KMP 都用的是 \(Next\) 的,也有人用 \(fail\) 命名),最後變成了輸出 \(border\) 長度。

2.2 字串的匹配

那麼回到我們的問題:求模式串 \(t\) 在文字串 \(s\) 內分別出現在哪幾個位置。

現在有了 \(Next\) 陣列,再加上我們前面說的,應該不難想了。

首先我們先初始化 \(j=0\),然後開始暴力匹配。

當我們發現 \(t_{j+1}=s_i\) 時,匹配成功,\(j\) 右移。

否則,\(t\)\(s\) 失配,此時根據我們最開始所說的,我們將 \(j\) 重置為 \(Next_j\) 繼續匹配。

當完全匹配到一個字串時,我們輸出位置 並且重置 \(j=Next_j\)(這點非常重要!否則在下一個位置匹配的時候 \(j\) 會被重置為一些奇奇怪怪的東西,導致操作失誤,想知道的讀者可以自己嘗試)

那麼這就是 KMP 的字串匹配過程。

程式碼:

j = 0;
for (int i = 1; i <= n; ++i)
{
	while (j && s2[j + 1] != s1[i]) j = Next[j];//不相同就跳
	if (s2[j + 1] == s1[i]) ++j;//注意 +1
	if (j == m) {printf("%d\n", i - m + 1); j = Next[j];}//一定要重置!
}

2.3 時間複雜度分析

KMP 的時間複雜度有一點迷。

在隨機資料下:

對於每一個 \(i\) 位置,我們在匹配字串時(包括自匹配)正常情況下 \(j++\) 只會執行一次,那麼 \(i\) 從 1 到 \(n\)\(j\) 從 1 到 \(m\),互不干擾,時間複雜度為 \(O(n+m)\)

但是很遺憾的是據說 KMP 比較容易被卡成 \(O(nm)\) 的時間複雜度,不過作者目前還沒有找到 hack 資料。

還是 hash 好,穩定的 O(n) 演算法

2.4 程式碼

話說上面都放出來了還有必要再放一遍嗎

程式碼:

#include <bits/stdc++.h>
using namespace std;

typedef long long LL;
const int MAXN = 1e6 + 10;
int n, m, Next[MAXN];
char s1[MAXN], s2[MAXN];

int read()
{
	int sum = 0, fh = 1; char ch = getchar();
	while (ch < '0' || ch > '9') {if (ch == '-') fh = -1; ch = getchar();}
	while (ch >= '0' && ch <= '9') {sum = (sum << 3) + (sum << 1) + (ch ^ 48); ch = getchar();}
	return sum * fh;
}

int main()
{
	scanf("%s", s1 + 1);
	scanf("%s", s2 + 1);
	n = strlen(s1 + 1); m = strlen(s2 + 1);
	int j = 0;//初始化為 0
	for (int i = 2; i <= m; ++i)
	{
		while (j && s2[j + 1] != s2[i]) j = Next[j];//不斷往前找
		if (s2[j + 1] == s2[i]) ++j;//注意 +1
		Next[i] = j;
	}
	j = 0;
	for (int i = 1; i <= n; ++i)
	{
		while (j && s2[j + 1] != s1[i]) j = Next[j];//不相同就跳
		if (s2[j + 1] == s1[i]) ++j;//注意 +1
		if (j == m) {printf("%d\n", i - m + 1); j = Next[j];}//一定要重置!
	}
	for (int i = 1; i <= m; ++i) printf("%d ", Next[i]);
	printf("\n"); return 0;
}

3.總結

KMP 的思想其實就是充分利用各個前後綴之間的關係,使得我們在字串失配的時候不至於從頭開始匹配,從而大大降低時間複雜度。