演算法·理論:KMP 筆記

godmoo發表於2024-08-02

\(\text{KMP}\) 筆記!

上次比賽,出題人出了一個 \(\text{KMP}\) 模板,我敲了個 \(\text{SAM}\) 跑了,但是學長給的好題中又有很多 \(\text{KMP}\),於是滾回來惡補字串基本演算法。

\(\text{KMP}\) 是上個寒假學的,為什麼最近才完全理解,但 \(\text{KMP}\) 短小精悍,極其精簡,確實難懂,所以很長一段時間都躲著它,最近突發靈感,遂寫此篇。

前置知識:字串基礎字串匹配(基本概念)

引入

\(\text{KMP}\) 演算法,用於解決字串匹配問題。這一類問題一般可以表述為「在主串 \(S\) 中查詢模式串 \(T\) 的某些資訊」。

顯然我們有一種十分暴力的演算法:列舉模式串 \(T\) 的起點,往後一位一位的比較。記 \(S\) 的長度為 \(n\)\(T\) 的長度為 \(m\),顯然這種演算法最壞是 \(O(nm)\) 的。

給出這種暴力的實現:

// force.cpp
int n,m;
char s[N],t[M];
void force(){
	for(int st=1;st+m-1<=n;st++){
		int i=st,j=1;
		while(j<=m&&s[i]==t[j]) i++,j++;
		if(j>m){
			// do something
		}
	}
}

當然我們可以用字串雜湊做到 \(O(n+m)\) 預處理,\(O(1)\) 比較兩個字串,複雜度變成 \(O(n+m)\)。實際上,這也是 \(\text{KMP}\) 的複雜度,但是 \(\text{KMP}\) 可以做到更多。

字首函式-定義與性質

定義一個字串 \(s\) 的字首函式 \(nxt[i]\)\(s\) 長度為 \(i\) 的字首 \(s[1\colon i]\)最長公共真前字尾的長度,為了方便用陣列形式表式。

暈?不慌,看個例子。

比如說 \(s=\texttt{abcab}\),容易發現,\(\texttt{ab}\) 既是 \(s\) 的真字首,又是 \(s\) 的真字尾,所以它是 \(s\)公共真前字尾,又發現 \(s\) 僅有 \(\texttt{ab}\) 一個公共真前字尾,所以 \(\texttt{ab}\) 自然是最長公共真前字尾,所以 \(s\)最長公共真前字尾的長度就等於 \(2\)

接下來,你能寫出 \(\texttt{ababc}\)\(nxt[]\) 嗎?

  • 對於 \(nxt[1]\),對應的字首是 \(\texttt{a}\)最長公共真前字尾為空串(真前/字尾不算本身),於是 \(nxt[1]=0\)
  • 對於 \(nxt[2]\),對應的字首是 \(\texttt{ab}\)最長公共真前字尾為空串,於是 \(nxt[2]=0\)
  • 對於 \(nxt[3]\),對應的字首是 \(\texttt{aba}\)最長公共真前字尾\(\texttt{a}\),於是 \(nxt[3]=1\)
  • 對於 \(nxt[4]\),對應的字首是 \(\texttt{abab}\)最長公共真前字尾\(\texttt{ab}\),於是 \(nxt[4]=2\)
  • 對於 \(nxt[5]\),對應的字首是 \(\texttt{ababc}\)最長公共真前字尾為空串,於是 \(nxt[5]=0\)

綜上,\(\texttt{ababc}\)\(nxt[]\)\(\{0,0,1,2,0\}\)

再來講兩個 \(nxt[]\) 陣列的性質:

  • 性質 \(\bf{1}\)\(nxt[i]<i\),由定義得到。
  • 性質 \(\bf{2}\):設字串 \(s\)\(p=\lvert s \rvert\),不斷去做 \(p=nxt[p]\),出來的每個值即為原串的每個公共真前字尾的長度。(包括 \(nxt[p]\)\(nxt[nxt[p]]\)\(nxt[nxt[nxt[p]]]\)……)

性質 \(\bf{2}\) 證明

拿前兩次來說,初始的 \(nxt[p]\)原串的每最長公共真前字尾的長度,所以 \(nxt[nxt[i]]\) 即為長度為 \(nxt[p]\) 的字首的最長公共真前字尾的長度,記這個最長公共真前字尾\(u\),那麼原串就有字首 \(u\)

但又因為長度為 \(nxt[p]\) 的字首等於長度為 \(nxt[p]\) 的字尾,所以長度為 \(nxt[p]\) 的字尾也有長度為 \(nxt[nxt[p]]\)最長公共真前字尾 \(u\),那麼原串就也有字尾 \(u\)。所以原串有公共真前字尾 \(u\)

於是繼續這樣遞迴的理解一下,這樣巢狀下去,每個值顯然都是原串的公共真前字尾(把空串也理解為公共真前字尾)。

\(\bf{KMP}\)-匹配過程

想想怎麼最佳化我們前文講的 \(O(nm)\) 暴力。

比如說 \(S=\texttt{abababc}\)\(T=\texttt{ababc}\)

\(\texttt{\color{grey}{abababc}}\)
\(\texttt{\color{grey}{ababc}}\)

