測試開發基礎之算法(15):字符串匹配算法——BF算法和RK算法

在實際工作中,我們一定遇到過在字符串中查找子字符串的需求。很多編程語言的字符串數據類型都提供了方便的方法。比如Python中的in、find(),Java中的indexOf()。

那如果我們自己實現一個字符串查找算法,該如何做呢?字符串匹配算法很多,這篇文章介紹兩種比較簡單的、好理解的算法,它們分別是:BF 算法和 RK 算法。這兩種算法,都是單模式串匹配的算法,也就是在一個主串中查找一個模式串。

BF 算法

BF 算法中的 BF 是 Brute Force 的縮寫,中文叫作暴力匹配算法,也叫樸素匹配算法
。從名字上大家可以感覺到,這個算法應該是很暴力、很直接,所有人都能理解的算法。

直接藉助極客時間《數據結構與算法之美》裏面的一個圖片,來感受一下它有多麼直接、邏輯多麼簡單。
在這裏插入圖片描述
在這張圖片中,主串baddef,長度記作n,模式串abc,長度記作 m。在主串中,從位置 0開始到n-m爲止,長度爲 m 的 n-m+1 個子串中,看有沒有能跟模式串匹配的。如果有,我們記錄主串下標i。

可以看到,BF算法思想簡單,代碼實現也會非常簡單,簡單意味着不容易出錯,在工程實踐中應用還是挺廣泛的。另外,在工程實踐大部分情況下,模式串和主串的長度都不會太長,而且每次模式串與主串中的子串匹配的時候,當中途遇到不能匹配的字符的時候,就可以就停止了,不需要把 m 個字符都比對一下。BF 算法雖然最壞的時間複雜度是O(m*n),但是這種最壞情況很少發生。

上面已經把算法邏輯講清楚了,爲了代碼實現,我們再更加細緻的分解一下具體的匹配比較動作。

第一輪,我們從主串的位置0開始,把主串和模式串的字符逐個比較:
在這裏插入圖片描述
顯然,主串的首位字符是a,模式串的首位字符是b,兩者並不匹配。
第二輪,我們把模式串後移一位,從主串的第二位開始,把主串和模式串的字符逐個比較:
在這裏插入圖片描述
主串的第二位字符是b,模式串的第一位字符也是b,兩者匹配,繼續比較:
在這裏插入圖片描述
主串的第三位字符是b,模式串的第二位字符是c,兩者並不匹配。
第三輪,把模式串再次後移一位,從主串的第三位開始,把主串和模式串的字符逐個比較:
在這裏插入圖片描述
主串的第三位字符是b,模式串的第一位字符也是b,兩者匹配,繼續比較。
主串的第四位字符是c,模式串的第二位字符也是c,兩者匹配,繼續比較。
主串的第五位字符是e,模式串的第三位字符也是e,兩者匹配,至此,模式串的每一個字符都與主串的子串中每一個字符匹配了。
由此得到結果,模式串 bce 是主串 abbcefgh 的子串,在主串第一次出現的位置下標是 2。如果只想找到主串的一個位置,那麼代碼就可以結束了。如果想在主串中找到更多的子串,還可以繼續按照前面的邏輯尋找。
將上面的步驟轉換成代碼如下:

def brute_force_string_match(text, pattern):
    m = len(pattern)
    n = len(text)
    for i in range(0, n - m + 1):  # 模式串最大需要在主串中進行n-m+1個子串的比較
        # +1 because range(0,1) is 1 , But we want it to include i at
        j = 0  # 從模式串位置0開始
        while j < m and pattern[j] == text[i + j]:  # 模式串還沒到最後一個字符,並且pattern[j] 與 text[i + j]匹配
            print(j)
            j = j + 1  # 比較模式串下一個字符
        if j == m:  # 注意這裏是m,不是m-1,因爲上面while循環最後j被加了1
            return i
    return -1


if __name__ == '__main__':
    text = "abbcefgh"
    pattern = "bce"
    print(brute_force_string_match(text, pattern))

RK 算法

前面說到,BF 算法極端情況的時間複雜度是O(mn)。來看一個例子:
在這裏插入圖片描述
這個情況,比較過程如下:
在這裏插入圖片描述
兩個字符串在每一輪都需要白白比較4次,顯然非常浪費。這就是BF 算法最壞的情況了,時間複雜度是O(m
n)。

聰明的計算機行業前輩 Rabin 和 Karp 發命令一種基於Hash算法的高效匹配算法。後人把他們發明的這個算法叫做 RK 算法。

RK 算法的思路是這樣的:我們通過Hash算法對主串中的 n-m+1 個子串分別求hash值,然後逐個與模式串的hash值比較大小。如果某個子串的哈希值與模式串的hash值相等,那就說明對應的子串和模式串 可能 匹配了(有可能哈希衝突)。這時,再逐個比較模式串中的字符與子串中的字符。

