[筆記](更新中)KMP

Sinktank發表於2024-08-02

演算法詳解

KMP是一種字串匹配演算法,可以線上性的時間複雜度內解決字串的“模式定位”問題,即:
在字串 \(A\) 中查詢字串 \(B\) 出現的所有位置。

我們稱 \(A\) 為主串,\(B\) 為模式串。下文都用\(n\)表示\(A\)的長度,\(m\)表示\(B\)的長度,下標從\(1\)開始。

初始狀態,我們用兩個指標 \(i,j\) 分別指向 \(A\)\(B\) 的第 \(1\) 位。

KMP的主過程如下(後面會證明它的正確性):

  • 如果\(A[i]=B[j]\),則i++j++
  • 如果\(A[i]\neq B[j]\)
    • 如果\(j>1\),則j=nxt[j-1]+1
    • 如果\(j\le 1\),則i++

我們定義\(nxt[0]=0\)

其中,\(nxt\)陣列是一個用於表示發生失配後,\(j\)回退到的位置。具體求法待會會說,下面我們用一個例子來理解\(nxt\)的功能,我將\(nxt\)陣列畫在圖片的下方了。

  • 初始狀態。\(i=1,j=1\)
  • \(A[i]=B[j]\),同時右移\(i,j\)\(i=2,j=2\)
  • \(A[i]=B[j]\),同時右移\(i,j\)\(i=3,j=3\)
  • \(A[i]=B[j]\),同時右移\(i,j\)\(i=4,j=4\)
  • \(A[i]=B[j]\),同時右移\(i,j\)\(i=5,j=5\)
  • \(A[i]=B[j]\),同時右移\(i,j\)\(i=6,j=6\)
  • 發現\(A[i]\neq B[j]\),於是查表\(nxt[j-1]+1=nxt[5]+1=3\),於是\(j=3\)。此時\(i=6\)
    可以發現此時灰色箭頭指向的兩部分是相等的。
    進一步來說,從上一張圖開始,下劃線的部分都是相等的。
  • OK我們繼續,\(A[i]=B[j]\),於是\(i,j\)同時右移,\(i=7,j=4\)
  • 發現\(A[i]\neq B[j]\),查表得\(nxt[3]+1=2\),於是\(j=2\)。此時\(i=7\)
    我們又發現\(A\)\(B\)的下劃線部分重合了,所以仍然可以從重合部分之後開始比較。
  • 然後\(A[i]=B[j]\)……就一直比到結束了,就不放圖了w。

我們可以把根據上面的過程(KMP主過程)寫出程式碼(下標從\(1\)開始):

i=1,j=1;
while(i<=n){
	if(a[i]==b[j]) i++,j++;
	else if(j>1) j=nxt[j-1]+1;
	else i++;
	if(j==m+1) cout<<i-j+1<<"\n";
	//輸出配對的開始位置,理解一下這裡
}

可以發現\(i\)是始終不降的,比起暴力,KMP透過避免\(i\)指標的回滾操作,減少了時間開銷。

到這裡我們已經可以初步理解\(nxt\)的含義,它是用來幫我們瞭解發生失配時,\(j\)應該回退到什麼位置。

再進一步分析,我們發現:\(nxt[x]\),其實就是\(B[1\sim x]\)這一段的最長公共前字尾長度。

舉個例子,YABAPQYABZABAAPYABAPQYABA。這是一個長度為\(24\)的字串,那麼當它作為\(B\)時:

  • \(nxt[23]=9\),如你所見,紅色部分是\(B[1\sim 23]\)的公共前字尾,它的長度為\(9\)
  • \(nxt[9]=3\),因為YAB\(B[1\sim 9]\)的公共前字尾,它的長度為\(3\)
  • \(nxt[3]=0\),因為\(B[1\sim 3]\)沒有公共前字尾。
  • \(nxt[24]=0\),因為\(B[1\sim 24]\)沒有公共前字尾。

注意:公共前字尾可能有重疊部分,但是一個字串不能作為它自己的公共前字尾(嚴格來說,我們把這種前/字尾稱作前/字尾,所以後面都帶“真”來稱呼了)。

我們規定,\(nxt[x]\)表示的必須是\(B[1\sim x]\)這一段的最長公共真前字尾長度,這麼定義是為了保證正確性,如果不規定最長,不能保證答案的正確性。後面的證明會提到。

現在我們的首要問題是,如何求\(nxt\)陣列?

我們假定\(i\)之前的答案都已經求出,現在需要求\(nxt[i]\)

我們設\(j\)\(nxt[i-1]+1\),如下圖:

最簡單的情況自然就是\(B[i]=B[j]\),那麼\(nxt[i]=j\),這是顯然的。

但是也可能遇到更棘手的情況:

\(B[i]\neq B[j]\),怎麼辦呢?

既然YABAPQYAB這個公共真前字尾不行,那就再找一個更小的!

