Python 細聊從暴力(BF)字串匹配演算法到 KMP 演算法之間的精妙變化

一枚大果殼發表於2022-03-25

1. 字串匹配演算法

所謂字串匹配演算法,簡單地說就是在一個目標字串中查詢是否存在另一個子字串。如在字串 "ABCDEFG" 中查詢是否存在 “EF” 字串。

可以把字串 "ABCDEFG" 稱為原始(目標)字串,“EF” 稱為子字串模式字串

本文試圖通過幾種字串匹配演算法的演算法差異性來探究字串匹配演算法的本質。

常見的字串匹配演算法:

  • BF(Brute Force,暴力檢索演算法)
  • RK (Robin-Karp 演算法)
  • KMP (D.E.Knuth、J.H.Morris、V.R.Pratt 演算法)

2. BF(Brute Force,暴力檢索)

BF 演算法是一種原始、低階的窮舉演算法。

2.1 演算法思想

下面使用長、短指標方案描述 BF 演算法:

  1. 初始指標位置: 長指標指向原始字串的第一個字元位置、短指標指向模式字串的第一個字元位置。這裡引入一個輔助指標概念,其實可以不用。

    輔助指標是長指標的替身,替長指標和短指標所在位置的字元比較。

    每次初始化長指標位置時,讓輔助指標和長指標指向同一個位置。

  1. 如果長、短指標位置的字元不相同,則長指標向右移動(短指標不動)。如果長、短指標所指位置的字元相同,則用輔助指標替代長指標(長指標位置暫不動)和短指標位置的字元比較,如果比較相同,則同時向右移動輔助指標和短指標。

  1. 如果輔助指標和短指標位置的字元不相同,則重新初始化長指標位置(向右移動),短指標恢復到最原始狀態。

  1. 使用重複或者遞迴的方式重複上述流程,直到出口條件成立。

    • 查詢失敗:長指標到達了原始字串的尾部。當 長指標位置=原始字串長度 - 模式字串長度+1 時就可以認定查詢失敗。

  • 查詢成功: 短指標到達模式字串尾部。

2.2 編碼實現

使用輔助指標:

# 原始字串
src_str = "thismymyre"
# 長指標
sub_str = "myr"
# 長指標 :在原始字串上移動
long_index = 0
# 短指標:在模式字串上移動
short_index = 0
# 輔助指標
fu_index = long_index 
# 原始字串長度
str_len = len(src_str)
# 模式字串的長度
sub_len = len(sub_str)
# 是否存在
is_exist = False
while long_index < str_len-sub_len+1:
    # 把長指標的位值賦給輔助指標
    fu_index = long_index
    # 短指標初始為原始位置
    short_index = 0
    while short_index < sub_len and src_str[fu_index] == sub_str[short_index]:
        # 輔助指標向右
        fu_index += 1
        # 短指標向右
        short_index += 1
    if short_index == sub_len:
        is_exist = True
        break
    # 比較不成功,則長指標向右移動    
    long_index += 1

if not is_exist:
    print("{0} 不存在於 {1} 字串中".format(sub_str, src_str))
else:
    print("{0} 存在於 {1} 的 {2} 位置".format(sub_str, src_str, long_index))

使用一個增量:

# 原始字串
src_str = "thisismymyrdodmyrd"
# 子子符串
sub_str = "myrd"
# 長指標
long_index = 0
# 短指標
short_index = 0
# 原始字串長度
str_len = len(src_str)
# 模式字串的長度
sub_len = len(sub_str)
is_exist = False
while long_index < str_len:
    i = 0
    short_index = 0
    while short_index < sub_len and src_str[long_index + i] == sub_str[short_index]:
        i += 1
        # 短指標向右
        short_index += 1
    if short_index == sub_len:
        is_exist = True
        break
    long_index += 1

if not is_exist:
    print("{0} 不存在於 {1} 字串中".format(sub_str, src_str))
else:
    print("{0} 存在於 {1} 的 {2} 位置".format(sub_str, src_str, long_index))

使用或不使用輔助指標的程式碼邏輯是一樣。

在原始字串和模式字串齊頭並進逐一比較時,最好不要修改長指標的位置,否則,在比較不成功時,修正長指標的邏輯就沒有單純的直接向右移動那麼好理解。

如下直接使用長指標和短指標進行比較:

# 原始字串
src_str = "thisismymyrdodmyrd"
# 子子符串
sub_str = "myrd"
# 長指標
long_index = 0
# 短指標
short_index = 0
# 原始字串長度
str_len = len(src_str)
# 模式字串的長度
sub_len = len(sub_str)
is_exist = False
while long_index < str_len:
    short_index = 0
    # 直接使用長指標和短指標位置相比較
    while short_index < sub_len and src_str[long_index] == sub_str[short_index]:
        long_index+=1
        # 短指標向右
        short_index += 1
    if short_index == sub_len:
        is_exist = True
        break
    # 修正長指標的位置
    long_index = long_index-short_index+1