一開始,顯然前四位都能匹配上:

\(\texttt{\color{green}{abab}\color{grey}{abc}}\)
\(\texttt{\color{green}{abab}\color{grey}{c}}\)

但到第五位,字元不同,這時我們稱發生了一次失配

\(\texttt{\color{green}{abab}\color{red}{a}\color{grey}{bc}}\)
\(\texttt{\color{green}{abab}\color{red}{c}}\)

正常暴力時,我們需要將模式串往右移一位,全部重新匹配,像這樣:

\(\texttt{\color{grey}{abababc}}\)
\(\texttt{ \color{grey}{ababc}}\)

然後繼續比較:

\(\texttt{\color{grey}{a}\color{red}{b}\color{grey}{ababc}}\)
\(\texttt{ \color{red}{a}\color{grey}{babc}}\)

但是,顯然有一種更加聰明的辦法,由於模式串 \(T\) 已經匹配好了一段字首 \(\texttt{abab}\),並且這段字首中存在兩段一樣的字元 \(\texttt{ab|ab}\),所以後面一段 \(\texttt{ab}\) 匹配好的 \(S\) 的第三第四位可以直接給 \(T\) 的第一第二為用,並且直接從 \(T\) 的第三位為開始比較:

\(\texttt{\color{grey}{ab}\color{green}{ab}\color{grey}{abc}}\)
\(\texttt{\color{white}{--}\color{green}{ab}\color{grey}{abc}}\)

接著向後比較,匹配成功:

\(\texttt{\color{grey}{ab}\color{green}{ababc}}\)
\(\texttt{\color{white}{--}\color{green}{ababc}}\)

懂了又好像沒懂?總而言之,我們的基本思想就是利用已經匹配了的部分和模式串 \(T\) 本身相同的部分進行最佳化。

已經匹配了的部分簡單,但是現在問題就來了:什麼樣的 \(T\) 的相同部分可以被利用呢?怎麼跳轉呢?

先說結論:設當前 \(S[i+1] \neq T[j+1]\),即 \(S\)\(T\) 即將失配,那麼接下來的最小可能匹配位置\(i-nxt[j]+1\)\(T\) 的開頭位置),同時不需要移動 \(i\),直接 \(j\leftarrow nxt[j]\),然後繼續比較 \(S[i+1] \neq T[j+1]\) 即可。

比如在上面的例子中,\(\texttt{ababc}\) 的匹配到 \(S[4]=T[4]\),但是在下一位 \(S[5] \neq T[5]\),也就是要失配了,所以我們 \(j\leftarrow nxt[j]\)\(j\leftarrow nxt[4]\)\(j\leftarrow 2\),於是繼續比較 \(S[5]\)\(T[3]\) 即可。

證明

首先 \(i-nxt[i]+1\) 的正確性顯然,就是利用 \(nxt[i]\) 提供的前後相等的資訊,直接完成前 \(nxt[i]\) 位的匹配。

接著採用反證法證明 \(i-nxt[i]+1\)最小可能匹配位置。假設存在一個可能的匹配位置 \(1\lt p \lt i-nxt[j]+1\),如下圖:

\(p\)\(j\) 的子串長為 \(len\),顯然 \(len>nxt[i]\)。我們畫出移動後的模式串 \(T\)(第三條橙色線),那麼移動後第三條橙色線 \(T\) 的長為 \(len\) 的字首等於第二條橙色線 \(p\)\(j\) 的子串即 \(T\) 長為 \(j\) 的字首的長為 \(len\) 的字尾(上下對應)。

於是在長為 \(T\)\(j\) 的字首中,長為 \(len\) 的字首等於長為 \(len\) 的字尾,所以這個串是 \(T\)公共真前字尾,但是前面我們又說 \(len>nxt[i]\),這與 \(nxt[]\) 的定義矛盾,於是假設不成立,得證。

所以下一個可能的匹配位置就為 \(i-nxt[j]+1\),接著長度為 \(nxt[j]\) 的字首,就可以利用我們前面匹配好的長度為 \(nxt[j]\) 的字尾匹配好,於是從 \((i-nxt[j]+1)+nxt[j]-1=i\) 繼續匹配即可(相當於 \(i\) 不用改);至於 \(j\),由於前面 \(nxt[j]\) 為已經匹配好了,所以 \(j\leftarrow nxt[j]\) 即可。

那要是還失配呢?繼續 \(j\leftarrow nxt[j]\) ,直到 \(S[i+1]=T[j+1]\)\(j=0\) 為止。

結合前面字首函式 \(nxt[]\) 的性質感性理解一下:根據性質 \(\bf{2}\),這是一個找一遍每一個公共真前字尾的過程,根據 性質 \(\bf{1}\),每次操作後,長度不斷減小,起始點就越近,這其實是一種不斷 “退而求其次” 的思路,近一點就意味著更多的重複利用之前的匹配結結果,太近的匹配不上,就往後走,走太多了,那就會超出匹配過的範圍,就沒有可利用的結果了,那就不是當前的 \(i\) 下需要解決的問題了。

容易寫成程式碼:

