KMP算法是一種改進的字符串匹配算法,由D.E.Knuth
,J.H.Morris
和V.R.Pratt
提出的,因此人們稱它爲克努特—莫里斯—普拉特
操作(簡稱KMP
算法)。
累贅一下,KMP算法是字符串匹配算法,比如搜索字符串“abcdefg”
中是否含有子串“bcd”
。
1、KMP
算法引入
假設給你兩個字符串strOne
=“abababaabc
”、strTwo
= “ababaab
”,要求你判斷strOne
字符串中是否含有子串strTwo
。
可能你的第一反映是,遍歷一遍strOne
,依次搜索strTwo
,時間複雜度爲O(m*n)
級別(m、n分別爲兩個字符串的長度)。也就是俗稱的暴搜法
,而KMP
算法是一種時間複雜度在O(m+n)
級別的高效算法。
暴力匹配僞代碼
//第一層循環,依次設置strOne字符串中的匹配起始下標
//注:m爲strOne的長度,n爲strTwo的長度
for (int i = 0; i < m - n; ++i) {
int j = 0;
//從strOne[i]處開始匹配字符串strTwo
while (j < n && strOne[i + j] == strTwo[j]) {
j += 1;
}
//j到達strTwo的尾端時,說明在strOne[i]處開始,成功匹配到了一個strTwo子串
if (j == n) {
cout << "strOne中包含子串strTwo" << endl;
break;
}
}
//如果蠻力法都看不懂,那我真的沒辦法了。。。
在介紹KMP
算法前,我們先搞明白暴搜法費勁的地方在哪裏。
下標: 0123456789
strOne = "abababaabc"
strTwo = "ababaab"
根據蠻力法的思路,
第一輪匹配:當i = 0爲strOne的起始匹配下標時,我們最多能夠匹配到j = 4,
也就是我們只能成功匹配strTwo的前5個字符,因爲strOne[i + 6] != strTwo[5],
第二輪匹配:接着我們得將i自增1,即i = 1爲strOne的起始匹配下標,重新開始匹配
下標: 0123456789
strOne = "abababaabc"
strTwo = "ababaab"
顯然strOne[i] != strTwo[0],即j = 0就開始不匹配了
第三輪匹配:接着我們得將i自增1,即i = 2爲strOne的起始匹配下標,重新開始匹配
下標: 0123456789
strOne = "abababaabc"
strTwo = "ababaab"
根據while循環,此次能完全匹配strTwo,查找成功
缺陷:每次當strOne[i + j] != strTwo[j]
時,即匹配不下去(比如,第一、二輪匹配),我們選擇調整i自增,然後又重新開始匹配。等於就是說每次我們好不容易成功匹配了幾個字符後,突然發生斷裂,這時我們需要重頭開始做,那之前做的匹配工作能不能利用起來呢?
2、next[i]
引入(非常重要)
何爲字符串前綴,比如字符串str="abab"
前綴有"a"、"ab"、"aba"
、"abab"
何爲字符串後綴,比如字符串str="abab"
後綴有"b"、"ab"、"bab"
、"abab"
那麼str="abab"
的相等的最長前、後綴分別爲 "ab"
、"ab"
,前綴"ab"
最後一個字符’b’的下標爲1
,所以字符串str="abab"
的next[3] = 1
。
再舉幾個栗子
比如str="abcda"
,str[0, 4] = ”abcda“的最長相等前、後綴分別爲"a","a"
,即next[4] = 0(前綴"a"最後一位的下標爲0)
比如str="abcdab"
,str[0, 4] = ”abcda“的最長相等前、後綴分別爲"a","a"
,即next[4] = 0(前綴"a"最後一位的下標爲0)
注意i == 4
,限定了子串str[0, 4]
的範圍"abcda"
比如str="abcdab"
,str[0, 5] = ”abcdab“的最長相等前、後綴分別爲"ab","ab"
,即next[5] = 1(前綴"ab"最後一位的下標爲1)
比如str="ababac"
,str[0, 4] = ”ababa“的最長相等前、後綴分別爲"aba","aba"
,即next[4] = 2(前綴"aba"最後一位的下標爲2)
比如str="ababac"
,str[0, 5] = ”ababac“的最長相等前、後綴分別爲"",""
,沒有滿足的前後綴,因此next[5] = -1(也可以理解爲空前綴""最後一位的下標爲-1)
檢驗一下你是否理解了next[i]
的計算規則,
請計算str="ababa"各個next[i]組成的next數組。
(拿出紙筆來畫畫唄~)(拿出紙筆來畫畫唄~)(拿出紙筆來畫畫唄~)
答案爲next[-1, -1, 0, 1, 2]
答 防 當i = 0時,str[0, 0] = "a",next[i]的最長相等前、後綴分別爲"","",
案 偷 注意前後綴不能選自己"a",因此next[0] = -1
在 窺 當i = 1時,str[0, 1] = "ab",next[i]的最長相等前、後綴分別爲"","",
右 😂 同樣前後綴不能選自己"ab",因此next[1] = -1
邊 當i = 2時,str[0, 2] = "aba",next[i]的最長相等前、後綴分別爲"a","a",
同樣前後綴不能選自己"aba",前綴"a"的最後一個字符下標爲0,因此next[2] = 0
請 防 當i = 3時,str[0, 3] = "abab",next[i]的最長相等前、後綴分別爲"ab","ab",
向 偷 同樣前後綴不能選自己"abab",前綴"ab"的最後一個字符下標爲1,因此next[3] = 1
右 窺 當i = 4時,str[0, 4] = "ababa",next[i]的最長相等前、後綴分別爲"aba","aba",
滑 😂 同樣前後綴不能選自己"ababa",前綴"aba"的最後一個字符下標爲2,因此next[4] = 2
因此next = [-1, -1, 0, 1, 2]
到目前爲止,我們只介紹了next[i]
這一個概念,如果你不能正確計算出str="ababa"
的next
數組,請不要往下看,否則只會越看越迷糊。
3、
在上面計算str="ababa"
各個next[i]
時,我們每次都是取出str[0, i]
,然後找出最長的相等的前、後綴,才能得到前綴的最後一個字符的下標。仔細思考一下,這裏有一個遞推公式我們沒有發現!
上面的圖,我們莫名其妙的映入了變量j
,並且兩次變量j
的初始化都設置的很巧妙,下面我們來解釋一下變量j
的含義。
變量j
的含義
我們引入變量j
的主要目的是在計算next[i]
時,使用next[i - 1]
的成果,找個變量j = next[i - 1]
,那麼next[i]
的值爲j + 1
,即迭代公式next[i] = next[i - 1] + 1
。
但是這個迭代公式使用有一個條件str[i] == str[j + 1]
,(計算next[3]時i、j的值爲i = 3, j = next[i - 1] = next[2] = 0
)
也就是說變量j
的作用是在計算next[i]
的時候,存儲str[0, i - 1]
的最長相等前後綴的前綴的最後一個字符的下標,即next[i - 1]
,即j = next[i - 1]
那麼在計算next[2]
的時候,爲啥j
初始化賦值爲 -1
呢?
根據上面說的j = next[i - 1] = next[1]
,對於str[0, 1] = "ab"
,不難計算next[1] == -1
吧?
注意,對於任何字符串next[0] = -1
,因爲字符串string[0, 0]只有一個字符,next[i]
的計算時,前後綴不能取自身
得出一個結論,
當str[i] == str[j + 1]
時,next[i] = j + 1
結論的證明思路:
既然結論next[i] = j + 1
的使用需要條件str[i] == str[j + 1]
。
str[i] != str[j + 1]
時如何處理?
當str[i] != str[j + 1]
,此時就不能利用next[i - 1]
的結果了。
比如str = "ababc"
,計算next[4]
next數組快速計算代碼實現
vector<int> getNexts(const string &str) {
vector<int> next(str.size());
//對於一個字符的子串,最長相等前後綴爲空,賦值爲空
next[0] = -1;
for (int i = 1, j = -1; i < str.size(); ++i) {
while (j != -1 && str[i] != str[j + 1]) {
//如果str[i] != str[j + 1],只能一直往前退next[j]
j = next[j];
}
if (str[i] == str[j + 1]) {
//如果有匹配成功了一個字符,則next[i] = j + 1
//否則next[i] = -1,(匹配不成功的時候j必定-1(否則可以繼續j = next[j]),即可賦值next[i] = j = -1,)
j += 1;
}
next[i] = j;
}
return next;
}
4、KMP算法
寫了這麼多的篇幅,終於到了介紹KMP的關鍵時刻。
給定示例 strOne = "ababaabc"
,strTwo = "abaab"
,判斷strOne
是否包含子串strTwo
。
算法的關鍵:匹配出現斷裂如何處理?
算法的重點在第5步,當出現已經匹配完子串strTwo[0, j]
,匹配strTwo[j + 1]
字符,出現斷裂時,此時j倒退到j = next[j]
,也就是j
倒退到strTwo[0, j]
的最長相等前後綴的前綴最後一個字符的下標位置next[j]
(蠻力法是直接倒退到-1
,重新開始匹配。)
那麼問題來了,爲什麼j退到next[j]
是最優的呢?
因爲我們在將strTwo[j]
匹配成功的時候,
strTwo[0, j]
這一段已經和strOne[0, i]
的後綴匹配成功了。
而strTwo[0, j]
的前綴strTwo[0, next[j]]
(想一下next[j]
的定義,最長相等前、後綴的前綴最後一個字符的下標)
又因爲strTwo[0, j]
與strOne[0, i]
的後綴相等,也就是說strTwo[0, next[j]]
是strOne[0, i]
的更短的一個後綴。
這就能保證strTwo[0, next[j]]
是strTwo
當前能夠匹配的最長前綴。
假設我們已經將j
退回到next[j]
的位置,我們還得期望strTwo[j + 1] == strOne[i]
,這樣我們就能連續匹配strTwo[0, j + 1]
,否則我們把j退回就沒有什麼實質意義。
比如第5步,j
回退到next[j] = 0
時,由於strTwo[j + 1] == strOne[i]
,所以執行完第5的時候,已經成功連續匹配了strTow[0, 1]
。
那麼問題又來了,j
退到next[j]
,strTwo[j + 1]
!= strOne[i]
怎麼辦
前面已多次強調,一直重複賦值j = next[j]
(一直後退j),直到出現strTwo[j + 1]
== strOne[i]
,
如果j = -1
還不滿足strTwo[j + 1]
== strOne[i]
,也就是j
退到strTwo
的起始位置都不滿足條件,此時只能後移i
,期待後面能匹配成功。
KMP算法的代碼實現
//判斷字符串strOne是否包含子串strTwo
bool kmp(const string &strOne, const string &strTwo) {
//先計算字符串strTwo的next數組
vector<int> strTwoNext = getNexts(strTwo);
for (int i = 0, j = -1; i < strOne.size(); ++i) {
while (j != -1 && strOne[i] != strTwo[j + 1]) {
//如果str[i] != str[j + 1],只能一直往退j
j = strTwoNext[j];
}
if (strOne[i] == strTwo[j + 1]) {
//匹配成功了一個字符,則j後移
j += 1;
}
if (j == strTwo.size() - 1) {
//j已經到達了strTwo的尾端,說明以及成功連續匹配strTwo
return true;
}
}
return false;
}
總算是寫完了,修改了三四次。。。
KMP算法關鍵是理解next[j]
的定義,j = next[j]
的含義,並不怎麼難理解。
如果有什麼疑問歡迎在評論區討論。