這篇長文歷時近兩天終於完成了,前兩天幫網站翻譯一篇文章“為什么GNU grep如此之快?”,里面提及到grep速度快的一個重要原因是使用了Boyer-Moore算法作為字符串搜索算法,興趣之下就想了解這個算法,發現這個算法一開始還挺難理解的,也許是我理解能力不是很好吧,花了小半天才看懂,看懂了過后就想分享下,因為覺得這個算法真的挺不錯的,以前一直以為字符串搜索算法中KMP算很不錯的了,沒想到還有更好的,Boyer-Moore算法平均要比KMP快3-5倍。
下面是我對該算法的理解,參考了一些關於該算法的介紹,里面每一張圖都畫的很認真,希望能講清楚問題,有什么錯誤、疑問或不懂的地方麻煩大家一定要提出來,共同學習進步!下面正文開始。
1. 簡單介紹
在用於查找子字符串的算法當中,BM(Boyer-Moore)算法是目前被認為最高效的字符串搜索算法,它由Bob Boyer和J Strother Moore設計於1977年。 一般情況下,比KMP算法快3-5倍。該算法常用於文本編輯器中的搜索匹配功能,比如大家所熟知的GNU grep命令使用的就是該算法,這也是GNU grep比BSD grep快的一個重要原因,具體推薦看下我最近的一篇譯文“為什么GNU grep如此之快?”作者是GNU grep的編寫者Mike Haertel。
2. 主要特征
假設文本串text長度為n,模式串pattern長度為m,BM算法的主要特征為:
- 從右往左進行比較匹配(一般的字符串搜索算法如KMP都是從從左往右進行匹配);
- 算法分為兩個階段:預處理階段和搜索階段;
- 預處理階段時間和空間復雜度都是是O(m+
),
是字符集大小,一般為256;
- 搜索階段時間復雜度是O(mn);
- 當模式串是非周期性的,在最壞的情況下算法需要進行3n次字符比較操作;
- 算法在最好的情況下達到O(n / m),比如在文本串bn中搜索模式串am-1b ,只需要n/m次比較。
這些特征先讓大家對該算法有個基本的了解,等看懂了算法再來看這些特征又會有些額外的收獲。
3.算法基本思想
常規的匹配算法移動模式串的時候是從左到右,而進行比較的時候也是從左到右的,基本框架是:
j = 0;
while(j <= strlen(text) - strlen(pattern)){
for (i = 0; i < strlen(pattern) && pattern[i] == text[i + j]; ++i);
if (i == strlen(pattern)) {
Match;
break;
}
else
++j;
}
而BM算法在移動模式串的時候是從左到右,而進行比較的時候是從右到左的,基本框架是:
j = 0;
while(j <= strlen(text) - strlen(pattern)){
for (i = strlen(pattern); i >= 0 && pattern[i] == text[i + j]; --i);
if (i < 0)) {
Match;
break;
}
else
j += BM();
}
BM算法的精華就在於BM(text, pattern),也就是BM算法當不匹配的時候一次性可以跳過不止一個字符。即它不需要對被搜索的字符串中的字符進行逐一比較,而會跳過其中某些部分。通常搜索關鍵字越長,算法速度越快。它的效率來自於這樣的事實:對於每一次失敗的匹配嘗試,算法都能夠使用這些信息來排除盡可能多的無法匹配的位置。即它充分利用待搜索字符串的一些特征,加快了搜索的步驟。
BM算法實際上包含兩個並行的算法(也就是兩個啟發策略):壞字符算法(bad-character shift)和好后綴算法(good-suffix shift)。這兩種算法的目的就是讓模式串每次向右移動盡可能大的距離(即上面的BM()盡可能大)。
下面不直接書面解釋這兩個算法,為了更加通俗易懂,先用實例說明吧,這是最容易接受的方式。
4. 字符串搜索頭腦風暴
大家來頭腦風暴下:如何加快字符串搜索?舉個很簡單的例子,如下圖所示,navie表示一般做法,逐個進行比對,從右向左,最后一個字符c與text中的d不匹配,pattern右移一位。但大家看一下這個d有什么特征?pattern中沒有d,因此你不管右移1、2、3、4位肯定還是不匹配,何必花這個功夫呢?直接右移5(strlen(pattern))位再進行比對不是更好嗎?好,就這樣做,右移5位后,text中的b與pattern中的c比較,發現還是不同,這時咋辦?b在pattern中有所以不能一下右移5位了,難道直接右移一位嗎?No,可以直接將pattern中的b右移到text中b的位置進行比對,但是pattern中有兩個b,右移哪個b呢?保險的辦法是用最右邊的b與text進行比對,為啥?下圖說的很清楚了,用最左邊的b太激進了,容易漏掉真正的匹配,圖中用最右邊的b后發現正好所有的都匹配成功了,如果用最左邊的不就錯過了這個匹配項嗎?這個啟發式搜索就是BM算法做的。
圖1
But, 如果遇到下面這樣的情況,開始pattern中的c和text中的b不匹配,Ok,按上面的規則將pattern右移直至最右邊的b與text的b對齊進行比對。再將pattern中的c與text中的c進行比對,匹配繼續往左比對,直到位置3處pattern中的a與text中的b不匹配了,按上面講的啟發式規則應該將pattern中最右邊的b與text的b對齊,可這時發現啥了?pattern走了回頭路,干嗎?當然不干,才不要那么傻,針對這種情況,只需要將pattern簡單的右移一步即可,堅持不走回頭路!
圖2
好了,這就是所謂的“壞字符算法”,簡單吧,通俗易懂吧,上面用紅色粗體字標注出來的b就是“壞字符”,即不匹配的字符,壞字符是針對text的。
BM難道就這么簡單?就一個啟發式規則就搞定了?當然不是了,大家再次頭腦風暴一下,有沒有其他加快字符串搜索的方法呢?比如下面的例子
圖3
一開始利用了壞字符算法一下移了4位,不錯,接下來遇到了回頭路,沒辦法只能保守移一位,但真的就只能移一位嗎?No,因為pattern中前面其他位置也有剛剛匹配成功的后綴ab,那么將pattern前面的ab右移到text剛匹配成功的ab對齊繼續往前匹配不是更好嗎?這樣就可以一次性右移兩位了,很好的有一個啟發式搜索規則啊。有人可能想:要是前面沒已經匹配成功的后綴咋辦?是不是就無效了?不完全是,這要看情況了,比如下面這個例子。
圖4
cbab這個后綴已經成功匹配,然后b沒成功,而pattern前面也沒發現cbab這樣的串,這樣就直接保守移一位?No,前面有ab啊,這是cbab后綴的一部分,也可以好好利用,直接將pattern前面的ab右移到text已經匹配成功的ab位置處繼續往前匹配,這樣一下子就右移了四位,很好。當然,如果前面完全沒已經匹配成功的后綴或部分后綴,比如最前面的babac,那就真的不能利用了。
好了,這就是所謂的“好后綴算法”,簡單吧,通俗易懂吧,上面用紅色字標注出來的ab(前面例子)和cbab(上面例子)就是“好后綴”,好后綴是針對pattern的。
下面,最后再舉個例子說明啥是壞字符,啥是好后綴。
主串 : mahtavaatalomaisema omalomailuun
模式串: maisemaomaloma
壞字符:主串中的“t”為壞字符。
好后綴:模式串中的aloma為“好后綴”。
BM就這么簡單?是的,容易理解但並不是每個人都能想到的兩個啟發式搜索規則就造就了BM這樣一個優秀的算法。那么又有個問題?這兩個算法怎么運用,一下壞字符的,一下好后綴的,什么時候該用壞字符?什么時候該用好后綴呢?很好的問題,這就要看哪個右移的位數多了,比如上面的例子,一開始如果用好后綴的話只能移一位而用壞字符就能右移三位,此時當然選擇壞字符算法了。接下來如果繼續用壞字符則只能右移一位而用好后綴就能一下右移四位,這時候你說用啥呢?So,這兩個算法是“並行”的,哪個大用哪個。
光用例子說明當然不夠,太淺了,而且還不一定能完全覆蓋所有情況,不精確。下面就開始真正的理論探討了。
5. BM算法理論探討
(1)壞字符算法
當出現一個壞字符時, BM算法向右移動模式串, 讓模式串中最靠右的對應字符與壞字符相對,然后繼續匹配。壞字符算法有兩種情況。
Case1:模式串中有對應的壞字符時,讓模式串中最靠右的對應字符與壞字符相對(PS:BM不可能走回頭路,因為若是回頭路,則移動距離就是負數了,肯定不是最大移動步數了),如下圖。

