圖解BM(Boyer-Moore)字串匹配演算法+程式碼實現

Carol淋發表於2022-05-09

簡介

本篇文章主要分為兩個大的部分,第一部分通過圖解的方式講解BM演算法,第二部分則程式碼實現一個簡易的BM演算法。

基本概念

bm是一個字串匹配演算法,有實驗統計,該演算法是著名kmp演算法效能的3~4倍,其中有兩個關鍵概念,壞字元好字尾

首先舉一個例子

需要進行匹配的主串:a b c a g f a c j k a c k e a c

匹配的模式串:a c k e a c

壞字元

如下圖所示,從模式串最後一個字元開始匹配,主串中第一個出現的不匹配的字元叫做壞字元。

 

好字尾

如下圖所示,從模式串最後一個字元開始匹配,匹配到的主串中的字元為好字尾。

工作過程

壞字元

依舊是這張圖,接下來我們按從簡單情況到複雜情況進行分析。

 

step1: 找到壞字元f,該字元對應模式串中位置si=5,如果當前沒有找到壞字元,即完全匹配,直接返回。

step2: 查詢字元f在模式串中出現位置,在當前模式串中,f沒有出現,證明之前沒有情況可以匹配,模式串直接滑到f後面位置。此次結束,否則step3。

step3: 舉個例子吧,如果主串和模式串如下,f為壞字元,模式串中存在f,記位置xi=3,這時候不能直接滑到f的後面,這時候應該將模式串中的f和主串中的f對齊,如果是下面這個例子,此時直接匹配成功。如果模式串中不止存在一個f我們如何選擇呢?用哪個f與模式串f對齊?答案是模式串中靠後的,如果使用靠前的,可能會多滑。

 

在壞字元匹配方法中,模式串往後滑動的距離應該是si-xi(如果壞字元在模式串中不存在,xi=-1)。
但是壞字元方法可能存在一個問題,看下面這個例子,壞字元a,對應匹配串中位置si=0,但是在匹配串中靠後出現位置xi=2,si-xi=-2,匹配串還往前移動,這樣就會出現問題,但是當我們把下面的好字尾講了之後,這個問題就迎刃而解了。

好字尾

首先看這張圖,這時候我們暫時不管壞字元方法(壞字元為k),由簡單情況到複雜情況進行分析。

step1:找到好字尾ac,起始位置si=4

step2:在模式串中查詢其他位置是否存在好字尾ac(如果存在多個,為了不過度滑動,仍然選擇靠後的一個),找到開頭的ac,起始位置xi=0,滑動模式串使得找到的開頭ac與好字尾ac匹配,滑動距離si-xi=4。此次結束,否則step3。

step3:還是先舉個例子,假設模式串如下圖所示,此時好字尾為ac,但是在整個模式串其他地方不存在ac,此時如果我們直接將模式串滑到ac之後,則會出現問題,實際上我們只需要滑到c的位置即可。一般化的場景我們需要怎麼操作呢?對於好字尾,如果匹配串的字首能夠和好字尾的字尾匹配上,則我們直接滑到匹配位置。計算方式:好字尾字尾起始位置-0。

   

思考一下:如果匹配串中間出現與好字尾字尾匹配的情況,是否需要考慮?答案是否定的,當中間出現的時候,滑動過去肯定匹配不上。

BM演算法

說完了BM演算法中的兩個重要概念之後,BM演算法具體怎樣實現的呢?

其實BM演算法就是壞字元和好字尾的結合,具體就是匹配串向前滑動距離取兩者計算出來的較大值。

具體步驟我們用圖來演示一遍

程式碼實現

在上面,我們說到了,在BM演算法中有兩個關鍵概念--壞字元和好字尾,所以我們的程式碼實現將分為三個步驟。

  • 利用壞字元演算法,計算匹配串可以滑動的距離

  • 利用好字尾演算法,計算匹配串可以滑動的距離

  • 結合壞字元演算法和好字尾演算法,實現BM演算法,檢視匹配串在主串中存在的位置

step1: 壞字元演算法,經過之前的分析,我們找到壞字元之後,需要查詢匹配串中是否出現過壞字元,如果出現多個,我們滑動匹配串,將靠後的壞字元與主串壞字元對齊。如果不存在,則完全匹配。如果我們每次找到壞字元都去查詢一次匹配串中是否出現過,效率不高,所以我們可以用一個hash表儲存匹配串中出現的字元以及最後出現的位置,提高查詢效率。 

 

我們設定的只有小寫字母,可以直接利用一個26大小的陣列儲存,陣列下標儲存出現的字元(字元-‘a’),陣列值儲存出現的位置。  

int[] modelStrIndex;
    private void badCharInit(char[] modelStr) {
        modelStrIndex = new int[26];
        //-1表示該字元在匹配串中沒有出現過
        for (int i = 0 ; i < 26 ; i ++) {
            modelStrIndex[i] = -1;
        }
        for (int i = 0 ; i < modelStr.length ; i++) {
            //直接依次存入,出現相同的直接覆蓋,
            //保證儲存的時候靠後出現的位置
            modelStrIndex[modelStr[i] - 'a'] = i;
        }
    } 
  1. 查詢壞字元出現位置badCharIndex,未出現,匹配成功,直接返回0。

  2. 查詢匹配串中出現的壞字元位置modelStrIndex,未出現,滑動到壞字元位置之後,直接返回匹配串的長度。

  3. 返回badCharIndex - modelStrIndex。

