字符串匹配算法 -- BM(Boyer-Moore) 和 KMP(Knuth-Morris-Pratt)详细设计及实现
文章目錄
- 1. 算法背景
- 2. BM(Boyer-Moore)算法
- 2.1 壞字符規則(bad character rule)
- 2.2 好后綴規則(good suffix shift)
- 2.3 復雜度及完整代碼
- 3. KMP(Knuth Morris Pratt)算法
- 3.1 好前綴 和 壞字符規則
- 3.2 高效構建 失效函數
- 3.3 復雜度及完整代碼
- 4. 總結
1. 算法背景
文本編輯器/搜索引擎 隨著處理的數據量的不斷增加,對字符串匹配的效率要求越來越高,字符串匹配的效率也在不斷演進。
傳統的字符串匹配算法:BF(Brute Force) 和 RK(Rabin Karp) 算法 都消耗大量的時間。其中BF 比較粗暴,會將主串中的每一個字符都和模式串進行比較。RK在其基礎上做了優化,雖然不再一個一個字符比較,而是從主串中構建模式串長度的子串hash,從而達到高效的hash比較,但是構建每個子串的hash值的過程同樣需要取m-n+1個子串,并且對其計算hash,代價同樣巨大。
以上兩種字符串匹配算法滿足不了我們在大數據量下文本編輯器的查找性能(vim / less / more 的內容查找),對字符串匹配高效性能的需求 促進了BM/KMP這樣的高效匹配算法的出現。核心匹實現 還是說 每一次發現主串和模式串不匹配的字符之后,盡可能在主串中移動多個一定不會匹配的字符。
當然,目前BF,RK,BM,KMP 這樣的算法能夠在單模式串之間完成匹配,也就一個主串,一個模式串之間的匹配。對于 文本編輯器,像VIM這樣的 還需要有多模式串之間的高性能匹配需求,也就是一個主串多個模式串之間的匹配。所以,Trie樹這樣的數據結構 以及 AC 自動機這樣的高效算法 應運而生,并且他們被廣泛應用在了搜索引擎之中。
本篇將帶你欣賞 高效的單模式串匹配算法 BM和KMP。
2. BM(Boyer-Moore)算法
像背景中介紹的BF/RK 算法,核心還是對主串中的一個一個字符進行匹配,如下圖,如果第一次匹配到c發現模式串對應下標為d不匹配了,則會回到主串的第二個字符作為重新開始匹配的起始字符。
更加高效的匹配方式應該是跳過一定不會匹配的字符,比如上圖中主串的b和c ,直接將主串的起始匹配字符向后移動到a開始進行匹配,這樣能夠有效減少匹配次數。
所以BM算法是為了找到能夠保證不會漏過匹配可能性的情況下 高效移動的規律。
主要就是兩種規則:
- 壞字符
- 好后綴
2.1 壞字符規則(bad character rule)
一般情況下,我們完成字符串匹配的過程是從左向右進行匹配的,比較符合我們的思維習慣,而壞字符規則 是反向 從右向左進行匹配。
先匹配最右邊的字符,發現主串c和模式串d不匹配,則認為c是壞字符,接下來模式串的移動將向后滑動三位,移動到壞字符之后。仍然從右向左進行匹配,發現主串中的a和模式串的d不匹配。
接下來的移動則不能直接移動到壞字符之后,因為模式串中也有一個a,如果我們直接移動到主串壞字符之后,就錯過了匹配, 應該將模式串向后滑動兩位,如下:
所以壞字符規則下的算法移動方式如下:
- 從右向左匹配模式串和主串,發現壞字符
ch時記錄 此時對應模式串的下標Si - 檢測模式串中是否有ch,有的話將其下標記錄
Xi,否則記錄Xi = -1 - 移動時 直接移動
Si - Xi的長度
比如上圖 的算法移動方式如下:
- 第一次發現壞字符時
Si=2;Xi=-1,則直接將模式串滑動Si-Xi=3 - 第二次發現壞字符時
Si=2;,因為模式串存在字符a,所以Xi=0,最后移動Si-Xi=2次 - 第三次 Match
實現代碼:
generateBC函數會預先記錄模式串中每一個字符的index,如果有兩個一樣的字符,將后一個字符的下標作為index(防止匹配漏掉)bm_alg函數中先從右向左,發現不匹配的字符,記錄該字符對應的模式串下標; 從mp中取壞字符在模式串中存在的index,相減即可。
// storage the des's position
//
// @params:
// des: destination string
// mp: update the relationship between char in
// des with it's index in des
//
// examples:
// s t r _ e x a m p l e
// 0 1 2 3 4 5 6 7 8 9 10
//
// mp['s'] = 0
// ...
// mp['e'] = 10
// ....
void generateBC(string des, map<char ,int> &mp) {int i;for(i = 0;i < des.size(); i ++) {// and the map relationship between des's// element with it's positionmp[des[i]] = i; }
} int bm_alg(string src, string des) {map<char ,int> last_pos;generateBC(des, last_pos);int i=0;int src_len = src.size();int des_len = des.size();while (i <= (src_len - des_len)) {int j;// find the bad charfor (j = des_len - 1; j >= 0; j--) {if (src[i+j] != des[j]) {break;}}if (j < 0) {return i;}// bad char's move positionint x = j - last_pos[src[i+j]];// move the max of position between badchari = i + x;}return -1;
}
壞字符實現的最好時間復雜度能夠達到O(n/m),尤其是主串和模式串有類似特點的場景:aaabaaabaaabaaab 和 aaaa 這種情況下的匹配時十分高效的。
但是壞字符并不能處理所有的匹配情況,比如aaaaaaaaaaaaa和baaa這種情況,使用壞字符規則 下的滑動位置會變成負數(Si=0,Xi=3,滑動-3,顯然不合理),所以BM算法還需要好后綴規則。
2.2 好后綴規則(good suffix shift)
當我們從右往左遍歷模式串時找到壞字符時,前面已經相同的部分表示好后綴。
通過好后綴方式移動的算法有兩種:
- 如果好后綴在模式子串v 中的另一個位置還存在v’,則將模式子串的v’ 和主串的好后綴對齊。
- 如果好后綴不存在于模式串中的其他位置,則判斷好后綴的后綴子串 是否包含在模式串的前綴子串中;包含,則移動模式串 使后綴子串和匹配的前綴子串對齊;否則,按照壞字符規則移動。
對于第一種算法,移動方式如下:
找到好后綴在模式串的起始下標Si,以及另一個在模式串中匹配的好后綴的起始下標Xi,最終移動Si - Xi。
為什么會有第二種判斷好后綴的后綴子串的過程呢?如下匹配:
但實際上,這種情況已經過度滑動了,按照第一種移動方式會錯過正確的結果。
第二種辦法是取好后綴的后綴子串,判斷其是否能夠和模式串的前綴子串匹配,匹配則移動到兩個子串相匹配的位置。
后綴子串表示最后一個字符相同,前綴子串表示第一個字符相同;比如:abcd的后綴子串為 d,cd,bcd;對應的前綴子串為 a,ab,abc。這兩個指標是固定的,且能夠在開始匹配前快速初始化模式串的所有后綴子串和前綴子串 ,已經其是否匹配。
構建過程如下:
- suffix 數組下標表示 后綴子串的長度,值表示后綴子串在模式串中匹配的另一個子串的起始位置。比如
suffix[1]=2,后綴子串b長度為1,模式串中還有另一個b,則2表示這個b的其實下標。 - Prefix 數組 的bool值表示這個后綴子串是否和前綴子串匹配。
構建兩個數組的代碼如下:
// generate suffix and prefix arr
//
// @params:
// des: destination str
// suffix: the first position of the suffix str in des
// prefix: if the suffix exist in prefix of des, update
// prefix arr vector<bool> to true
//
// examples:
// des: b a b c a b
// pos: 0 1 2 3 4 5
// suffix: b, ab, cab, bcab, abcab
// suffix[1] = 0
// suffix[2] = 1
// suffix[3] = -1
// suffix[4] = -1
// suffix[5] = -1
//
// prefix[1] = true
// prefix[2] = true
// prefix[3] = false
// ...
void generateGS(string des, vector<int> &suffix,vector<bool> &prefix) {int i;int des_len = des.size();for (i = 0;i < des_len; i++) {suffix.push_back(-1);prefix.push_back(false);}// traverse the prefix str , update the suffix vector // and prefix vectorfor (i =0; i< des_len - 1; i++) {int j = i; // prefix start indexint k = 0;while(j >= 0 && des[j] == des[des_len - 1 -k]) {--j;++k;suffix[k] = j + 1;}if (j == -1) {prefix[k] = true;}}
}
完整好后綴的匹配規則 整體以盡可能短得方式移動:
- 判斷好后綴u是否包含在模式串中的其他位置u’, 移動到u’的起始位置,結束。
- 判斷好后綴的后綴子串v 是否包含在模式串的其他位置v’,并且和前綴子串匹配,則移動到前綴子串的起始位置,結束。
- 移動模式串長度 m 個位置,結束。
移動代碼如下:
// move the des str to a position
//
// @params:
// j: bad char's index
// des: destination string
// suffix: suffix in des's start index
// prefix: wether the suffix math with prefix
//
// case:
// src: a b c a c a b c b c b a c a b c
// des: c b a c a b c
int movePosition(int j, string des, vector<int> suffix, vector<bool> prefix) {// case1: move to the longest len of sufix// 'k' is the good suffix's start index// 'j' is the bad char's indexint k = des.size() - 1 - j;if (suffix[k] != -1) {return j - suffix[k] + 1;}// case2: move to other suffix position//// longest suffix's range [j+1, len(des)-1]// other suffix's range [j+2, len(des)-1]for (int r = j + 2; r <= des.size() - 1; r++) {if (prefix[des.size() - r] == true) {return r;}}// case3: move the len of desreturn des.size();
}
在好后綴的移動規則和壞字符的移動規則之間取最大的移動位置來移動即可(好后綴的移動是普遍大于壞字符的移動方式),也就是好后綴的規則可以單獨使用。
2.3 復雜度及完整代碼
- 空間復雜度:使用額外的兩個vector和一個map來保存中間數據。其大小與模式串的長度m有關。
- 時間復雜度:
- A new proof of the linearity of the Boyer-Moore string searching algorithm 證明最壞場景上限為O(5n)
- Tight bounds on the complexity of the Boyer-Moore string matching algorithm 證明最壞場景上限為O(3n)
source_code: https://github.com/BaronStack/DATA_STRUCTURE/blob/master/string/bm_alg.cc
完整代碼實現如下:
#include <iostream>
#include <map>
#include <string>
#include <vector>#define MAP_SIZE 256using namespace std;// storage the des's position
//
// @params:
// des: destination string
// mp: update the relationship between char in
// des with it's index in des
//
// examples:
// s t r _ e x a m p l e
// 0 1 2 3 4 5 6 7 8 9 10
//
// mp['s'] = 0
// ...
// mp['e'] = 10
// ....
void generateBC(string des, map<char ,int> &mp) {int i;for(i = 0;i < des.size(); i ++) {// and the map relationship between des's// element with it's positionmp[des[i]] = i; }
} // generate suffix and prefix arr
//
// @params:
// des: destination str
// suffix: the first position of the suffix str in des
// prefix: if the suffix exist in prefix of des, update
// prefix arr vector<bool> to true
//
// examples:
// des: b a b c a b
// pos: 0 1 2 3 4 5
// suffix: b, ab, cab, bcab, abcab
// suffix[1] = 0
// suffix[2] = 1
// suffix[3] = -1
// suffix[4] = -1
// suffix[5] = -1
//
// prefix[1] = true
// prefix[2] = true
// prefix[3] = false
// ...
void generateGS(string des, vector<int> &suffix,vector<bool> &prefix) {int i;int des_len = des.size();for (i = 0;i < des_len; i++) {suffix.push_back(-1);prefix.push_back(false);}// traverse the prefix str , update the suffix vector // and prefix vectorfor (i =0; i< des_len - 1; i++) {int j = i; // prefix start indexint k = 0;while(j >= 0 && des[j] == des[des_len - 1 -k]) {--j;++k;suffix[k] = j + 1;}if (j == -1) {prefix[k] = true;}}
}// move the des str to a position
//
// @params:
// j: bad char's index
// des: destination string
// suffix: suffix in des's start index
// prefix: wether the suffix math with prefix
//
// case:
// src: a b c a c a b c b c b a c a b c
// des: c b a c a b c
int movePosition(int j, string des, vector<int> suffix, vector<bool> prefix) {// case1: move to the longest len of sufixint k = des.size() - 1 - j;if (suffix[k] != -1) {return j - suffix[k] + 1;}// case2: move to other suffix position//// longest suffix's range [j+1, len(des)-1]// other suffix's range [j+2, len(des)-1]for (int r = j + 2; r <= des.size() - 1; r++) {if (prefix[des.size() - r] == true) {return r;}}// case3: move the len of desreturn des.size();
}// boyer-moore algorithm
//
// two rules:
// 1. bad char
// 2. good suffix
int bm_alg(string src, string des) {if (src.size() == 0 || des.size() == 0) {return -1;}map<char ,int> last_pos;vector<int> suffix;vector<bool> prefix;generateBC(des, last_pos);generateGS(des, suffix, prefix);int i=0;int src_len = src.size();int des_len = des.size();while (i <= (src_len - des_len)) {int j;// find the bad charfor (j = des_len - 1; j >= 0; j--) {if (src[i+j] != des[j]) {break;}}if (j < 0) {return i;}// bad char's move positionint x = j - last_pos[src[i+j]];int y = 0;// good suffix's move positionif (j < des_len - 1) {y = movePosition(j, des, suffix, prefix);}// move the max of position between badchar and // good suffixi = i + std::max(x,y);}return -1;
} int main() {string src;string des;cout << "input src ";cin >> src;cout << "input des ";cin >> des;if (bm_alg(src, des) != -1) {printf("%s is the substr of %s with index : %d\n",des.c_str(), src.c_str(), bm_alg(src,des));} else {printf("not match \n");}return 0;
}
3. KMP(Knuth Morris Pratt)算法
有了之前對BM算法的理解,接下來再看KMP算法就事半功倍了。
BM算法中通過 壞字符 和 好后綴 規則 來約束模式串的滑動,兩個規則的結合能夠高效得檢索模式串是否和主串匹配。KMP算法相比于BM算法 都希望加速模式串的滑動,提升匹配效率。不同的是KMP將好后綴規則變成了好前綴規則。
如上圖,將模式串和主串中已經匹配的字符串稱為好前綴,不同的字符同樣是壞字符,就像BM算法的好后綴規則下,監測到好后綴的后綴子串在模式串中的其他位置,則向后滑動到這個匹配后綴子串的起始位置即可。同樣,KMP的好前綴中也希望滑動到匹配的后綴子串的位置(為了避免滑動過多,跳過匹配的情況,這里選擇最大匹配的后綴子串)。
3.1 好前綴 和 壞字符規則
如下圖,取好前綴的最大匹配后綴子串,其長度為k,將模式串匹配的前綴子串的結尾index移動 j-k個位置 即能夠和最大匹配后綴子串對齊,此事壞字符的位置i不用發生變化。
由上圖可知,KMP的滑動除了需要依賴主串找到壞字符之外, 其他的最大匹配前綴子串的計算并不需要主串的參與,僅僅通過模式串就能夠預先完成所有情況的最大匹配前綴子串的結尾下標計算。
也就是我們在KMP的算法規則中 只需要知道當出現壞字符時,模式串的最大可匹配前綴的下標即可,這樣就能夠完成模式串的移動了。這個最大可匹配下標的計算也就是KMP 常說的失效函數的計算,屬于KMP的核心計算過程了,我們將失效函數用next數組來表示。
基本的kmp代碼實現如下:
int kmp_alg(string src, string des) {if (src.size() == 0 || des.size() <= 0) {return -1;}int src_len = src.size();int des_len = des.size();vector<int> next;int i, j;next.resize(des_len);// Get the next vectorgetNext(des,next);for (i = 0;i < src_len; i++) {// 1. Find the bad char// 2. Next[j-1] is the longest match prefix's tail index// move the j to the destinations.// // Example:// i// 0 1 2 3 4 5 6 7 8 9// src: a b a b a e a b a c// des: a b a b a c d // 0 1 2 3 4 5 6// j//// when find bad char: i = 5, j = 5;// good prefix is : ababa// longest match prefix : aba// longest match prefix tail in des: 2// next[j-1] : next[5-1] = 2// j: 2 + 1 = 3//// after slide ,the src and des is bellow:// i// 0 1 2 3 4 5 6 7 8 9// src: a b a b a e a b a c// des: a b a b a c d // 0 1 2 3 4 5 6// j// while(j > 0 && src[i] != des[j]) {j = next[j-1] + 1; }// The good prefix, just add the jif (src[i] == des[j]) {j++;}// Match the des and return the index in srsif (j == des_len) {return (i - des_len + 1);}}return -1;
}
接下來看一下如何構建失效函數這一部分。
3.2 高效構建 失效函數
如下圖,左側的圖能夠非常直觀得看到 當發現了好前綴之后,如何找到其最大可匹配前綴的過程。
通過將好前綴的所有前綴子串和后綴子串列出來,找到其中最長一個能夠完成匹配的前綴子串,即是我們的最長可匹配前綴子串。這個尋找過程,我們也可以用來直接構造如右側圖的next數組(失效函數),也就是暴力法。
暴力法就是 **要對模式串中的每一個好前綴(m-1個)都要分別找出其后綴子串和前綴子串,并從中找到能夠匹配的最長的子串,**這個代價是極大的。
更優的辦法 是使用next[i-1] 來 嘗試構建next[i],理論上 next[i] 中肯定包含next[i-1],因為next[i]對應的好前綴是包含next[i-1]對應的好前綴的,這種方式理論上是可行的,需要關注的細節如下:
-
case1: 假如next[i-1] = k-1,也就是子串des[0,k-1]是des[0,i-1]的最長可匹配前綴子串。如果des[0,k-1]的下一個字符des[k]和 des[0,i-1]的下一個字符des[i] 相等,則next[i] = next[i-1] + 1= k。
-
case2: 如果des[0,k-1]的下一個字符des[k]和 des[0,i-1]的下一個字符des[i] 不相等,這個時候的處理稍微麻煩一些。
也就是des[0,k-1]中無法滿足找到最長可匹配前綴子串,那么des[0,next[k-1]]的下一個字符能否滿足等于des[i]的要求呢,即des[next[k-1]]和des[i]是否相等,想等則認為des[0,next[k-1]] 是 des[0,i]的最長可匹配前綴子串,next[i] = next[k-1]+1 。
整體上類似于數學的遞推公式:
- 前一個的最長串des[0,k] 的下一個字符des[k+1]不與最后一個字符des[i]相等,則需要找前一個的次長串
- 問題也就變成了求des[0, next[k]]的最長串:如果下一個字符des[next[k] + 1]還不和des[i]相等,那么繼續回溯到求des[0,next[next[k]]]的最長串,再判斷des[next[next[k]] + 1]和des[i]是否相等,相等則next[i] = next[next[k]] + 1
- 否則繼續,依此直到找到 能夠和des[i]相等的字符。此時的next[next[…]]+1 就是next[i]的值。
next數組的求值代碼如下:
void getNext(string des, vector<int> &next) {next[0] = -1;int k = -1;int i;// i begin with 1, next[0] always is -1for (i = 1; i< des.size(); i++) {// Find the longest match prefix by judge // two char dex[i] and des[k+1].// Just like case1 and case2: next[i-1]=k-1// if des[i] == des[k-1], then next[i] = next[i-1] + 1 = k;while(k != -1 && des[i] != des[k+1]) {//let 'k' storage 'next[next[...]]'k = next[k];}// find the match char, let k ++if (des[i] == des[k+1]) {k++;}next[i] = k;} }
關于次長可匹配前綴和最長可匹配前綴之間的關系可以參考如下圖理解一下:
y = next[i-1] 其實就是上面代碼中的 k = next[k]的邏輯。
3.3 復雜度及完整代碼
KMP算法以難理解著稱,總體上就是一個回溯的過程,當前next[k]不滿足匹配子串[0,k]的下一個字符相等時,則回到next[next[k]]看看。
空間復雜度:維護一個 m(模式串長度)大小的數組。
時間復雜度:
-
在next數組計算過程中,第一層for 循環[1,m-1],第二層的while循環中k=next[k]并不是每次都會執行,總的執行次數可以通過語句k++來判斷,并不會超過m次,畢竟next[k]的回溯過程一定會碰到-1的情況。所以next數據計算過程整體的時間復雜度是O(m) – while循環的執行次數小于m,且不穩定,可以當作常量來看。
-
在外部主體進行匹配的過程中,外部循環[0,n-1],n表示主串的長度;因為j本身比i小, 所以while循環中 j=next[j-1]+1不可能循環n次,又因為next[j-1] 肯定小于j,相當于j之前的一個下標,也就是while中語句的執行次數是小于m的,也能夠看作一個常量。
即外部匹配過程的時間復雜度是O(n)
所以總體的時間復雜度是O(m+n),當然相比于BM 的復雜度計算還不夠嚴謹,像O(5n)也是O(n)的范疇,這個就比BM算法消耗時間更久了。
source code:
https://github.com/BaronStack/DATA_STRUCTURE/blob/master/string/kmp_alg.cc
完整代碼如下:
#include <iostream>
#include <vector>
#include <string>using namespace std;void getNext(string des, vector<int> &next) {next[0] = -1;int k = -1;int i;// i begin with 1, next[0] always is -1for (i = 1; i< des.size(); i++) {// Find the longest match prefix by judge // two char dex[i] and des[k+1].// Just like case1 and case2: next[i-1]=k-1// if des[i] == des[k-1], then next[i] = next[i-1] + 1 = k;while(k != -1 && des[i] != des[k+1]) {//let 'k' storage 'next[next[...]]'k = next[k];}// find the match char, let k ++if (des[i] == des[k+1]) {k++;}next[i] = k;}
}int kmp_alg(string src, string des) {if (src.size() == 0 || des.size() <= 0) {return -1;}int src_len = src.size();int des_len = des.size();vector<int> next;int i, j;next.resize(des_len);// Get the next vectorgetNext(des,next);j = 0;for (i = 0;i < src_len; i++) {// 1. Find the bad char// 2. Next[j-1] is the longest match prefix's tail index// move the j to the destinations.// // Example:// i// 0 1 2 3 4 5 6 7 8 9// src: a b a b a e a b a c// des: a b a b a c d // 0 1 2 3 4 5 6// j//// when find bad char: i = 5, j = 5;// good prefix is : ababa// longest match prefix : aba// longest match prefix tail in des: 2// next[j-1] : next[5-1] = 2// j: 2 + 1 = 3//// after slide ,the src and des is bellow:// i// 0 1 2 3 4 5 6 7 8 9// src: a b a b a e a b a c// des: a b a b a c d // 0 1 2 3 4 5 6// j//while(j > 0 && src[i] != des[j]) {j = next[j-1] + 1; }// The good prefix, just add the jif (src[i] == des[j]) {j++;}// Match the des and return the index in srsif (j == des_len) {return (i - des_len + 1);}}return -1;
}int main() {string s1,s2;cin >> s1;cin >> s2;if (kmp_alg(s1,s2) == -1) {cout << s1 << " with " << s2 << " not match !" << endl;} else {cout << s1 << " with " << s2 << " match !" << endl;}return 0;
}
4. 總結
本節僅僅描述的是高效的單模式串匹配算法,也能夠看到BM/KMP這樣的復雜算法的設計,還是比較難以理解,尤其是KMP中用到了一些動態規劃的思想。需要花費大量的時間和精力去思考實驗,才能了解清楚內部的詳細設計(從上周天開始研究,跟著極客時間的王爭老師理解,差點懷疑自己智力是不是有問題,最后還是下班路上想明白細節。。。還是得盡可能多得思考訓練,數據結構和算法功底決定未來技術壁壘的高度,尤其是現在搞的存儲引擎,分布式存儲/分布式數據庫領域的核心,更是一點不能馬虎),歡迎大家一起討論。
后續將會深入多模式串的數據結構Tire樹和算法AC自動機的設計,畢竟搜索引擎/文本編輯器中還是需要高效的多字符串的匹配才能滿足實際生產環境的需求。
總結
以上是生活随笔為你收集整理的字符串匹配算法 -- BM(Boyer-Moore) 和 KMP(Knuth-Morris-Pratt)详细设计及实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: qq飞车网名大全男生
- 下一篇: git submodule 使用场景汇总