if not is_exist:
    print("{0} 不存在於 {1} 字串中".format(sub_str, src_str))
else:
    print("{0} 存在於 {1} 的 {2} 位置".format(sub_str, src_str, long_index-short_index))

使用字串切片實現: 使用 Python 的切片實現起來更簡單。但不利於初學者理解 BF 演算法的細節。

# 原始字串
src_str = "thisismymyrdodmyrd"
# 子子符串
sub_str = "myrd"
# 原始字串長度
str_len = len(src_str)
# 模式字串的長度
sub_len = len(sub_str)
is_exist = False
for index in range(str_len - sub_len + 1):
    if src_str[index:index + sub_len] == sub_str:
        is_exist = True
        break
if not is_exist:
    print("{0} 不存在於 {1} 字串中".format(sub_str, src_str))
else:
    print("{0} 存在於 {1} 的 {2} 位置".format(sub_str, src_str, index))

BF 演算法的時間複雜度:

BF 演算法直觀,易於實現。但程式碼中有迴圈中巢狀迴圈的結構,這是典型的窮舉結構。如果原始字串的長度為 m ,模式字串的長度為 n。時間複雜度則是 O(m*n),時間複雜度較高。

3. RK(Robin-Karp 演算法)

RK演算法 ( 指紋字串查詢) 在 BF 演算法的基礎上做了些改進,基本思路:

在模式字串和原始字串的字元準備開始逐一比較時,能不能通過一種演算法,快速判斷出本次比較是沒有必要。

3.1 RK 的演算法思想

  • 選定一個雜湊函式(可自定義)。

  • 使用雜湊函式計算模式字串的雜湊值。

    如上計算 thia 的雜湊值

  • 再從原始字串的開始比較位置起,擷取一段和模式字串長度一樣的子串,也使用雜湊函式計算雜湊值。

    如上計算 this 的雜湊值

  • 如果兩次計算出來的雜湊值不相同,則可判斷兩段模式字串不相同,沒有比較的必要。

  • 如果兩次計算的雜湊值相同,因存在雜湊衝突,還是需要使用 BF 演算法進行逐一比較。

RK 演算法使用雜湊函式演算法減少了比較次數。

3.2 編碼實現:

# 原始字串
src_str = "thisismymyrdodmyrd"
# 子子符串
sub_str = "myrd"
# 長指標
long_index = 0
# 短指標
short_index = 0
# 輔助指標
fu_index = 0
# 原始字串長度
str_len = len(src_str)
# 模式字串的長度
sub_len = len(sub_str)
is_exist = False
for long_index in range(str_len - sub_len + 1):
    # 這裡使用 python 內建的 hash 函式
    if hash(sub_str) != hash(src_str[long_index:long_index + sub_len]):
        # 雜湊值一樣就沒有必要比較了
        continue
    # 把長指標的位置賦給輔助指標
    fu_index = long_index
    short_index = 0
    while short_index < sub_len and src_str[fu_index] == sub_str[short_index]:
        # 輔助指標向右
        fu_index += 1
        # 短指標向右
        short_index += 1
    if short_index == sub_len:
        is_exist = True
        break

if not is_exist:
    print("{0} 不存在於 {1} 字串中".format(sub_str, src_str))
else:
    print("{0} 存在於 {1} 的 {2} 位置".format(sub_str, src_str, long_index))

RK 的時間複雜度:

RK 的程式碼結構和 BF 看起來一樣,使用了迴圈巢狀。但內建迴圈只有當雜湊值一樣時才會執行,執行次數是模式字串的長度。如果原始子符串長度為 m,模式字串的長度為 n。則時間複雜度為 O(m+n),如果不考慮雜湊衝突問題,時間複雜度為 O(m)。

很顯然 RK 演算法比 BF 演算法要快很多。

4. KMP演算法

演算法的本質都是窮舉,這是由計算機的思維方式決定的。我們在談論"好"和“壞” 演算法時,所謂好就是想辦法讓窮舉的次數少一些。比如前面的 RK 演算法,通過一些特性提前判斷是否值得比較,這樣可以省掉很多不必要的內迴圈。

KMP 也是一樣,也是儘可能減少比較的次數。

4.1 KMP 演算法思路:

KMP 的基本思路和 BF 是一樣的(字串逐一比較),BF 演算法中,如果比較不成功,長指標每次只會向右移動一位。如下圖:輔助指標和短指標對應位置字元不相同,說明比較失敗。

長指標向右移一位,短指標恢復原始狀態。重新逐一比較。

KMP 演算法對長、短指標的移位做了優化。

  • 沒有必要再使用輔助指標。
  • 直接把長指標和短指標所在位置的字元逐一比較。
  • 比較失敗後,長指標位置不動。根據 KMP 演算法中事先計算好的 “部分匹配表(PMT:Partial Match Table)” 修改短指標的位置。

