二分探索

ループは 6 行。正しさを支えるのは公式ではなく不変条件、汎用性を支えるのはソート済み配列ではなく単調な述語、そして遅さの原因は CPU が予測できない分岐だ。半分にすることからリファレンスまで、7 節構成。

01

ソート済み区間を半分にする

6 行のコード。たいていのエンジニアが書けて、驚くほど多くが間違える。まずこの「半分にする」が何を買っているのかから。

ソート済みの 32 個から 1 つ探す方法は 2 つある。線形走査は目当ての手前を全部読む。二分探索は 1 つ読み、配列の半分を捨て、繰り返す。スライダーでターゲットを動かし、回数を比べてほしい:

ターゲット 21: 走査は 22 個、二分探索は 4 個を読む

注目してほしいのは、走査のコストがターゲットの位置そのものだということ —— に置けば比較は 32 回になる —— 一方二分探索はターゲットがどこにあっても 6 回を超えない。得をしているのは探す場所が賢いからではない。1 回の比較が「残り全体の半分」に値するからだ。

同じ探索を、配列を階段で描いたもので見る —— ソート済みとは階段のことだからだ。破線はターゲット自身の高さ。プローブはその上か下に立つだけで、判断はそれだけだ。軸上のハンドルをドラッグし、琥珀色のスライダーで比較を 1 回ずつ進めてほしい:

値 22 を探す。軸上のハンドルをドラッグしてターゲットを動かす。左右の矢印キーで 1 値ずつ、Home で元の位置へ
5 回中 0 回の比較を実行、候補は残り 32 個

下のブラケットが何をしているかに注目してほしい。これはまだターゲットを持ちうるインデックスの集合で、1 つも取り落とさない:各反復の先頭で、ターゲットが配列にあるならそのインデックスは [lo, hi] の中にある。どの分岐もこれを保つ —— a[mid] < target なら mid までのインデックスはすべて小さすぎるので、lo = mid + 1 が捨てるものの中に答えになりうるものはない。

コストもこの不変条件から出てくる。長さ n の区間から mid を除くと片側に ⌊(n−1)/2⌋、もう片側に ⌈(n−1)/2⌉ が残るので、最悪の生き残りは ⌊n/2⌋ になる。n を動かして段数を数えてほしい:

32 個の値は最大で比較 6 回

最悪ケースが T(n) = 1 + T(⌊n/2⌋) に従うので、n から空までの鎖はちょうど⌊log₂ n⌋ + 1 段になる —— 32 なら 6、 なら 7、 なら 11。これは「半分になるから」ではなく数え上げの論証で、漸化式は区間が空になったところで底を打つ。1 目盛りごとに nが同じ倍率で増える軸に載せて、ハンドルを 10 億まで引いてほしい:

1,000,000 個の値。ハンドルを軸に沿ってドラッグ。矢印キーで 1 目盛り、Home で 100 万に戻る
1,000,000 個の値:比較 20 回

その軸の上では比較回数はまっすぐな階段になる ——n が倍になるたびに 1 段、100 万で 20 段、10 億で 30 段 —— その下の数字が走査が読む回数だ。空間はインデックス 3 つ —— lo、mid、hi —— n がいくつでも変わらない。

02

「ソート済み」が本当に意味すべきこと

誰もが口にする前提条件は間違っていて、しかもバグの出どころを隠してしまう。

二分探索はソート済み配列を必要としない。もっと弱い条件でいい。mid で問う yes / no の問いが、真になったらそれ以降どのインデックスでも真であり続けること。ソート済みはそれを用意する最もありふれた手段にすぎない。ターゲットを動かして、配列の下の答えの帯を見てほしい:

ターゲット 8: 最初の真のインデックスは 7

帯が常に F…FT…T であり、F T F T にはならないことに注目してほしい —— どのターゲットでも、でもそうだ。これが本当の前提条件だ。述語が単調であること、配列の仕事はそれを成り立たせることだけ。残りは全部、帯の中の境界を見つける話だ。lower_bound、upper_bound、「通る最小の k」、「回転点」は、1 つのループと 4 つの問いにすぎない。

では前提が崩れると何が起きるか。例外ではない。下はソート済み配列で、1 本のバーだけ動かせる。探しているのは41 で、ずっとインデックス 10 にある。a[9] をそれより上まで持ち上げてほしい:

a[9] = 37: 探索はインデックス 10 を返す

