1. 程式人生 > >字串匹配基礎中——BM 演算法

字串匹配基礎中——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 演算法針對兩個子串的比較是從後向前進行的,也就是按照下標從大到小進行比較。


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

我們拿壞字元 c 在模式串中查詢,發現模式串中根本不存在這個字元。此時,我們就可以直接將模式串向後移動三個位置,再繼續進行比較。

此時,最後一個字元 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 ) O(m^2) 。當然,大部分情況下,時間複雜度不會這麼差。現有一些論文證明了在最壞情況下, BM 演算法的比較次數上限是 3n。

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

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