如上圖比較失敗後,長指標位置保持不變,只需要移動短指標。短指標具體移動哪裡,由 PMT 表決定。上圖灰色區域就是根據 PMT 表計算出來的可以不用再比較的字元。

在移動短指標之前,先要理解 KMP 演算法中 的 "部分匹配表(PMT)" 是怎麼計算出來的。

先理解與 PMT 表有關係的 3 個概念:

  • 字首集合:

    如: ABAB 的字首(不包含字串本身)集合 {A,AB,ABA}

  • 字尾集合:

    如:ABAB 中字尾(不包含字串本身)集合 { BAB,AB,B }

  • PMT值: 字首、字尾兩個集合的交集元素中最長元素的長度。

    如:先求 {A,AB,ABA}{ BAB,AB,B } 的交集,得到集合 {AB} ,再得到集合中最長元素的長度, 所以 ABAB 字串的 PMT 值是 2 。

如前面圖示,原始字串和模式字串逐一比較時,前 4 位即 ABAB 是相同的,而 ABAB 存在最大長度的字首和字尾 ‘AB’ 子串。意味著下一次比較時,可以直接讓模式字串的字首和原始字串中已經比較的字串的字尾對齊,公共部分不用再比較。

所以,KMP 演算法的核心是得到 PMT 表,現使用手工方式計算 ABABCAPMT 值:

  • 當僅匹配第一個字元 A 時,A 沒有字首集合也沒有字尾集合,所以 PMT[0]=0,短指標要移到模式字串的 0 位置。

  • 當僅匹配前二個字元 AB 時,AB的字首集合{A},字尾集合是{B},沒有交集,所以 PMT[1]=0,短指標要移到模式字串的 0 位置。

  • 當僅匹配前三個字元 ABA 時,ABA 的字首集合{A,AB} ,字尾集合{BA,A},交集{A},所以 PMT[2]=1,短指標要移到模式字串 1 的位置。

  • 當僅匹配前四個字元 ABAB 時,ABAB 的字首集合 {A ,AB,ABA },字尾集合{BAB,AB,B},交集{AB},所以 PMT[3]=2,短指標要移到模式字串 2 的位置。

  • 當僅匹配前五個字元 ABABC 時,ABABC 的字首集合{ A,AB,ABA,ABAB },字尾集合{ C,BC,ABC,BABC },沒有交集,所以PMT[4]=0,短指標要移到模式字串的 0 位置。

  • 當全部匹配後,ABABCA 的字首是{A,AB,ABA,ABABC,ABABCA},字尾是{A,CA,BCA,ABCA,BABCA} 交集是{A},PMT[5]=1。

其實在 KMP 演算法中,本沒有直接使用 PMT 表,而是引入了next 陣列的概念,next 陣列中的值是 PMT 的值向右移動一位。

KMP演算法實現: 先不考慮 next 陣列的演算法,先以上面的手工計算值作為 KMP 演算法的已知資料。

src_str = 'ABABABCAEF'
sub_str = 'ABABCA'
# next 陣列,現在不著急討論 next 陣列如何編碼實現,先用上面手工推演出來的結果
p_next = [-1, 0, 0, 1, 2, 0]
# long_index 指向原始字元的第一個位置
long_index = 0
# short_index 指向模式字串的第一個
short_index = 0
# 原始字串的長度
src_str_len = len(src_str)
# 模式字串的長度
sub_str_len = len(sub_str)
# 儲存長指標、短指標位置有效 當長指標越界時,說明查詢失敗,當短指標越界,說明查詢成功
while long_index < src_str_len and short_index < sub_str_len:
    # 理論上 當長指標和短指標所在位置的字元相同時,長、短指標向右移動
    # 如果長指標和短指標所在位置的字元不相同時,這裡 -1 就起到神奇的作用,長指標可以前進,短指標會變成 0 。
    # 下次比較時,如果還是不相同 short_index 又變回 -1, 長指標又可以前進,短指標還是指向 0 位置
    if short_index == -1 or src_str[long_index] == sub_str[short_index]:
        long_index += 1
        short_index += 1
    else:
        short_index = p_next[short_index]
if short_index == sub_str_len:
    print(long_index - short_index)

上面的程式碼是沒有通用性的,因為 next 陣列的值是固定的,現在實現求解 netxt 陣列的演算法:

求 next 也可以認為是一個字串匹配過程,只是原始字串和模式字串都是同一個字串,因第一個字元沒有字首也沒有字尾,所以從第二個字元開始。

# 求解 next 的演算法
def getNext(p):
    i, j = 0, -1
    m = len(p)
    pnext = [-1] * m
    while i < m - 1:
        if j == -1 or p[i] == p[j]:
            i += 1
            j += 1
            pnext[i] = j
        else:
            j = pnext[j]
    return pnext

KMP演算法的時間複雜度為 O(m+n)

5. 總結

字串匹配演算法除了上述幾種外,還有 Sunday演算法、Sunday演算法。從暴力演算法開始,其它演算法的目的都是儘可能減少比較的次數。加快演算法的速度。

相關文章