41 を越える瞬間の読み出しを見てほしい。 より下ではすでに配列は崩れているのに、探索は正しいインデックスを返す —— このバグが本番に出るのはそれが理由で、テストデータが崩れの影響しない経路を踏んだだけなのだ。 より上では、同じ探索が配列の中にある値に対して −1 を返し、何も報告しない。静かに失敗するので、最終的に手にするスタックトレースはまるで別の場所から来る。

だから前提条件の有用な読み方は、配列ではなく述語のほうだ。下は同じ配列を回転させたもので —— 2 つの連なりの内側以外どこもソートされていない —— それでも a[i] ≥ a[0] という問いは単調のままだ。回転量を回して最初の偽を見てほしい:

5 だけ回転:最小値はインデックス 11

帯が T…TF…F になっていることに注目してほしい —— 前の帯の鏡像で、同じように探索できる。最初の偽が最小値のインデックスで、LC 153 と LC 33 が聞いているのはそれだ。 では偽がなく、ループは n を返す。前提条件は最初から配列ではない。単調だと証明できる述語を組み立てることが、難しい変種のほとんどすべてだ。

03

中点と、それが住む区間

アルゴリズムそのものとは無関係で、正しく書けるかどうかには決定的な 2 つの選択。

1 つ目は算術だ。lo も hi も正当なインデックスなのでそれぞれ int に収まる —— しかしその和は収まるとはかぎらない。配列長を 2³⁰ より先へドラッグして、バーが線を越えるのを見てほしい:

536,870,911 要素では両式が一致する

越えた先の (lo + hi) / 2 を見てほしい。32 ビット 2 の補数では和が桁あふれして負になり、シフトしても負のままだ —— では中点が −3 として返ってくる。lo + (hi − lo) / 2 は、あふれようのない差から同じ中点を計算する。架空の話ではない。java.util.Arrays.binarySearch は 9 年間壊れたまま出荷され、 Bloch の 2006 年の記事が、今みんなが後者を書く理由だ。 2³⁰ 要素の int[] は 4 GiB —— 1997 年には荒唐無稽、今は普通。

2 つ目の選択は区間の流儀だ。閉区間 [lo, hi] は両端とも候補だと言い、半開区間 [lo, hi) は hi が末尾の 1 つ先だと言う。同じ「最初の真」探索を両方の流儀で進めて、2 つの hi マーカーを見てほしい:

ステップ 0

どこに立っているかに注目してほしい。閉区間の hiは常にセルを指す。半開区間のほうはセルとセルのあいだの隙間を指し、存在しないインデックスをでっち上げずに「最後の 1 つ先」を表せる。終わり方も違う。閉区間は lo = hi + 1、半開区間は lo = hi で止まり、どちらも答えは lo にある。混ぜると while (lo <= hi) に hi = mid が組み合わさる。

それが終わらないバグだ。どの分岐も生存区間を厳密に縮めなければならないが、閉区間ループでは lo・hi・プローブが同じインデックスのとき hi = mid は縮めない。安全な規則で進めてから、壊れたほうに切り替えてほしい:

ステップ 0

4 ステップ目以降のブラケットを見てほしい。hi = mid − 1 なら区間は空になり、ループは終わる。hi = mid なら候補 1 つの上に座り込み、読み出しがそのステップ数を数える。本番ではクラッシュではなく、張り付いたコアと決して返らないリクエストで、戻り値を調べるテストでは捕まらない。同じ検査は、その鏡像 —— 生き残る分岐が lo = mid で、中点が逆向きに丸められる場合 —— も捕まえる:

mid = lo + (hi − lo) / 2: 0

mid = lo + (hi − lo) / 2 だと、hi が lo + 1 になった時点でプローブは lo に落ちる。lo = mid は lo を自分自身に代入するだけで、区間は縮まなくなる。切り上げが修正のすべてだ。各分岐で区間が厳密に小さくなることを示し、hi == lo + 1 で試すこと。

04

変種は「最初の真」ひとつだけ

3 種類の探索を暗記するのはやめてよい。ループは 1 つで、変わるのは中点で問う内容だけだ。

区間の流儀が決まれば、半開区間版は 6 行で、更新し忘れる ans 変数も存在しない。lo と hi 自体が答えを挟むブラケットで、2 つが出会ったとき lo が述語の真になる最初のインデックスになる。進めてから、述語を ≥ から > に切り替えてほしい:

