二分探索:四つの選択、十六通りのループ

コード中の四か所を選びます。ループ条件、hiの初期値、そして二つの更新式です。隣の実行欄では、小さな整列済み配列に対する処理を一回ずつ追えます。判定欄では、指定した長さまでのすべての配列と探索値を検査し、最初に失敗するケースをワンクリックで開けます。

インタラクティブシミュレーションを読み込んでいます...

レッスン

理論 — 二分探索:四つの選択、十六通りのループ

二分探索は、整列済み配列から値xを探す方法です。xが存在するなら必ず含まれる添字の範囲をloからhiまでに保ち、各回で中央の要素a[mid]を読み、xがある側の半分だけを残します。hiが範囲内に残る最後の添字なら閉区間です(lo <= hi、hiの初期値はn − 1)。hiが範囲の直後を指すなら半開区間です(lo < hi、hiの初期値はn)。

各記号の意味

lo, hi
まだ探索すべき範囲の両端
mid
⌊(lo + hi)/2⌋、今回読む要素の添字
n
要素数。添字は0からn − 1まで
N
判定欄で調べる最長の配列。0からNまでの各nと全探索値を合わせて(N + 1)²ケース
前提
配列中の値は互いに異なり、a[mid]がxと等しくなれば直ちに返すループを考えます。また、実行欄と判定欄では上限のない整数を仮定します。オーバーフロー表だけは最後の仮定を外し、Javaと同じ32ビット演算を行います。
成り立たない場合
値に重複があっても、二つの正しいループはxが置かれた添字を返します。ただし、最初の位置とは限りません。整列済み索引の範囲検索などで最初の位置が必要なら、等しい場合にも止まらないループを使います。半開区間とし、a[mid] ≥ xなら常にhi = midと更新すれば、終了時のloが答えです。

十六通りのうち十四通りは誤りで、二要素あれば見抜ける 🖖

コード中の四つの選択は、いずれも慎重なプログラマーが検討すべきものです。どちらの選択肢も一見もっともらしく見えます。結果の下にある表では、十六通りのループを、12要素までの全配列と全探索値、計169ケースで試します。すべてに正解するのは二通りだけです。残るループはどれも二要素以下の配列で失敗し、十四通り中十三通りは一要素か空の配列で破綻します。検査範囲を1要素にすると三通りが残り、2要素では正しい二通りだけになります。256要素まで広げても、最初の失敗ケースは一つも動きません。この小さなケース群は標本ではなく、完全な検査です。ループが配列の値に対して行うことは、xとの比較だけだからです。異なる数からなる長さnの整列済み配列は、値を1、3、5…とした配列と同じように振る舞います。探索値の位置は、要素上か要素間の隙間にある2n + 1通りで、判定欄はそのすべてを試します。Jon BentleyはProgramming Pearlsで、この難しさを二つの年によって示しています。「最初の二分探索は1946年に発表されたが、nのすべての値に対して正しく動く二分探索が現れたのは1962年だった。」

正解にあと一歩の二つのループは、どの配列でも探索値を高々一つだけ見落とす 🖖

末尾を見落とすを押してください。hi = n − 1でlo < hiを使うと、loとhiが重なった時点で止まり、その位置の要素を読みません。どの更新でもxは探索範囲内に保たれ、midがhiに達することはないため、読み落としうるのはhiの初期位置にある要素、つまり末尾だけです。それ以外の要素は、どの配列でも見つかります。配列外を読むを押すと、ちょうど逆の誤りが見られます。hi = nとlo <= hiの組合せではa[n]が探索範囲に入り、全要素より大きい探索値だけがloをそこまで進めます。256要素まででは、二つのループは全66,049ケース中それぞれ256ケースと257ケースを誤ります。256要素の配列だけなら、該当する探索値は0.19%です。この配列で探索値を百回無作為に選んでも、通常はどちらのバグも見つかりません。二つ目のループは空配列の唯一のケースで、一つ目は一要素の配列で見抜けます。JavaScriptでは、二つ目のループが偶然正しい答えを返すことさえあります。a[n]はundefinedとなり、それとの比較はどちらも偽です。そこでループはhi = mid − 1とし、正しく−1を返します。同じ読み出しをJavaで行えば例外が発生し、Cでは動作が未定義です。

