從Go標準庫strings看字串匹配演算法

redstorm發表於2017-10-18

Go 的標準庫本身質量非常高,本文主要深入 strings 庫,從原始碼中探查字串匹配常用演算法的具體實現

我們先看一個簡單的例子開始。

在目標字串中檢查是否有子串等於匹配文字,這是非常常見的操作. 最容易讓人想到的演算法就是從目標字串第一位開始,逐個字元與待匹配文字比較.匹配成功則指標右移繼續比較,要不然從目標文字的第二位開始和待匹配文字繼續逐個比較。如果指標先到達待匹配文字末尾,則匹配成功,要不然匹配失敗。該演算法稱之為樸素演算法,非常容易理解,但效率也比較慢.具體實現如下:

#include<stdio.h>
#include<string.h>
void search(char *pat, char *txt)
{
    int M = strlen(pat);
    int N = strlen(txt);
    int i;
    // 只需要遍歷目標文字 N-M 次, 因為從目標文字的 N-M 位開始的子串,長度永遠小於 M, 所以不會匹配成功
    for (i = 0; i <= N - M; i++)  
    {
        int j;
        for (j = 0; j < M; j++)
        {
            if (txt[i+j] != pat[j])
                break;
        }
        if (j == M)
        {
           printf("Pattern found at index %d \n", i);
        }
    }
}

int main()
{
   char *txt = "AABAACAADAABAAABAA";
   char *pat = "AABA";
   search(pat, txt);
   return 0;
}

Go 標準庫中的strings.Contains函式使用了Rabin-Karp 演算法, 主要思想如下:

假設匹配文字的長度為 M,目標文字的長度為 N

  1. 計算匹配文字的 hash 值
  2. 計算目標字串中每個長度為 M 的子串的 hash 值(需要計算 N-M+1 次)
  3. 比較 hash 值, 如果 hash 值不同,字串必然不匹配,如果 hash 值相同,還需要使用樸素演算法再次判斷

步驟 2 中每次都要重新計算 hash, Rabin-Karp 演算法的優點在於設計了一個特別的 hash 演算法,使其在計算下一個子串的 hash 時可以利用之前的 hash 結果, 以達到加速計算的效果。將每一個位元組看作數字, 選擇一個比較大的質數作為 base. 位元組的值是包含在基數之內的

舉例說明:
文字為"abracadabra",base 為 101,那麼 hash("abr") = 97 * 101 的 2 次方 + 98 * 101 的 1 次方 + 114 * 101 的 0 次方= 999509
下一個子串 "bra"的 hash 值為 98 * 101 的 2 次方 + 114 * 101 的 1 次方 + 97 * 101 的 0 次方. 我們可以利用之前"abr"的 hash 值, 寫成:

>//       base  old hash      new 'a'    old 'a' * base
>hash("bra") = 1011 * hash("abr") + (97 × 101 的 0 次方) - (97 × 101 的 3 次方)

可以看出 hash 演算法裡要點是確立一個非常大的數字作為 base,同時根據子串長度得到乘數因子 (上述的 101 的 3 次方,其實就是 base 的 len(待匹配文字) 次方).

src/strings/strings_amd64.go相關程式碼註釋

// 選擇非常大的一個質數16777619 作為 base 
const primeRK = 16777619   

// hashStr 返回子串的hash值和乘數因子
func hashStr(sep string) (uint32, uint32) {
    hash := uint32(0)
    for i := 0; i < len(sep); i++ {
        hash = hash*primeRK + uint32(sep[i])  //計算hash值
    }
    // 計算(最高位 + 1)位的乘數因子, 使用位移, 沒有使用 i--, 可以有效減少迴圈次數. i >>=1 相當於遍歷二進位制的每一位
    var pow, sq uint32 = 1, primeRK
    for i := len(sep); i > 0; i >>= 1 {
        if i&1 != 0 {
            pow *= sq
        }
        sq *= sq
    }
    return hash, pow
}

// Index 返回sep在s裡第一次匹配時的index, 無法匹配則返回-1.
func Index(s, sep string) int {
    n := len(sep)
    // 先分析一些常見情況, 起到進一步加速的效果
    switch {   
    case n == 0:
        return 0
    case n == 1:  //如果為一個位元組,則呼叫IndixByte(組合語言)
        return IndexByte(s, sep[0])
    case n <= shortStringLen:  //如果sep的長度小於31且大於1, 則使用匯編程式碼(也是一種優化). 
        return indexShortStr(s, sep)  
    case n == len(s):  
        if sep == s {
            return 0
        }
        return -1
    case n > len(s):
        return -1
    }
    // 使用Rabin-Karp演算法匹配
    // 步驟1 初始計算待匹配的文字的hash值和乘數因子, 
    hashsep, pow := hashStr(sep)
    var h uint32
    for i := 0; i < n; i++ {
        h = h*primeRK + uint32(s[i])  // 步驟2 計算長度跟sep一樣的s子串的hash值
    }
    if h == hashsep && s[:n] == sep {
        return 0
    }
    for i := n; i < len(s); {
        // 利用先前的hash值, 計算新的hash值 
        h *= primeRK  // 乘以base
        h += uint32(s[i]) // 加上下一個字元的 hash 值
        h -= pow * uint32(s[i-n]) // 減去先前子串的第一個字元的hash值
        i++
        // 如果hash相等則繼續使用樸素演算法比較, 如果hash不一致,則直接用下一個匹配
        if h == hashsep && s[i-n:i] == sep {   
            return i - n
        }
    }
    return -1
}

