【輪子已造好】來了,字串匹配演算法

弒曉風發表於2019-03-13
Live each day like it could be your last. 
把每天都當作生命的最後一天,認真生活。

引言

大道至簡,我的理想是用最簡單的程式碼實現最美的演算法。字串匹配的應用非常廣泛,java的indexOf(),js的全家桶一套匹配(find,indexOf,include...)等等。本文主要分享了它們底層依賴的字串匹配演算法。兩種簡單,兩種複雜。話不多說,所有原始碼均已上傳至github:連結

ps:BF演算法是字串匹配演算法裡最簡單的了,需要做到知其然並知其所以然。RK演算法理解是BF + HASH。至於後面的BM和KMP,能做到看懂理解其原理就行,前輩們已經造好了輪子,在需要的時候能想到用即可,尬寫出來有不少難度。

BF演算法

bf演算法俗稱樸素匹配演算法,為什麼叫這個名字呢,因為很暴力,在主串中,檢查起始位置分別是 0、1、2…n-m 且長度為 m 的 n-m+1 個子串,看有沒有跟模式串匹配的。

解析

在這裡i迴圈是跟蹤主串txt,j是跟蹤模式串pattern,首先外圍先確定訪問次數,tLen-pLen。

j迴圈來進行比較,這裡可能唯一比較不好理解的是i + j,檢視測試結果,應該可以明白。

    private int bfSearch(String txt, String pattern) {
        int tLen = txt.length();
        int pLen = pattern.length();
        if (tLen < pLen) return -1;
        for (int i = 0; i <= tLen - pLen; i++) {
            int j = 0;
            for (; j < pLen; j++) {
                System.out.println(txt.charAt(i + j) + " -- " + pattern.charAt(j));
                if (txt.charAt(i + j) != pattern.charAt(j)) break;
            }
            if (j == pLen) return i;
        }
        return -1;
    }複製程式碼

變體

bf演算法還有一個變化,用到了顯示回退的思想,i,j的作用和常規的一樣,這裡的i相當於常規的i+j,只不過當發現不匹配的時候,需要回退i和j這兩個指標,j重新回到開頭,i指向下一個字元。

    private int bfSearchT(String txt, String pattern) {
        int tLen = txt.length();
        int i = 0;

        int pLen = pattern.length();
        int j = 0;

        for (; i < tLen && j < pLen; i++) {
            System.out.println(txt.charAt(i) + " -- " + pattern.charAt(j));
            if (txt.charAt(i) == pattern.charAt(j)) {
                ++j;
            } else {
                i -= j;
                j = 0;
            }
        }
        if (j == pLen) {
            System.out.println("end... i = " + i + ",plen = " + pLen);
            return i - pLen;
        }
        return -1;
    }複製程式碼

測試程式碼

ps: hello world

    public static void main(String[] args) {
        BFArithmetic bf = new BFArithmetic();
        String txt = "hello world";
        String pattern = "world";
        int res = bf.bfSearch(txt, pattern);
        System.out.println("BF演算法匹配結果:" + res);
//        int resT = bf.bfSearchT(txt, pattern);
//        System.out.println("BF演算法(顯示回退)匹配結果:" + resT);
    }複製程式碼

測試結果

【輪子已造好】來了,字串匹配演算法

RK演算法

rk演算法相當於bf演算法的進階版,它主要是引入了雜湊演算法。降低了時間複雜度。通過雜湊演算法對主串中的 n-m+1 個子串分別求雜湊值,然後逐個與模式串的雜湊值比較大小。如果某個子串的雜湊值與模式串相等,那就說明對應的子串和模式串匹配了。因為雜湊值是一個數字,數字之間比較是否相等是非常快速的,所以模式串和子串比較的效率就提高了。

初始化

這裡要把模式串預製進去,生成相對應的hash值,然後隨機生成一個大素數,便於後續的使用。

    private RKArithmetic(String pattern) {
        this.pattern = pattern;
        pLen = pattern.length();
        aLen = 256;
        slat = longRandomPrime();
        System.out.println("隨機素數:" + slat);
        aps = 1;
        // aLen^(pLen - 1) % slat
        for (int i = 1; i <= pLen - 1; i++) {
            aps = (aLen * aps) % slat;
        }
        patHash = hash(pattern, pLen);
        System.out.println("patHash = " + patHash);
    }複製程式碼

