不用找了,學習BM算法,這篇就夠了(思路+詳註代碼)

寫在前面

在計算機科學裏,Boyer-Moore字符串搜索算法是一種非常高效的字符串搜索算法。它由Bob Boyer和J Strother Moore設計於1977年。此算法僅對搜索目標字符串(關鍵字)進行預處理,而非被搜索的字符串。雖然Boyer-Moore算法的執行時間同樣線性依賴於被搜索字符串的大小,但是通常僅爲其它算法的一小部分:它不需要對被搜索的字符串中的字符進行逐一比較,而會跳過其中某些部分。通常搜索關鍵字越長,算法速度越快。它的效率來自於這樣的事實:對於每一次失敗的匹配嘗試,算法都能夠使用這些信息來排除儘可能多的無法匹配的位置。

在學習研究BM算法之前,我是已經掌握了KMP算法,所以建議還沒有掌握的同學,先去學習一下,循序漸進的來,可以看我的KMP算法的文章。爲什麼說循序漸進,是因爲BM算法,在大多數情況下,表現的比KMP算法優秀,所以大部分時候,都當做KMP進階的算法來學習。BM算法從模式串的尾部開始匹配,且擁有在最壞情況下 $O(N) $的時間複雜度。有數據表明,在實踐中,比 KMP 算法的實際效能高,可以快大概 3-5 倍,很值得學習。在學習BM算法的時候,找了很多資料,也遇到了很多優秀的文章,不過目前還沒有碰到即講清楚了原理,又實現了代碼的文章,java版的更是不容易找。所以我這裏打算站在大神的肩膀上學習,寫下這篇文章,由於一些圖畫的比較佔用時間,我就直接引用一些博文中的圖片。感興趣的可以直接看大神們的文章。

BM算法原理

BM算法定義了兩個規則:

  • 壞字符規則:當文本串中的某個字符跟模式串的某個字符不匹配時,我們稱文本串中的這個失配字符爲壞字符,此時模式串需要向右移動,移動的位數 = 壞字符在模式串中的位置 - 壞字符在模式串中最右出現的位置。此外,如果"壞字符"不包含在模式串之中,則最右出現位置爲-1
  • 好後綴規則:當字符失配時,後移位數 = 好後綴在模式串中的位置 - 好後綴在模式串上一次出現的位置,且如果好後綴在模式串中沒有再次出現,則爲-1

在這裏插入圖片描述

下面舉例說明BM算法。例如,給定文本串“HERE IS A SIMPLE EXAMPLE”,和模式串“EXAMPLE”,現要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。

  • 首先,“文本串"與"模式串"頭部對齊,從尾部開始比較。”S“與”E“不匹配。這時,”S“就被稱爲"壞字符”(bad character),即不匹配的字符,它對應着模式串的第6位。且"S“不包含在模式串”EXAMPLE“之中(相當於最右出現位置是-1),這意味着可以把模式串後移6-(-1)=7位,從而直接移到”S"的後一位。
    在這裏插入圖片描述
  • 依然從尾部開始比較,發現"P“與”E“不匹配,所以”P“是"壞字符”。但是,"P“包含在模式串”EXAMPLE"之中。因爲“P”這個“壞字符”對應着模式串的第6位(從0開始編號),且在模式串中的最右出現位置爲4,所以,將模式串後移6-4=2位,兩個"P"對齊。
    在這裏插入圖片描述
    在這裏插入圖片描述
  • 依次比較,得到 “MPLE”匹配,稱爲"好後綴"(good suffix),即所有尾部匹配的字符串。注意,"MPLE"、"PLE"、"LE"、"E"都是好後綴。
    在這裏插入圖片描述
  • 發現“I”與“A”不匹配:“I”是壞字符。如果是根據壞字符規則,此時模式串應該後移2-(-1)=3位。問題是,有沒有更優的移法?
    在這裏插入圖片描述
    在這裏插入圖片描述
  • 更優的移法是利用好後綴規則:當字符失配時,後移位數 = 好後綴在模式串中的位置 - 好後綴在模式串中上一次出現的位置,且如果好後綴在模式串中沒有再次出現,則爲-1。所有的“好後綴”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的頭部出現,所以後移6-0=6位。可以看出,“壞字符規則”只能移3位,“好後綴規則”可以移6位。每次後移這兩個規則之中的較大值。這兩個規則的移動位數,只與模式串有關,與原文本串無關。
    在這裏插入圖片描述
  • 繼續從尾部開始比較,“P”與“E”不匹配,因此“P”是“壞字符”,根據“壞字符規則”,後移 6 - 4 = 2位。因爲是最後一位就失配,尚未獲得好後綴。
    在這裏插入圖片描述

好後綴加深理解