strings 庫裡還實現了 BM 演算法, 在這之前,我們先來看另一個非常經典的 KMP 演算法

假設檢查 bacbababaabcbab 是否包含 abababca, 此時發現第 6 位不一樣

bacbababaabcbab   
    abababca
         |
       第六位

樸素演算法:
bacbababaabcbab   
     abababca
     |
   移動一位後開始重新比較

KMP演算法:
bacbababaabcbab   
      abababca
      |
直接移動兩位後開始重新比較

如果按樸素演算法則按上面所示需要搜尋詞移一位後重新從第一位開始匹配。仔細想想, 前 5 個字元 ababa 已經匹配成功,也就是我們已經知道雙方的文字, 通過提前的計算,可以多移幾位, 而不僅僅移一位. 這樣可以加快搜尋

KMP 演算法的主要原理如下:
s 為目標文字, 長度為 m
p 為搜尋詞,長度為 n
假設 p[i] 與 s[x] 匹配失敗,那麼 p[i-1] 與 s[x-1] 是匹配成功的, 則試圖找到一個索引 j, 使得 p[0:j] = p[i-j-1:i-1] (p[0:j] 包含 p[j])
如果有則 s[x] 繼續與 p[j+1] 進行比較, 相當於搜尋詞移動 i-j-1 位
無則 s[x] 與 p[0] 比較. (具體程式碼實現時無可以表示為-1, 這樣 +1 後正好為 0) 相當於搜尋詞移動 i 位

void cal(char *p, int *next)
{
    int i;
    int k;
    /*第一次字元前面沒有索引了, 算corner case, 直接賦值為-1*/
    next[0] = -1;
    /* 迴圈每一個索引, 並計算next值 */
    for (i = 1; p[i] != '\0'; i++) {
        /* 獲取前一個索引的next值 */
        k = next[i - 1];
        /* 當p[i] != p[k + 1]時, 則令 k = next[k], 直到相等或者k == -1 退出*/
        while (p[i] != p[k + 1]) {
            if (k == -1) {
                k = -2;
                break;
            }
            k = next[k];
        }
        /*  1. p[i] == p[k + 1] 則 i對應的next值為 ++k
            2. 無索引時, k= -2, 則++k正好為-1
        */
        next[i] = ++k;
    }
}

int kmp(char *p, char*t)
{
    /*next為陣列, 儲存搜尋詞裡每一個索引對應的next值, 使得 p[0:next[i]] == p[i-j-1:i-1]*/
    int next[strlen(p)];
    cal(p, next);
    int i, j;
    i = 0;
    j = 0;
    while (p[i] != '\0' && t[j] != '\0') {
        if (p[i] == t[j]) {
            /* 值相等, 則指標 i, j 都遞增 */
            i++;
            j++;
        } else {
            if (i == 0) {
                j++;
                continue;
            }
            i = next[i - 1] + 1;
        }
    }
    if (p[i] == '\0') {
        return 0;
    } else {
        return 1;
    }
}

Go 語言裡在 strings/search.go 裡使用了 Boyer-Moore 字串搜尋演算法, 這個思想和 KMP 類似,都是根據 Pattern 自身計算出移動的步數. 有兩個優化點:

  1. BM 演算法是從後向前逐漸匹配.
  2. kmp 裡的通過已匹配的文字增加移動步數的叫做好規則,那麼 BM 裡同時還增加了壞規則

假定 Text 為"HERE IS A SIMPLE EXAMPLE",Pattern 為"EXAMPLE"。
當 T[i] != P[j], P[j] 右邊都匹配時時, 具體的移動規則如下:
壞字元規則: 此時 T[i] 定義為壞字元, 如果 P[0..j-1] 中包含 T[i] 這個字元, 則移動 T 使壞字元與相等的字元對齊, 如果不包含,則直接移動 len(P)

HERE IS A SIMPLE EXAMPLE
             |
       EXAMPLE

此時P為壞字元, 因EXAMPLE包含P, 則T的i指標右移二位使之對齊,然後重新開始從P的末端繼續匹配(下面打X處).

HERE IS A SIMPLE EXAMPLE
             | X
         EXAMPLE

如下場景,T中的M與P中的E不匹配, 按Go的程式碼實現,是移動兩位(取該字元到P末尾的最短距離),沒完全按上面的規則實現
大家是不是發現沒有跳躍前進,反而匹配又倒回到之前已完成的匹配過程。 Go程式碼這麼做是為了實現簡單。 
因為還有好規則可以保證最終的移動步數是正確的
ABCADADEEFXYZ
   | 
 AYEDADE