這種算法高效的原因,就在於下進行Hash值的比較,因爲哈希值是一個數字,數字之間比較是否相等是非常快速的,所以模式串和子串比較的效率就提高了。但是至於高效多少,這就取決於設計的hash算法的設計了。

整個 RK 算法包含三部分:

  • 計算子串Hash值
  • 比較模式串Hash值與子串Hash值
  • Hash值相同時,逐個對比字符

前提是有一個合適的hash算法。

設計Hash算法

hash算法要儘量簡單,又要儘可能避免hash衝突。這裏介紹兩種比較容易理解的hash算法比如按位加,或者按照進制加。

1. 按位相加

這是最簡單的方法,我們可以把a當做1,b當做2,c當做3…然後把字符串的所有字符相加,相加結果就是它的hashcode。

bce = 2 + 3 + 5 = 10

但是,這個算法雖然簡單,卻很可能產生hash衝突,比如bce、bec、cbe的hashcode是一樣的。

2. 按進制加

既然字符串只包含26個小寫字母,那麼我們可以把每一個字符串當成一個26進制數來計算。

bce = 2*(26^2) + 3*26 + 5 = 1435

這樣做的好處是大幅減少了hash衝突,缺點是計算量較大,而且有可能出現超出整型範圍的情況,需要對計算結果進行取模。

比較模式串Hash值與子串Hash值

這裏我們採用第一種Hash算法,下面重點介紹一下流程。

第一步,計算模式串的Hash值。根據前面設計的Hash算法,計算模式串的Hash值。即bce = 2 + 3 + 5 = 10

第二步,在主串中計算第一個和模式串等長的子串的Hash值。即abb = 1 + 2 + 2 = 5:在這裏插入圖片描述
第三步,比較兩個hash值。顯然,5!=10,說明模式串和第一個子串不匹配,我們繼續將模式串與下一個子串比較。

第四步,重複上面的第二步和第三步。生成主串當中第二個等長子串的hash值,bbc = 2 + 2 + 3 = 7。比較模式串的Hash值與這個子串的Hash值,顯然,7!=10,說明模式串和第二個子串不匹配,我們繼續下一輪比較。重複第四步。
在這裏插入圖片描述

Hash值相同時逐個對比字符

發現主串中第三個等長子串的Hash值與模式串的Hash值相等,接着,我們對兩個字符串逐個字符比較,最終判斷出兩個字符串匹配。

再來回顧一下這個Hash算法

我們發現後一個子串的Hash值的計算,都可以根據前面一個Hash值推導出來,而不需要重新累加計算。比如,
在這裏插入圖片描述
已知子串abbcefg的hash值是26,那麼如何計算下一個子串,也就是bbcefgd的hash值呢?由於新子串的前面少了一個a,後面多了一個d,所以:
後一個子串hash值 = 前一個子串hash值 - 1 + 4 = 26-1+4 = 29。

代碼實現

def rabin_karp_string_match(text, pattern):
    m = len(pattern)
    n = len(text)
    pattern_hash = hash_code(pattern)
    # 計算主串當中第一個和模式串等長的子串hash值
    text_hash = hash_code(text[0: m])

    # 用模式串的hash值和主串的局部hash值比較。如果匹配,則進行精確比較;如果不匹配,計算主串中相鄰子串的hash值。
    for i in range(0, n - m + 1):
        if text_hash == pattern_hash and text[i: m+i] == pattern:
            return i
        # 如果不是最後一輪,更新主串從i到i+m的hash值
        if i < n - m:
            text_hash = next_hash(text, text_hash, i, m)
    return -1


def hash_code(string):
    """
    這裏採用最簡單的hashcode計算方式,把a當做0,把b當中1,把c當中2.....然後按位相加
    :param string: 
    :return: 
    """
    hashcode = 0
    for i in range(0, len(string)):
        hashcode += ord(string[i]) - ord('a')
    return hashcode


def next_hash(string, hash_code, start, end):
    """
    根據前一個hash_code計算string中從start~end之間子串的hash_code
    :param string: 
    :param hash_code: 
    :param start: 
    :param end: 
    :return: 
    """
    hash_code -= ord(string[start]) - ord('a')
    hash_code += ord(string[start+end]) - ord('a')
    return hash_code


if __name__ == '__main__':
    text = "abbcefgh"
    pattern = "bce"
    print(rabin_karp_string_match(text, pattern))

複雜度分析

我們開頭的時候提過,RK 算法的效率要比 BF 算法高,現在,我們就來分析一下,RK 算法的時間複雜度到底是多少呢?

整個 RK 算法包含兩部分,計算子串哈希值和模式串哈希值與子串哈希值之間的比較。第一部分,我們前面也分析了,可以通過設計特殊的哈希算法,只需要掃描一遍主串就能計算出所有子串的哈希值了,所以這部分的時間複雜度是 O(n)。

模式串哈希值與每個子串哈希值之間的比較的時間複雜度是 O(1),總共需要比較 n-m+1 個子串的哈希值,所以,這部分的時間複雜度也是 O(n)。所以,RK 算法整體的時間複雜度就是 O(n)。

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