注:壞字元是指與匹配串字元不匹配的主串字元,是看的主串,但是我們計算的位置,是匹配串中的位置。

/**
     * @param mainStr 主串
     * @param modelStr 模式串
     * @param start 模式串在主串中的起始位置
     * @return 模式串可滑動距離,如果為0則匹配上
     */
    private int badChar(char[] mainStr, char[] modelStr, int start) {
        //壞字元位置
        int badCharIndex = -1;
        char badChar = '\0';
        //開始從匹配串後往前進行匹配
        for (int i = modelStr.length - 1 ; i >= 0 ; i --) {
            int mainStrIndex = start + i;
            //第一個出現不匹配的即為壞字元
            if (mainStr[mainStrIndex] != modelStr[i]) {
                badCharIndex = i;
                badChar = mainStr[mainStrIndex];
                break;
            }
        }
        if (-1 == badCharIndex) {
            //不存在壞字元,需匹配成功,要移動距離為0
            return 0;
        }
        //檢視壞字元在匹配串中出現的位置
        if (modelStrIndex[badChar - 'a'] > -1) {
            //出現過
            return badCharIndex - modelStrIndex[badChar - 'a'];
        }
        return modelStr.length;
    }

step2:好字尾演算法,經過之前的分析,我們在實現好字尾演算法的時候,有一個字尾字首匹配的過程,這裡我們仍然可以事先進行處理。將匹配串一分為二,分別匹配字首和字尾字串。ps:開始我的處理是兩個陣列,將字首字尾存下來,需要的時候進行匹配,但是在寫文章的時候,我突然回過神來,我已經處理了一遍了,為什麼不直接標記是否匹配呢?

初始化匹配串字首字尾是否匹配陣列,標誌當前長度的字首字尾是否匹配。

//對應位置的字首字尾是否匹配
    boolean[] isMatch;
    public void goodSuffixInit(char[] modelStr) {
        isMatch = new boolean[modelStr.length / 2];
        StringBuilder prefixStr = new StringBuilder();
        List<Character> suffixChar = new ArrayList<>(modelStr.length / 2);
        for (int i = 0 ; i < modelStr.length / 2 ; i ++) {
            prefixStr.append(modelStr[i]);
            suffixChar.add(0, modelStr[modelStr.length - i - 1]);
            isMatch[i] = this.madeSuffix(suffixChar).equals(prefixStr.toString());
        }
    }
    /**
     * 組裝字尾資料
     * @param characters
     * @return
     */
    private String madeSuffix(List<Character> characters) {
        StringBuilder sb = new StringBuilder();
        for (Character ch : characters) {
            sb.append(ch);
        }
        return sb.toString();
    }

step3: 結合壞字元和好字尾演算法實現BM演算法,起始就是每一次匹配,同時呼叫壞字元和好字尾演算法,如果返回移動距離為0,表示已經匹配成功,直接返回當前匹配的起始距離。其餘情況下,滑動壞字元和好字尾演算法返回的較大值。如果主串匹配完還沒有匹配成功,則返回-1。

注:加了一些日誌列印匹配過程

public int bmStrMatch(char[] mainStr, char[] modelStr) {
        //初始化壞字元和好字尾需要的資料
        this.badCharInit(modelStr);
        this.goodSuffixInit(modelStr);
    int start = 0;
        while (start + modelStr.length <= mainStr.length) {
            //壞字元計算的需要滑動的距離
            int badDistance = this.badChar(mainStr, modelStr, start);
            //好字尾計算的需要滑動的距離
            int goodSuffixDistance = this.goodSuffix(mainStr, modelStr, start);
            System.out.println("badDistance = " +badDistance  + ", goodSuffixDistance = " + goodSuffixDistance);
            //任意一個匹配成功即成功(可以計算了壞字元和好字尾之後分別判斷一下)
            //減少一次操作
            if (0 == badDistance || 0 == goodSuffixDistance) {
                System.out.println("匹配到的位置 :" + start);
                return start;
            }
            start += Math.max(badDistance, goodSuffixDistance);
            System.out.println("滑動至:" + start);
        }
        return -1;
    }  

最後

使用前面使用的例子,我們來實際呼叫一下

  

public static void main(String[] args) {
        BoyerMoore moore = new BoyerMoore();
        char[] mainStr = new char[]{'a','b', 'c', 'a', 'g', 'f', 'a', 'c', 'j', 'k', 'a', 'c', 'k', 'e', 'a', 'c'};
        char[] modelStr = new char[]{'a', 'c', 'k', 'e', 'a', 'c'};
        System.out.println(moore.bmStrMatch(mainStr, modelStr));
    }

呼叫結果

 

相關文章