字符串匹配基礎中——BM 算法

文本編輯器中的查找功能是如何實現的呢?算法

文本編輯器中的查找功能本質上就是一個字符串匹配過程,所以能夠用 BF 算法和 RK 算法 實現,可是在某些極端狀況下,BF 算法性能會退化得比較嚴重,而 RK 算法須要用到哈希算法,設計一個能夠適用於各類字符的哈希算法並非那麼簡單。數組

1. BM 算法的核心思想

模式串和主串的匹配,能夠看做是模式串在子串中不斷向後滑動的過程。若是遇到兩個子串不匹配, BF 算法和 RK 算法的作法就是將模式串向後移動一個字符的位置,而後繼續進行比較。數據結構

在上面的例子中,c 和 d 不匹配,咱們就將模式串向後移動一位。可是,咱們發現,模式串中根本不存在字符 c,所以,咱們能夠直接將模式串向後多移動幾位。框架

一樣地,在遇到相似狀況的時候,咱們是否是均可以一次性將模式串向後多移動幾位呢? BM 算法其實就是在尋找這些規律,藉助這些規律,字符串匹配的效率也就會大大提升。編輯器

2. BM 算法原理分析

BM 算法包含兩部分,分別是壞字符規則(bad character rule)和好後綴規則(good suffix shift)。性能

2.1. 壞字符規則

首先,BM 算法針對兩個子串的比較是從後向前進行的,也就是按照下標從大到小進行比較。優化

咱們從模式串的末尾向前比較,當發現某個字符無法匹配的時候,這個沒法匹配的字符就叫做壞字符(主串中的字符)。spa

咱們拿壞字符 c 在模式串中查找,發現模式串中根本不存在這個字符。此時,咱們就能夠直接將模式串向後移動三個位置,再繼續進行比較。.net

此時,最後一個字符 a 和 d 仍是沒法匹配。可是,壞字符 a 存在於模式串中,咱們不能直接向後移動 3 位 ,而是應該讓主串中的字符 a 和模式串中的 a 對齊,而後再繼續進行比較。設計

能夠看到,模式串的移動位數在不一樣狀況下是不同的,它們有什麼規律呢?咱們將壞字符對應於模式串中的下標記爲 si,將壞字符在模式串中從前日後第一次出現的位置記爲 xi,若是壞字符在模式串中不存在,那麼其值就爲 -1。而後,模式串應該向後移動的位數就等於 si - xi。

利用壞字符規則,BM 算法在最好狀況下的時間複雜度很是低,爲 O(n/m)。好比,主串是 aaabaaabaaabaaab,模式串是 aaaa,每次匹配均可以直接向後移動 4 位,很是高效。

不過,只單純利用壞字符規則是不夠的。由於根據 si-xi 計算出來的移動位數,有多是負數。好比,主串是 aaaaaaaaaaaaaaaa,模式串是 baaa,不但不會向後移動,還會倒退。

2.1. 好後綴規則

實際上,好後綴規則和壞字符規則的思路很相似。

在上面的例子中,壞字符後面的字符 bc 是匹配的,它們就稱之爲好後綴,記做 {u}。咱們拿它在模式串中查找,若是找到了另外一個和 {u} 匹配的子串 {u*},那咱們就將模式串滑動到子串 {u*} 與主串 {u} 對齊的位置。

若是在模式串中找不到另外一個等於 {u} 的子串,咱們就直接將模式串移動到主串 {u} 的後面,由於中間的滑動過程都沒法和 {u} 匹配上。

不過,當模式串中不存在等於 {u} 的子串時,直接將模式串移動到主串 {u} 的後面是有問題的,咱們有可能會錯過主串和模式串匹配的狀況。

事實上,當模式串中不存在等於 {u} 的子串時,只要 {u} 和模式串徹底重合,那確定模式串和主串就不可能匹配,但如果 {u} 和模式串部分重合,那就有可能會存在模式串和主串匹配的狀況。

因此,針對這種狀況,咱們不只要考慮好後綴是否存在於模式串中,還要考慮好後綴的後綴子串是否和模式串的前綴子串匹配。所謂好後綴的後綴子串,便是和好後綴最後一個字符對齊的子串,好比 abc 的後綴子串就是 c、bc。所謂前綴子串,便是和模式串第一個字符對齊的子串,好比 abc 的前綴子串就是 a、ab。

