KMP 演算法(1):如何理解 KMP
http://www.61mon.com/index.php/archives/183/
系列文章目錄
KMP 演算法(1):如何理解 KMP
KMP 演算法(2):其細微之處
一:背景TOC
給定一個主字串(以 S 代替)和模式串(以 P 代替),要求找出 P 在 S 中出現的位置,即串的模式匹配問題。今天來介紹解決這一問題的常用演算法之一,Knuth-Morris-Pratt 演算法(簡稱 KMP),這個演算法是由高德納(Donald Ervin Knuth)和沃恩 · 普拉特在 1974 年構思,同年詹姆斯 ·H· 莫里斯也獨立地設計出該演算法,最終由三人於 1977 年聯合發表。
在繼續下面的內容之前,有必要在這裡介紹下兩個概念:字首和字尾。
由上圖所得, "字首" 指除了最後一個字元以外,一個字串的全部頭部組合;"字尾" 指除了第一個字元以外,一個字串的全部尾部組合。
二:樸素字串匹配演算法TOC
初遇串的模式匹配問題,我們腦海中的第一反應,必是樸素字串匹配(暴力匹配),即遍歷 S 的每個字元,以該字元為始與 P 比較,全部匹配就輸出;否則直到 S 結束。程式碼如下:
/* 字串下標始於0 */
int NaiveStringSearch(string S, string P)
{
int i = 0; //S的下標
int j = 0; //P的下標
int s_len = S.size();
int p_len = P.size();
while (i < s_len && j < p_len)
{
if (S[i] == P[j]) //若相等,都前進一步
{
i++;
j++;
}
else //不相等
{
i = i - j + 1;
j = 0;
}
}
if (j == p_len) //匹配成功
return i - j;
return -1;
}
上述演算法的時間複雜度為 ,其中 為 S 的長度, 為 P 的長度。這種時間複雜度很難滿足我們的需求,接下來進入正題:時間複雜度為 的 KMP 演算法。
三:KMP 字串匹配演算法TOC
3.1 演算法流程TOC
以下摘自阮一峰的字串匹配的 KMP 演算法,並作稍微修改。
(1)
首先,主串 "BBC ABCDAB ABCDABCDABDE" 的第一個字元與模式串 "ABCDABD" 的第一個字元,進行比較。因為 B 與 A 不匹配,所以模式串後移一位。
(2)
因為 B 與 A 又不匹配,模式串再往後移。
(3)
就這樣,直到主串有一個字元,與模式串的第一個字元相同為止。
(4)
接著比較主串和模式串的下一個字元,還是相同。
(5)
直到主串有一個字元,與模式串對應的字元不相同為止。
(6)
這時,最自然的反應是,將模式串整個後移一位,再從頭逐個比較。這樣做雖然可行,但是效率很差,因為你要把 "搜尋位置" 移到已經比較過的位置,重比一遍。
(7)
一個基本事實是,當空格與 D 不匹配時,你其實知道前面六個字元是 "ABCDAB"。KMP 演算法的想法是,設法利用這個已知資訊,不要把 "搜尋位置" 移回已經比較過的位置,而是繼續把它向後移,這樣就提高了效率。
(8)
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D | '\0' |
next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
怎麼做到這一點呢?可以針對模式串,設定一個跳轉陣列int next[ ]
,這個陣列是怎麼計算出來的,後面再介紹,這裡只要會用就可以了。
(9)
已知空格與 D 不匹配時,前面六個字元 "ABCDAB" 是匹配的。根據跳轉陣列可知,不匹配處 D 的 next 值為 2,因此接下來從模式串下標為 2 的位置開始匹配。
(10)
因為空格與C不匹配,C 處的 next 值為 0,因此接下來模式串從下標為 0 處開始匹配。
(11)
因為空格與 A 不匹配,此處 next 值為 - 1,表示模式串的第一個字元就不匹配,那麼直接往後移一位。
(12)
逐位比較,直到發現 C 與 D 不匹配。於是,下一步從下標為 2 的地方開始匹配。
(13)
逐位比較,直到模式串的最後一位,發現完全匹配,於是搜尋完成。
3.2 next 陣列是如何求出的TOC
next 陣列的求解基於 “字首” 和“字尾”,即next[i]
等於P[0]...P[i-1]
最長的相同前後綴的長度(請暫時忽視 i 等於 0 時的情況,下面會有解釋)。我們依舊以上述的表格為例,為了方便閱讀,我複製在下方了。
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D | '\0' |
next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
(1):i=0,對於模式串的首字元,我們統一為next[0]=-1
;
(2):i=1,前面的字串為A
,其最長相同前後綴長度為 0,即next[1]=0
;
(3):i=2,前面的字串為AB
,其最長相同前後綴長度為 0,即next[2]=0
;
(4):i=3,前面的字串為ABC
,其最長相同前後綴長度為 0,即next[3]=0
;
(5):i=4,前面的字串為ABCD
,其最長相同前後綴長度為 0,即next[4]=0
;
(6):i=5,前面的字串為ABCDA
,其最長相同前後綴為A
,即next[5]=1
;
(7):i=6,前面的字串為ABCDAB
,其最長相同前後綴為AB
,即next[6]=2
;
(8):i=7,前面的字串為ABCDABD
,其最長相同前後綴長度為 0,即next[7]=0
。
那麼,為什麼根據最長相同前後綴的長度就可以實現在不匹配情況下的跳轉呢?舉個代表性的例子:假如i=6
時不匹配,此時我們是知道其位置前的字串為ABCDAB
,仔細觀察這個字串,首尾都有一個AB
,既然在i=6
處的 D 不匹配,我們為何不直接把i=2
處的 C 拿過來繼續比較呢,因為都有一個AB
啊,而這個AB
就是ABCDAB
的最長相同前後綴,其長度 2 正好是跳轉的下標位置。
有的讀者可能存在疑問,若在i=5
時匹配失敗,按照我講解的思路,此時應該把i=1
處的字元拿過來繼續比較,但是這兩個位置的字元是一樣的啊,都是B
,既然一樣,拿過來比較不就是無用功了麼?其實不是我講解的有問題,也不是這個演算法有問題,而是這個演算法還未優化,關於這個問題在下面會詳細說明,不過建議讀者不要這裡糾結,跳過這個,下面你自然會恍然大悟。
思路如此簡單,接下來的問題就是程式碼實現了,如下:
/* P為模式串,下標從0開始 */
void GetNext(string P, int next[])
{
int p_len = P.size();
int i = 0; //P的下標
int j = -1;
next[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
next[i] = j;
}
else
j = next[j];
}
}
一臉懵逼,是不是。。。
上述程式碼是求解每個位置的 next 值,即求解每個位置前面字串的最長相同前後綴的長度。下面具體分析,我把程式碼分為 3 部分來講:
(1):i 的作用是什麼?
i 為模式串 P 的下標,從 0 開始,程式中我們依次求出next[i]
的值,這很簡單。
(2):j 的作用是什麼?
其一,從 if 語句中P[i] == P[j]
,它作為模式串的下標,判斷下一個字元是否相等。其二,從next[i] = j;
可以很容易推斷出,j 代表最長相同前後綴的長度。兩者在數值上正好相等。(之所以正好相等,是因為 i 和 j 初始化的時候,正好差 1,這點讀者仔細思考下就能明白的)
(3):if...else... 語句裡做了什麼?
首先我們必須要明確一個事實:若此時i = 3
,那我們接下來要求解的便是P[0]...p[3]
的最長相同前後綴的長度,也就是next[4]
,而非next[3]
,這從下面的程式碼就可以得到證明:
i++;
j++;
next[i] = j;
有了這個事實,下面具體分析:
假設 i 和 j 的位置如上圖,由next[i]=j
得,也就是對於位置 i 來說,區段 0 到 i-1 的最長相同前後綴分別是 0 到 j-1 和 i-j 到 i-1,即這兩區段內容相同。
按照演算法流程,if(P[i]==P[j])
,則i++;j++;next[i]=j;
;若不等,則j=next[j]
,見下圖:next[j]
代表 0 到 j-1 區段中最長相同前後綴的長度。如圖,用左側兩個橢圓來表示這個最長相同前後綴,即這兩個橢圓代表的區段內容相同;同理,右側也有相同的兩個橢圓。所以 else 語句就是利用第一個橢圓和第四個橢圓內容相同來加快得到 0 到 i-1 區段的相同前後綴的長度。
細心的朋友會問 if 語句中j==-1
存在的意義是何?第一,程式剛執行時,j 是被初始為 - 1,直接進行P[i]==P[j]
判斷無疑會邊界溢位;第二,else 語句中j=next[j]
,j 是不斷後退的,若 j 在後退中被賦值為 - 1(也就是j=next[0]
),在P[i]==P[j]
判斷也會邊界溢位。綜上兩點,其意義就是為了特殊邊界判斷。
四:完整程式碼TOC
/**
*
* author 劉毅(Limer)
* date 2017-03-05
* mode C++
*/
#include<iostream>
#include<string>
using namespace std;
/* P為模式串,下標從0開始 */
void GetNext(string P, int next[])
{
int p_len = P.size();
int i = 0; //P的下標
int j = -1;
next[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
next[i] = j;
}
else
j = next[j];
}
}
/* 在S中找到P第一次出現的位置 */
int KMP(string S, string P, int next[])
{
GetNext(P, next);
int i = 0; //S的下標
int j = 0; //P的下標
int s_len = S.size();
int p_len = P.size();
while (i < s_len && j < p_len)
{
if (j == -1 || S[i] == P[j]) //P的第一個字元不匹配或S[i] == P[j]
{
i++;
j++;
}
else
j = next[j]; //當前字元匹配失敗,進行跳轉
}
if (j == p_len) //匹配成功
return i - j;
return -1;
}
int main()
{
int next[100] = { 0 };
cout << KMP("bbc abcdab abcdabcdabde", "abcdabd", next) << endl; //15
return 0;
}
五:KMP 優化TOC
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D | '\0' |
next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
以 3.2 的表格為例(已複製在上方),若在i=5
時匹配失敗,按照 3.2 的程式碼,此時應該把i=1
處的字元拿過來繼續比較,但是這兩個位置的字元是一樣的,都是B
,既然一樣,拿過來比較不就是無用功了麼?這我在 3.2 已經解釋過,之所以會這樣是因為 KMP 不夠完美。那怎麼改寫程式碼就可以解決這個問題呢?很簡單。
/* P為模式串,下標從0開始 */
void GetNextval(string P, int nextval[])
{
int p_len = P.size();
int i = 0; //P的下標
int j = -1;
nextval[0] = -1;
while (i < p_len)
{
if (j == -1 || P[i] == P[j])
{
i++;
j++;
if (P[i] != P[j])
nextval[i] = j;
else
nextval[i] = nextval[j]; //既然相同就繼續往前找字首
}
else
j = nextval[j];
}
}
在此也給各位讀者提個醒,KMP 演算法嚴格來說分為 KMP 演算法(未優化版)和 KMP 演算法(優化版),所以建議讀者在表述 KMP 演算法時,最好告知你的版本,因為兩者在某些情況下區別很大,這裡簡單說下。
KMP 演算法(未優化版): next 陣列表示最長的相同前後綴的長度,我們不僅可以利用 next 來解決模式串的匹配問題,也可以用來解決類似字串重複問題等等,這類問題大家可以在各大 OJ 找到,這裡不作過多表述。
KMP 演算法(優化版): 根據程式碼很容易知道(名稱也改為了 nextval),優化後的 next 僅僅表示相同前後綴的長度,但不一定是最長(我個人稱之為 “最優相同前後綴”)。此時我們利用優化後的 next 可以在模式串匹配問題中以更快的速度得到我們的答案(相較於未優化版),但是上述所說的字串重複問題,優化版本則束手無策。
所以,該採用哪個版本,取決於你在現實中遇到的實際問題。
參考文獻:
[1] 嚴蔚敏. 資料結構(C 語言版)
[2] 阮一峰. 字串匹配的 KMP 演算法