準備

隨機生成一個大素數

    private static long longRandomPrime() {
        BigInteger prime = BigInteger.probablePrime(31, new Random());
        return prime.longValue();
    }複製程式碼

雜湊演算法

    private long hash(String txt, int i) {
        long h = 0;
        for (int j = 0; j < i; j++) {
            h = (aLen * h + txt.charAt(j)) % slat;
        }
        return h;
    }複製程式碼

校驗字串是否匹配

    private boolean check(String txt, int i) {
        for (int j = 0; j < pLen; j++)
            if (pattern.charAt(j) != txt.charAt(i + j))
                return false;
        return true;
    }複製程式碼

實現

該實現還是比較容易閱讀的,只不過將比較換成了hash值的比較。

    private int rkSearch(String txt) {
        int n = txt.length();
        if (n < pLen) return -1;
        long txtHash = hash(txt, pLen);
        if ((patHash == txtHash) && check(txt, 0))
            return 0;

        for (int i = pLen; i < n; i++) {
            txtHash = (txtHash + slat - aps * txt.charAt(i - pLen) % slat) % slat;
            txtHash = (txtHash * aLen + txt.charAt(i)) % slat;
            int offset = i - pLen + 1;
            System.out.println("第" + offset + "次txtHash = " + txtHash);
            if ((patHash == txtHash) && check(txt, offset))
                return offset;
        }
        return -1;
    }複製程式碼

測試程式碼

    public static void main(String[] args) {
        String pat = "world";
        String txt = "hello world";
        RKArithmetic searcher = new RKArithmetic(pat);
        int res = searcher.rkSearch(txt);
        System.out.println("RK演算法匹配結果:" + res);
    }複製程式碼

測試結果

【輪子已造好】來了,字串匹配演算法

BM演算法

BM演算法的輪子已經造好。據說是最高效,最常用的字串匹配演算法。

