字符串匹配之 BM 算法

1、基本概念

字符串匹配是計算機科學領域中最古老、研究最普遍的問題之一,層出不窮的前輩們也總結了很是多經典的優秀算法,例如 BF 算法、RK 算法、BM 算法、KMP 算法,今天我介紹的主角是 BM 算法。java

字符串匹配能夠簡單歸納爲前綴匹配,後綴匹配,子串匹配,下面的講解我都以最多見的子串匹配爲例。子串匹配的概念很簡單,一句話解釋就是:在一個字符串 A 中尋找另外一個字符串 B,這裏的字符串 A 能夠叫作 主串,字符串 B 能夠叫作 模式串算法

2、基礎解法

1. BF 算法

在一個字符串中尋找另外一字符串,最容易想到的,也是最簡單的辦法是:取主串和模式串中的每一位依次比較,若是匹配則同時後移一位繼續比較,直至匹配到模式串的最後一位;若是出現不匹配的字符,則模式串向後移動一位,繼續比較。這種解決問題的思路簡單暴力,也是這個算法被叫作BF(Brute Force)的緣由。數組

整個匹配的過程能夠參考下圖:緩存

在這裏插入圖片描述

整個代碼實現也很是簡單,我這裏寫一個示例供參考:編輯器

public static int bruteForce(String main, String ptn){
    if (main == null || ptn == null){
        return -1;
    }

    int m = main.length();
    int n = ptn.length();
    if (n > m){
        return bf(ptn, main);
    }

    for (int i = 0; i <= m - n; i++) {
        int j = i;
        int k = 0;
        while (k < n && main.charAt(j) == ptn.charAt(k)){
            ++j;
            ++k;
        }
        if (k == n){
            return i;
        }
    }
    return -1;
}

複雜度分析函數

這個算法的複雜度仍是比較好分析的,咱們假設主串的長度是 m,模式串的長度是 n,在最好的狀況下,在第一個字符處的匹配就可以成功,例如主串是 a b c d ,模式串是 a b c,這時只遍歷了模式串的長度,由於時間複雜度是 O(n);學習

在最壞的狀況下,每次都須要遍歷整個模式串,可是又未能匹配成功,例如主串是 a a a a a ...a,模式串是 a a a a b ,因此須要遍歷 m - n + 1 次,時間複雜度是 O(m * n) 。優化

從代碼中能夠看出,整個過程沒有藉助額外的存儲空間,只使用到了幾個常數,所以空間複雜度是 O(1)。spa

2. RK 算法

RK 算法是以其兩位發明者 Rabin、Karp 的名字命名的,它實際上是 BF 算法的一個優化版本。設計

前面講到的 BF 算法,每次在主串和模式串進行匹配時,都須要遍歷模式串,而 RK 算法藉助了哈希算法來下降時間複雜度。

當模式串和主串進行比較時,並不直接依次遍歷每一個字符,而是計算主串的子字符串的哈希值,而後將哈希值和模式串的哈希值進行比較,若是哈希值相等,則能夠斷定匹配成功,整個匹配的過程能夠參考下圖:

在這裏插入圖片描述

能夠看到,優化的地方在於將每次的遍歷模式串比較,變成了哈希值之間的比較,這樣的話匹配的速度就加快了。但須要注意的是,若是求解哈希值的函數須要遍歷字符串的話,那麼這個算法的時間複雜度並無獲得提高,反而還有可能降低,由於每次遍歷的話,這和 BF 算法暴力匹配的思路就沒有區別了,因此哈希函數的設計就要避免這個問題。

容易想到的一種簡單的哈希函數是,直接將每一個字符的 ASCII 碼相加,獲得一個哈希值。假設主串是 a b d e a,模式串爲 d e a ,長度是 3,第一次匹配時,取的子字符串是 a b d,第二次取的子字符串是 b d e,能夠看到這兩個子字符串是有重合的部分的,只有首尾兩個字符不同,因此代碼實現能夠利用這一點來避免遍歷字符串。固然這種哈希函數比較簡單,還有其餘一些更加精妙的設計,感興趣的話能夠自行研究下。