咱們從好後綴的後綴子串中,找到一個最長的而且能和模式串前綴子串匹配的子串,假設是 {v},而後將模式串滑動到好後綴的後綴子串與模式串的前綴子串對齊的位置。

最後,當模式串和主串中的某個字符不匹配的時候,咱們分別利用壞字符規則和好後綴規則計算出兩個數字,選取較大的那個數做爲模式串應該日後移動的位數

3. BM 算法代碼實現

首先,咱們應該怎麼查找壞字符在模式串中的位置呢?若是每次都要在模式串中遍歷查詢,那確定效率很是低。這時候,散列表就派上用場了。咱們能夠將模式串中的字符及其在模式串中的位置存儲在散列表中,這樣查找壞字符位置的時候就直接從散列中取出便可。

假設字符串的字符集不是很大,每一個字符長度是 8 個字節,那麼咱們就能夠用一個大小爲 256 的數組來實現散列表的功能,數組下標就是對應字符的 ASCII 碼,數組中的數據就是該字符在模式串中出現的位置。

# define SIZE 256

// 生成壞字符對應的散列表
void GenerateBC(char str[], int m, int bc[]) {
    // 全部字符初始化爲 -1
    for (int i = 0; i < SIZE; i++)
    {
        bc[i] = -1;
    }

    for (int i = 0; i < m; i++)
    {
        int ascii = str[i] - '\0'; // 求出字符對應的 ASCII 碼
        bc[ascii] = i;
    }
}
複製代碼

接下來,咱們先把 BM 算法的大框架寫好,只考慮壞字符規則,且不考慮移動位數爲負的狀況。

int BM(char str1[], int n, char str2[], int m) {
    int bc[SIZE]; // 記錄每一個字符在模式串中最後出現的位置,做爲壞字符散列表
    GenerateBC(str2, m, bc);

    int i = 0; // 表示主串和模式串對齊時第一個字符的位置
    int si = 0; // 壞字符對應於模式串中的位置
    int xi = -1; // 壞字符在模式串中出現的位置

    while (i <= n-m)
    {
        int j = 0;
        // 從後向前進行匹配
        for (j = m-1; j >= 0; j--)
        {
            // 找到了第一個不匹配的字符
            if (str1[i+j] != str2[j]) break;
        }

        if (j < 0) return i; // 匹配成功

        si = j;
        xi = bc[str1[i+j] - '\0'];
        i = i + si - xi; // 將模式串後移 si-xi 個位置
    }

    return -1;
}
複製代碼

這樣咱們就實現了包含壞字符規則的框架代碼,接下來,咱們只須要向其中填入好後綴規則便可。好後綴處理過程當中最核心的兩點是:

  • 在模式串中,查找和好後綴匹配的另外一個子串;

  • 在好後綴的的後綴子串中,查找最長的能和模式串前綴子串匹配的後綴子串。

由於好後綴也是模式串自己的後綴子串,所以,咱們就能夠在模式串和主串匹配以前經過預處理,來預先計算出模式串的每一個後綴子串,對應的另外一個與之匹配子串的位置。

由於後綴子串的最後一個字符位置固定,所以,要表示模式串的後綴子串,咱們只須要記錄其長度便可。

接下來,咱們引入 suffix 數組,其下標表示後綴子串的長度,而數組裏面存儲的是與這個後綴子串匹配的另外一個子串在模式串中的起始位置,以下所示。

另外,爲了不模式串滑動過頭,若是有多個子串都和後綴子串匹配,咱們須要記錄最靠後的那個子串的起始位置。此時,咱們已經找出了和後綴子串匹配的子串,但最終咱們須要的是好後綴子串和模式串的前綴子串匹配的位置。所以,只有這一個數組是不夠的,咱們引入另一個布爾型數組 prefix,來記錄模式串的後綴子串是否能匹配其前綴子串。

咱們拿模式串中下標從 0 到 i 的子串(i 能夠是 0 到 m-2)與整個模式串,求公共後綴子串。若是公共後綴子串的長度爲 k,那咱們就記錄 suffix[k] = j(j 表示公共後綴子串的起始下標)。若是 j=0,也就說公共後綴子串也是模式串的前綴子串,咱們就記錄 prefix[k]=true。

