cv.cm

編碼 · 演算法

KMP 字串搜尋教學:原理、程式碼與時間複雜度

閱讀約 6 分鐘

簡答

Knuth-Morris-Pratt(KMP)演算法能在 O(n + m) 時間內,找出長度為 m 的模式在長度為 n 的文字中的所有出現位置。它先為模式建立前綴函式(也稱失敗表):對每個位置,記錄最長的「同時也是後綴的真前綴」的長度。發生不匹配時,KMP 利用這張表移動模式,而不需要讓文字的指標往回走。

開啟正規測試

KMP 解決什麼問題

字串搜尋要問的是:一個短的模式在較長的文字中出現在哪裡。樸素的方法會嘗試每一個起始位置,並逐字元比對。在一般文字上它很快,但在重複性高的輸入上就很慢:在 aaaaaaaaaa 中搜尋 aaaab,每個位置幾乎都要比對完整個模式,時間複雜度為 O(n × m)。

KMP 在不匹配之後絕不會重讀文字中的字元。Donald Knuth、James H. Morris 與 Vaughan Pratt 於 1977 年在「Fast Pattern Matching in Strings」中發表了它。

用白話說明原理

假設你正在搜尋模式 ABABC,已經匹配了 ABAB,但下一個文字字元不是 C。剛剛匹配的 ABAB 以 AB 結尾,而 AB 也正好是模式的開頭。所以你已經知道下一次嘗試一開始就有 2 個字元匹配,可以直接從模式的第 2 個位置繼續比對,而不必從頭開始。

前綴函式就是用來儲存這些數字。pi[i] 是 pattern[0..i] 的最長真前綴的長度,且該前綴同時也是它的後綴(真的意思是不等於整個字串)。

i0123456
pattern[i]ABABACA
pi[i]0012301

例如,pi[4] = 3,因為 ABABA 的開頭與結尾都是 ABA。pi[5] = 0,因為 ABABAC 的任何前綴都不是以 C 結尾。

逐步範例

在文字 ABABABC 中搜尋模式 ABABC(前綴函式為 0, 0, 1, 2, 0)。變數 k 記錄目前已匹配的模式字元數。

文字位置文字字元之前的 k動作之後的 k
0–3A B A B0連續四次匹配4
4A4與 pattern[4] = C 不匹配:設定 k = pi[3] = 2,然後 A 與 pattern[2] 匹配3
5B3與 pattern[3] 匹配4
6C4與 pattern[4] 匹配:完整匹配結束於位置 6,因此起點是 6 − 5 + 1 = 25 → pi[4] = 0

文字指標向前移動了 7 次,從未往回走。樸素方法則會從位置 1 重新開始,並重複比對一些字元。

Python 實作

def prefix_function(p):
    pi = [0] * len(p)
    k = 0
    for i in range(1, len(p)):
        while k > 0 and p[i] != p[k]:
            k = pi[k - 1]          # fall back to a shorter border
        if p[i] == p[k]:
            k += 1
        pi[i] = k
    return pi

def kmp_search(text, pattern):
    """Return the start index of every occurrence (overlaps included)."""
    if not pattern:
        return []
    pi, k, hits = prefix_function(pattern), 0, []
    for i, ch in enumerate(text):
        while k > 0 and ch != pattern[k]:
            k = pi[k - 1]
        if ch == pattern[k]:
            k += 1
        if k == len(pattern):
            hits.append(i - k + 1)
            k = pi[k - 1]          # keep going to find overlapping matches
    return hits

print(prefix_function("ABABACA"))      # [0, 0, 1, 2, 3, 0, 1]
print(kmp_search("ABABABC", "ABABC"))  # [2]
print(kmp_search("AAAAA", "AA"))       # [0, 1, 2, 3]

時間與空間複雜度

階段時間空間原因
建立前綴函式O(m)O(m)k 每一步最多增加一次,而每次回退都會讓它減少,所以回退的總次數最多為 m。
搜尋O(n)額外 O(1)對文字套用相同的論證:最多增加 n 次,所以最多回退 n 次。
總計O(n + m)O(m)最差情況與最佳情況在常數倍以內相等;不存在糟糕的輸入。

樸素演算法的最差情況是 O(n × m),但在自然語言文字上通常接近 O(n),所以 KMP 的優勢主要出現在重複性高的資料上。

變化版本與常見錯誤

  • 回退時差一:不匹配之後要設定 k = pi[k - 1],而不是 pi[k]。pi 是以最後一個匹配位置作為索引。
  • 回退時用 if 而不是 while:在某個字元匹配之前,可能需要連續回退好幾次。
  • 完整匹配後忘了重設 k:要找出重疊的匹配,請設定 k = pi[k - 1];若只要不重疊的匹配,則設為 k = 0。
  • 串接技巧:計算 pattern + separator + text 的前綴函式,分隔字元要選擇兩者都未出現的字元;值等於 m 的每個位置都是一個匹配的結尾。
  • Z 演算法計算的是一個密切相關的陣列,同樣能在 O(n + m) 內搜尋。

何時使用與實際應用

演算法最適合的用途
KMP單一模式且保證線性時間;串流文字,因為它從不回頭看。
Boyer-Moore 及其變化版本大字母表上的長模式;常能跳過許多字元,grep 等工具就使用它。
Rabin-Karp多個相同長度的模式,或利用滾動雜湊做抄襲偵測。
Aho-Corasick同時搜尋多個模式;它把 KMP 的失敗連結推廣到字典樹(trie)上。
後綴陣列或後綴自動機對同一份固定文字做許多不同的查詢。
  • 面試題目:實作 strStr()、最短回文(s + "#" + reversed(s) 的前綴函式)、重複的子字串模式、字串的最小週期(m - pi[m - 1])。
  • 實際應用:在 DNA 序列和網路封包串流中搜尋,這些場合很重視線性的最差情況時間。

常見問題

KMP 中的前綴函式或失敗表是什麼?

對模式的每個位置 i,它儲存 pattern[0..i] 的最長真前綴的長度,且該前綴同時也是它的後綴。發生不匹配時,KMP 靠它得知還有多少字元仍在匹配。

KMP 的時間複雜度是多少?

O(n + m):建立前綴函式需要 O(m),掃描文字需要 O(n)。即使在最差情況下也是線性的。

KMP 比 Python 的 str.find 快嗎?

不會。str.find 以 C 實作,使用最佳化的搜尋演算法,實務上快得多。KMP 的價值在於保證線性時間、用於學習,以及處理非字串的序列。

KMP 能找出重疊的匹配嗎?

可以,只要在完整匹配之後設定 k = pi[k - 1] 而不是 0。例如,它能在 AAAAA 中找到位置 0、1、2、3 的 AA。

全部編碼教學