BM演算法詳解

來源:互聯網
上載者:User

1977年,Robert S.Boyer和J Strother Moore提出了另一種在O(n)時間複雜度內,完成字串匹配的演算法,其在絕大多數場合的效能表現,比KMP演算法還要出色,下面我們就來詳細瞭解一下這一出色的單模式比對演算法,在此之前推薦讀者讀一下我的另一篇文章《KMP演算法詳解》,對於透徹理解BM演算法大有裨益。

在講解Boyer-Moore演算法之前,我們還是要提一提KMP演算法的老例子,當模式串與目標串匹配至如下位置時:

 1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
b a b c b a b c a b c a a b c a b c a b c a c a b c
          a b c a b c a c a b                      

我們發現target[13]!=pattern[7],此時根據KMP演算法的next值,我們將target[13]與pattern[5]對齊,再依次執行匹配。這裡target[13]='a'。如果target[13]='d',因為'd'不是模式串pattern中的字元,所以無論將target[13]與pattern中任何一個字元對齊都會匹配失敗,所以當我們在匹配過程中發現target[i]是不屬於模式串的字元,則我們可以直接將target[i+1],與pattern[1]對齊,再向後執行匹配。這樣就獲得了更大的跳轉幅度,同時也能保證匹配的正確性。這便是BM演算法相較於KMP演算法的一個重要改進。

BM演算法之所以能夠在單模式比對中有更加出色的表現,主要是其使用了兩個跳轉表,一個是壞字元表(論文中稱為delta1),一個是好尾碼表(論文中稱為delta2),下面我們以BM演算法對目標串的一次匹配操作,來講解這兩個表的具體跳轉策略,這裡模式串為"AT-THAT",目標串為"WHICH-FINALLY-HALTS.--AT-THAT-POINT"。

   1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
  W H I C H - F I N A L L Y - H A L T S . - - A T - T H A T - P O I N T
 1 A T - T H A T                                                        
 2               A T - T H A T                                          
 3                       A T - T H A T                                  
 4                                   A T - T H A T                      
 5                                             A T - T H A T            

BM演算法與KMP演算法的最大的不同之處在於,當目標串與模式串在某個位置對齊之後,KMP演算法是從對齊位置向後依次執行匹配(不一定是模式串的第一個元素)。而BM演算法是從模式串的末尾位置(一定是模式串的最後一個元素)向前與目標串依次執行匹配。上面的例子,在4次模式串移動之後,就發現了匹配模式。

第一次,pattern[1]與target[1]對齊,從pattern[7]向前依次與target執行比較,但是第1次比較就發現,target[7]='F',而'F'不是pattern串中的字元,所以target中包含target[7]的任何子串都不可能與pattern匹配,此時我們可以直接將pattern串滑動到target[7]之後,讓pattern[1]與target[8]對齊,然後再由target[14]依次向前執行比較。

第二次,target[14]='-',雖然'-'是模式串中的字元,但是如果要target串中包含target[14]的字串與pattern串匹配,則至少target[14]需與pattern中最後一個'-'對齊。而pattern中只有一個'-'pattern[3],所以將target[14],與pattern[3]對齊,然後再由target[18]向前依次執行比較。

第三次,雖然target[18]=pattern[7]='T',但是target[17]='L','L'不是pattern中的字元,所以包含target[17]的任何字串都不可能與pattern匹配,所以pattern[1]直接與target[18]對齊再執行匹配。

第四次,target[23...24]=pattern[6...7],target[22]!=pattern[5],我們注意到,pattern[6...7]=pattern[1...2]所以pattern[1...2]也是模式串的一個自包含尾碼(下文詳述),所以我們可以令pattern[1]與target[23]對齊再向後執行匹配,此時我們就發現了滿足條件的匹配串target[23...29]。

該樣本使用到了BM演算法中的所有跳轉最佳化,大幅加速了模式串的向後滑動過程,實現了模式的快速匹配,其中第1,2,3次滑動使用的是演算法中的壞字元移動規則,第4次滑動使用的是好尾碼移動規則,那麼什麼是所謂的壞字元和好尾碼規則呢。

