コーディング · アルゴリズム
KMP法(文字列検索)とは?仕組み・コード・計算量をわかりやすく解説
結論
クヌース–モリス–プラット法(KMP法)は、長さ n のテキストの中から、長さ m のパターンの出現位置をすべて O(n + m) 時間で見つけるアルゴリズムです。まずパターンについてプレフィックス関数(失敗関数テーブルとも呼ばれます)を作ります。これは各位置について、接尾辞にもなっている最長の真の接頭辞の長さを表します。不一致が起きると、KMP法はこの表を使って、テキスト側を後戻りすることなくパターンをずらします。
正規表現テストを開くKMP法が解く問題
文字列検索は、短いパターンが長いテキストのどこに現れるかを求める問題です。素朴な方法では、すべての開始位置を試して文字を1つずつ比較します。通常のテキストでは高速ですが、繰り返しの多い入力では遅くなります。aaaaaaaaaa から aaaab を探すと、各位置でパターンのほぼ全体を比較することになり、計算量は O(n × m) です。
KMP法は、不一致の後にテキストの文字を読み直すことがありません。ドナルド・クヌース(Donald Knuth)、ジェームズ・H・モリス(James H. Morris)、ヴォーン・プラット(Vaughan Pratt)が、1977年に "Fast Pattern Matching in Strings" で発表しました。
考え方を言葉で説明すると
パターン ABABC を探していて、ABAB まで一致し、次のテキストの文字が C ではなかったとします。いま一致した ABAB は 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 |
たとえば、ABABA は ABA で始まり ABA で終わるので pi[4] = 3 です。ABABAC の接頭辞で C で終わるものはないので pi[5] = 0 です。
具体例で一歩ずつ確認
テキスト ABABABC から、パターン ABABC(プレフィックス関数は 0, 0, 1, 2, 0)を探します。変数 k は、現在一致しているパターンの文字数を数えます。
| テキストの位置 | テキストの文字 | 変更前の k | 動作 | 変更後の k |
|---|---|---|---|---|
| 0–3 | A B A B | 0 | 4文字連続で一致 | 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 は1ステップごとに高々1しか増えず、フォールバックのたびに減るので、フォールバックの合計は高々 m 回です。 |
| 検索 | O(n) | 追加で O(1) | テキストについても同じ議論で、増加は高々 n 回なので、フォールバックも高々 n 回です。 |
| 合計 | O(n + m) | O(m) | 最悪の場合も最良の場合と定数倍しか違わず、悪い入力は存在しません。 |
素朴なアルゴリズムは最悪 O(n × m) ですが、自然言語のテキストでは O(n) に近いことが多く、KMP法の利点が目立つのは主に繰り返しの多いデータです。
バリエーションとよくある間違い
- フォールバックでの1つのずれ:不一致の後は
k = pi[k]ではなくk = pi[k - 1]にします。piは、最後に一致した位置で添字を付けるためです。 - フォールバックに
whileではなくifを使う:文字が一致するまでに、連続して何度かフォールバックが必要になることがあります。 - 完全一致の後に k をリセットし忘れる:重なる一致も見つけるには
k = pi[k - 1]に、重ならない一致だけならk = 0に設定します。 - 連結のテクニック:
pattern + separator + textのプレフィックス関数を計算します。区切り文字には、どちらにも現れない文字を使います。値が m に等しい位置はすべて、一致の終端です。 - Zアルゴリズムは、これとよく似た配列を計算し、同じく O(n + m) で検索できます。
使う場面と実世界での用途
| アルゴリズム | 最適な用途 |
|---|---|
| KMP法 | パターンが1つの場合に線形時間が保証される。後戻りしないので、ストリーミングされるテキストにも向く。 |
| Boyer-Moore法とその変種 | 大きなアルファベット上の長いパターン。多くの文字を読み飛ばせることが多く、grepなどのツールで使われる。 |
| Rabin-Karp法 | 同じ長さの多数のパターン、またはローリングハッシュを使った盗用検出。 |
| Aho-Corasick法 | 多数のパターンを同時に検索する。KMP法の失敗リンクをトライ木に一般化したもの。 |
| 接尾辞配列または接尾辞オートマトン | 同じ固定のテキストに対する、多数の異なるクエリ。 |
- コーディング面接の問題:
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法は重なる一致も見つけられますか?
はい。完全一致の後に、0 ではなく k = pi[k - 1] に設定すれば見つけられます。たとえば AAAAA の中の AA は、位置 0、1、2、3 で見つかります。