Dev Study
← アルゴリズムの手法 — CLRS を読むための地図
レッスン 12 / 13·約 9 分·第29期 アルゴリズムの手法 — CLRS を読むための地図

一致した分を捨てない — 文字列照合と失敗関数

アプリと DB の下に何が積まれているか(左が下、右が上)
  1. 物理機リージョン/AZ
  2. ハイパーバイザVM exit で捕まえる
  3. VMゲストOS
  4. コンテナnamespace / cgroup
  5. プロセスアプリ / DB今ここ

1つのプロセスの中で、長い文字列から短い文字列を探す処理です。今回は探すパターン自身の構造を使います。

まず結論

素朴な文字列照合は、食い違うたびに本文を戻って読み直します。KMP 法はパターンの「頭と尻の重なり」を前もって表(失敗関数)にしておき、本文を一度も戻りません。

使っている構造は本文の性質ではなく、パターン自身の中の繰り返しです。本文 "A"*n+"B" での実測では、素朴法が約 n×11 回比べるところを、KMP は 2n 未満で済みました。

? 読む前に予測

Python 3.14 の str.find や in 演算子(部分文字列の検索)の C 実装は、どんな手法を使っているでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

素朴法は、一致した分を捨てて戻る

本文の中からパターンを探す一番素直な方法は、本文の各位置を開始点にして、パターンを先頭から1文字ずつ比べることです。食い違ったら開始点を1つずらし、また先頭から比べます。

困るのは一致しかけてから食い違うときです。パターン AAAAAAAAAAB を本文 AAAA…AB で探すと、どの開始点でも A を10文字一致させたあと B で食い違います。そして開始点を1つずらすと、いま一致を確かめた9文字をもう一度読み直します。

ここで捨てているのは「直前に何文字一致したか」という情報です。食い違った時点で、直前の本文がパターンの頭の何文字と同じだったかは分かっています。本文を読み直さなくても、パターンだけを見れば次にどこから比べればよいかが決まります。

KMP 法はこれを前もって表にします。パターンの各位置について「そこまでの部分の、頭と尻が一致する最長の長さ」を並べたものが失敗関数です。照合中に守る約束(不変式)は1つです。

本文 ABABABAC からパターン ABABAC を探す(本文の6文字目で食い違う場面)
ABABA まで一致q = 5
本文の B とパターンの C が食い違う
素朴法開始点を1つずらし、本文を戻って先頭から
KMP失敗関数の値 3 まで縮める
KMP は ABA を一致済みとして、同じ本文の B と比べる
ABAB まで一致q = 4。本文の位置は戻っていない
続く A・C も一致
3文字目から一致を発見q = 6(パターン全体)
  • ・ABABA の尻3文字 ABA は、パターンの頭3文字と同じ
  • ・この「頭と尻の重なり」はパターンだけで決まり、本文を見なくても前もって計算できる
具体実物

失敗関数の表と、比較回数の実測

ABABAC の失敗関数を計算すると次のとおりです(Python 3.14.6 / macOS 26.5.2 arm64、2026-10-07 実行)。

$ python3 kmp_exp.py(ABABAC の失敗関数)コマンド出力
prefix function of ABABAC:
i : [0, 1, 2, 3, 4, 5]
P[i]: ['A', 'B', 'A', 'B', 'A', 'C']
pi : [0, 0, 1, 2, 3, 0]

位置4までの ABABA は、頭の ABA と尻の ABA が重なるので値が3です。最後の C は頭の A と合わないので0に戻ります。

次に、素朴法が苦手な本文 "A"*n+"B" とパターン "A"*10+"B" で、文字を比べた回数を数えました。KMP の列は走査中の比較で、表を作る比較(19回、n に依らない)は含みません。