所謂的壞字元移動規則,就是一個表,其以輸入字元集的所有字元作為索引,每個輸入字元c對應著一個值,表示如果目標串的當前位置字元c與模式串匹配失敗,那麼目標串當前位置應該可以向前滑動的步數。假設字元集為"ABCDEFGHIJKLMNOPQRSTUVWXYZ-",那麼他對應模式串"AT-THAT"的壞字元表為。

  A B C D E F G H I J K L M N O P Q R S T U V W X Y Z -
delta1 1 7 7 7 7 7 7 2 7 7 7 7 7 7 7 7 7 7 7 0 7 7 7 7 7 7 4

壞字元表的定義為,對於輸入字元集合中的字元c,如果c不在模式串中,則delta1[c]= patlen(模式串的長度),如果c在模式串中,則delta1[c]=j-i,其中,j是模式串最末元素的索引值,i是字元c在模式串中最右出現的位置(這裡與Boyer-Morre兩人的論文略有差別,主要是因為BM的論文中,字串的索引從1開始,其最末元素的索引值,就等於模式串的長度,而在實際計算模式串中含有字元的壞字元滑動值時,使用到的是模式串最末元素的索引值,這個值與模式串的長度不一定相等)。下面就是用於產生壞字元表的代碼,為了簡單起見,這裡沒有使用字典結構,而是假設輸入的字元只能是A-Z,然後將這26個字元對應表到一個數組中。

inline void BuildBadC(const char* pattern, size_t pattern_length, unsigned int* badc, size_t alphabet_size){unsigned int i;for(i = 0; i < alphabet_size; ++i){badc[i] = pattern_length;}for(i = 0; i < pattern_length; ++i){badc[pattern[i] - 'A'] = pattern_length - 1 - i;}}

所謂的好尾碼移動規則,是BM演算法的核心部分,下面詳細說明。在KMP演算法中,我們知道了所謂的首碼自包含問題,也就是模式串的首碼也可能是模式串的非首碼子串。在BM演算法中,有一個與其非常相似的概念,叫尾碼自包含。對於pattern[1...j],存在長度為k的子串,滿足pattern[m+1...m+k]=pattern[j-k+1...j],其中k<j,0<m<j-k。以字串"BCDBCDABCDABCD"為例,pattern[7...10]就是一個包含尾碼,因為pattern[7...10]=pattern[11...14]。      

我們定義數組pre[],與pattern中的元素一一對應,對於pattern中的元素,pattern[i],pre[i]是使得pattern[k+1...j-i]=pattern[i+1...j],且pattern[k]!=pattern[i]的k的最大值,如果不存在這樣的k,pre[i]=patlen。對於對於模式串的尾碼k pattern[j-k+1...j],滿足條件的包含尾碼可能不止一個,這裡我們需要關注所有滿足條件的pattern[m+1...m+k]中,滿足pattern[m]
!= pattern[j-k]的m的最大值。對於上例的模式串,其尾碼3 pattern[12...14],其包含尾碼有pattern[8...10],pattern[4...6],pattern[1...3],在這3個包含尾碼中,pattern[7]=pattern[11],所以pattern[8...10]不是我們想要的包含尾碼。pattern[0] != pattern[11](這裡面我們假設pattern[0]不等於任何可輸入字元),pattern[3]!=pattern[11],在這兩個備選子串中,pattern[4...6]的m值(3)大於pattern[1...3]的m值(0),所以pattern[4...6]就是我們需要的pre值。對於為什麼要滿足pattern[m]!=pattern[j-k],請參考我的《KMP演算法詳解》一文中對於next[j]與f(j)不同之處的解釋,以及本文後面演算法正確性方面的說明。

現在我們發現了pattern[12...14]在模式串中的包含尾碼pattern[4...6],此時如果我們發現目標串target[n]與模式串pattern[11]比較失敗,我們就直接可以將pattern[3]與target[n]對齊,然後再從target[n+11]處向前依次與模式串進行匹配。目標串當前位置的跳轉距離goods[i]=j-pre[i]。