JDKの正しいループも230要素を越えると壊れた 🖖

JDKのループは表の先頭行にあり、判定欄で試せる全ケースに正解します。Joshua Blochがjava.util.Arraysのために書いたループです。Javaではlo + hiが32ビットのintとして計算され、2,147,483,647を越えると負数に回り込みます。2004年5月11日にSunへ提出されたバグ報告では、1,100,000,000バイトの配列の末尾要素を探索したところ、ArrayIndexOutOfBoundsException: -1064671149が発生しました。オーバーフロー表は、配列を確保せずに同じ探索を再現します。6回目にはlo + hiが2,165,624,999となり、回り込みとシフトの結果、中央値は報告どおり−1,064,671,149になります。同じ回でも、lo + ((hi − lo) >> 1)なら1,082,812,499です。報告の題名には、1<<30より大きな配列で探索が失敗するとあります。この境界は厳密です。1,073,741,824を入力すると、lo + hiの最大値は231 − 2で収まります。要素が一つ増えるだけで、31回目にオーバーフローします。Blochが2006年にこのバグについて書いた記事によれば、バグは「九年ほど」潜んでいました。Programming Pearlsで正しさを証明されたBentley自身のコードにも、同じ問題があります。Bentleyの「mをlとuの平均とし、端数を切り捨てて最も近い整数にする」という記述は、数学上の整数には正しくても、32ビット整数には当てはまりません。

全プロセスの詳細解説

  1. 最後まで残るループが二つだけなのはなぜか 4 ステップ

    lo = mid で停止を押してみよう。判定器が調べられるのは 256 要素までで、その先は試せない。任意の n について、lo = mid + 1 を使う半開区間型のループが必ず終了すること、同じループを lo = mid に変えると hi = lo + 1 かつ a[lo] < x のとき停止することを示し、この議論から閉区間型のループに必要な更新を導こう。

    この問題をツールで開く

    1. 半開区間型では lo < hi の間だけ本体を実行する。このとき、切り捨てて求めた mid は lo 以上であり、hi より必ず小さい。

    2. したがって、hi = mid なら hi は少なくとも一つ下がり、lo = mid + 1 なら lo は少なくとも一つ上がる。どちらの場合も、初めは n だった hi − lo が反復ごとに少なくとも一つ減る。しかも負にはならないので、ループは n 回以内に終了する。

    3. 次に lo = mid とする。hi = lo + 1 なら、mid は ⌊(2lo + 1)/2⌋ = lo である。ここで a[lo] < x なら、更新しても lo は動かず、次もまったく同じ反復になる。用意された例では lo = 6、hi = 7 に達したとき、a[6] = 13、x = 14 となる。

    4. 閉区間型のループは lo <= hi の間動くので、lo = hi も許される。このとき mid = lo = hi であり、mid までしか動かさない更新では何も変わらない。したがって、どちらの更新も mid を越えなければならない。a[mid] はすでに読んでいるので、それで候補を失うことはない。一方、半開区間では hi は初めから範囲外にある。そこで hi = mid とすれば、a[mid] > x によって候補でないと分かった、今読んだ要素とそれより上を除外できる。hi = mid − 1 では、x かもしれない a[mid − 1] まで除外してしまう。

    解答

    各反復で探索範囲を必ず狭め、x があり得ないインデックスだけを捨て、初期範囲には配列全体を含める必要がある。lo <= hi なら hi = n − 1、hi = mid − 1、lo = mid + 1。lo < hi なら hi = n、hi = mid、lo = mid + 1 となる。判定器でも、この二つだけが 256 要素まで生き残る。しかし今の議論は任意の n に通用する。有限のテストでは得られない保証である。32ビット演算では、さらに lo + hi が表現範囲に収まるという前提が要る。その前提を外した結果が、三つ目の発見につながる。

学習の道すじ

アルゴリズムが正しい理由

参考文献 (2)