KMP算法是一種改進的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt同時發現,因此人們稱它爲克努特——莫里斯——普拉特操作(簡稱KMP算法)。KMP算法的關鍵是利用匹配失敗後的信息,儘量減少模式串與主串的匹配次數以達到快速匹配的目的。具體實現就是實現一個next()函數,函數本身包含了模式串的局部匹配信息。時間複雜度O(m+n)。
關於字符串匹配算法基本用這些算法解決問題:
- 字典樹:只查詢某個串在字典中是否匹配。
- KMP:在主串中線性遍歷查詢匹配單個模式串
- AC自動機:在一個主串中線性遍歷查詢匹配多個子串。
- 後綴數組:不可/可重疊最長重複子串查詢。
當遇到以下問題時可以用KMP算法解決:
主 串 :a b a c a a b a c a b a c a b
模式串:a b a c a b
找出主串中有多少個子串與模式串相同?
若是嘗試最樸素的暴力匹配方式,我們可能會使用N^2的時間複雜度解決該類問題
可以看到,在暴力匹配的過程中我們做了很多的無用功,當第一輪匹配前六個字符出現失配時,我們就該知道前六個字符不會出現正確的匹配結果了,並且已經遍歷過一次的字符串重複遍歷判斷多次就會感覺這種方法非常愚蠢。
優雅的做法是,我們應該儘量利用失配信息,用上一次遍歷時獲取的信息。使得我們的遍歷不會再做無用功,進行重複多次的遍歷。
於是我們嘗試用以下性質來優化:
假設主串爲’s[1],s[2]…s[n]’,模式串爲’p[1],p[2]…p[m]’。當匹配過程中產生 “失配”(即s[i]≠p[j]) 時,模式串“向右滑動”可行的距離多遠。換句話說,當主串中第i個字符與模式中第j個字符“失配”(即比較不等)時,主串中第i個字符(i指針不回溯)應與模式中哪個字符再比較?
假設此時應與模式中第k(k<j)個字符繼續比較,則模式中前k-1個字符的子串必須滿足下列關係式①且不可能存在k’< k滿足下列關係式①。
① ‘p[1]p[2]…p[k-1]’=‘s[i-k+1]s[i-k+2]…s[i-1]’
而已經得到的“部分匹配”的結果是
② ‘p[j-k+1]p[j-k+2]…p[j-1]’=‘s[i-k+1]s[i-k+2]…s[i-1]’
由式①和式②推得下列等式
③ ‘p[1]p[2]…p[k-1]’= ‘p[j-k+1]p[j-k+2]…p[j-1]’
反之,若模式串中存在滿足式③的兩個子串,則當匹配過程中,主串中第i個字符與模式中第j個字符比較不等時,僅需將模式向右滑動至模式中第k個字符和主串中第i個字符對齊,此時,模式中頭k-1個字符的子串‘ p[1]p[2]…p[k-1]’必定與主串中第i個字符之前長度爲k-1的子串‘ s[i-k+1]s[i-k+2]…s[i-1]’相等,由此,匹配僅需從模式中第k個字符與主串中第i個字符比較起繼續進行。
上面過程敘述了我們應該如何利用好遍歷過一次的字符串的失配信息優化匹配過程。
以上文字來源於 嚴蔚敏版《數據結構》 雖然其看起來比較晦澀,但是以文字的形式確實將KMP所依靠的精髓原理說明清楚了。這也是我第一次看到用文字敘述來嚴謹的說明 KMP算法利用模式串前綴後綴重複的性質 來優化匹配的詳細過程。
如果沒有弄明白上面敘述的過程,這很正常,我們可以通過畫圖來敘述一邊。
以上是我們在一次普通匹配過程中遇到的失配情況。其中下方模式串的左邊綠色部分也是上面文字敘述中的關係式①:
① ‘p[1]p[2]…p[k-1]’=‘s[i-k+1]s[i-k+2]…s[i-1]’
而下方模式串P的右邊綠色部分則是關係式②
② ‘p[j-k+1]p[j-k+2]…p[j-1]’=‘s[i-k+1]s[i-k+2]…s[i-1]’
由此我們可以得到,式①和式②中的等號右邊是一樣的即主串中的綠色部分,最後得到結論,我們需要的信息是,模式串中兩段綠色部分相等,當我們知道模式串匹配過程中失配部分之前一段子串是與模式串開頭一段子串是完全相等的,那麼我們就可以繼續從主串失配位置往後匹配,而不用回退主串的匹配指針,因爲我們可以將模式串的匹配指針向前移動,並認爲該指針之前的字符是完全匹配正確的。
這樣就不會出現樸素暴力匹配時無腦回退主串和模式串指針的行爲,而是跳躍着匹配,保證這樣匹配過程正確的依據即是利用了失配信息,前綴後綴重複的特性。保證了某段子串是一定匹配的。
明確了優化原理,那麼我們就該探究如何預處理出模式串的前綴後綴重複信息,並使其能在匹配時被利用上。
以下介紹Next數組 的構造:
Next數組:
next[i](i從1開始算)代表着,除去第i個數,在一個字符串裏面從第一個數到第(i-1)字符串前綴與後綴最長重複的個數。
構造next數組使用的基本方法是遞推,要計算當前第i位字符的next值即計算從字符串開始至第i位前(不包括第i位)的字符串的最長前綴與後綴重複數量。
規定第0位(第一個字符)的next值是0或-1(有不同構造next函數的方法)。
第i+1位的next值分兩種情況討論:
①當p[i]=p[j]時,前綴和後綴相等,最大長度可以延續因此next[++i]=next[++j]
②當p[i]≠p[j]時,前綴和後綴匹配到此不等,那麼可以利用已計算好的next值,將j回溯,直至找到可匹配的重複前綴後綴。也就是j=next[j]
代碼實現如下所示:
void getNext(char *T , int len)
{
int i = 0;
int j =next[0] = -1;
while(i < len)
if(j == -1 || T[i] == T[j])
next[++i] = ++j;
else
j = next[j];
}
由上面代碼我們即可處理出next數組來表示模式串中第i位於前綴重複的長度
以abacab舉例,我們將構造出如上數組,可以注意到,數字所代表的每一位是其前一位字符的最長前綴後綴匹配。爲什麼會錯位呢?
可以看出,當第4位字符是a時,其最長前綴後綴匹配長度是1,而這個1在第5位,這樣表示的意義時,當第5位字符b出現失配時,我們已經知道了b字符之前的a是與模式串第一位字符a是匹配的,因此無需再匹配a,而是需要匹配a的下一位字符,也就是下標是1的字符b,這就造成了結果與字符錯位的現象,實際上只是爲了方便失配時快速找到應該繼續匹配的位置。
PS:next數組的值僅取決於模式串本身,而與匹配的主串無關
我們用該next數組模擬一下匹配過程:
可以發現,在失配時,不再像原來那樣無腦直接從頭匹配,而是在明確哪些可能有匹配價值的情況下跳躍指針的匹配,並且主串指針從未回退,一直是模式串在回退。
以下是KMP樸素模板:
#include <iostream>
#include<stdio.h>
#include <cstring>
using namespace std;
const int N = 1000002;
int next[N];
char S[N], T[N];
int slen, tlen;
void getNext()
{
int j, k;
j = 0;
k = -1;
next[0] = -1;
while(j < tlen)
{
if(k == -1 || T[j] == T[k])
{
next[++j] = ++k;
}
else
{
k = next[k];
}
}
for(int i=0;i<=tlen;i++)printf("%d%c",next[i],i==tlen?'\n':' ');
}
/*
返回模式串T在主串S中首次出現的位置
返回的位置是從0開始的。
*/
int KMP_Index()
{
int i = 0, j = 0;
getNext();
while(i < slen && j < tlen)
{
if(j == -1 || S[i] == T[j])
{
i++;
j++;
}
else
{
j = next[j];
}
}
if(j == tlen)
{
return i - tlen;
}
else
{
return -1;
}
}
/*
返回模式串在主串S中出現的次數
*/
int KMP_Count()
{
int ans = 0;
int i, j = 0;
if(slen == 1 && tlen == 1)
{
if(S[0] == T[0])
return 1;
else
return 0;
}
getNext();
for(i = 0; i < slen; i++)
{
while(j > 0 && S[i] != T[j])
j = next[j];
if(S[i] == T[j])
j++;
if(j == tlen)
{
ans++;
j = next[j];
}
}
return ans;
}
int main()
{
int TT;
int i, cc;
cin>>TT;
while(TT--)
{
cin>>S>>T;
slen = strlen(S);
tlen = strlen(T);
cout<<"模式串T在主串S中首次出現的位置是: "<<KMP_Index()<<endl;
cout<<"模式串T在主串S中出現的次數爲: "<<KMP_Count()<<endl;
for(int i=0; i<=tlen; i++)
cout<<next[i]<<" ";
cout<<endl;
}
return 0;
}
其實KMP本質用的是模式串的前綴後綴匹配特性,Next數組還有其他應用,並且在僅僅是加速匹配上next數組仍有優化的空間,具體請看:KMP優化
關於優化KMP
樸素的KMP按照這個操作寫會超時,原因在與可能後臺數據有大量的類似於aaaaaaaa的相同字符長串,此處我們用到KMP優化。
可以知道,樸素的KMP中NEXT數組構造,其意義在匹配過程中表示了,如果子串在該位置失配,那麼子串匹配指針j應該回溯到的位置。這樣就保證了只回溯子串指針,而主串指針一直是增加的。
引用網上的圖表即樸素KMP對於模式串aaac的NEXT數組構造如下:
我們發現,這個看似優秀的,能夠直接將子串指針飛躍回最大前綴的NEXT數組,實際上在連續相同字母中效果不佳,如果有連續1e5個字符’a’,那麼根據NEXT的回溯,當匹配主串到aaaaaaa…aaaaab時【假設中間省略1e4個a】,這一個突如其來的b,將使子串根據NEXT數組以龜速,一個一個回溯迭代O(n)的速度,回退到第0位。我們明知道子串第i個字符和第i+1個字符已經相等了,那麼如果在第i+1位不匹配,第i位也不可能匹配的,因此爲什麼還要回退到i,直接回退到next【i】纔是飛速跳躍式的回退。
如上,這個不匹配字符b要判斷多次才能回到最初位置。
多次冗餘比較導致了KMP的低效。
對於一個樸素KMP中構造NEXT數組的模板,我們考慮做一些改動,**就像並查集中的路徑壓縮一樣。**路徑壓縮考慮將一個節點的父親直接牽引至其父親的父親,這樣避免了多次迭代查找父親。
我們考慮相鄰相同字符的next值,應該是一樣的,那麼第i位爲next【i】,若第i+1位字符等於第i位字符,則next【i+1】=next【i】
對比樸素模板和優化模板:
void kmp_pre(char x[],int m,int next[])
{
int i,j;
j=next[0]=-1;
i=0;
while(i<m)
{
while(j!=-1&&x[i]!=x[j])j=next[j];
next[++i]=++j;///樸素KMP
}
}
優化後:
void kmp_pre(char x[],int m,int next[])
{
int i,j;
j=next[0]=-1;
i=0;
while(i<m)
{
while(j!=-1&&x[i]!=x[j])j=next[j];
++i,++j;
next[i]= x[i]==x[j]?next[j]:j;///優化KMP
}
}
值得注意的是,這個優化已經改變了next數組的本質,即最大前綴後綴匹配,也就是說,我們只是爲了查詢得更快而進行數據存儲方式的優化,對於一些利用next本質的題目,這樣的優化可能會出現意想不到的錯誤,甚至反而會超時。如 POJ2752 ,因此優化KMP不能完全替代樸素KMP。