這裡我們需要解釋一下如此大幅跳轉的正確性。還是以上述模式串為例,當target[n]與pattern[11]匹配失敗時,我們需要找到一個適當的位置,令target[n+1...n+3]與pattern[k+1...k+3]相同,才有可能找到匹配結果,這裡target[n+1...n+3]=pattern[12...14]。根據pre[i]的定義,只有當k=3時,才能保證pattern[12...14]
= pattern[4...6],對於任何k>3都有pattern[12...14] != pattern[k+1...k+3],因為如果存在k>3使得pattern[12...14]
= pattern[k+1...k+3],那麼pre[11]必然大於3。所以這一對齊不會漏過中間可能的匹配。

這裡讀者可能會有疑問,你說的實際是錯的,對於k=7,有target[n+1...n+3]=pattern[8...10],為什麼不讓target[n]與pattern[7]對齊,然後從target[n+7]位置開始依次向前比較呢?這個問題和KMP演算法中next[j]和f(j)的不同之處一樣。雖然有pattern[8...10]=pattern[12...14],但是pattern[7]=pattern[11]。因為target[n]
!= target[11],所以target[n]!=pattern[7]所以將target[n]與pattern[7]對齊所執行匹配嘗試必然失敗,所以target[n]可以直接跳過pattern[7]直接與pattern[3]對齊。

另一方面,如果target[n]與pattern[k]對齊,但是pattern[k+1...j]在模式串中不存在包含尾碼,我們該如何決定模式串向後的滑動距離呢。此時target[n+1...n+j-k] = pattern[k+1...j],因為pattern[k+1...j]不存在包含尾碼,所以對於任何m(0<=m<k),pattern[m+1...m+j-k]!=pattern[k+1...j](m<k+1),所以將target[n]與pattern[m]對齊,相當於執行pattern[k+1...j]與pattern[m+1...m+j-k]的匹配,結果必然失敗。

此時可以考慮pattern[1]與target[n+1]對齊。pattern[1]與target[n+1]對齊後,pattern[1...j-k]是模式的首碼j-k,target[n+1...n+j-k]相當於pattern[k+1...j],因為pattern[k+1...j]不存在包含子串,所以此次匹配也會失敗。繼續移動pattern[1],pattern[1]與target[n+2]對齊,此時target[n+2...n+j-k]相當於pattern[1...j-k-1],與pattern[k+2...j]比較,此時兩者是否相等依賴於我們之前計算pre表的結果,能夠使這個匹配成立的是使pattern[1...m]=pattern[j-m+1...j]的m的最大值,將pattern[1]與target[n+j-k-m+1]對齊,如果這樣的m不存在,則pattern[1]可以直接與target[n+j-k+1]對齊,再執行匹配。如下例,當在target[4]處發生匹配失敗,根據之前的介紹,pattern[1]與2,3,4,5,6對齊也都會失敗,這裡j=9,k=4,m=3,n=4。

   1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
target A B C D X X A B C . . . . . .
  A B C X X X A B C            
        A B C D X X A B C      
              A B C D X X A B C

根據上面的介紹,我們就可以得出根據pre[i]計算goods[i]的方法,在計算pre值之前,我們先將所有pre[i]初始化為patlen,對於pattern[i],如果不存在m,使得pattern[m+1...m+j-i]=pattern[i+1...j](m<i),且pattern[m]!=pattern[i],則我們不去修改pre[i]的值。計算完所有元素的pre值之後,對於pre[i]!=patlen的情況,goods[i]
= j - pre[i],否則,對於pattern[i](j-i<c)的情況goods[i] = patlen+j-i,對於pattern[i](j-i>=c),goods[i]=patlen+j-i-c,其中c是滿足pattern[1...c]=pattern[j-c+1...j](c>0)的c的最大值,如果不存在這樣的c,c=0。模式中最末元素的goods值固定為1。

   1  2  3  4  5  6  7  8  9 10 11 12 13 14
  B C D B C D A B C D A B C D
pre[i] 14 14 14 14 14 14 14 14 14 14 3 14 14  
goods[i] 24 23 22 21 20 19 18 17 16 15 11 13 12 1