還有一個問題即是,若是存在哈希衝突的話,即兩個字符串的哈希值雖然同樣,可是字符串可能並不同,這個問題的解決思路其實很簡單,當哈希值相同時,再依次遍歷兩個字符串看是否相同,若是相同,則匹配成功。由於哈希衝突並不會常常出現,因此這一次的依次遍歷匹配的開銷仍是能夠接受的,對算法總體效率的影響並不大。

下面是一個簡單的代碼示例供參考:

public static int rabinKarp(String main, String ptn){
    if (main == null || ptn == null){
        return -1;
    }

    int m = main.length();
    int n = ptn.length();
    if (n > m){
        return rk(ptn, main);
    }

    //計算模式串的hash值
    int ptnHash = 0;
    for (int i = 0; i < n; i++) {
        ptnHash += ptn.charAt(i);
    }

    int mainHash = 0;
    for (int i = 0; i <= m - n; i++) {
        //i == 0時須要遍歷計算哈希值,後續不須要
        if (i == 0) {
            for (int j = 0; j < n; j++) {
                mainHash += main.charAt(j);
            }
        }else {
            mainHash = mainHash - main.charAt(i - 1) + main.charAt(i + n - 1);
        }

        //若是哈希值相同,爲避免哈希衝突,再依次遍歷比較
        if (mainHash == ptnHash){
            int k = i;
            int j = 0;
            while (j < n && main.charAt(k) == ptn.charAt(j)){
                ++k;
                ++j;
            }
            if (j == n){
                return i;
            }
        }
    }

    return -1;
}

模式串和主串之間的匹配是常數時間的,最多隻須要遍歷 m - n + 1 次,再加上可能存在哈希衝突的狀況,所以 RK 算法的整體的時間複雜度大概爲 O(m)。

在極端狀況下,若是哈希函數設計得十分不合理,產生哈希衝突的機率很高的話,那麼每次遍歷都須要掃描一遍模式串,那麼算法的時間複雜度就退化爲 O(m * n) 了。

3、BM 算法

瞭解了兩種基本的字符串匹配算法以後,其實能夠發現 BF 算法和 RK 算法都存在一個很明顯的缺陷,那就是若是出現了不匹配的狀況,那麼每次模式串都是向後移動一位,而後再繼續比較。那有沒有更加高效的辦法讓模式串多移動幾位呢,這就是 BM 算法試圖解決的問題。

BM 算法也是由兩位發明者 Boyer 和 Moore 的名字命名的,其核心思想是在主串和模式串進行比較時,若是出現了不匹配的狀況,可以儘量多的獲取一些信息,藉此跳過那些確定不會匹配的狀況,以此來提高字符串匹配的效率,大多數文本編輯器中的字符查找功能,通常都會使用 BM 算法來實現。

BM 算法主要由兩部分組成,分別是壞字符規則和好後綴規則,下面依次介紹。

1. 壞字符規則

前面說到的字符串匹配,匹配的順序都是從前到後,按位依次匹配的,而利用壞字符規則的時候,主串和模式串的匹配順序是從後往前,倒着匹配的,像下圖這樣:

在這裏插入圖片描述

匹配的時候,若是主串中的字符和模式串的字符不匹配,那麼就將主串中這個字符叫作壞字符,例如上圖中的字符 e ,由於它和模式串中的 d 不匹配:

在這裏插入圖片描述

若是遇到了壞字符,能夠看到壞字符 e 在模式串中是不存在的,那麼能夠斷定,主串中字符 e 以前的部分確定不會匹配,所以能夠直接將模式串移動至壞字符 e後面繼續匹配,相較於暴力匹配每次移動一位,這樣顯然更加高效:

在這裏插入圖片描述

但須要注意的是,若是壞字符在模式串中是存在的,那麼就不能直接移動模式串至壞字符的後面了,而是將模式串移動至和壞字符相同的字符處,而後繼續比較,參考下圖:

在這裏插入圖片描述

能夠看到,壞字符可能在模式串中存在多個,那麼移動模式串的時候,應該移動至更靠前的那個,仍是更靠後的那個呢?爲了不錯過正確匹配的狀況,咱們應該移動更少的位數,所以必須移動至更靠後的那個壞字符處。就像上圖中的那樣,壞字符 f在模式串中存在兩個,移動時須要將模式串移動至更靠後的那個壞字符處。

