字串匹配演算法(二)-BM演算法詳解

公眾號程式設計師學長發表於2021-07-31

     我們在字串匹配演算法(一)學習了BF演算法和RK演算法,那有沒更加高效的字串匹配演算法呢。我們今天就來聊一聊BM演算法。

BM演算法

       我們把模式串和主串的匹配過程,可以看做是固定主串,然後模式串不斷在往後滑動的過程。當遇到不匹配的字元時,BF算和RK演算法的做法是,把模式串向後滑動一位,然後從模式串的第一位開始重新匹配。如下圖所示。

      由於BF演算法和RK演算法,在遇到不匹配的字元時,模式串只是向後滑動一位,這樣的話時間複雜度比較高,那有沒有什麼演算法可以一下子多滑動幾位呢?比如遇到主串A中的字元d,由於d不在模式串中,所以只要d和模式串有重合,那就肯定不能匹配。所以我們可以直接多滑動幾位,直接滑到d的後面,然後再繼續匹配,這樣不就提高了效率了嗎?

      今天要聊的BM演算法,本質上就是尋找這種規律。藉助這種規律,在模式串和主串匹配的過程中,當模式串和主串遇到不匹配的字元時,能夠跳過一些肯定不匹配的情況,多往後滑動幾位。

BM演算法的原理

      BM演算法包含2部分,分別是壞字元規則和好字尾規則。

1.壞字元規則

     我們在BF演算法和RK演算法中,在模式串和主串匹配的過程中,我們都是按模式串的下標從小到大的順序依次匹配的。而BM演算法的匹配順序則相反,是從大到小匹配的。如下所示。

     從模式串的末尾倒著匹配,當發現主串中某個字元匹配不上時,我們就把這個字元稱為壞字元。我們拿著壞字元d在模式串中查詢,發現d不在模式串中。這個時候,我們可以將模式串直接滑動3位,滑動到字元d的後面,然後再從模式串的末尾開始比較。

        這個時候,我們發現主串中的字串b和模式串的中的c不匹配。這個時候由於壞字元b在模式串中是存在的,模式串中下標為1的位置也是字元b,所以我們可以把模式串向後滑動1位,讓主串中的b和模式串中的b相對齊。然後再從模式串的末尾字元開始重新進行匹配。

       從上面的例子中,我們可以總結出規律。當發生不匹配的時候,我們把壞字元對應的模式串中的字元下標記做Ai。如果壞字元在模式串中存在,我們把這個壞字元在模式串中的下標記做Bi(如果壞字元在模式串中出現多次,我們把靠後的那個位置記做是Bi,這麼做是為了不讓模式串向後滑動過多,導致可能匹配的情況錯過)。那模式串向後滑動的位數就是Ai-Bi。

       不過單純的使用壞字元規則是不夠的。因為根據Ai-Bi計算出來的移動位數有可能是負數。比如主串是aaaaaa,模式串是baaa。所以,BM演算法還需要用到“好字尾規則”。

2.好字尾規則

        好字尾規則和壞字元規則思路上很相似。如下圖所示。

     當模式串滑動到圖中的位置時,模式串和主串有2個字元是匹配的,倒數第三個字元發生了不匹配的情況。我們把已經匹配的ab叫做好字尾,記做{u}。我們拿它在模式串中進行尋找另一個和{u}相匹配的子串{u*}。那我們就將模式串滑動到子串{u*}和主串{u}對齊的位置。

     如果在模式串中找不到另一個等於{u}的子串,我們就直接將模式串,滑動到主串{u}的後面。因為之前的任何一次往後滑動,都沒有匹配主串{u}的情況。不過,當模式串中不存在等於{u}的子串時,我們直接將模式串滑動到{u}的後面,這樣是否會錯過可能匹配的情況呢。如下所示,這裡的ab是好字尾,儘管在模式串中沒有另一個相匹配的子串{u*},但如果我們將模式串移動到{u}的後面,那就錯過了模式串和主串相匹配的情況。

     所以,當模式串滑動到字首與主串中{u}的字尾有部分重合的時候,並且重合的部分相等的時候,就有可能會存在完全匹配的情況。針對這種情況,我們不僅要看好字尾在模式串中,是否存在另一個匹配的子串。我們還要考察好字尾的字尾子串,是否和模式串的字首子串相匹配。

      這裡我們再來解釋一下字串的字尾子串和字首子串。所謂字串A的字尾子串,就是最後一個字元跟A對齊的子串,比如,字串abc的字尾子串是c、bc。所謂的字首子串,就是起始字元和A對齊的子串。比如,字串abc的字首子串是a、ab。我們從好字尾子串中,找一個最長並且能和模式串字首子串匹配的,假如是{v}。然後滑動到如圖所示的位置。

       到目前位置,我們的壞字元和好字尾就講完了,我們接下來想這麼一個問題。當模式串和主串中某個字元不匹配的時候,我們是選好字尾規則呢還是壞字元規則來計算向後滑動的位數呢?

       我們可以分別計算壞字元規則和好字尾規則向後滑動的位數,然後取兩個數的最大的,作為模式串往後滑動的位數。