很遺憾,在Boyer-Moore兩人的論文中,並沒有給出像KMP演算法中計算next表那麼犀利的演算法,所以這裡用窮舉法給出了一個時間複雜度為O(n^2)的笨法。如果讀者有更漂亮的求好尾碼表的演算法,請指教。

inline void BuildGoodS(const char* pattern, size_t pattern_length, unsigned int* goods){unsigned int i, j, c;for(i = 0; i < pattern_length - 1; ++i){goods[i] = pattern_length;}//初始化pattern最末元素的好尾碼值goods[pattern_length - 1] = 1;//此迴圈找出pattern中各元素的pre值,這裡goods數組先當作pre數組使用for(i = pattern_length -1, c = 0; i != 0; --i){for(j = 0; j < i; ++j){if(memcmp(pattern + i, pattern + j, (pattern_length - i) * sizeof(char)) == 0){if(j == 0){c = pattern_length - i;}else{if(pattern[i - 1] != pattern[j - 1]){goods[i - 1] = j - 1;}}}}}//根據pattern中個元素的pre值,計算goods值for(i = 0; i < pattern_length - 1; ++i){if(goods[i] != pattern_length){goods[i] = pattern_length - 1 - goods[i];}else{goods[i] = pattern_length - 1 - i + goods[i];if(c != 0 && pattern_length - 1 - i >= c){goods[i] -= c;}}}}

現在BM演算法的兩個基本工具壞字元,好尾碼都已具備,我們如何在目標串target[1...n]中飛快的找到我們想要的模式pattern[1..j]呢。

首先,我們將pattern[1]與target[1]對齊,然後從target[j]向前依次執行匹配操作。如果在pattern[i]位置發現匹配失敗,則在好首碼表裡用i尋找滑動距離goods[i],在壞字元表中用target[i]做索引,尋找滑動距離badc[target[i]],假設前者返回的值為p,後者返回的值為q,這時我們取其中的較大者(假設為p),然後將pattern[j]與target[i+p]對齊,然後依次向前匹配,直到發現匹配,或者遍曆整個target串沒有找到目標模式為止。下面是BM演算法的實現代碼,該演算法與之前KMP演算法一樣,都進行了擴充,可以找到目標串中的所有匹配模式,相比之下,BM擴充為找到目標序列中的所有匹配模式串要比KMP簡單,不需要引入任何新的東西,只需要在發現匹配模式之後,仍然按照goods[0]移動目標串遊標即可。

unsigned int BM(const char* text, size_t text_length, const char* pattern, size_t pattern_length, unsigned int* matches){unsigned int i, j, m;unsigned int badc[ALPHABET_SIZE];unsigned int goods[pattern_length];i = j = pattern_length - 1;m = 0;//構建好尾碼和壞字元表BuildBadC(pattern, pattern_length, badc, ALPHABET_SIZE);BuildGoodS(pattern, pattern_length, goods);while(j < text_length){//發現目標傳與模式傳從後向前第1個不匹配的位置while((i != 0) && (pattern[i] == text[j])){--i;--j;}//找到一個匹配的情況if(i == 0 && pattern[i] == text[j]){matches[m++] = j;j += goods[0];}else{//壞字元表用字典構建比較合適j += goods[i] > badc[text[j]-'A'] ? goods[i] : badc[text[j]-'A'];}i = pattern_length - 1;}return m;}

後記:

  • 對於進階的單模式比對演算法而言,子串(首碼/尾碼)的自包含,是至關重要的概念,是加速模式比對效率的金鑰匙,而將其發揚光大的無疑是KMP演算法,BM演算法使用尾碼自包含,從後向前匹配模式串的靈感,也源於此,只有透徹理解KMP演算法,才可能透徹理解BM演算法。
  • 壞字元表,可以用於加速任何的單模式比對演算法,而不僅限於BM演算法,對於KMP演算法,壞字元表同樣可以起到大幅增加匹配速度的效果。對於大字元集的文字,我們需要改變壞字元表的使用思路,用字典來儲存模式串中的字元的跳轉步數,對於在字典中沒有查到的字元,說明其不在模式串中,目標串當前字元直接滑動patlen個字元。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.