總結一下規律,當匹配的時候,若是遇到了壞字符,有兩種狀況:一是壞字符不在模式串中,那麼直接移動模式串至壞字符後一位;若是壞字符在模式串中,那麼移動模式串至與壞字符匹配的最靠後的那個字符處,而後繼續比較。

如今很關鍵的一個問題來了,咱們怎麼知道一個字符在模式串中是否存在呢?最常規的思路是遍歷整個模式串,查找是否存在,可是這樣的時間複雜度是 O(n),對算法效率的影響很大,有沒有更加高效的方法呢?此時很容易想到哈希表,哈希表的特性是經過哈希映射實現了高效的查找,用到如今這個場合是很是合適的。

先假設一種比較基礎的狀況,咱們的匹配的字符只包含常規的英文字符,總共 256 個,那麼能夠構建一個數組,模式串中字符的 ACSII 碼爲數組的下標,下標對應的值爲模式串每一個字符的位置,參考下圖:

在這裏插入圖片描述

這樣,當匹配的時候,若是遇到了壞字符,就能夠從數組中對應的下標查詢,若是值大於等於 0,說明壞字符在模式串中,而且數組中的值是字符在模式串中的位置,能夠利用這個值來判斷模式串移動的位數,大體的代碼實現以下:

private static final int[] badChar = new int[256];

    public static int bm(String main, String ptn){
        if (main == null || ptn == null){
            return -1;
        }

        int m = main.length();
        int n = ptn.length();
        badCharRule(ptn, badChar);

        int i = n - 1;
        while (i <= m - 1) {
            int j = n - 1;
            while (j >= 0 && main.charAt(i) == ptn.charAt(j)){
                --i;
                if (--j == -1){
                    return i + 1;
                }
            }

            //計算壞字符規則下移動的位數
            int moveWithBC = j - badChar[main.charAt(i)];
            i += moveWithBC;
        }

        return -1;
    }

    /**
     * 生成壞字符數組
     */
    private static void badCharRule(String str, int[] badChar){
        if (str == null){
           return;
        }

        Arrays.fill(badChar, -1);
        for (int i = 0; i < str.length(); i++) {
            badChar[str.charAt(i)] = i;
        }
    }

壞字符規則雖然利用起來比較高效,可是在某些狀況下,它仍是有點問題的,例如主串是 a a a a a a a a ,模式串是 b a a a的這種狀況,若是利用壞字符規則,那麼計算出來的移動位數有多是負數,所以 BM 算法還須要使用好後綴規則來避免這種狀況。

2. 好後綴規則

好後綴規則要稍微複雜一點了,當匹配的時候,若是遇到了壞字符,而且若是前面已經有匹配的字符的話,那麼就把這段字符叫作好後綴,參考下圖:

在這裏插入圖片描述

和壞字符規則的處理思路相似,若是出現了好後綴,那麼能夠查看好後綴在模式串中是否存在。

第一種狀況,若是不存在的話,則直接移動模式串至好後綴的後一位,而後繼續匹配。例以下圖中的好後綴 a f 在模式串中是不存在的,所以移動模式串至a f 後面:

在這裏插入圖片描述

可是這樣移動會存在一個問題,例以下面的這個例子,主串是c a c d e f a d e f c a,模式串是e f a d e f,好後綴 d e f 雖然在模式串中是不存在的,若是直接移動模式串至好後綴的後面,那麼就會錯過正確匹配的狀況,因此下圖這樣的移動方式就是錯誤的:

在這裏插入圖片描述

因此這種狀況下應該怎麼移動呢?能夠看到,雖然好後綴 d e f 不在模式串中,可是好後綴的後綴子串 e f 和模式串的前綴子串 e f 是相同的,所以咱們須要移動模式串至和好後綴的後綴子串重合的地方。

這段話稍微有點很差理解,再來解釋一下,一個字符串的後綴子串,就是除了第一個字符的其他子串,例如字符串 d e f,它的後綴子串就有兩個,分別是 fe f;而一個字符串的前綴子串,就是除了最後一個字符的其他子串,例如 a d e f,它的前綴子串就有 aa da d e 這三個。

具體到上面的那個例子,好後綴是 d e f ,它的一個後綴子串 e f 和模式串的前綴子串 e f 是匹配的,所以須要移動至兩部分重合的地方:

