字串匹配演算法有很多種,現在為大家帶來 BF 和 KMP 的講解,其中講 BF 是為了與 KMP 演算法做一個對比,讓本文有由淺到深這樣的一個遞進關係。
BF 演算法
查詢一個字串是否在另一個字串中,我們最容易想到的方法就是使用兩個迴圈來解決,這種演算法就叫做 BF,全稱是 Brute-Force,中文翻譯為:暴力匹配。
這是 BF 匹配的過程
BF 演算法的思路
首先設文字串為 S,模式串為 P,假設文字串匹配到 i 位置,模式串匹配到 j 位置。
1、如果當前字元匹配成功(即 S[i] == P[j]),則 i++,j++,繼續匹配下一個字元;
2、如果失配(即 S[i] != P[j]),這時令 i = i - (j - 1),j = 0。
因為 i 的位置根據上述公式計算後,往前回退了,所以叫做 i 回溯。
至於 i = i - (j - 1) 這個公式是如何推匯出來的,可以這麼理解,你往前走了 j 步,要退回到原位,那就往回走 j 步就可以了,如果是要在原來的位置上再進一步,那就往回走 j - 1 步就可以了。
當然你也可以寫成 i = i - j + 1 表示回到原點後再往前走一步,更容易理解。
3、匹配成功後,返回 i - j,可以理解為往前走那麼多步後再回到原點。
BF 演算法程式碼實現
這裡我是用 JavaScript 實現的:
// for 迴圈的寫法
let str = "abdfadfadfgrereabdgfa";
let pattern = "dfadfg";
function BF(S, P) {
for (let i = 0; i < S.length;) {
for (let j = 0; j < P.length;) {
if (S[i] === P[j]) {
i++;
j++;
} else {
i = i - (j - 1);
break;
}
if (j === P.length) {
return i - j;
}
}
}
return -1;
}
console.log(BF(str, pattern));
複製程式碼
// while 迴圈的兩種寫法
let str = "ababcdaaabbc ababc";
let pattern = "cda";
function BF(S, P) {
let i = 0; let j = 0;
while(i < S.length) {
if (S[i] === P[j]) {
i++;
j++;
} else {
i = i - (j - 1);
j = 0;
}
if (j === P.length) {
return i - j;
}
}
return -1;
}
console.log(BF(str, pattern));
function BF2(S, P) {
let i = 0; let j = 0;
while(i < S.length && j < P.length) {
if (S[i] === P[j]) {
i++;
j++;
} else {
i = i - (j - 1);
j = 0;
}
}
if (j === P.length) {
return i - j;
}
return -1;
}
console.log(BF2(str, pattern));
複製程式碼
KMP 演算法
接下來為大家介紹 KMP 演算法,KMP 的全稱是 Knuth-Morris-Pratt , 中文名:克努斯-莫里斯-普拉特,這個演算法是由 D.E.Knuth 和 V.R.Pratt 在 1974 年構思,同年 J.H.Morris 也獨立地設計出該演算法,最終由三人於 1977 年聯合發表。屬於字串匹配演算法中比較重要的一個演算法。
Donald Knuth
BF 和 KMP 的區別
我們來看一張圖對比一下 BF 和 KMP 的區別:
可以看到在失配時, BF 的 i 回溯的位置會很遠,同時 j 也會回溯到 0 位置;
而 KMP 的做法是 i 保持不變,只需要按條件設定好 j 的位置後,繼續往下匹配就可以了。
BF 的複雜度為 O(n*m)
,KMP 的複雜度為 O(n+m)
,所以 KMP 是一種比較高效的字串匹配演算法。
通過這張對比圖,我們知道,KMP 高效的原因就在於,只需要移動模式串就可以了。
那麼如何找到一種辦法來達到這個目的呢?
PMT 陣列的作用
KMP 提出了一個叫 PMT 的陣列來達到這個目的,它的全稱是 Partial-Match-Table,中文翻譯為:部分匹配表。
這個表是根據模式串生成出來的,裡面記錄的就是當失配後,模式串需要移動的位置。
我們看一下這個 PMT 長什麼樣:
可以看到模式串中的每一個字元都對應了一個數字,這個數字就是這個字元的 PMT 值,前面也說過這個 PMT 值就代表了當失配後,模式串需要移動的位置。
如何計算 PMT
那麼如何計算出 PMT 呢?答案是通過計算字串的前字尾來得出:
第一步,我們需要找到模式串的所有子串,需要按從前往後的順序來寫出所有子串(想象成一個集合,所以也會包括自己本身)。
第二步,找到每一個子串的前字尾組合,字首是不包含最後一個字元的子串集合,字尾是不包含第一個字元的子串集合,然後找到前字尾集合的交集,最後找到交集中字串長度最長的那個,這個字串的長度,就是我們需要的 PMT 值。
通過上圖可以看到,其真實模式串中每個字元對應的 PMT 值,就是這個子串的前字尾共有集合中,字串長度最大的值。
最大前字尾的小練習
接下來做一些找共有最大前字尾的小練習吧:
從這些練習中能發現什麼規律呢?沒錯,模式串的最大前字尾分別只出現在開頭和結尾,而且它們都是相等的。
那,這又代表了什麼意思呢?
到此為止,你應該能明白 PMT 的真正作用了吧!
KMP 的匹配過程
我們來看一遍 KMP 的匹配過程:
解釋一下圖中標記的三個點:
1、圖中 v 代表 PMT。
2、失配時,找 j - 1 位的 PMT 值,圖中是 2,即此時 PMT[j - 1] = 2。圖中標紅的字元,就是已經匹配過的子串 dfadf 中的前字尾。
3、接下來保持 i 不變,把 j 的值設為 PMT[j-1],也就是 2,這步操作就是把模式串的字首對齊了已匹配串的字尾,這就是所說的充分利用已知資訊來提高匹配效率。
PMT 陣列用途總結
共有最大前字尾分別處於原字串中的開始和結尾處,我們可以利用這個性質來記錄已知資訊,也就是已匹配過的字元,如果有大於 0 的 PMT 值,說明已匹配過的地方有需要再匹配避免遺漏的,否則都是 0 的話,j 就簡單地回溯到 0 就可以了,這就是 PMT 陣列的用途。
生成 PMT 的思路
知道 PMT 的用途後,那我們如何使用程式來生成 PMT 呢?
思路:
1、
2、
3、
4、
5、從匹配進入到失配狀態時 i 的回溯稍微有點複雜:
6、這時因為 i = 0 了,就可以進行比較了,如果 i != 0 那麼還要繼續重複第 5 和第 6 的過程。
程式計算的大致過程就是這樣的,你可以再做幾個小練習來鞏固一下:
KMP 演算法的程式碼實現
下面就是根據以上思路用程式來實現演算法了:
let str = "abdfadfadfgrereabdgfa";
let pattern = "dfadfg";
// 求 PMT
function partialMatchTable(P) {
// 初始條件
let PMT = [], i = 0, j = 1;
PMT[0] = 0;
while(j < P.length) {
if (P[i] === P[j]) {
i++;
PMT[j] = i;
j++;
} else {
if (i !== 0) {
i = PMT[i - 1];
} else {
PMT[j] = 0;
j++;
}
}
}
return PMT;
}
// KMP 主體
function KMP(S, P) {
let i = 0, j = 0, TMP = partialMatchTable(P);
while (i < S.length && j < P.length) {
if (S[i] === P[j]) {
i++;
j++;
} else {
if (j !== 0) {
j = TMP[j - 1];
} else {
i++;
}
}
}
if (j === P.length) {
return i - j;
}
return -1;
}
console.log(KMP(str, pattern));
複製程式碼
本文在寫作過程中參考了很多優秀的文章和視訊,在此對他們的幫助表示感謝。
如果你在看本文時發現有不懂的地方或者是有不正確的地方,歡迎評論告訴我,我會繼續修訂。