寫在前面
在計算機科學裏,Boyer-Moore字符串搜索算法是一種非常高效的字符串搜索算法。它由Bob Boyer和J Strother Moore設計於1977年。此算法僅對搜索目標字符串(關鍵字)進行預處理,而非被搜索的字符串。雖然Boyer-Moore算法的執行時間同樣線性依賴於被搜索字符串的大小,但是通常僅爲其它算法的一小部分:它不需要對被搜索的字符串中的字符進行逐一比較,而會跳過其中某些部分。通常搜索關鍵字越長,算法速度越快。它的效率來自於這樣的事實:對於每一次失敗的匹配嘗試,算法都能夠使用這些信息來排除儘可能多的無法匹配的位置。
在學習研究BM算法之前,我是已經掌握了KMP算法,所以建議還沒有掌握的同學,先去學習一下,循序漸進的來,可以看我的KMP算法的文章。爲什麼說循序漸進,是因爲BM算法,在大多數情況下,表現的比KMP算法優秀,所以大部分時候,都當做KMP進階的算法來學習。BM算法從模式串的尾部開始匹配,且擁有在最壞情況下 $O(N) $的時間複雜度。有數據表明,在實踐中,比 KMP 算法的實際效能高,可以快大概 3-5 倍,很值得學習。在學習BM算法的時候,找了很多資料,也遇到了很多優秀的文章,不過目前還沒有碰到即講清楚了原理,又實現了代碼的文章,java版的更是不容易找。所以我這裏打算站在大神的肩膀上學習,寫下這篇文章,由於一些圖畫的比較佔用時間,我就直接引用一些博文中的圖片。感興趣的可以直接看大神們的文章。
BM算法原理
BM算法定義了兩個規則:
- 壞字符規則:當文本串中的某個字符跟模式串的某個字符不匹配時,我們稱文本串中的這個失配字符爲壞字符,此時模式串需要向右移動,
移動的位數 = 壞字符在模式串中的位置 - 壞字符在模式串中最右出現的位置
。此外,如果"壞字符"不包含在模式串之中,則最右出現位置爲-1
。 - 好後綴規則:當字符失配時,
後移位數 = 好後綴在模式串中的位置 - 好後綴在模式串上一次出現的位置
,且如果好後綴在模式串中沒有再次出現,則爲-1
。
下面舉例說明BM算法。例如,給定文本串“HERE IS A SIMPLE EXAMPLE
”,和模式串“EXAMPLE
”,現要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。
- 首先,“文本串"與"模式串"頭部對齊,從尾部開始比較。”
S
“與”E
“不匹配。這時,”S
“就被稱爲"壞字符”(bad character),即不匹配的字符,它對應着模式串的第6
位。且"S
“不包含在模式串”EXAMPLE
“之中(相當於最右出現位置是-1
),這意味着可以把模式串後移6-(-1)=7
位,從而直接移到”S
"的後一位。
- 依然從尾部開始比較,發現"
P
“與”E
“不匹配,所以”P
“是"壞字符”。但是,"P
“包含在模式串”EXAMPLE
"之中。因爲“P
”這個“壞字符”對應着模式串的第6
位(從0
開始編號),且在模式串中的最右出現位置爲4
,所以,將模式串後移6-4=2
位,兩個"P"對齊。
- 依次比較,得到 “
MPLE
”匹配,稱爲"好後綴"(good suffix),即所有尾部匹配的字符串。注意,"MPLE
"、"PLE
"、"LE
"、"E
"都是好後綴。
- 發現“
I
”與“A
”不匹配:“I
”是壞字符。如果是根據壞字符規則,此時模式串應該後移2-(-1)=3
位。問題是,有沒有更優的移法?
- 更優的移法是利用好後綴規則:當字符失配時,
後移位數 = 好後綴在模式串中的位置 - 好後綴在模式串中上一次出現的位置
,且如果好後綴在模式串中沒有再次出現,則爲-1
。所有的“好後綴”(MPLE、PLE、LE、E
)之中,只有“E
”在“EXAMPLE
”的頭部出現,所以後移6-0=6
位。可以看出,“壞字符規則”只能移3
位,“好後綴規則”可以移6
位。每次後移這兩個規則之中的較大值。這兩個規則的移動位數,只與模式串有關,與原文本串無關。
- 繼續從尾部開始比較,“P”與“E”不匹配,因此“P”是“壞字符”,根據“壞字符規則”,後移 6 - 4 = 2位。因爲是最後一位就失配,尚未獲得好後綴。
好後綴加深理解
由上可知,BM算法不僅效率高,而且構思巧妙,容易理解。壞字符規則相對而言比較好理解,好後綴如果還不理解,我這裏再繼續舉個例子解釋一下,這裏加深理解。
- 如果模式串中存在已經匹配成功的好後綴,則把目標串與好後綴對齊,然後從模式串的最尾元素開始往前匹配。
- 如果無法找到匹配好的後綴,找一個匹配的最長的前綴,讓目標串與最長的前綴對齊(如果這個前綴存在的話)。模式串[m-s,m] = 模式串[0,s] 。
- 如果完全不存在和好後綴匹配的子串,則右移整個模式串。
先實現好字符規則
BM算法還是很好理解的,其實如果你之前學習KMP算法你也會有同樣的感受,KMP算法理解起來不是很難,但是重點在於怎麼去實現next數組。BM算法也是,原理理解起來其實非常的容易,不過怎麼去實現,沒有一套標準的代碼。不過可以研究別人的代碼,然後實現一套儘量適合精簡的代碼。還是一樣,一步一步來,我們先來實現好字符規則。好字符規則的代碼如下,我會在代碼中必要的地方加入註釋,輔助理解,代碼是最好的老師。
public static void getRight(String pat, int[] right) {
//首先創建一個模式串的字符位置的數組,初始化爲-1,就是用於記錄模式串
//中,每個字符在模式串中的相對位置,這裏直接用的是256,也
//就是ASCII碼的最大值,當然,如果你的字符串中只限制了26個
//字符,你也可以直接使用26
for (int i = 0; i < 256; i++) {
right[i] = -1;
}
//值得一提的是,通過這種方式,可以你會發現,如果模式串中存在相同的
//字符,那麼right數組中,記錄的是最右的那個字符的位置
for (int j = 0; j < pat.length(); j++) {
right[pat.charAt(j)] = j;
}
}
public static int Search(String txt, String pat, int[] right) {
int M = txt.length();//主串的長度
int N = pat.length();//模式串的長度
int skip;//用於記錄跳過幾個字符
for (int i = 0; i < M - N; i += skip) {
skip = 0;//每次進入循環要記得初始化爲0
for (int j = N - 1; j >= 0; j--) {
//不相等,意味着出現壞字符,按照上面的規則移動
if (pat.charAt(j) != txt.charAt(i + j)) {
skip = j - right[txt.charAt(i + j)];
//skip之所以會小於1,可能是因爲壞字符在模式串中最右的位置,可能
//在j指向字符的右側,就是已經越過了。
if (skip < 1)
skip = 1;
break;
}
}
//注意了這個時候循環了一遍之後,skip如果等於0,意味着沒有壞字符出現,所以
//匹配成功,返回當前字符i的位置
if (skip == 0)
return i;
}
return -1;
}
完整BM實現
上面的代碼不難理解,相信你已經看懂了,那麼接下來也不用單獨來講好後綴的實現,直接上完整的實現代碼。因爲完整的BM實現中,就是比較壞字符規則以及好後綴規則,哪個移動的字符數更多,就使用哪個。老樣子,下面的代碼中我儘量的加註釋。
public static int pattern(String pattern, String target) {
int tLen = target.length();//主串的長度
int pLen = pattern.length();//模式串的長度
//如果模式串比主串長,沒有可比性,直接返回-1
if (pLen > tLen) {
return -1;
}
int[] bad_table = build_bad_table(pattern);// 獲得壞字符數值的數組,實現看下面
int[] good_table = build_good_table(pattern);// 獲得好後綴數值的數組,實現看下面
for (int i = pLen - 1, j; i < tLen;) {
System.out.println("跳躍位置:" + i);
//這裏和上面實現壞字符的時候不一樣的地方,我們之前提前求出壞字符以及好後綴
//對應的數值數組,所以,我們只要在一邊循環中進行比較。還要說明的一點是,這裏
//沒有使用skip記錄跳過的位置,直接針對主串中移動的指針i進行移動
for (j = pLen - 1; target.charAt(i) == pattern.charAt(j); i--, j--) {
if (j == 0) {//指向模式串的首字符,說明匹配成功,直接返回就可以了
System.out.println("匹配成功,位置:" + i);
//如果你還要匹配不止一個模式串,那麼這裏直接跳出這個循環,並且讓i++
//因爲不能直接跳過整個已經匹配的字符串,這樣的話可能會丟失匹配。
// i++; // 多次匹配
// break;
return i;
}
}
//如果出現壞字符,那麼這個時候比較壞字符以及好後綴的數組,哪個大用哪個
i += Math.max(good_table[pLen - j - 1], bad_table[target.charAt(i)]);
}
return -1;
}
//字符信息表
public static int[] build_bad_table(String pattern) {
final int table_size = 256;//上面已經解釋過了,字符的種類
int[] bad_table = new int[table_size];//創建一個數組,用來記錄壞字符出現時,應該跳過的字符數
int pLen = pattern.length();//模式串的長度
for (int i = 0; i < bad_table.length; i++) {
bad_table[i] = pLen;
//默認初始化全部爲匹配字符串長度,因爲當主串中的壞字符在模式串中沒有出
//現時,直接跳過整個模式串的長度就可以了
}
for (int i = 0; i < pLen - 1; i++) {
int k = pattern.charAt(i);//記錄下當前的字符ASCII碼值
//這裏其實很值得思考一下,bad_table就不多說了,是根據字符的ASCII值存儲
//壞字符出現最右的位置,這在上面實現壞字符的時候也說過了。不過你仔細思考
//一下,爲什麼這裏存的壞字符數值,是最右的那個壞字符相對於模式串最後一個
//字符的位置?爲什麼?首先你要理解i的含義,這個i不是在這裏的i,而是在上面
//那個pattern函數的循環的那個i,爲了方便我們稱呼爲I,這個I很神奇,雖然I是
//在主串上的指針,但是由於在循環中沒有使用skip來記錄,直接使用I隨着j匹配
//進行移動,也就意味着,在某種意義上,I也可以直接定位到模式串的相對位置,
//理解了這一點,就好理解在本循環中,i的行爲了。
//其實仔細去想一想,我們分情況來思考,如果模式串的最
//後一個字符,也就是匹配開始的第一個字符,出現了壞字符,那麼這個時候,直
//接移動這個數值,那麼正好能讓最右的那個字符正對壞字符。那麼如果不是第一個
//字符出現壞字符呢?這種情況你仔細想一想,這種情況也就意味着出現了好後綴的
//情況,假設我們將最右的字符正對壞字符
bad_table[k] = pLen - 1 - i;
}
return bad_table;
}
//匹配偏移表
public static int[] build_good_table(String pattern) {
int pLen = pattern.length();//模式串長度
int[] good_table = new int[pLen];//創建一個數組,存好後綴數值
//用於記錄最新前綴的相對位置,初始化爲模式串長度,因爲意思就是當前後綴字符串爲空
//要明白lastPrefixPosition 的含義
int lastPrefixPosition = pLen;
for (int i = pLen - 1; i >= 0; --i) {
if (isPrefix(pattern, i + 1)) {
//如果當前的位置存在前綴匹配,那麼記錄當前位置
lastPrefixPosition = i + 1;
}
good_table[pLen - 1 - i] = lastPrefixPosition - i + pLen - 1;
}
for (int i = 0; i < pLen - 1; ++i) {
//計算出指定位置匹配的後綴的字符串長度
int slen = suffixLength(pattern, i);
good_table[slen] = pLen - 1 - i + slen;
}
return good_table;
}
//前綴匹配
private static boolean isPrefix(String pattern, int p) {
int patternLength = pattern.length();//模式串長度
//這裏j從模式串第一個字符開始,i從指定的字符位置開始,通過循環判斷當前指定的位置p
//之後的字符串是否匹配模式串前綴
for (int i = p, j = 0; i < patternLength; ++i, ++j) {
if (pattern.charAt(i) != pattern.charAt(j)) {
return false;
}
}
return true;
}
//後綴匹配
private static int suffixLength(String pattern, int p) {
int pLen = pattern.length();
int len = 0;
for (int i = p, j = pLen - 1; i >= 0 && pattern.charAt(i) == pattern.charAt(j); i--, j--) {
len += 1;
}
return len;
}
理解一下上面代碼,這裏我針對上面代碼舉個例子,計算之後的兩張表的數值如下:
總結
其實如果你把上面理解了,我相信你會有一種興奮,可能還會有一種疑問,就是BM的算法,如果將壞字符和好後綴規則都實現了,看着代碼量怎麼多,而且在計算兩個位移數組的時候,相當於要做這麼多準備工作,難道不會影響效率麼。這個問題我也不好回答,根據不同語言的具體實現,代碼的執行效率也會有所影響,不過有一點可以肯定的是,當字符匹配的長度量非常大的時候,BM的算法優勢還是很明顯的,而且要知道,BM算法在CV中應用的還是很廣泛的,畢竟CV中的數據集樣本動不動就是上百萬。BM算法非常值得一學。