grep之字串搜尋演算法Boyer-Moore由淺入深(比KMP快3-5倍)

敏敏發表於2013-06-26

這篇長文歷時近兩天終於完成了,前些天幫伯樂線上翻譯一篇文章《為什麼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+sigma),sigma是字符集大小,一般為256;
  • 搜尋階段時間複雜度是O(mn);
  • 當模式串是非週期性的,在最壞的情況下演算法需要進行3n次字元比較操作;
  • 演算法在最好的情況下達到O(n / m),比如在文字串bn中搜尋模式串am-1b ,只需要n/m次比較。

這些特徵先讓大家對該演算法有個基本的瞭解,等看懂了演算法再來看這些特徵又會有些額外的收穫。

 

3.演算法基本思想

常規的匹配演算法移動模式串的時候是從左到右,而進行比較的時候也是從左到右的,基本框架是:

而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演算法做的。

BM-math

 

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簡單的右移一步即可,堅持不走回頭路!

BM-math02

好了,這就是所謂的“壞字元演算法”,簡單吧,通俗易懂吧,上面用紅色粗體字標註出來的b就是“壞字元”,即不匹配的字元,壞字元是針對text的。

BM難道就這麼簡單?就一個啟發式規則就搞定了?當然不是了,大家再次頭腦風暴一下,有沒有其他加快字串搜尋的方法呢?比如下面的例子

BM-math03

一開始利用了壞字元演算法一下移了4位,不錯,接下來遇到了回頭路,沒辦法只能保守移一位,但真的就只能移一位嗎?No,因為pattern中前面其他位置也有剛剛匹配成功的字尾ab,那麼將pattern前面的ab右移到text剛匹配成功的ab對齊繼續往前匹配不是更好嗎?這樣就可以一次性右移兩位了,很好的有一個啟發式搜尋規則啊。有人可能想:要是前面沒已經匹配成功的字尾咋辦?是不是就無效了?不完全是,這要看情況了,比如下面這個例子。

BM-math04

 

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不可能走回頭路,因為若是回頭路,則移動距離就是負數了,肯定不是最大移動步數了),如下圖。

BM-math05

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

BM-math06

 

(2)好字尾演算法

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

Case1:模式串中有子串和好字尾完全匹配,則將最靠右的那個子串移動到好字尾的位置繼續進行匹配。

BM-math07

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

BM-math08

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演算法越高效的原因之一)。

BM-math09

如前所述,bmBc[]的計算分兩種情況,與前一一對應。

Case1:字元在模式串中有出現,bmBc[‘v’]表示字元v在模式串中最後一次出現的位置,距離模式串串尾的長度,如上圖所示。

Case2:字元在模式串中沒有出現,如模式串中沒有字元v,則BmBc[‘v’] = strlen(pattern)。

寫成程式碼也非常簡單:

計算pattern需要右移的距離,要藉助bmBc陣列,那麼bmBc的值是不是就是pattern實際要右移的距離呢?No,想想也不是,比如前面舉例說到利用bmBc演算法還可能走回頭路,也就是右移的距離是負數,而bmBc的值絕對不可能是負數,所以兩者不相等。那麼pattern實際右移的距離怎麼算呢?這個就要看text中壞字元的位置了,前面說過壞字元演算法是針對text的,還是看圖吧,一目瞭然。圖中v是text中的壞字元(對應位置i+j),在pattern中對應不匹配的位置為i,那麼pattern實際要右移的距離就是:bmBc[‘v’] – m + 1 + i。

BM-math10

(2)計算好字尾陣列bmGs[]

這裡bmGs[]的下標是數字而不是字元了,表示字元在pattern中位置。

如前所述,bmGs陣列的計算分三種情況,與前一一對應。假設圖中好字尾長度用陣列suff[]表示。

Case1:對應好字尾演算法case1,如下圖,j是好字尾之前的那個位置。

BM-math11

Case2:對應好字尾演算法case2:如下圖所示:

BM-math13

Case3:對應與好字尾演算法case3,bmGs[i] = strlen(pattern)= m

BM-math14

這樣就更加清晰了,程式碼編寫也比較簡單:

So easy? 結束了嗎?還差一步呢,這裡的suff[]咋求呢?

在計算bmGc陣列時,為提高效率,先計算輔助陣列suff[]表示好字尾的長度。

suff陣列的定義:m是pattern的長度

a. suffix[m-1] = m;
b. suffix[i] = k
    for [ pattern[i-k+1] ….,pattern[i]] == [pattern[m-1-k+1],pattern[m-1]]

看上去有些晦澀難懂,實際上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

這樣看來程式碼也很好寫:

這樣可能就萬事大吉了,可是總有人對這個演算法不滿意,感覺太暴力了,於是有聰明人想出一種方法,對上述常規方法進行改進。基本的掃描都是從右向左,改進的地方就是利用了已經計算得到的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了嗎。

BM-math15

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可能會在這次得到匹配。

好了,這樣解釋過後,程式碼也比較簡單:

結束了?OK,可以說重要的演算法都完成了,希望大家能夠看懂,為了驗證大家到底有沒有完全看明白,下面出個簡單的例子,大家算一下bmBc[]、suff[]和bmGs[]吧。

舉例如下:

BM-math16

 PS:這裡也許有人會問:bmBc[‘b’]怎麼等於2,它不是最後出現在pattern最後一個位置嗎?按定義應該是0啊。請大家仔細看下bmBc的演算法:

這裡是i < m – 1不是i < m,也就是最後一個字元如果沒有在前面出現過,那麼它的bmBc值為m。為什麼最後一位不計算在bmBc中呢?很容易想啊,如果記在內該字元的bmBc就是0,按前所述,pattern需要右移的距離bmBc[‘v’]-m+1+i=-m+1+i <= 0,也就是原地不動或走回頭路,當然不幹了,前面這種情況已經說的很清楚了,所以這裡是m-1。

好了,所有的終於都講完了,下面整合一下這些演算法吧

執行效果如下:

BM-math17

相關文章