// 生成好後綴數組
void GenerateGS(char str[], int m, int suffix[], bool prefix[]) {
    for (int i = 0; i < m; i++)
    {
        suffix[i] = -1;
        prefix[i] = false;
    }

    // [0, i] 的子串和模式串求公共後綴子串
    for (int i = 0; i < m-1; i++)
    {
        int j = i;
        int k = 0;
        while (j>=0 && str[j] == str[m-1-k]) // 下標都向前移動
        {
            j--;
            k++;
        }

        if (k != 0) suffix[k] = j + 1; // 公共後綴子串的起始位置
        if (j == -1) prefix[k] = true; // 公共後綴子串同時也是模式串的前綴子串
    }
}
複製代碼

接下來,咱們來看遇到不匹配的字符時,如何根據好後綴規則,計算模式串向後移動的位數?

假設好後綴的長度是 k,咱們首先檢查 suffix[k] 是否爲 -1。若是不爲 -1,那 x=suffix[k] 就表明與好後綴匹配的前綴子串在模式串中的起始位置,咱們就須要將模式串向後移動 j-x+1 個位置,j 爲壞字符對應於模式串中的位置。若是爲 -1 則說明不存在匹配的子串,咱們就尋找是否存在與好後綴的後綴子串匹配的前綴子串。

好後綴的後綴子串 b[r, m-1] 的長度爲 k=m-r,其中 r 取值爲 [j+2, m-1],若是 prefix[k]=true,表示長度爲 k 的後綴子串有可匹配的前綴子串,咱們就須要將模式串向後移動 r 個位置。

若是上面兩種狀況都不知足,那咱們就須要將模式串向後移動 m 個位置,即移動到好後綴後面的位置。下圖中應該是寫錯了,注意!!!

// 判斷好後綴規則應該移動的位數
int MoveByGS(int j, int m, int suffix[], bool prefix[]) {
    int k = m - j - 1; // 好後綴長度
    if (suffix[k] != -1) return j + 1 - suffix[k];

    for (int r = j + 2; r < m; r++)
    {
        if (prefix[m-r] == true) return r;
    }

    return m;
}

int BM(char str1[], int n, char str2[], int m) {
    int bc[SIZE]; // 記錄每一個字符在模式串中最後出現的位置,做爲壞字符散列表
    GenerateBC(str2, m, bc);

    int suffix[m];
    bool prefix[m];
    GenerateGS(str2, m, suffix, prefix);

    int i = 0; // 表示主串和模式串對齊時第一個字符的位置
    int si = 0; // 壞字符對應於模式串中的位置
    int xi = -1; // 壞字符在模式串中最後出現的位置

    while (i <= n-m)
    {
        int j = 0;
        // 從後向前進行匹配
        for (j = m-1; j >= 0; j--)
        {
            // 找到了第一個不匹配的字符
            if (str1[i+j] != str2[j]) break;
        }

        if (j < 0) return i; // 匹配成功

        si = j;
        xi = bc[str1[i+j] - '\0'];
        int x = si - xi; // 壞字符規則應該向後移動的位數
        int y = 0; // 好後綴規則應該向後移動的位數

        if (j < m-1) y = MoveByGS(j, m, suffix, prefix);

        x = x > y ? x : y;
        i = i + x;
    }

    return -1;
}
複製代碼

4. BM 算法性能分析及優化

整個算法用到了額外的三個數組,bc 與字符集的大小有關,suffix 和 prefix 與模式串大小有關。若是咱們處理字符集很大的字符串匹配問題,bc 數組對內存的消耗就會比較多。由於好後綴規則和壞字符規則是獨立的,若是咱們對運行的環境內存要求比較苛刻,那麼就能夠只使用好後綴規則。不過,這樣 BM 算法的效率就會有一些降低。

另外,在極端狀況下,預處理計算 suffix 和 prefix 數組的性能會比較差,好比模式串是 aaaaaa 這種包含不少重複字符的模式串,預處理的時間複雜度就是 O(m^2)。固然,大部分狀況下,時間複雜度不會這麼差。現有一些論文證實了在最壞狀況下, BM 算法的比較次數上限是 3n。

參考資料-極客時間專欄《數據結構與算法之美》

獲取更多精彩,請關注「seniusen」!

相關文章
相關標籤/搜索