\(\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 進行許可,附加條款亦可使用。