在這裏插入圖片描述

而後再看第二種狀況,就很簡單了,若是好後綴在模式串中是存在的,那麼移動模式串至和好後綴匹配的地方:

在這裏插入圖片描述

總結一下規律,好後綴狀況下,模式串的移動整體分爲了三種狀況,一是好後綴在模式串中,那麼移動模式串至好後綴匹配的地方,二是好後綴不在模式串中,而且好後綴的後綴子串和模式串的前綴子串無重合部分,那麼直接移動模式串至好後綴的後一位,三是好後綴不在模式串中,可是好後綴的後綴子串和模式串的前綴子串有重合部分,那麼須要移動模式串至和好後綴的後綴子串重合的地方,參考下圖:

在這裏插入圖片描述

再來看看這部分的代碼實現,所以好後綴自己也是在模式串中的,因此整個好後綴的匹配均可以經過預處理模式串來解決。

這裏引入一個 int 類型的數組 suffix,長度爲模式串的長度,數組的下標爲模式串後綴子串的長度,值爲後綴子串在模式串中可匹配的子串的起始下標;而後再引入一個 boolean 類型的 prefix 數組,它表示的是模式串的後綴子串是否有可匹配的前綴子串,若是有,則值爲 true。

在這裏插入圖片描述

計算 suffix 數組和 prefix 數組的代碼以下:

/**
 * 生成好後綴數組
 */
private static void goodSuffixRule(String str, int[] suffix, boolean[] prefix){
    if (str == null){
        return;
    }

    Arrays.fill(suffix, -1);
    Arrays.fill(prefix, false);

    int n = str.length();
    for (int i = 0; i < n - 1; i++){
        int j = i;
        int k = 0;

        while (j >= 0 && str.charAt(j) == str.charAt(n - k - 1)){
            --j;
            ++k;
            suffix[k] = j + 1;
        }
        if (j == -1){
            prefix[k] = true;
        }
    }
}

這段代碼稍微有點難以理解,看的時候能夠舉一個具體的例子,而後根據代碼推出來結果,這樣理解起來會比較容易些。

根據生成的這兩個數組,就能夠計算在好後綴狀況下的模式串移動位數,須要注意的是,前面壞字符狀況下也有一個模式串移動的位數,這二者該如何選擇呢?其實很是簡單,咱們固然但願模式串可以儘可能多移動一點,所以只須要取這兩個規則所計算出來的移動位數中的較大的那個值便可。

將壞字符規則和好後綴規則兩種狀況結合在一塊兒,就是 BM 算法的完整實現,代碼以下:

public class BoyerMoore {
    
    private static final int[] badChar = new int[256];

    public static int bm(String main, String ptn){
        if (main == null || ptn == null){
            return -1;
        }

        int m = main.length();
        int n = ptn.length();
        badCharRule(ptn, badChar);

        int[] suffix = new int[n];
        boolean[] prefix = new boolean[n];
        goodSuffixRule(ptn, suffix, prefix);

        int i = n - 1;
        while (i <= m - 1) {
            int j = n - 1;
            while (j >= 0 && main.charAt(i) == ptn.charAt(j)){
                --i;
                if (--j == -1){
                    return i + 1;
                }
            }

            //計算壞字符規則下移動的位數
            int moveWithBC = j - badChar[main.charAt(i)];

            //計算好後綴規則下移動的位數
            int moveWithGS = Integer.MIN_VALUE;
            if (j < n - 1){
                moveWithGS = moveWithGS(n, j, suffix, prefix);
            }
            i += Math.max(moveWithBC, moveWithGS);
        }

        return -1;
    }

    /**
     * 生成壞字符數組
     */
    private static void badCharRule(String str, int[] badChar){
        Arrays.fill(badChar, -1);
        for (int i = 0; i < str.length(); i++) {
            badChar[str.charAt(i)] = i;
        }
    }

    /**
     * 生成好後綴數組
     */
    private static void goodSuffixRule(String str, int[] suffix, boolean[] prefix){
        Arrays.fill(suffix, -1);
        Arrays.fill(prefix, false);

        int n = str.length();
        for (int i = 0; i < n - 1; i++){
            int j = i;
            int k = 0;

            while (j >= 0 && str.charAt(j) == str.charAt(n - k - 1)){
                --j;
                ++k;
                suffix[k] = j + 1;
            }
            if (j == -1){
                prefix[k] = true;
            }
        }
    }

