字符串匹配KMP算法詳解(這可能是東半球最好理解的解釋)

KMP算法是一種改進的字符串匹配算法,由D.E.KnuthJ.H.MorrisV.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]引入(非常重要)

next[i]str[0,i]\color{red}next[i]記錄字符串str[0, 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

next[i]\color{red}next[i]計算需要排除自身,即前、後綴不能選自己!

再舉幾個栗子
比如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、next[i]\color{blue}next[i]計算方式的優化

在上面計算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]的含義,並不怎麼難理解。
如果有什麼疑問歡迎在評論區討論。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章