ステップ 0: lo 0、hi 16

このスイッチがループには何の変更も加えず、lo の落ち着く先には全部の変更を加えることに注目してほしい。a[i] ≥ 8 は最初の 8 で、a[i] > 8は最後の 8 の 1 つ先で止まる。この 2 つが lower_bound と upper_bound、「最後の 8」は upper_bound − 1 だ。最右探索も、閉区間版が要求する ans = mid の記帳も書かずに済む。

おかげで数えるのはタダになる。2 つの境界が等しい値の連なり全体を挟むので、その差がそのまま個数だ。探索 2 回、走査なし、連なりが配列全体でも O(log n)。ターゲットを値のあいだで動かし、それから存在しない値へ動かしてほしい:

ターゲット 5: lower_bound 3、upper_bound 7

どちらの境界も要素の位置ではなく挿入位置なので、存在しない値についても定義される —— そのときは 2 つが一致し、ブラケットは線につぶれ、個数は になる。これは存在判定でもある:i = lower_bound(x); i < n && a[i] == x。完全一致は特殊ケース、境界が一般で、組み合わせが効くのも境界のほうだ。範囲の両端を動かして、個数がついてくるのを見てほしい:

5 から 11: 9 個

下限と上限がいま答えているのは別々の問いで、個数はその差でしかない。upper_bound(hi) − lower_bound(lo)、探索 2 回、走査なし。範囲が配列全体でも同じだ。両端を 20 の先まで押すとブラケットは線につぶれ、空の範囲はエラーではなく 0 と数えられる。挿入位置はどこでも定義されるからだ。

05

インデックスではなく答えを探索する

ループは自分が配列を索引しているとは知らない。答えの範囲を渡せば、それを探索する。

Koko にはバナナの山が 4 つ —— 3、6、7、11 —— と、見張りが戻るまでの 8 時間がある。速さ k なら p 本の山を ⌈p/k⌉ 時間で片づける。1 時間のうちに 2 つ目の山へ移らないからだ。速さを動かして、何口に切れるか数えてほしい:

1 時間 1 本なら 27 時間かかる

下の帯がまた F…FT…T になっていることに注目してほしい。そうなるしかない。速く食べて遅くなることはないので、ある速さが間に合えば、それより速い速さも全部間に合う。この単調性が —— ソート済み配列はここにどこにもない —— 前提条件のすべてだ。候補は 1 … max(山)、述語 1 回の評価は山を 1 周ぶんなので、全体は O(山の数 · log 最大の山) だ。

そこで同じ半開区間の「最初の真」ループを、インデックスではなく速さの上で走らせる。ブラケットはいまや答えの区間で、mid はまだ誰も試していない速さだ:

ステップ 0: 生存区間は 1 から 12

ブラケットが 4 に閉じるのを見てほしい —— プローブ 4 回、総当たりなら 個の候補速度を試すところだ。膨大な種類の問題がこの形をしている。最小の積載量、最小の日数、最小の除数、そして「最大値を最小化する」あらゆる問い。難所は探索ではなく、述語を見つけ単調だと示すほうだ。単調でないとき、ループはそれでも何かを返す —— 静かに、配列が崩れていたときとまったく同じように。

答えの軸は、配列にはできないことを 1 つくれる。有界でなくてよいのだ。自然な上限がないときは、述語が真に変わるまで境界を倍にし、飛び越えた区間を二分探索する。答えをドラッグして、両方の段階を数えてほしい:

答え 23: 倍化 6 回と二分 4 回

倍化の行き過ぎは 2 倍未満なので、二分の段階は倍化の段階より高くつくことがない —— 答えが a なら約 2·log₂ a プローブで、どこにも上限を仮定しない。これが指数探索で、std::equal_range が前方イテレータでしていることであり、 Timsort の galloping モードが片方の連なりだけ勝ち続けるときにしていることだ。

06

どこで負けるか

O(log n) は、実際のコードが使うサイズでどの参照が最速かについて何も言っていない。

二分探索の最初のプローブどうしは配列の半分ぶん離れているので、それぞれが自分だけの 64 バイトキャッシュラインに落ちる。走査は 1 ラインあたり int32 を 16 個、アドレス順に読むので、ハードウェアプリフェッチャがそのコストを完全に隠す。n を動かして、琥珀色のライン数を走査が流し読みするラインと比べてほしい:

256 個の値:二分探索は 4 ラインに触れる

