在實際工作中,我們一定遇到過在字符串中查找子字符串的需求。很多編程語言的字符串數據類型都提供了方便的方法。比如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(mn)。
聰明的計算機行業前輩 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)。