$ python3 kmp_exp.py(比較回数。2n 列は本文長 n+1 の2倍)コマンド出力
n naive_cmp kmp_cmp(scan) kmp_table_cmp 2n kmp<=2n found_at
100 1001 191 19 202 True [90]
1000 10901 1991 19 2002 True [990]
10000 109901 19991 19 20002 True [9990]

素朴法は n が10倍になるたびに約10倍、1か所あたり11回です。 これはパターン長 m に比例する分で、全体は約 n×m 回です。KMP は 2n 未満に収まり、n に比例して増えるだけです。 見つけた位置は3つとも str.find の結果と一致することを確かめてあります。

ここで線を引いておきます。「2n 以下」は本教材の実測と下の説明であって、KMP の原論文の文言として引くものではありません(論文本文は取得できていません)。出版社の抄録が書いているのは次の範囲です。

An algorithm is presented which finds all occurrences of one given string within another, in running time proportional to the sum of the lengths of the strings.

✓ ここまでの確認

パターン ABABAC の失敗関数は [0, 0, 1, 2, 3, 0] です。本文と ABABA の5文字まで一致し、6文字目で食い違いました。KMP は次にどうしますか。

抽象原理

使っているのは、パターン自身の繰り返し

なぜ 2n 未満に収まるのかは、一致済みの長さ q の増減で説明できます。q が増えるのは本文を1文字読むときに最大1だけです。失敗関数で戻るときは必ず減ります。0より下には行かないので、戻った総量は進んだ総量を超えません。 1回ごとの戻りが長い場面はあっても、合計は本文長の2倍で抑えられます。第11期の『ならし計算量は、平均ではない』で見た「合計を数える」測り方と同じです。

KMP が前提にしているのは本文の性質ではなく、パターンの中の繰り返しです。だから本文が意地悪でも崩れません。逆に、パターンに頭と尻の重なりがなければ失敗関数は全部0で、得をする場面もありません。本文が1文字目ですぐ食い違うなら素朴法も各開始点で1回で終わります。KMP が直しているのは「一致しかけては外れる」場面だけです。

具体実物

対比例 — 窓のハッシュを転がす Rabin-Karp 法

別の構造で解くのが Rabin-Karp 法です。本文の長さ m の窓ごとにハッシュ値を作り、パターンのハッシュと比べます。窓を1文字ずらすときは、出ていく1文字と入ってくる1文字の分だけを直して次の窓のハッシュを得ます(ローリングハッシュ)。m 文字を読み直しません。

ハッシュが違えば確実に別の文字列で、同じなら文字を比べて確かめます。違う文字列が同じハッシュになる(衝突)と、確かめが無駄になります。論文の題 "Efficient randomized pattern-matching algorithms" が示すとおり、乱数を使う側の手法です。ハッシュの選び方に乱数を混ぜれば、入力を作る側は衝突を前もって狙えません。速さは最悪ではなく期待値で語られる——第3回で見た期待値と同じ型です。

実際の標準ライブラリはどうか。CPython 3.14 の Objects/stringlib/fastsearch.h の冒頭です(2026-10-07 取得)。

fast search/count implementation, based on a mix between boyer-moore and horspool, with a few more bells and whistles on the top.

If the strings are long enough, use Crochemore and Perrin's Two-Way algorithm, which has worst-case O(n) runtime and best-case O(n/k).

KMP そのものではありません。 ファイル内に KMP・Knuth・Morris の語はありません。Two-Way は、コメントの言う O(m) の準備(startup cost)を先に払って最悪を線形に抑える点で、KMP と同じ発想の別手法です。どちらを使うかは長さで切り替えます(n は本文長、m はパターン長)。