探索の終盤が 1 ラインに寄る。64 バイトに int32 は 16 個入るので、最後のプローブたちはすでに L1 にあるラインへ落ちる。何回ぶんかは読み出しが数える。一定ではない —— 整列していない 15 要素の区間は 2 ラインにまたがるからだ。 では 10 プローブで 6 ライン、2²⁰ では 19 で 16 ラインだ。その大半はプリフェッチャが先読みできないミスになる —— アドレスがまだ終わっていない比較に依存しているからだ。

数字を入れてみる。走査 1 要素は 0.25 ns —— AVX2 の比較は約 3.5 GHz で 1 サイクルに数個退役する。二分探索のプローブ 1 回 は 2.5 ns、予測器が学習できないデータ依存分岐で約半分は 15 サイクル失敗するからだ。ハッシュ参照は一律 12 ns、配列を触る 2 つには 2 ns を足す。n を交差点の向こうまで動かしてほしい:

16 個の値では走査が最速

軸が 1 目盛りごとに n を同じ倍率で増やすので、二分探索のコストは倍化ごとに 1 段上がるだけ、走査は跳ね上がる。ハッシュの一律 12 ns こそが唯一の直線だ。走査は 40 要素あたりまで持ちこたえ、その先はスライダーが届くどの n でもハッシュが最安になる —— では 12 ns、探索は 30 ns、走査は 258 ns だ。このモデルで二分探索が単独で勝つ場面はない。残りの 2 つが答えられない問いのために選ぶのだ:

9 は無い:ハッシュは何も返さない

配列に無い値でどうなるかを見てほしい。ハッシュは返すものを持たないが、同じ lower_bound は前者・後者・挿入位置を言い当てる —— なら在否も同時に答える。順序こそ、ハッシュが捨てたものだ。

07

6 行と、3 つのバグ

変種はすべてこの 1 ループで足りる。述語だけを変える。

# pred(i) が成り立つ最初のインデックス。なければ n
lo, hi = 0, n                      # 半開区間 [lo, hi)
while lo < hi:
    mid = lo + (hi - lo) // 2      # 桁あふれしない
    if pred(mid):  hi = mid        # mid はまだ候補
    else:          lo = mid + 1    # mid は除外
return lo                          # == hi。n は「真がない」の意味

16 セルの上を走らせたところ。境界はあなたの手の中、上の数字はプローブ順だ。まん中で動かしてから、と まで押し込んでほしい:

インデックス 11 から真。境界を行に沿ってドラッグ。矢印キーで 1 セルずつ、Home で元に戻る
境界は 11: ループは比較 4 回で 11 を返す

両端でも答えが返る。lo は要素でなく挿入位置なので、n は「真がない」、0 は「全部そう」を意味する。空入力の特別扱いは要らない。

このループは、自分が頼っているただ 1 つのことを検査しない。帯の答えを 1 つだけ壊しても、走り続け、停止し、インデックスを返し続ける:

裏返しなし:ループは 11 を返す

途中では何も壊れない。プローブはすべて範囲内、どの分岐でも区間は縮み、戻り値も正当なインデックスだ —— ただ最初の真ではないだけで、裏返す位置によっては正しいことすらある。ループの中に足せるアサーションは無い。単調性は帯全体の性質で、ループは ⌊log₂ n⌋ + 1 セルしか見ないからだ。下の 3 つが、レビューを生き延びるバグだ:

  • (lo + hi) / 2 → lo + (hi − lo) / 2。 2³⁰ 要素を超えると前者は負のインデックスを返す。
  • while (lo <= hi) と hi = mid。 lo == hi で空回り。閉区間は mid − 1。
  • 単調でない述語。例外も範囲外もなく、答えが返るだけ。
時間
O(log n)
区間は比較ごとに半減し、⌊log₂ n⌋ + 1 回で空になる。
空間
O(1)
インデックス 3 つの反復。再帰は O(log n) のスタックを無駄にする。

この計算量が下がらない理由

各比較が残りの ⌊(n−1)/2⌋ を少なくとも捨てるので厳密だ。ソートは O(n log n) かかり、1 回では元が取れない。

バリエーション

exact match (LC 704)O(log n)O(1)
lower_bound / first-true (LC 34)O(log n)O(1)
upper_bound − 1 / last-trueO(log n)O(1)
on the answer (LC 875)O(f · log R)O(1)