Case2:模式串中不存在壞字符,很好,直接右移整個模式串長度這么大步數,如下圖。

(2)好后綴算法
如果程序匹配了一個好后綴, 並且在模式中還有另外一個相同的后綴或后綴的部分, 那把下一個后綴或部分移動到當前后綴位置。假如說,pattern的后u個字符和text都已經匹配了,但是接下來的一個字符不匹配,我需要移動才能匹配。如果說后u個字符在pattern其他位置也出現過或部分出現,我們將pattern右移到前面的u個字符或部分和最后的u個字符或部分相同,如果說后u個字符在pattern其他位置完全沒有出現,很好,直接右移整個pattern。這樣,好后綴算法有三種情況,如下圖所示:
Case1:模式串中有子串和好后綴完全匹配,則將最靠右的那個子串移動到好后綴的位置繼續進行匹配。

Case2:如果不存在和好后綴完全匹配的子串,則在好后綴中找到具有如下特征的最長子串,使得P[m-s…m]=P[0…s]。

Case3:如果完全不存在和好后綴匹配的子串,則右移整個模式串。
(3)移動規則
BM算法的移動規則是:
將3中算法基本框架中的j += BM(),換成j += MAX(shift(好后綴),shift(壞字符)),即
BM算法是每次向右移動模式串的距離是,按照好后綴算法和壞字符算法計算得到的最大值。
shift(好后綴)和shift(壞字符)通過模式串的預處理數組的簡單計算得到。壞字符算法的預處理數組是bmBc[],好后綴算法的預處理數組是bmGs[]。
6. BM算法具體執行
BM算法子串比較失配時,按壞字符算法計算pattern需要右移的距離,要借助bmBc數組,而按好后綴算法計算pattern右移的距離則要借助bmGs數組。下面講下怎么計算bmBc[]和bmGs[]這兩個預處理數組。
(1)計算壞字符數組bmBc[]
這個計算應該很容易,似乎只需要bmBc[i] = m - 1 - i就行了,但這樣是不對的,因為i位置處的字符可能在pattern中多處出現(如下圖所示),而我們需要的是最右邊的位置,這樣就需要每次循環判斷了,非常麻煩,性能差。這里有個小技巧,就是使用字符作為下標而不是位置數字作為下標。這樣只需要遍歷一遍即可,這貌似是空間換時間的做法,但如果是純8位字符也只需要256個空間大小,而且對於大模式,可能本身長度就超過了256,所以這樣做是值得的(這也是為什么數據越大,BM算法越高效的原因之一)。

