cv.cm

コーディング · アルゴリズム

二分探索の仕組み・コード・計算量をわかりやすく解説

約8分で読めます

結論

二分探索は、ソート済みの配列から目的の値を探すとき、中央の要素を調べては目的の値を含みえない半分を捨てることを繰り返すアルゴリズムです。1ステップごとに探索範囲が半分になるので、必要な比較は最大でも約 log₂ n + 1 回、100万件でも20回です。時間は O(log n)、追加の空間は O(1) ですが、データがソート済み(より一般には単調)である場合にしか使えません。

二分探索が解決する問題

線形探索は要素を1つずつ調べるため、最大 n 回の比較が必要です。データがソート済みなら、もっと効率よく探せます。目的の値を中央の要素と比べれば、どちらの半分を残せばよいかがすぐにわかります。紙の辞書で単語を引くのと同じ考え方です。

二分探索は配列に限らず、単調なものなら何にでも使えます。ある真偽の条件が小さい値ではすべて偽、大きい値ではすべて真になるなら、二分探索でその境目を見つけられます。これを「答えに対する二分探索」と呼びます。

具体例をステップごとに見る

ソート済みのリスト [2, 5, 8, 12, 16, 23, 38, 56, 72, 91](インデックス 0〜9)から 23 を探します。まだ調べるべき範囲を挟む2つのインデックス lo と hi を保持します。

ステップlohimiditems[mid]判断
10941616 < 23 なので右を探索:lo = 5
25975656 > 23 なので左を探索:hi = 6
356523インデックス 5 で発見

線形探索の6回に対して、比較は3回で済みました。100万件のリストでも、二分探索なら最大20回です。

Pythonでの実装

以下の反復版は、閉区間 [lo, hi] を使います。両端とも、まだ候補に含まれます。範囲が空でない間、ループを回します。

def binary_search(items, target):
    lo, hi = 0, len(items) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if items[mid] == target:
            return mid
        if items[mid] < target:
            lo = mid + 1          # target is in the right half
        else:
            hi = mid - 1          # target is in the left half
    return -1                     # not found
data = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
print(binary_search(data, 23))   # 5
print(binary_search(data, 7))    # -1

実際のPythonコードでは、標準ライブラリの bisect モジュールを使います。bisect_left は、リストのソート順を保ったまま目的の値を挿入できる最も左の位置を返すので、値が存在するかどうかも判定できます。

from bisect import bisect_left, bisect_right, insort
data = [1, 3, 3, 3, 7, 9]
i = bisect_left(data, 3)
print(i, bisect_right(data, 3))   # 1 4  (3 occupies indexes 1..3)
print(i < len(data) and data[i] == 3)   # True: 3 is present
print(bisect_left(data, 5))       # 4: insertion point for 5
insort(data, 5)
print(data)                       # [1, 3, 3, 3, 5, 7, 9]

時間計算量と空間計算量

ケース時間理由
最良O(1)最初の中央の要素が目的の値
平均O(log n)ステップごとに範囲が半分になる
最悪O(log n)範囲が空になるまで約 log₂ n + 1 ステップ
空間(反復版)O(1)インデックスが2〜3個あるだけ
空間(再帰版)O(log n)半分にするたびにスタックフレームが1つ増える

応用:最初・最後の位置と、答えに対する二分探索

多くの問題では、値が存在するかどうかではなく、境目がどこにあるかが問われます。値が x 以上になる最初のインデックス、x が最後に現れる位置、条件を満たす最小の数などです。半開区間 [lo, hi) を使い、途中で止めないようにします。

def first_true(lo, hi, ok):
    """Smallest x in [lo, hi) with ok(x) True, or hi if none.
    ok must be False...False True...True over the range."""
    while lo < hi:
        mid = (lo + hi) // 2
        if ok(mid):
            hi = mid          # mid may be the answer, keep it
        else:
            lo = mid + 1      # mid is too small
    return lo
data = [1, 3, 3, 3, 7, 9]
print(first_true(0, len(data), lambda i: data[i] >= 3))   # 1
# Integer square root of 200: largest x with x*x <= 200
print(first_true(0, 201, lambda x: x * x > 200) - 1)      # 14
  • 下限(値が ≥ x となる最初のインデックス):bisect_left。
  • 上限(値が > x となる最初のインデックス):bisect_right。
  • ソート済みリスト中の x の個数:bisect_right(a, x) - bisect_left(a, x)。
  • 回転したソート済み配列での探索、ピーク探索、最大値の最小化(例:D日以内にすべての荷物を運べる最小の積載量)は、面接でよく出る定番の応用問題です。

よくある間違い

  • 範囲の規約を混在させる。閉区間では lo <= hi と hi = mid - 1、半開区間では lo < hi と hi = mid を使います。混ぜると無限ループや要素の取りこぼしが起こります。
  • lo = mid + 1 と書くべきところを lo = mid と書く。hi = lo + 1 のとき mid は lo と等しくなり、ループが終わりません。
  • ソートされていないデータに二分探索を使う。エラーも出さずに間違った答えを返します。
  • Java、C、C++ のような固定長整数の言語での (lo + hi) / 2 の整数オーバーフロー。lo + (hi - lo) / 2 を使ってください。Pythonの整数はオーバーフローしないので、(lo + hi) // 2 で安全です。
  • 重複する値の中で最初の出現位置を求める問題なのに、最初に見つかった一致を返してしまう。

二分探索を使う場面

状況より適した選択
ほとんど変更されないデータに対して何度も検索する一度ソートしてから二分探索する
完全一致の検索だけで、データが頻繁に変わるハッシュテーブル(set、dict)、平均 O(1)
ソート済みデータで、挿入や範囲クエリが頻繁にある平衡二分探索木またはB木
ソートされていないデータへの1回の検索線形探索、O(n)

実際の利用例には、データベースやファイルシステムのインデックス(B木はこの考え方を一般化したものです)、バグを埋め込んだコミットを探す git bisect、IPブロックのような範囲のソート済みテーブルからの値の検索、閾値を探す数多くの数値問題があります。

よくある質問

二分探索の時間計算量は?

平均・最悪ケースで O(log n)、最良ケースで O(1) です。比較のたびに残りの範囲が半分になるため、1,000,000 件でも比較は最大20回で済みます。

二分探索にはソート済みの配列が必要ですか?

はい。配列は比較に使うのと同じ順序でソートされている必要があり、そうでなければ、調べる条件が単調である必要があります。ソートされていないデータでは、結果は信頼できません。

Pythonの bisect は何をするものですか?

bisect モジュールは、ソート済みリストに対して二分探索を行います。bisect_left と bisect_right は挿入位置を返し、insort はソート順を保ったまま挿入します(検索は O(log n) ですが、挿入そのものは O(n) です)。

再帰版と反復版の二分探索はどちらがよいですか?

空間が O(1) で再帰のオーバーヘッドもないため、通常は反復版が好まれます。再帰版も同じく正しく動作しますが、スタック空間を O(log n) 使います。

二分探索で lo + (hi - lo) / 2 と書くのはなぜですか?

固定長整数の言語では、lo + hi が大きい値同士だと桁あふれすることがあります。lo + (hi - lo) / 2 なら、オーバーフローせずに同じ中央の値が得られます。Pythonでは不要です。

コーディング講座の一覧