相信不少人在學資料結構的時候都被KMP演算法搞的迷迷糊糊的,原理看的似懂非懂,程式碼寫不出來,或者寫出來了也不知道為什麼就可以這麼寫。本文力求儘可能通俗詳細的講解KMP演算法,讓你不再受到KMP演算法的困擾。
暴力匹配的痛點
所謂暴力匹配,就是從文字串的首端開始依次檢查子串是否與模式串匹配,如果不匹配就將模式串往後移一個位置,從頭開始匹配,直到在某處成功匹配或匹配到末尾也沒能成功匹配。如下圖:
設文字串為T,模式串為P,i為文字串中的下標,j為模式串中的下標,文字串的長度為m,模式串的長度為n,則程式碼如下:
int bruteForce(std::string t, std::string p) {
int i = 0, j = 0;
int m = t.length(), n = p.length();
while (i < m && j < n) {
if (t[i] == p[j]) {
i++; j++;
} else {
i = i - j + 1;
j = 0;
}
}
return j == n ? i - j : -1;
}
那麼暴力匹配的時間效率如何呢?不難發現,每一次匹配中,我們都需要花費\(O(n)\)的時間成本來判斷子串是否與模式串匹配,而總共的判斷次數最多為\(m-n+1\),由於實際情況下有\(m>>n\),因此\(m-n+1\)近似等於\(m\),整個暴力匹配的時間複雜度為\(O(mn)\),顯然不理想。
經過觀察,我們不難發現,暴力匹配方法做了很多次不必要的匹配。在第一輪發現不匹配的時候,我們無需只將模式串後移一個位置,而是後移到文字串中下標為3的位置(第二個A),並直接從文字串中下標為5的位置(第2個C)開始匹配。從相對運動的角度來講,也就是將j前移為2,而i不用回退。
KMP演算法
事實上,之所以這麼做,是因為模式串中j前面的某些字元恰好與模式串的某個字首相等。如果你想到了這點,那你的想法剛好就跟發明KMP演算法的那三個人的想法一樣了(認真)。KMP演算法就利用了這一點,每次匹配失敗的時候不直接從頭開始繼續匹配,而是將j回溯到這個字首後面的字元,而i不用回退,以解決暴力匹配演算法的這一痛點。如圖:
構建next陣列
為了應對各種匹配失敗的情況,我們需要另開一個與模式串等長的陣列next
,其中next[j]
表示P[j]
與T[i]
匹配失敗的情況下,j要移動到的下標。(顯然,對於任意的j,一定有next[j] < j
)按照上面那個性質,P[next[j]]
之前的p個字元也與P[j]
左邊的p個字元相等。(其中p為P[next[j]]
之前的字元數量)(這一點非常重要,可以說是next
陣列構建演算法的靈魂!)
接下來的一個問題就是,如何判定某次匹配過程失敗後,j該移到哪個位置呢?
我們可以用遞推的思路來求解。
考慮模式串的第一個字元就不與文字串中的相應字元匹配的情況。如圖:
這個時候我們需要將i往後移,不妨將next[0]
設為-1。(後面你就會看到這樣做自有其精妙之處)
再來考慮next[k]
已知的情況,如何求得next[k+1]
呢?分兩種情況討論:
第一種情況,P[k]==P[next[k]]
,如下圖。由上面那條性質,P[k]
之前的p個字元與P[next[k]]
之前的p個字元相等。而P[k]
又是等於P[next[k]]
的,因此,P[k+1]
之前的p+1個字元與P[next[k]+1]
之前的p+1個字元相等。所以,next[k+1]
應該設為next[k]+1
,以符合上面那條性質。
第二種情況,P[k]!=P[next[k]]
,如下圖。(這裡我用不同的顏色標出來了)
怎麼辦呢?再考慮P[next[next[k]]]
與P[k]
之間的關係。
此時的思路與上面相似,如果P[k]==P[next[next[k]]]
,就將next[k+1]
設為next[next[k]]+1
,否則就依次檢查next[next[next[k]]]
、next[next[next[next[k]]]]
、...
不難看出,接受檢查的下標是依次遞減的,但是遞減也得有個限度;另外next[0]
永遠為-1,因此遞減到-1的時候,就說明一直檢查到P的第一個字元也沒檢查到與P[k]
相等的字元。此時next[k+1]
前面有0個字元與P中長度為0的字首相等。因此j需要回溯到0,將next[k+1]
設為0。
將以上思路稍作整理,可得在next[k]
已知的情況下,求得next[k+1]
的步驟:
- 令t為
next[k]
。 - 如果t等於-1,就將
next[k+1]
設為0。 - 否則,檢查
P[k]
是否等於P[t]
。如果等於,就將next[k+1]
設為t+1;否則,將t設為next[t]
,跳轉到第2步。
細心的你可能已經發現了,既然next[0]
為-1,-1再加上1剛好也等於0,因此兩個條件可以合併起來,上述步驟可以最佳化一下:
- 令t為
next[k]
。 - 如果t等於-1,或者
P[k]
等於P[t]
,就將next[k+1]
設為t+1。 - 否則,將t設為
next[t]
,跳轉到第2步。
現在你應該看到將next[0]
設為-1這種做法的巧妙之處了吧!
這樣,由於next[0]
事先約定為-1,而由next[0]
可以求得next[1]
,由next[1]
可以求得next[2]
...,因此我們就可以得出構建next
陣列的步驟:
- 初始化
next
陣列,令其長度為n。 - 將
next[0]
設為-1。 - 初始化k為0,迴圈執行以下步驟,每次迴圈完k就加一,如果k加到了n-1就退出迴圈。
- 令t為
next[k]
。 - 如果t等於-1,或者
P[k]
等於P[t]
,就將next[k+1]
設為t+1。 - 否則,將t設為
next[t]
,跳轉到第5步。
程式碼實現:
std::vector<int> buildNext(std::string p) {
int n = p.length();
std::vector<int> next(n);
next[0] = -1;
for (int k = 0; k < n - 1; k++) {
int t = next[k];
while (t != -1 && p[k] != p[t]) {
t = next[t];
}
next[k + 1] = t + 1;
}
return next;
}
KMP主演算法
有了next
陣列,一切都好辦了。
每次匹配的時候,如果匹配成功了就i與j同時往後移一個位置,匹配失敗的話j設為next[j]
。如果j為-1的話,i就往後移,同時j設為0。
int kmp(std::string t, std::string p) {
int m = t.length(), n = p.length();
int i = 0, j = 0;
auto next = buildNext(p);
while (i < m && j < n) {
if (j < 0 || t[i] == p[j]) {
i++; j++;
} else {
j = next[j];
}
}
return j == n ? i - j : -1;
}
複雜度分析
不難看出,KMP演算法的空間複雜度(不計T和P本身所佔的記憶體空間)為\(O(n)\),這是來自next
陣列所佔用的空間開銷。
那麼時間複雜度為多少呢?網上大多數博文直接在這裡放個結論,缺少必要的分析,讀者只是知道了結論,至於為什麼是這樣則是一頭霧水。
整個KMP演算法的時間複雜度分為以下兩部分:
- 構建
next
陣列的時間複雜度; - 匹配的時間複雜度。
其中,構建next
陣列的時間複雜度為多少呢?
這主要取決於給next
陣列各項賦值的時間複雜度和對t賦值的次數。
顯而易見,前者的時間複雜度為\(O(n)\)。那後者的時間複雜度怎麼計算呢?
注意到,每次for迴圈的結尾,有一個next[k + 1] = t + 1;
的語句,而下一次for迴圈開始時,由於k自增了1,因此int t = next[k];
裡的next[k]
其實就是上一次迴圈裡的next[k + 1]
,這條語句執行後的新t其實就是舊t加上1,可以等效的認為對t進行了一次++
運算。顯而易見,t++
的次數為n-1。而while迴圈裡面t = next[t];
的最壞次數怎麼計算呢?我們知道,next[t]
是必然小於t的,所以這條語句執行後t是要往回跳的。但是跳一次跨越的步數是大於等於1的,而往回跳的極限是-1,所以同樣的長度,往前跳的次數是n-1,往後跳的次數必然不超過n-1,所以對t賦值的次數(不如說是t跳躍的次數)不會超過2n-2,當然就是\(O(n)\)量級的。所以,構建next
陣列的時間複雜度為\(O(n)\)。
而匹配的時間複雜度又是多少呢?
這主要取決於while迴圈執行的次數,而while迴圈是否執行取決於i和j的取值,因此這也取決於對i和j賦值的次數。
對i賦值的操作只有i++
這一條語句,顯然這條語句最多會執行m次。
對j的賦值(或者說是跳躍)呢,分析思路與上述類似,包括往前跳躍(j++
)和往後跳躍(j = next[j]
)。其中前者是與i“攜手並進”的,因此執行次數也不會超過m。往後跳躍的次數同樣不會超過往前跳躍的次數(原因與上述分析一致)。因此,j的跳躍次數也是\(O(m)\)量級的。
因此,匹配的時間複雜度是\(O(m)\)。
綜上所述,整個KMP演算法的時間複雜度為\(O(m+n)\),比暴力演算法的\(O(mn)\)要好得多。
這就完美了嗎?
考慮下面的情況:
文字串:AAAABAAAAA
模式串:AAAAA
如果我們用KMP演算法進行匹配的話,會由於T[4] != P[4]
發生一次匹配失敗:
根據next陣列的指示,將會由P[3]
繼續匹配T[4]
:
然後是P[2]
、P[1]
、P[0]
,最後因為P[0]
與T[4]
匹配失敗而開始T[5]
與P[0]
的比對。
但是,明眼人一眼就能看出,T[4]
與P[4]
比對失敗後可以直接進行T[5]
與P[0]
之間的比對,不需要進行T[4]
與P[3]
、P[2]
...P[0]
之間的比對了,因為P[4]
和P[3]
、P[2]
...P[0]
是一樣的,既然T[4]
與P[4]
比對失敗了,那麼T[4]
與P[3]
、P[2]
...P[0]
之間的比對就一定會失敗,就像推銷員給你推銷某樣產品,你不感興趣,對方一直喋喋不休,只會讓你感到厭煩。
改進
那怎樣才能在一次比對失敗後不再比對P中相同的字元,而是從不相同的字元開始比對呢?換句話說,如何在比對失敗後,能夠讓j一次性跳轉到不一樣的字元呢?我們只需要對構建next陣列的程式碼稍作修改。在給next[j+1]
賦值的時候,我們還需要檢查P[k+1]
是否等於P[t+1]
。如果等於的話,就賦值為next[t+1]
。否則才賦值為t+1
。如圖:
但是直接這樣改的話,每次for迴圈後的t就不一定等於上一次迴圈的t加1了,所以我們要顯式的維護變數t。
std::vector<int> buildNext() {
int n = p.length();
std::vector<int> next(n);
next[0] = -1;
int t = -1;
for (int k = 0; k < n - 1; k++) {
while (t != -1 && p[k] != p[t]) {
t = next[t];
}
next[k + 1] = p[k + 1] == p[t + 1] ? next[t + 1] : t + 1;
t++;
}
return next;
}
顯然,時間複雜度是不變的,但是因為跳躍次數減少了,整個演算法的效率也會提升。