由上可知,BM算法不僅效率高,而且構思巧妙,容易理解。壞字符規則相對而言比較好理解,好後綴如果還不理解,我這裏再繼續舉個例子解釋一下,這裏加深理解。

  • 如果模式串中存在已經匹配成功的好後綴,則把目標串與好後綴對齊,然後從模式串的最尾元素開始往前匹配。
    在這裏插入圖片描述
    在這裏插入圖片描述
  • 如果無法找到匹配好的後綴,找一個匹配的最長的前綴,讓目標串與最長的前綴對齊(如果這個前綴存在的話)。模式串[m-s,m] = 模式串[0,s] 。
    在這裏插入圖片描述
    在這裏插入圖片描述
  • 如果完全不存在和好後綴匹配的子串,則右移整個模式串。

先實現好字符規則

BM算法還是很好理解的,其實如果你之前學習KMP算法你也會有同樣的感受,KMP算法理解起來不是很難,但是重點在於怎麼去實現next數組。BM算法也是,原理理解起來其實非常的容易,不過怎麼去實現,沒有一套標準的代碼。不過可以研究別人的代碼,然後實現一套儘量適合精簡的代碼。還是一樣,一步一步來,我們先來實現好字符規則。好字符規則的代碼如下,我會在代碼中必要的地方加入註釋,輔助理解,代碼是最好的老師。

public static void getRight(String pat, int[] right) {
	//首先創建一個模式串的字符位置的數組,初始化爲-1,就是用於記錄模式串
	//中,每個字符在模式串中的相對位置,這裏直接用的是256,也
	//就是ASCII碼的最大值,當然,如果你的字符串中只限制了26個
	//字符,你也可以直接使用26
    for (int i = 0; i < 256; i++) {
        right[i] = -1;
    }
    //值得一提的是,通過這種方式,可以你會發現,如果模式串中存在相同的
    //字符,那麼right數組中,記錄的是最右的那個字符的位置
    for (int j = 0; j < pat.length(); j++) {
        right[pat.charAt(j)] = j;
    }
}

public static int Search(String txt, String pat, int[] right) {
    int M = txt.length();//主串的長度
    int N = pat.length();//模式串的長度
    int skip;//用於記錄跳過幾個字符
    for (int i = 0; i < M - N; i += skip) {
        skip = 0;//每次進入循環要記得初始化爲0
        for (int j = N - 1; j >= 0; j--) {
        	//不相等,意味着出現壞字符,按照上面的規則移動
            if (pat.charAt(j) != txt.charAt(i + j)) {
                skip = j - right[txt.charAt(i + j)];
                //skip之所以會小於1,可能是因爲壞字符在模式串中最右的位置,可能
                //在j指向字符的右側,就是已經越過了。
                if (skip < 1) 
                    skip = 1;
                break;
            }
        }
        //注意了這個時候循環了一遍之後,skip如果等於0,意味着沒有壞字符出現,所以
        //匹配成功,返回當前字符i的位置
        if (skip == 0)
            return i;
    }
    return -1;
}

完整BM實現

上面的代碼不難理解,相信你已經看懂了,那麼接下來也不用單獨來講好後綴的實現,直接上完整的實現代碼。因爲完整的BM實現中,就是比較壞字符規則以及好後綴規則,哪個移動的字符數更多,就使用哪個。老樣子,下面的代碼中我儘量的加註釋。

public static int pattern(String pattern, String target) {
    int tLen = target.length();//主串的長度
    int pLen = pattern.length();//模式串的長度

	//如果模式串比主串長,沒有可比性,直接返回-1
    if (pLen > tLen) {
        return -1;
    }

    int[] bad_table = build_bad_table(pattern);// 獲得壞字符數值的數組,實現看下面
    int[] good_table = build_good_table(pattern);// 獲得好後綴數值的數組,實現看下面

    for (int i = pLen - 1, j; i < tLen;) {
        System.out.println("跳躍位置:" + i);
        //這裏和上面實現壞字符的時候不一樣的地方,我們之前提前求出壞字符以及好後綴
        //對應的數值數組,所以,我們只要在一邊循環中進行比較。還要說明的一點是,這裏
        //沒有使用skip記錄跳過的位置,直接針對主串中移動的指針i進行移動
        for (j = pLen - 1; target.charAt(i) == pattern.charAt(j); i--, j--) {
            if (j == 0) {//指向模式串的首字符,說明匹配成功,直接返回就可以了
                System.out.println("匹配成功,位置:" + i);
                //如果你還要匹配不止一個模式串,那麼這裏直接跳出這個循環,並且讓i++
                //因爲不能直接跳過整個已經匹配的字符串,這樣的話可能會丟失匹配。
//					i++;   // 多次匹配
//					break;
                return i;
            }
        }
        //如果出現壞字符,那麼這個時候比較壞字符以及好後綴的數組,哪個大用哪個
        i += Math.max(good_table[pLen - j - 1], bad_table[target.charAt(i)]);
    }
    return -1;
}

