編碼 · 演算法
KMP 字串搜尋教學:原理、程式碼與時間複雜度
簡答
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] 的最長真前綴的長度,且該前綴同時也是它的後綴(真的意思是不等於整個字串)。
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| pattern[i] | A | B | A | B | A | C | A |
| pi[i] | 0 | 0 | 1 | 2 | 3 | 0 | 1 |
例如,pi[4] = 3,因為 ABABA 的開頭與結尾都是 ABA。pi[5] = 0,因為 ABABAC 的任何前綴都不是以 C 結尾。
逐步範例
在文字 ABABABC 中搜尋模式 ABABC(前綴函式為 0, 0, 1, 2, 0)。變數 k 記錄目前已匹配的模式字元數。
| 文字位置 | 文字字元 | 之前的 k | 動作 | 之後的 k |
|---|---|---|---|---|
| 0–3 | A B A B | 0 | 連續四次匹配 | 4 |
| 4 | A | 4 | 與 pattern[4] = C 不匹配:設定 k = pi[3] = 2,然後 A 與 pattern[2] 匹配 | 3 |
| 5 | B | 3 | 與 pattern[3] 匹配 | 4 |
| 6 | C | 4 | 與 pattern[4] 匹配:完整匹配結束於位置 6,因此起點是 6 − 5 + 1 = 2 | 5 → 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。