if (n < 2500 || (m < 100 && n < 30000) || m < 6) {
    return STRINGLIB(default_find)(s, n, p, m, maxcount, mode);
}
else if ((m >> 2) * 3 < (n >> 2)) {
    /* 33% threshold, but don't overflow. */

短いうちは default_find(boyer-moore と horspool の混合)、条件を超えると _two_way_find、どちらでもなければ途中で Two-Way へ移る adaptive_find です。この分岐は mode != FAST_RSEARCH(逆方向の検索でない)ときだけで、rfind は Two-Way を使いません。導入は 3.10 で、What's New はこう書いています。

Substring search functions such as str1 in str2 and str2.find(str1) now sometimes use Crochemore & Perrin’s “Two-Way” string searching algorithm to avoid quadratic behavior on long strings. (Contributed by Dennis Sweeney in bpo-41972)

✓ ここまでの確認

Rabin-Karp 法で、窓のハッシュがパターンのハッシュと一致したのに、さらに文字を1つずつ比べるのはなぜですか。

⇄ 2つを見比べる
具体例 A

KMP 法

パターンだけから失敗関数を作り、食い違っても本文の位置を戻しません。前提はパターン側にあるので、どんな本文でも走査の比較は本文長の2倍に収まります。乱数は使いません。

具体例 B

Rabin-Karp 法

窓のハッシュを1文字ずつ更新して比べ、一致したときだけ文字を確かめます。前提はハッシュが衝突しにくいことで、乱数を混ぜて入力側から狙えなくし、期待値で速さを言います。

攻撃者が本文を自由に作れるサービスで部分文字列を探します。KMP と Rabin-Karp のどちらの前提が、入力によって崩されにくいでしょうか。

それぞれの速さが「本文」「パターン」「乱数」のどれに依存しているかを書き出してください。

この章のまとめ

  • 素朴法は、一致しかけて外れるたびに本文を戻って読み直す。 "A"*n+"B" とパターン "A"*10+"B" では約 n×11 回(n=10000 で 109,901 回)。
  • KMP はパターンの「頭と尻の重なり」を失敗関数にしておき、本文を戻らない。 ABABAC の失敗関数は [0, 0, 1, 2, 3, 0]。走査の比較は 2n 未満(n=10000 で 19,991 回)。
  • 線形に収まる理由は合計で数えると分かる。 一致済みの長さは1文字ごとに最大1しか増えず、戻る総量は進んだ総量を超えない。
  • 使っている構造はパターン自身の繰り返し。 Rabin-Karp はハッシュの更新と衝突の少なさを使い、速さを期待値で言う。
  • CPython の in / find は KMP ではない。 boyer-moore と horspool の混合が基本で、長いときは Two-Way(3.10 から)。切替は長さの条件で決まり、rfind は Two-Way を使わない。
  • 既存クイズでは、「アルゴリズムとデータ構造 III(発展編)」の algorithm-3-1(KMP)・algorithm-3-15(ローリングハッシュ)がこの章の結論の一行版です。
  • CLRS(『アルゴリズムイントロダクション』)の目次や索引で string matching(文字列照合) を探すと、この回の内容に対応する章や節が見つかります。章の題と番号は版で変わるので、手元の目次で確かめてください。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

環境:

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

本文 "A"*n+"B"、パターン "A"*10+"B" で比較回数を数えました(n=10000)。結果として妥当な組はどれですか。

用語集(4語)
失敗関数(prefix function)
パターンの各位置 i について、先頭から i 文字目までの部分の「頭と尻が一致する最長の長さ」(全体そのものは除く)を並べた表。KMP は食い違ったとき、この値の分だけ一致済みとして照合を続ける。
素朴法
本文の各開始位置でパターンを先頭から比べ、食い違ったら開始位置を1つずらしてやり直す方法。一致しかけてから食い違う本文では、同じ文字を何度も読み直す。
ローリングハッシュ
長さ m の窓を1文字ずらすとき、出ていく1文字と入ってくる1文字の分だけを直して次の窓のハッシュを得る作り方。Rabin-Karp 法が使う。
Two-Way
Crochemore と Perrin の文字列照合手法。CPython は 3.10 から、長い文字列の部分文字列検索で使う。fastsearch.h のコメントは worst-case O(n) と書く。