//字符信息表
public static int[] build_bad_table(String pattern) {
    final int table_size = 256;//上面已經解釋過了,字符的種類
    int[] bad_table = new int[table_size];//創建一個數組,用來記錄壞字符出現時,應該跳過的字符數
    int pLen = pattern.length();//模式串的長度

    for (int i = 0; i < bad_table.length; i++) {
        bad_table[i] = pLen;  
        //默認初始化全部爲匹配字符串長度,因爲當主串中的壞字符在模式串中沒有出
        //現時,直接跳過整個模式串的長度就可以了
    }
    for (int i = 0; i < pLen - 1; i++) {
        int k = pattern.charAt(i);//記錄下當前的字符ASCII碼值
        //這裏其實很值得思考一下,bad_table就不多說了,是根據字符的ASCII值存儲
        //壞字符出現最右的位置,這在上面實現壞字符的時候也說過了。不過你仔細思考
        //一下,爲什麼這裏存的壞字符數值,是最右的那個壞字符相對於模式串最後一個
        //字符的位置?爲什麼?首先你要理解i的含義,這個i不是在這裏的i,而是在上面
        //那個pattern函數的循環的那個i,爲了方便我們稱呼爲I,這個I很神奇,雖然I是
        //在主串上的指針,但是由於在循環中沒有使用skip來記錄,直接使用I隨着j匹配
        //進行移動,也就意味着,在某種意義上,I也可以直接定位到模式串的相對位置,
        //理解了這一點,就好理解在本循環中,i的行爲了。

		//其實仔細去想一想,我們分情況來思考,如果模式串的最
        //後一個字符,也就是匹配開始的第一個字符,出現了壞字符,那麼這個時候,直
        //接移動這個數值,那麼正好能讓最右的那個字符正對壞字符。那麼如果不是第一個
        //字符出現壞字符呢?這種情況你仔細想一想,這種情況也就意味着出現了好後綴的
        //情況,假設我們將最右的字符正對壞字符
        bad_table[k] = pLen - 1 - i;
    }
    return bad_table;
}

//匹配偏移表
public static int[] build_good_table(String pattern) {
    int pLen = pattern.length();//模式串長度
    int[] good_table = new int[pLen];//創建一個數組,存好後綴數值
    //用於記錄最新前綴的相對位置,初始化爲模式串長度,因爲意思就是當前後綴字符串爲空
    //要明白lastPrefixPosition 的含義
    int lastPrefixPosition = pLen;

    for (int i = pLen - 1; i >= 0; --i) {
        if (isPrefix(pattern, i + 1)) {
        //如果當前的位置存在前綴匹配,那麼記錄當前位置
            lastPrefixPosition = i + 1;
        }
        good_table[pLen - 1 - i] = lastPrefixPosition - i + pLen - 1;
    }

    for (int i = 0; i < pLen - 1; ++i) {
    //計算出指定位置匹配的後綴的字符串長度
        int slen = suffixLength(pattern, i);
        good_table[slen] = pLen - 1 - i + slen;
    }
    return good_table;
}

//前綴匹配
private static boolean isPrefix(String pattern, int p) {
    int patternLength = pattern.length();//模式串長度
    //這裏j從模式串第一個字符開始,i從指定的字符位置開始,通過循環判斷當前指定的位置p
    //之後的字符串是否匹配模式串前綴
    for (int i = p, j = 0; i < patternLength; ++i, ++j) {
        if (pattern.charAt(i) != pattern.charAt(j)) {
            return false;
        }
    }
    return true;
}

//後綴匹配
private static int suffixLength(String pattern, int p) {
    int pLen = pattern.length();
    int len = 0;
    for (int i = p, j = pLen - 1; i >= 0 && pattern.charAt(i) == pattern.charAt(j); i--, j--) {
        len += 1;
    }
    return len;
}

理解一下上面代碼,這裏我針對上面代碼舉個例子,計算之後的兩張表的數值如下:
在這裏插入圖片描述
在這裏插入圖片描述

總結

其實如果你把上面理解了,我相信你會有一種興奮,可能還會有一種疑問,就是BM的算法,如果將壞字符和好後綴規則都實現了,看着代碼量怎麼多,而且在計算兩個位移數組的時候,相當於要做這麼多準備工作,難道不會影響效率麼。這個問題我也不好回答,根據不同語言的具體實現,代碼的執行效率也會有所影響,不過有一點可以肯定的是,當字符匹配的長度量非常大的時候,BM的算法優勢還是很明顯的,而且要知道,BM算法在CV中應用的還是很廣泛的,畢竟CV中的數據集樣本動不動就是上百萬。BM算法非常值得一學。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章