    /**
     * 計算好後綴狀況下的移動位數
     */
    private static int moveWithGS(int n, int j, int[] suffix, boolean[] prefix){
        int k = n - j - 1;
        if (suffix[k] != -1){
            return j - suffix[k] + 1;
        }

        for (int i = k - 1; i >= 0; i--) {
            if (prefix[i]){
                return n - i;
            }
        }

        return n;
    }
}

4、indexOf 方法解析

在 Java 語言中,經常使用的字符串匹配的方法是 String 類中的 indexOf() 方法,它的設計思路又是怎麼樣的呢?其實 indexOf 方法的邏輯很是簡單,看看源代碼就知道它其實就是 BF 算法的實現,其源代碼是這樣的:

static int indexOf(char[] source, int sourceOffset, int sourceCount,
        char[] target, int targetOffset, int targetCount,
        int fromIndex) {
    if (fromIndex >= sourceCount) {
        return (targetCount == 0 ? sourceCount : -1);
    }
    if (fromIndex < 0) {
        fromIndex = 0;
    }
    if (targetCount == 0) {
        return fromIndex;
    }

    char first = target[targetOffset];
    int max = sourceOffset + (sourceCount - targetCount);

    for (int i = sourceOffset + fromIndex; i <= max; i++) {
        /* Look for first character. */
        if (source[i] != first) {
            while (++i <= max && source[i] != first);
        }

        /* Found first character, now look at the rest of v2 */
        if (i <= max) {
            int j = i + 1;
            int end = j + targetCount - 1;
            for (int k = targetOffset + 1; j < end && source[j]
                    == target[k]; j++, k++);

            if (j == end) {
                /* Found whole string. */
                return i - sourceOffset;
            }
        }
    }
    return -1;
}

主要的匹配邏輯在代碼中的 for 循環這一段,能夠看到匹配的過程主要分爲了兩步,第一步是找出第一個匹配的字符,而後再依次遍歷模式串看是否匹配。這和 BF 算法的思路是一致的,只是代碼實現上和上面我寫的那個版本略有差異。

5、總結反思

如今回過頭來總結一下,普通的字符串匹配算法,例如 BF 算法和 RK 算法,匹配的方式比較簡單,相應的效率也不是很高,但實際上它們的應用仍是較多的。由於在大多數狀況下,若是匹配的字符串並非很長的話,那麼使用較簡單的算法是一種更好的選擇,代碼也更加簡單,維護的成本較低。這也是爲何 Java 中的字符串匹配 indexOf 方法採用了一種較簡單的實現方式。

軟件設計中有一個 Kiss 原則,即 Keep it simple & stupid,指的是在設計當中在可以解決問題的前提下,應該儘可能注重簡約,避免沒必要要的複雜性,這樣系統維護的成本會降到最低。

可是在某些狀況下,不得不該對更加複雜的字符串搜索場景,所以簡單且效率底下的算法就不適用了,由此引出了 BM 算法,BM 算法是目前爲止字符串匹配效率最高的算法之一,其效率能夠達到著名的 KMP 算法的 3-5 倍。可是效率提高了,代碼隨之變得更加複雜,維護起來就會更加的困難。

有人可能會以爲,BM 算法這麼複雜,就算我搞懂了,在實際的應用中也難以派的上用場。的確是這樣的,咱們幾乎不可能遇到須要本身動手實現一個 BM 算法的場景,可是這並不表明算法就白學了。

算法的學習對邏輯思惟的訓練是比較好的,我自認爲本身的邏輯思惟能力是稍微差一點的,所以有時候會尋求一些刻意的訓練,就拿 BM 算法來講,它解決問題的思路,對一些邊界條件的處理,都對思惟的嚴謹性起到了很好的提高做用。

學習算法其實主要學的是算法的思想,例如 BM 算法中的預處理思想,對模式串預先進行處理來提高匹配效率,這和如今的緩存技術的思想基本上是相似的,萬變不離其宗,思路打開了,再去解決其餘的一些問題纔可以遊刃有餘。

相關文章
相關標籤/搜索