BM演算法的程式碼實現

         我們接下來來看BM演算法的程式碼是如何實現的。

         "壞字元規則"中當遇到壞字元時,我們要計算後移的位數Ai-Bi,其中Bi的計算的重點。如果我們拿壞字元在模式串中順序查詢,這樣是可以實現,不過效率比較低下。我們可以用大小256的陣列,來記錄每個字元在模式串中出現的位置。陣列的下標對應的是字元的Ascii編碼,陣列中儲存的是這個字元在模式串中的位置。

SIZE = 256
def generateBC(b, m):
    bc=[-1]*SIZE
    for i in range(m):
        accii=ord(b[i])
        bc[accii]=i
    return b

        我們先把BM演算法中的“壞字元規則”寫好,先不考慮“好字尾規則”,並且忽略掉Ai-Bi為負數的情況。

def bm(a,n,b,m):
     #生成bc雜湊表
     bc=generateBC(b,m)
     #i表示主串與模式串對齊的第一個字元
     i = 0
     while i<=n-m:
          for j in range(m-1,-2,-1): #模式串從後往前匹配
               if(a[i+j]!=b[j]): #壞字元對應模式串中的下標是j
                    break
​
          if(j<0):
               #表示匹配成功
               return i
          #Ai-Bi,等同於向後滑動幾位
          i = i + (j - bc[ord(a[i+j])])
     return -1

  到目前為止,我們已經實現了“壞字元規則”的程式碼框架,剩下就是需要往裡面新增“好字尾規則”的程式碼邏輯了。在繼續講解之前,我們先簡單回顧一下,前面講的“好字尾規則”中最關鍵的內容。

  • 在模式串中,查詢和好字尾匹配的另一個子串。

  • 在好字尾的字尾子串中,查詢最長的,能跟模式串字首子串相匹配的字尾子串。

      我們可以這麼考慮,因為好字尾也是模式串的字尾子串,所以,我們可以在模式串和主串進行匹配之前,通過預處理模式串,預先計算好模式串中的每個字尾子串,對應的另外一個可匹配子串中的位置。這個預處理過程有點複雜。大家可以多讀幾遍,在紙上畫一畫。

     我們先來看如何表示模式串中的不同字尾子串呢?因為字尾子串的最後一個字元的位置是固定的,下標為m-1,所以我們只需要記錄長度就可以了。通過長度,我們可以唯一確定一個字尾子串。

      下面我們引入一個關鍵的陣列suffix。suffix陣列的下標k,表示字尾子串的長度,陣列對應的位置儲存的是,在模式串中和好字尾{u}相匹配的子串{u*}的起始下標位置。

      其中有一個點需要注意,如果模式串中有多個子串跟字尾子串相匹配,我們選最靠後的那個子串的起始位置,以免滑動的太遠,錯過可能匹配的情況。

    接下來我們來看好字尾規則的第二條,就是要在好字尾的字尾子串中,查詢最長的能跟模式串字首子串匹配的字尾子串。接來下,我們來引入另一個陣列變數prefix,來記錄模式串的字尾子串是否能匹配模式串的字首子串。

 

    接下來我們來看如何給這兩個陣列賦值呢?我們拿下標為0~i的子串(i 可以是 0 到 m-2)與整個模式串,求公共字尾子串。如果公共字尾子串的長度是 k,那我們就記錄 suffix[k]=j(j 表示公共字尾子串的起始下標)。如果 j 等於 0,也就是說,公共字尾子串也是模式串的字首子串,我們就記錄 prefix[k]=true。我們來看程式碼實現。