顯然更小的前字尾就是YAB了,怎麼得來的呢?就是看\(nxt[j-1]\)的值是多少,那麼新的\(j\)就是\(nxt[j-1]+1\)。此時再看看\(B[i]=B[j]\)是否成立。成立的話就\(nxt[i]=j\),不成立就繼續找\(j\),直到找到\(B[i]=B[j]\),或者\(j=1\)為止。

可以發現我們充分利用了之前計算出的\(nxt\),將原規模逐步縮小,是不是有點歸納法的意味?

再來舉一個不斷更新\(j\)直到\(j=1\)為止的例子:

我們可以把這一步驟也寫成程式碼(下標從\(1\)開始):

int i=2,j=1;//nxt[1]應為0,所以i從2開始
while(i<=m){
	if(b[i]==b[j]) nxt[i++]=j++;
	else if(j>1) j=nxt[j-1]+1;
	else nxt[i++]=0;
}

(是不是和上面的程式碼很像)


至此把兩個程式碼拼接起來就能得到KMP的全過程了。

P3375 【模板】KMP

下標從$1$開始
#include<bits/stdc++.h>
#define N 1000010
using namespace std;
string a,b;
int n,m,nxt[N];
int main(){
	cin>>a>>b;
	n=a.size(),m=b.size();
	a=' '+a,b=' '+b;
	int i=2,j=1;
	while(i<=m){
		if(b[i]==b[j]) nxt[i++]=j++;
		else if(j>1) j=nxt[j-1]+1;
		else nxt[i++]=0;
	}
	i=1,j=1;
	while(i<=n){
		if(a[i]==b[j]) i++,j++;
		else if(j>1) j=nxt[j-1]+1;
		else i++;
		if(j==m+1) cout<<i-j+1<<"\n";
	}
	for(int i=1;i<=m;i++) cout<<nxt[i]<<" ";
	return 0;
}
下標從$0$開始
#include<bits/stdc++.h>
#define N 1000010
using namespace std;
string a,b;
int n,m,nxt[N];
int main(){
	cin>>a>>b;
	n=a.size(),m=b.size();
	int i=1,j=0;
	while(i<m){
		if(b[j]==b[i]) nxt[i++]=++j;
		else if(j) j=nxt[j-1];
		else nxt[i++]=0;
	}
	i=0,j=0;
	while(i<n){
		if(a[i]==b[j]) i++,j++;
		else if(j) j=nxt[j-1];
		else i++;
		if(j==m) cout<<i-j+1<<"\n";
	}
	for(int i=0;i<m;i++) cout<<nxt[i]<<" ";
	return 0;
}

正確性證明

我們剛接觸\(nxt\)的定義時,可能會有疑問:為什麼\(nxt\)一定要表示\(B[1\sim x]\)這一段的最長公共真前字尾長度?

我們用反證法證明一下正確性,順帶解決這個問題。


根據KMP的流程,如果遇到失配的情況,我們先找\(B[1\sim (j-1)]\)中的最長公共真前字尾(如圖,用\(f\)表示)的長度\(nxt[j-1]\),然後把\(j\)置為\(nxt[j-1]+1\),如下圖。

要證明KMP是正確的,就是證明\(j\)回退時不會跳過正確答案。
我們假設在\(j>nxt[j-1]+1\)時存在匹配,如下圖,那麼灰色箭頭連線的部分都相等。
自然,黃色大括號部分(不包含\(i,j\))也相等,那麼黃色部分的長度\(=j-1\)

回到第\(1\)張圖(\(j'\)表示第\(1\)張圖情況下的\(j\)),根據假設,\(j>|f|+1\),即黃色部分長度\(>|f|\)
這樣的話,黃色部分就成了\(B[1\sim j']\)最長的公共真前字尾,與“\(f\)\(B[1\sim j']\)最長公共真前字尾”矛盾。

得到的結論是:\(j>nxt[j-1]+1\)時一定不存在匹配。

也就是說\(j\)回退是不會漏掉解的,正確性得證。

這樣我們的問題也迎刃而解了:如果\(nxt\)不是最長,上面的情況就可能不形成矛盾,進而\(j>nxt[j-1]+1\)時也可能存在解,而這被我們忽略掉了,所以正確性不能保證。

複雜度證明

透過看程式碼能發現兩部分程式碼相似,所以就一塊說了:

\(i\)每增加\(1\)\(j\)最多也增加\(1\),從而\(j\)最多增加\(len\)次,進而最多減少\(len\)次。
所以處理\(nxt\)\(O(m)\)的,主過程是\(O(n)\)的,總時間複雜度就是\(O(n+m)\)了。

——字串學習筆記 · 淺析KMP——單模式串匹配演算法 - 皎月半灑花

\(\mathcal{NEXT\ \ \ PHANTASM...}\)

相關文章