移動為
  ABCADADEEFXYZ
     | X   
 AYEDADE

好字尾規則: 當發生不匹配時,之前已經匹配成功的,稱之為好字元. 如下 I 和 A 不匹配, 後面的 MPLE 就是好字尾. 首先檢查 P 裡是否好字尾只出現過一次: 比如此時的MPLE作為好字尾在整個字串EXAMPLE中只出現過一次

  • 不是, 則移動 P 使 T 中的好字尾與 P 中長度相等的字串對齊
  • 是, 則繼續檢查好字尾的所有字尾(比如 PLE,PL,E) 是否和同等長度的 P 字首相等, 如果相等則移動 P 使之對齊, 不相等則移動 len(P).
    這裡相當於要求字尾必須出現在 P 的首部, 如果非首部, 因字首的前一個字元必然不相等,則整個字串肯定無法匹配
HERE IS A SIMPLE EXAMPLE
            ||||
         EXAMPLE
MPLE, PLE,LE沒法和首部匹配,但字尾E和P字首相等, 則移動T使其對齊,從打X出繼續從後向前比較
HERE IS A SIMPLE EXAMPLE
               |     X
               EXAMPLE

具體的程式碼註釋如下:

func makeStringFinder(pattern string) *stringFinder {
    f := &stringFinder{
        pattern:        pattern,
        goodSuffixSkip: make([]int, len(pattern)),
    }
    // last 是pattern最後一個字元的索引
    last := len(pattern) - 1

    // 建立壞字元表,記錄不匹配時T的i指標移動步數
    // 第一階段,初始化256個字元全部移動 len(pattern) 步
    for i := range f.badCharSkip {
        f.badCharSkip[i] = len(pattern)
    }

    // 第二階段:從左到右遍歷pattern,更新其索引與P末尾的距離,結果就是該字元到末尾的最小距離
    // 沒有計算last byte的距離, 因為移動至少要一步。 沒有0步。 
    for i := 0; i < last; i++ {
        f.badCharSkip[pattern[i]] = last - i
    }

    // 建立好字尾表
    // 第一階段: 此時pattern[i+1:]都是已經匹配的,且好字尾只出現了一次
    // 計算T中的指標要移動的步數
    lastPrefix := last
    for i := last; i >= 0; i-- {
        if HasPrefix(pattern, pattern[i+1:]) {
            lastPrefix = i + 1
        }
        // 好字尾時T的指標移動分兩步,首先移動到與 pattern的末尾對齊,即 last - i
        // lastPrefix 用來記錄 pattern[i+1:]中所有字尾與同等長度的字首相等時的最大索引
        // 然後移動 lastPrefix步
        f.goodSuffixSkip[i] = lastPrefix + last - i
    }
    // 第二階段: 好字尾在pattern前面部分還出現過, 如下計算相應的移動步數
    // 會覆蓋之前第一階段的部分值。但好字尾出現過移動步數比沒出現的小。所以最終值是正確的
    // 舉例: "mississi" 中好字尾是issi, 在pattern[1]處出現過,所以移動步數為 last-i  +  lenSuffix
    for i := 0; i < last; i++ {
        lenSuffix := longestCommonSuffix(pattern, pattern[1:i+1])
        if pattern[i-lenSuffix] != pattern[last-lenSuffix] {
            // (last-i) is the shift, and lenSuffix is len(suffix).
            f.goodSuffixSkip[last-lenSuffix] = lenSuffix + last - i
        }
    }
    return f
}
// longestCommonSuffix 僅僅比較兩個字串的共同字尾的長度, 沒有則為0
func longestCommonSuffix(a, b string) (i int) {
    for ; i < len(a) && i < len(b); i++ {
        if a[len(a)-1-i] != b[len(b)-1-i] {
            break
        }
    }
    return
}

// next 主要返回p在text裡第一次匹配時的索引, 不匹配則返回-1
func (f *stringFinder) next(text string) int {
    // i 是T(即變數text)中要檢查的字元索引, j為P中要檢查的字元索引

    // 因從後向前比較, 所以i初始化為P的最後一位索引
    i := len(f.pattern) - 1
    for i < len(text) {
        // 每次比較時都從p的最後一位開始比較
        j := len(f.pattern) - 1
        for j >= 0 && text[i] == f.pattern[j] {
            i--
            j--
        }
        // j為負數,說明匹配成功, 則直接返回 i+ 1 
        if j < 0 {
            return i + 1
        }
        // j為非負, 表明text[i] != f.pattern[j], 則從壞字元表和好字尾表中獲取分別獲取i需要移動的步數, 取最大值並使移動到新位置
        i += max(f.badCharSkip[text[i]], f.goodSuffixSkip[j])
    }
    return -1
}
更多原創文章乾貨分享,請關注公眾號
  • 從Go標準庫strings看字串匹配演算法
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章