def generateSP(b,m):
     suffix= [-1]*m
     prefix= [False]*m
     for i in range(m-1):
          j=i
          k=0 #公共字尾子串長度
          while (j>=0 and b[j]==b[m-1-k]):
               j=j-1
               k=k+1
               #j+1表示公共字尾子串在b[0, i]中的起始下標
               suffix[k]=j+1
​
          if (j==-1):
               #如果公共字尾子串也是模式串的字首子串
               prefix[k] = True
​
     return suffix,prefix

 下面我們來看如何根據好字尾規則,來計算模式串往後滑動的位數?假設好字尾的長度是 k。我們先拿好字尾,在 suffix 陣列中查詢其匹配的子串。如果 suffix[k]不等於 -1(-1 表示不存在匹配的子串),那我們就將模式串往後移動 j-suffix[k]+1 位(j 表示壞字元對應的模式串中的字元下標)。如果 suffix[k]等於 -1,表示模式串中不存在另一個跟好字尾匹配的子串片段。我們就用下面這條規則來處理。好字尾的字尾子串 b[r, m-1](其中,r 取值從 j+2 到 m-1)的長度 k=m-r,如果 prefix[k]等於 true,表示長度為 k 的字尾子串,有可匹配的字首子串,這樣我們可以把模式串後移 r 位。如果兩條規則都沒有找到可以匹配的好字尾及其字尾子串的字尾子串,我們就將整個模式串後移 m 位。到此為止,我們的好字尾規則也聊完了,我們現在把好字尾規則的程式碼插入到前面的框架中,就可以得到完整版本的BM演算法了。

#雜湊表的大小
SIZE = 256
def generateBC(b, m):
    bc=[-1]*SIZE
    for i in range(m):
        accii=ord(b[i])
        bc[accii]=i
​
    return bc
​
def generateSP(b,m):
     suffix= [-1]*m
     prefix= [False]*m
     for i in range(m-1):
          j=i
          k=0 #公共字尾子串長度
          while (j>=0 and b[j]==b[m-1-k]):
               j=j-1
               k=k+1
               #j+1表示公共字尾子串在b[0, i]中的起始下標
               suffix[k]=j+1
​
          if (j==-1):
               #如果公共字尾子串也是模式串的字首子串
               prefix[k] = True
​
     return suffix,prefix
​
#j表示壞字元對應的模式串中的字元下標
#m表示模式串長度
def moveSP(j,m,suffix,prefix):
     #好字尾的長度
     k=m-1-j
     if suffix[k]!=-1:
          return j-suffix[k]+1
     for r in range(j+2,m):
          if prefix[m-r]==True:
               return r
     return m
​
def bm(a,n,b,m):
     #生成bc雜湊表
     bc=generateBC(b,m)
     suffix, prefix = generateSP(b,m)
     #i表示主串與模式串對齊的第一個字元
     i = 0
     while i<=n-m:
          for j in range(m-1,-2,-1): #模式串從後往前匹配
               if(a[i+j]!=b[j]): #壞字元對應模式串中的下標是j
                    break
​
          if(j<0):
               #表示匹配成功
               return i
          #Ai-Bi,等同於先後滑動幾位
          x = j - bc[ord(a[i+j])]
          y = 0
          #如果有好字尾的話
          if j < m-1:
             y = moveSP(j, m, suffix, prefix)
​
          i = i + max(x, y)
     return -1
   到此為止,我們BM演算法就聊完。更多硬核知識,請關注公眾號。

相關文章