// kmp.cpp
int n,m,nxt[M];
char s[N],t[M];
void KMP(){
	// calculate nxt[]
	for(int i=1,j=0;i<=n;i++){
		while(j&&t[j+1]!=s[i]) j=nxt[j];
		j+=(t[j+1]==s[i]);
		if(j==m){
			// do something
		}
	}
} 

注意我們原來是說比較 \(S[i+1] \neq T[j+1]\),但是由於 \(i+1\) 的值在一輪迴圈中全程都不變,所以我們將 \(i\) 更新的工作交給迴圈提前做,我們在迴圈內部用 \(i\) 即可,不要寫成 \(i+1\)

還有,為什麼不是 j++ 呢?因為有可能根本就無法配對,所以要判斷能不能匹配,匹配上了再 ++,即 j+=(t[j+1]==s[i]);

講的很細了,應該程式碼沒有什麼問題,現在只剩下一個問題:怎麼求字首函式 \(nxt[]\)

字首函式-求法

好吧,其實字首函式 \(nxt[]\) 的求法才是 \(\text{KMP}\) 演算法最常考的內容,甚至大部分的題都不需要匹配的過程,重點都在考查對字首函式 \(nxt[]\) 的理解。

字首函式的求法運用了增量法,即我們在已知前面的 \(nxt[]\) 時來確定新的函式值,當然也可以說是一種 dp。

設原串為 \(s\),接下來要求出 \(nxt[i]\),前面的所有 \(nxt[]\) 值已知,那麼顯然初始狀態時是 \(nxt[1]=0\)

顯然,若原串長這樣:

\(\texttt{[aba]c}\cdots\texttt{[aba]}\)

其中被 \(\texttt{[ ]}\) 包裹的是最長公共真前字尾,那麼這是分兩種情況:

  • \(s[nxt[i-1]+1]=s[i]\) 時,即當加入 \(\texttt{c}\) 時,此時最長公共真前字尾變成 \(\texttt{[aba]c}\),即有 \(nxt[i]=nxt[i-1]+1\)
  • \(s[nxt[i-1]+1] \neq s[i]\),即加入一個不是 \(\texttt{c}\) 的字元時,那就無法與前面的最長公共真前字尾吻合上了,於是 “退而求其次”:最大不行,第二小的呢?相信你也想到了,就是利用字首函式的性質 \(\bf{2}\),不斷找更小的看看能不能吻合,找到最後,如果還沒有,那麼 \(nxt[i]=0\)

同樣容易寫成程式碼:

int n,m,nxt[M];
char s[N],t[M];
void KMP(){
	nxt[1]=0;
	for(int i=2,j=0;i<=m;i++){
		while(j&&t[j+1]!=t[i]) j=nxt[j];
		j=nxt[i]=j+(t[j+1]==t[i]);
	}
	for(int i=1,j=0;i<=n;i++){
		while(j&&t[j+1]!=s[i]) j=nxt[j];
		j+=(t[j+1]==s[i]);
		if(j==m){
			// do something
		}
	}
}

對比求字首函式 \(nxt[]\) 的程式碼和匹配的程式碼,兩份程式碼竟驚人的相似!

這是因為,設已經求出原串的一個字首 \(s\) 的所有字首函式,那麼此時若再新增一個字元 \(c\),相當於是在 \((s+c)\) 中用 \(s\) 進行匹配的最後一輪的過程,只是我們不關心是否匹配好罷了,這個感性理解一下就好了。

\(\bf{KMP}\)-複雜度

我們分析字首函式 \(nxt[]\) 的複雜度:

int m,nxt[M];
char t[M];
nxt[1]=0;
for(int i=2,j=0;i<=m;i++){
	while(j&&t[j+1]!=t[i]) j=nxt[j];
	j=nxt[i]=j+(t[j+1]==t[i]);
}

唯一有點迷的只有這個 while 了,其他都是 \(O(m)\) 的。

顯然 while 中的語句是 \(O(1)\) 的,所以重點在於 while 的執行次數。

我們這麼考慮,根據字首函式的性質 \(\bf{1}\)\(j\)while 中每次至少減少 \(1\),但是 \(j\) 僅會在每次迴圈中加至多 \(1\),所以 \(j\) 的所有增加不會超過 \(m\),於是最多進入 while 迴圈 \(O(m)\) 次,於是總複雜度為 \(O(m)\)

匹配的複雜度分析類似,複雜度 \(O(n+m)\)

綜上所述,\(\texttt{KMP}\) 演算法的複雜度為 \(O(n+m)\)

容易構造出一種最壞情況,\(S=\texttt{aaa}\cdots\texttt{ab}\)\(T=\texttt{aaa}\cdots\texttt{a}\)

實際上,還有很多一般表現上 \(\text{KMP}\) 的字串匹配演算法,如 \(\text{BM}\)\(\text{Sunday}\) 等等,但是 \(\text{KMP}\)\(\text{OI}\) 中已經夠用了,而且真正搞明白 \(\text{KMP}\) 也已經很不容易了呢~。

尾聲

寫完了!!!累!

本作品採用 CC BY-SA 4.0 進行許可,附加條款亦可使用。

相關文章