如前所述,bmBc[]的計算分兩種情況,與前一一對應。
Case1:字符在模式串中有出現,bmBc['v']表示字符v在模式串中最后一次出現的位置,距離模式串串尾的長度,如上圖所示。
Case2:字符在模式串中沒有出現,如模式串中沒有字符v,則BmBc['v'] = strlen(pattern)。
寫成代碼也非常簡單:
void PreBmBc(char *pattern, int m, int bmBc[])
{
int i;
for(i = 0; i < 256; i++)
{
bmBc[i] = m;
}
for(i = 0; i < m - 1; i++)
{
bmBc[pattern[i]] = m - 1 - i;
}
}
計算pattern需要右移的距離,要借助bmBc數組,那么bmBc的值是不是就是pattern實際要右移的距離呢?No,想想也不是,比如前面舉例說到利用bmBc算法還可能走回頭路,也就是右移的距離是負數,而bmBc的值絕對不可能是負數,所以兩者不相等。那么pattern實際右移的距離怎么算呢?這個就要看text中壞字符的位置了,前面說過壞字符算法是針對text的,還是看圖吧,一目了然。圖中v是text中的壞字符(對應位置i+j),在pattern中對應不匹配的位置為i,那么pattern實際要右移的距離就是:bmBc['v'] - m + 1 + i。
(2)計算好后綴數組bmGs[]
這里bmGs[]的下標是數字而不是字符了,表示字符在pattern中位置。
如前所述,bmGs數組的計算分三種情況,與前一一對應。假設圖中好后綴長度用數組suff[]表示。
Case1:對應好后綴算法case1,如下圖,j是好后綴之前的那個位置。
Case2:對應好后綴算法case2:如下圖所示:
Case3:對應與好后綴算法case3,bmGs[i] = strlen(pattern)= m
這樣就清晰了,代碼編寫也比較簡單:
void PreBmGs(char *pattern, int m, int bmGs[])
{
int i, j;
int suff[SIZE];
// 計算后綴數組
suffix(pattern, m, suff);
// 先全部賦值為m,包含Case3
for(i = 0; i < m; i++)
{
bmGs[i] = m;
}
// Case2
j = 0;
for(i = m - 1; i >= 0; i--)
{
if(suff[i] == i + 1)
{
for(; j < m - 1 - i; j++)
{
if(bmGs[j] == m)
bmGs[j] = m - 1 - i;
}
}
}
// Case1
for(i = 0; i <= m - 2; i++)
{
bmGs[m - 1 - suff[i]] = m - 1 - i;
}
}
So easy? 結束了嗎?還差一步呢,這里的suff[]咋求呢?
在計算bmGc數組時,為提高效率,先計算輔助數組suff[]表示好后綴的長度。
suff數組的定義:m是pattern的長度
看上去有些晦澀難懂,實際上suff[i]就是求pattern中以i位置字符為后綴和以最后一個字符為后綴的公共后綴串的長度。不知道這樣說清楚了沒有,還是舉個例子吧:
i : 0 1 2 3 4 5 6 7
pattern: b c a b a b a b
當i=7時,按定義suff[7] = strlen(pattern) = 8
當i=6時,以pattern[6]為后綴的后綴串為bcababa,以最后一個字符b為后綴的后綴串為bcababab,兩者沒有公共后綴串,所以suff[6] = 0
當i=5時,以pattern[5]為后綴的后綴串為bcabab,以最后一個字符b為后綴的后綴串為bcababab,兩者的公共后綴串為abab,所以suff[5] = 4
以此類推……
當i=0時,以pattern[0]為后綴的后綴串為b,以最后一個字符b為后綴的后綴串為bcababab,兩者的公共后綴串為b,所以suff[0] = 1
這樣看來代碼也很好寫:
void suffix(char *pattern, int m, int suff[])
{
int i, j;
int k;
suff[m - 1] = m;
for(i = m - 2; i >= 0; i--)
{
j = i;
while(j >= 0 && pattern[j] == pattern[m - 1 - i + j]) j--;
suff[i] = i - j;
}
}
這樣可能就萬事大吉了,可是總有人對這個算法不滿意,感覺太暴力了,於是有聰明人想出一種方法,對上述常規方法進行改進。基本的掃描都是從右向左,改進的地方就是利用了已經計算得到的suff[]值,計算現在正在計算的suff[]值。具體怎么利用,看下圖:
i是當前正准備計算suff[]值的那個位置。
f是上一個成功進行匹配的起始位置(不是每個位置都能進行成功匹配的, 實際上能夠進行成功匹配的位置並不多)。
g是上一次進行成功匹配的失配位置。
如果i在g和f之間,那么一定有P[i]=P[m-1-f+i];並且如果suff[m-1-f+i] < i-g, 則suff[i] = suff[m-1-f+i],這不就利用了前面的suff了嗎。
PS:這里有些人可能覺得應該是suff[m-1-f+i] <= i - g,因為若suff[m-1-f+i] = i - g,還是沒超過suff[f]的范圍,依然可以利用前面的suff[],但這是錯誤的,比如一個極端的例子:
i :0 1 2 3 4 5 6 7 8 9
pattern:a a a a a b a a a a
suff[4] = 4,這里f=4,g=0,當i=3是,這時suff[m-1=f+i]=suff[8]=3,而suff[3]=4,兩者不相等,因為上一次的失配位置g可能會在這次得到匹配。
好了,這樣解釋過后,代碼也比較簡單:
void suffix(char *pattern, int m, int suff[]) {
int f, g, i;
suff[m - 1] = m;
g = m - 1;
for (i = m - 2; i >= 0; --i) {
if (i > g && suff[i + m - 1 - f] < i - g)
suff[i] = suff[i + m - 1 - f];
else {
if (i < g)
g = i;
f = i;
while (g >= 0 && pattern[g] == pattern[g + m - 1 - f])
--g;
suff[i] = f - g;
}
}
}
結束了?OK,可以說重要的算法都完成了,希望大家能夠看懂,為了驗證大家到底有沒有完全看明白,下面出個簡單的例子,大家算一下bmBc[]、suff[]和bmGs[]吧。
舉例如下:
PS:這里也許有人會問:bmBc['b']怎么等於2,它不是最后出現在pattern最后一個位置嗎?按定義應該是0啊。請大家仔細看下bmBc的算法:
for(i = 0; i < m - 1; i++)
{
bmBc[pattern[i]] = m - 1 - i;
}
這里是i < m - 1不是i < m,也就是最后一個字符如果沒有在前面出現過,那么它的bmBc值為m。為什么最后一位不計算在bmBc中呢?很容易想啊,如果記在內該字符的bmBc就是0,按前所述,pattern需要右移的距離bmBc['v']-m+1+i=-m+1+i <= 0,也就是原地不動或走回頭路,當然不干了,前面這種情況已經說的很清楚了,所以這里是m-1。
好了,所有的終於都講完了,下面整合一下這些算法吧。
#include <stdio.h>
#include <string.h>
#define MAX_CHAR 256
#define SIZE 256
#define MAX(x, y) (x) > (y) ? (x) : (y)
void BoyerMoore(char *pattern, int m, char *text, int n);
int main()
{
char text[256], pattern[256];
while(1)
{
scanf("%s%s", text, pattern);
if(text == 0 || pattern == 0) break;
BoyerMoore(pattern, strlen(pattern), text, strlen(text));
printf("\n");
}
return 0;
}
void print(int *array, int n, char *arrayName)
{
int i;
printf("%s: ", arrayName);
for(i = 0; i < n; i++)
{
printf("%d ", array[i]);
}
printf("\n");
}
void PreBmBc(char *pattern, int m, int bmBc[])
{
int i;
for(i = 0; i < MAX_CHAR; i++)
{
bmBc[i] = m;
}
for(i = 0; i < m - 1; i++)
{
bmBc[pattern[i]] = m - 1 - i;
}
/* printf("bmBc[]: ");
for(i = 0; i < m; i++)
{
printf("%d ", bmBc[pattern[i]]);
}
printf("\n"); */
}
void suffix_old(char *pattern, int m, int suff[])
{
int i, j;
suff[m - 1] = m;
for(i = m - 2; i >= 0; i--)
{
j = i;
while(j >= 0 && pattern[j] == pattern[m - 1 - i + j]) j--;
suff[i] = i - j;
}
}
void suffix(char *pattern, int m, int suff[]) {
int f, g, i;
suff[m - 1] = m;
g = m - 1;
for (i = m - 2; i >= 0; --i) {
if (i > g && suff[i + m - 1 - f] < i - g)
suff[i] = suff[i + m - 1 - f];
else {
if (i < g)
g = i;
f = i;
while (g >= 0 && pattern[g] == pattern[g + m - 1 - f])
--g;
suff[i] = f - g;
}
}
// print(suff, m, "suff[]");
}
void PreBmGs(char *pattern, int m, int bmGs[])
{
int i, j;
int suff[SIZE];
// 計算后綴數組
suffix(pattern, m, suff);
// 先全部賦值為m,包含Case3
for(i = 0; i < m; i++)
{
bmGs[i] = m;
}
// Case2
j = 0;
for(i = m - 1; i >= 0; i--)
{
if(suff[i] == i + 1)
{
for(; j < m - 1 - i; j++)
{
if(bmGs[j] == m)
bmGs[j] = m - 1 - i;
}
}
}
// Case1
for(i = 0; i <= m - 2; i++)
{
bmGs[m - 1 - suff[i]] = m - 1 - i;
}
// print(bmGs, m, "bmGs[]");
}
void BoyerMoore(char *pattern, int m, char *text, int n)
{
int i, j, bmBc[MAX_CHAR], bmGs[SIZE];
// Preprocessing
PreBmBc(pattern, m, bmBc);
PreBmGs(pattern, m, bmGs);
// Searching
j = 0;
while(j <= n - m)
{
for(i = m - 1; i >= 0 && pattern[i] == text[i + j]; i--);
if(i < 0)
{
printf("Find it, the position is %d\n", j);
j += bmGs[0];
return;
}
else
{
j += MAX(bmBc[text[i + j]] - m + 1 + i, bmGs[i]);
}
}
printf("No find.\n");
}
運行效果如下:
參考資料
2. wiki: Boyer–Moore string search algorithm