分析

  1. 核心思想:是通過將模式串沿著主串大踏步的向後滑動,從而大大減少比較次數,降低時間複雜度。而演算法的關鍵在於如何兼顧步子邁得足夠大與無遺漏,同時要儘量提高執行效率。這就需要模式串在向後滑動時,遵守壞字元規則與好字尾規則,同時採用一些技巧。
  2. 壞字元規則:從後往前逐位比較模式串與主串的字元,當找到不匹配的壞字元時,記錄模式串的下標值si,並找到壞字元在模式串中,位於下標si前的最近位置xi(若無則記為-1),si-xi即為向後滑動距離。(PS:我覺得加上xi必須在si前面,也就是比si小的條件,就不用擔心計算出的距離為負了)。但是壞字元規則向後滑動的步幅還不夠大,於是需要好字尾規則。
  3. 好字尾規則:從後往前逐位比較模式串與主串的字元,當出現壞字元時停止。若存在已匹配成功的子串{u},那麼在模式串的{u}前面找到最近的{u},記作{u'}。再將模式串後移,使得模式串的{u'}與主串的{u}重疊。若不存在{u'},則直接把模式串移到主串的{u}後面。為了沒有遺漏,需要找到最長的、能夠跟模式串的字首子串匹配的,好字尾的字尾子串(同時也是模式串的字尾子串)。然後把模式串向右移到其左邊界,與這個好字尾的字尾子串在主串中的左邊界對齊。

準備

構建壞字元雜湊表

private void generateBC(char[] patChars, int[] records) {
    for (int i = 0; i < aLen; i++) {
        records[i] = -1;
    }
    for (int i = 0; i < patChars.length; i++) {
        // 計算 b[i] 的 ASCII 值
        int ascii = (int) patChars[i];
        records[ascii] = i;
    }
    System.out.println("壞字元雜湊表:");
    print(records);
}複製程式碼

好字尾

private void generateGS(char[] patChars, int[] suffix, boolean[] prefix) {
    int pLen = patChars.length;
    for (int i = 0; i < pLen; ++i) { // 初始化
        suffix[i] = -1;
        prefix[i] = false;
    }
    for (int i = 0; i < pLen - 1; ++i) {
        int j = i;
        // 公共字尾子串長度
        int k = 0;
        while (j >= 0 && patChars[j] == patChars[pLen - 1 - k]) {
            --j;
            ++k;
            //j+1 表示公共字尾子串在 patChars[0, i] 中的起始下標
            suffix[k] = j + 1;
        }
        // 如果公共字尾子串也是模式串的字首子串
        if (j == -1) prefix[k] = true;
    }
}複製程式碼

移動

private int moveByGS(int index, int pLen, int[] suffix, boolean[] prefix) {
    int k = pLen - 1 - index; // 好字尾長度
    if (suffix[k] != -1) return index - suffix[k] + 1;
    for (int i = index + 2; i <= pLen - 1; i++) {
        if (prefix[pLen - i])
            return i;
    }
    return -1;
}複製程式碼

實現

  1. suffix 在模式串中,查詢跟好字尾匹配的另一個子串 
  2. prefix 記錄模式串的字尾子串是否能匹配模式串的字首子串

private int bmSearch(String txt, String pattern) {
    // 記錄模式串中每個字元最後出現的位置
    int[] records = new int[aLen];
    char[] txtChars = txt.toCharArray();
    int tLen = txtChars.length;
    char[] patChars = pattern.toCharArray();
    int pLen = patChars.length;
    generateBC(patChars, records);
    int[] suffix = new int[pLen];
    boolean[] prefix = new boolean[pLen];
    generateGS(patChars, suffix, prefix);
    //主串與模式串對齊的第一個字元
    int index = 0;
    while (index <= tLen - pLen) {
        int i = pLen - 1;
        // 模式串從後往前匹配
        for (; i >= 0; --i) {
            // 壞字元對應模式串中的下標是 i
            if (txtChars[index + i] != patChars[i]) break;
        }
        if (i < 0) {
            return index;
        }
        int x = i - records[(int) txtChars[index + i]];
        int y = 0;
        if (i < pLen - 1) {
            y = moveByGS(i, pLen, suffix, prefix);
        }
        System.out.println("x = " + x + ",y = " + y);
        index = index + Math.max(x, y);
    }
    return -1;
}複製程式碼

測試程式碼

    public static void main(String[] args) {
        BMArithmetic bmArithmetic = new BMArithmetic();
        String txt = "hello world";
        String pattern = "world";
        int res = bmArithmetic.bmSearch(txt, pattern);
        System.out.println("BM演算法匹配結果:" + res);
    }
複製程式碼

測試結果

【輪子已造好】來了,字串匹配演算法

BM啟發式演算法(精簡版)

分析

BM演算法不愧是號稱線性級得計算,據說效率是KMP演算法的3~4倍,有時間一定要驗一下。

ps:遇到問題如果正著思考行不通,不妨反著考慮,新思想,get√。

初始化

    private BoyerMoore(String pattern) {
        this.pattern = pattern;
        pLen = pattern.length();
        int aLen = 256;
        records = new int[aLen];
        //初始化記錄陣列,預設-1
        for (int i = 0; i < aLen; i++) {
            records[i] = -1;
        }
        //模式串中的字元在其中出現的最右位置
        for (int j = 0; j < pLen; j++) {
            records[pattern.charAt(j)] = j;
        }
    }複製程式碼

實現

根據命名skip也能分析出倆關鍵字倒序,跳躍性。

    private int bmSearch(String txt) {
        int tLen = txt.length();
        int skip;
        for (int i = 0; i <= tLen - pLen; i += skip) {
            skip = 0;
            for (int j = pLen - 1; j >= 0; --j) {
                System.out.println(txt.charAt(i + j) + " -- " + pattern.charAt(j));
                if (txt.charAt(i + j) != pattern.charAt(j)) {
                    skip = j - records[txt.charAt(i + j)];
                    if (skip < 1) skip = 1;
                    break;
                }
            }
            if (skip == 0) return i;
        }
        return -1;
    }複製程式碼

測試程式碼

    public static void main(String[] args) {
        String txt = "hello world";
        String pattern = "world";
        BoyerMoore bm = new BoyerMoore(pattern);
        int res = bm.bmSearch(txt);
        System.out.println("BM演算法匹配結果:" + res);
    }複製程式碼

測試結果

【輪子已造好】來了,字串匹配演算法

KMP演算法

分析

kmp演算法引入一個失效函式--next陣列。這個演算法的關鍵就在於next函式是如何計算出來的。妙不可言?不,是頭皮發麻,難以理解。只能debug一步一步跟了。

準備

精髓:理解k = next[k]。因為前一個的最長串的下一個字元不與最後一個相等,需要找前一個的次長串,問題就變成了求0到next(k)的最長串,如果下個字元與最後一個不等,繼續求次長串,也就是下一個next(k),直到找到,或者完全沒有。

    private int[] getNext(char[] patChars, int pLen) {
        int[] next = new int[pLen];
        next[0] = -1;
        int k = -1;
        for (int i = 1; i < pLen; i++) {
            while (k != -1 && patChars[k + 1] != patChars[i]) {
                k = next[k];
            }
            if (patChars[k + 1] == patChars[i])
                ++k;
            next[i] = k;
        }
        System.out.println("好字首:");
        print(next);
        return next;
    }複製程式碼

實現

    private int kmpSearch(String txt, String pattern) {
        char[] txtChars = txt.toCharArray();
        int tLen = txtChars.length;
        char[] patChars = pattern.toCharArray();
        int pLen = patChars.length;
        int[] next = getNext(patChars, pLen);
        int index = 0;
        for (int i = 0; i < tLen; i++) {
            while (index > 0 && txtChars[i] != patChars[index]) {
                index = next[index - 1] + 1;
            }
            System.out.println(txtChars[i] + " -- " + patChars[index]);
            if (txtChars[i] == patChars[index])
                ++index;
            if (index == pLen)
                return i - pLen + 1;
        }
        return -1;
    }複製程式碼

測試程式碼

    public static void main(String[] args) {
        KMPArithmetic kmpArithmetic = new KMPArithmetic();
        String txt = "hello world";
        String pattern = "world";
        int res = kmpArithmetic.kmpSearch(txt, pattern);
        System.out.println("KMP演算法匹配結果:" + res);
    }複製程式碼

測試結果

【輪子已造好】來了,字串匹配演算法

KMP演算法(基於確定優先狀態自動機dfa)

初始化

    private KMPByDFA(String pattern) {
        this.pattern = pattern;
        this.pLen = pattern.length();
        int aLen = 256;
        dfa = new int[aLen][pLen];
        dfa[pattern.charAt(0)][0] = 1;
        int i = 0;
        for (int j = 1; j < pLen; j++) {
            for (int k = 0; k < aLen; k++) {
                //複製匹配失敗情況下的值
                dfa[k][j] = dfa[k][i];
            }
            //設定匹配成功情況下的值
            dfa[pattern.charAt(j)][j] = j + 1;
            //更新重新狀態
            i = dfa[pattern.charAt(j)][i];
        }
    }複製程式碼

實現

    private int kmpSearch(String txt) {
        int i = 0;
        int j = 0;
        int tLen = txt.length();
        for (; i < tLen && j < pLen; i++) {
            j = dfa[txt.charAt(i)][j];
        }
        //找到匹配,到達模式串的結尾
        if (j == pLen)
            return i - pLen;
        return -1;
    }複製程式碼

測試程式碼

    public static void main(String[] args) {
        String txt = "hello world";
        String pattern = "world";
        KMPByDFA kmp = new KMPByDFA(pattern);
        int res = kmp.kmpSearch(txt);
        System.out.println("BM演算法匹配結果:" + res);
    }複製程式碼

測試結果

【輪子已造好】來了,字串匹配演算法

end

【輪子已造好】來了,字串匹配演算法

您的點贊和關注是對我最大的支援,謝謝!


相關文章