Dev Study
← データ構造とアルゴリズムの土台
レッスン 4 / 13·約 9 分·第11期 データ構造とアルゴリズムの土台

半分にすると、なぜ20回で済むのか

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

前回までに、計算量の読み方・配列と連結リストの違い・ハッシュの平均O(1)が置く仮定を見ました。今回は「毎回半分を捨てる」という形そのものを扱います。

まず結論

100万件を1件ずつ見れば100万回ですが、毎回半分を捨てられるなら20回で1件まで絞れます。log₂(1,000,000) = 19.93… で、切り上げて20回です。

そして二分探索は「配列を探す技術」ではありません。Python の bisect 公式は「挿入点を決める道具であり __eq__ を一度も呼ばない」と書き、git bisect 公式は「範囲を絞る」「任意の性質が変わったコミットを見つけられる」と書いています。

? 読む前に予測

ソート済みでない配列に二分探索を掛けたら、C++ の標準ライブラリでは何が起きると規定されているでしょうか。

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

抽象原理

1回で半分を捨てられるなら、回数は桁で決まる

1件ずつ見ていけば、100万件は最悪100万回です。ところが1回の判定で候補の半分を捨てられるなら話が変わります。

100万 → 50万 → 25万 → …… と半分にして1件になるまで何回か。これは「2を何回掛ければ100万を超えるか」と同じ問いで、答えが log₂(1,000,000) です(Python 3.14.6 / macOS 26.5.2、2026-09 実行)。

$ python3 -c "import math; ..."(log₂ の検算)コマンド出力
n= 1000 log2=9.965784 ceil=10
n= 1000000 log2=19.931569 ceil=20
n= 1000000000 log2=29.897353 ceil=30
2**20 = 1048576

100万件が20回です。1000件から10億件へ、つまり件数が100万倍になっても回数は10から30へ、20しか増えません。2の20乗が 1,048,576 なので「100万は2の20乗より少し少ない」と言えます。

ただしこの 20 は判定の回数であって、実行時間ではありません。

毎回半分を捨てると、残る候補がどう減るか(100万件から)
1,000,000開始
1回目の判定 — 半分を捨てる
500,000残り
10回目まで同じことを繰り返す
977残り(1,000,000 / 2^10)
さらに10回
120回目で1件
  • ・log2(1,000,000) = 19.931569 を切り上げて 20
  • ・捨てる側に答えが無いこと(境目が1つであること)が前提
具体実物

規格が要求しているのは「ソート済み」ではない

「二分探索にはソート済みの配列が要る」と教わることが多いはずです。ところが C++ 標準ライブラリの規定はそれより弱い条件です。cppreference の std::lower_bound(2026-09-18 取得)より。

Searches for the first element in the partitioned source range [first, last) which is not ordered before value.

要求は partitioned(分割済み)、つまり「探している述語について前半が false・後半が true に分かれている」ことです。ソート済みはこれを満たす一例であって、必要条件ではありません。満たさなかった場合の規定が続きます。

If the elements e of the source range are not partitioned with respect to the expression bool(e < value) (until C++20) bool(std::less{}(e, value)) (since C++20), the behavior is undefined.

「見つかりませんでした」が返るのではありません。何が返るか決まっていません。

では、なぜ実用上はソート済みを使うのか。同じページの Notes が理由を書いています。

Although std::lower_bound only requires the source range to be partitioned, this algorithm is usually used in the case where the source range is sorted, so that the binary search is valid for any value.

特定の1つの値についてだけなら partitioned で足りますが、任意の値について正しくあるには結局ソート済みが要る——この関係です。

計算量の規定も言葉が選ばれています。

Given N as std::distance(first, last): 1) At most log₂(N)+𝓞(1) comparisons with value

comparisons(比較)の回数です。同じページの Notes にこうあります。

If ForwardIt is not a LegacyRandomAccessIterator, the number of iterator increments is linear in std::distance(first, last).

連結リストのように「何番目」へ一発で飛べない列では、真ん中へ移動する手間が線形に掛かります。比較が20回でも、そこへたどり着くのに100万歩あるくなら意味がありません。

✓ ここまでの確認

「二分探索は常に O(log n) だから速い」という説明の評価として、最も適切なものはどれですか。

抽象原理

探しているのは値ではなく、境目

二分探索を「配列から値を探す関数」だと思っていると、使える場面が配列に限られます。Python の bisect 公式ドキュメントは、この見方を正面から否定しています。

Unlike other bisection tools that search for a specific value, the functions in this module are designed to locate an insertion point. Accordingly, the functions never call an __eq__() method to determine whether a value has been found. Instead, the functions only call the __lt__() method and will return an insertion point between values in an array.

等しいかどうかの判定を一度も行いません。 返るのは「その値を入れても順序が崩れない位置」、つまり境目です。そう捉え直すと、要るのは「値が入った配列」ではなく、調べたい性質が、ある地点を境に前後で分かれていることだけになります。

具体実物

git bisect は、コミット履歴に同じ形を掛けている

git bisect の公式ドキュメント(2026-09-18 取得)の説明です。

This command uses a binary search algorithm to find which commit in your project's history introduced a bug. You use it by first telling it a "bad" commit that is known to contain the bug, and a "good" commit that is known to be before the bug was introduced. Then git bisect picks a commit between those two endpoints and asks you whether the selected commit is "good" or "bad". It continues narrowing down the range until it finds the exact commit that introduced the change.

narrowing down the range ——範囲を絞る、と公式が書いています。そしてバグ探しに限らないことまで明言されています。

In fact, git bisect can be used to find the commit that changed any property of your project; e.g., the commit that fixed a bug, or the commit that caused a benchmark's performance to improve. To support this more general usage, the terms "old" and "new" can be used in place of "good" and "bad", or you can choose your own terms.

「バグが入ったコミット」ではなく「任意の性質が変わったコミット」。判定できる性質なら何でもよい、というのが公式の立場です。実行例の出力はこうなっています。

git-bisect 公式ドキュメントの実行例より(逐語)コマンド出力
Bisecting: 675 revisions left to test after this (roughly 10 steps)
 
(good / bad を1回答えた後)
 
Bisecting: 337 revisions left to test after this (roughly 9 steps)

残りが 675 から 337 へ概ね半分になり、steps が10から9へ1減っています。

ここで線を引いておきます。公式ドキュメントに「log₂ 回で見つかる」という式は書かれていません。 書かれているのは上の出力だけです。検算は教材側でできます。log₂(675) = 9.40 なので、切り上げて ceil(log₂(675)) = 10。出力の roughly 10 steps と一致します。数字は合いますが、「公式がそう書いている」と言ってはいけない部分です。

同じ形はコミット履歴以外にも現れます。怪しい範囲の半分をコメントアウトする、設定ファイルの半分を無効にする——どれも「境目を挟み撃ちにする」同じ手順です。Angular コースのデバッグ回でも、この型を扱っています。

✓ ここまでの確認

git bisect がコミット履歴に使えるのは、履歴のどんな性質があるからですか。

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

ソート済み配列の二分探索

100万件の並んだ値から1件を絞ります。判定は「真ん中の値は探す値より前か後か」。20回繰り返すと候補が1件になります。ただし規格が保証しているのは比較回数で、ランダムアクセスできない列では移動の手間が別に乗ります。

具体例 B

git bisect / 半分コメントアウト

コミット履歴やコード範囲から「変化が起きた地点」を絞ります。判定は「この地点は good か bad か」。値の大小は一切使わず、性質が変わったかだけを見ます。公式は old / new という語も選べるとしています。

値の大小を比べる二分探索と、コミットが good か bad かを答える git bisect。扱っているものが全く違いますが、共通している構造は何でしょうか。

それぞれ「1回の判定で何が起きるか」と「その判定が意味を持つために入力に何を要求しているか」を並べてみてください。

この章のまとめ

  • 1回の判定で半分を捨てられるなら、必要な回数は候補数の log₂ で決まる。 100万件で 19.93… → 20回。件数が100万倍になっても回数は20しか増えない。
  • log なのは判定(比較)の回数であって、実行時間ではない。 cppreference は「At most log₂(N)+𝓞(1) comparisons」と書き、同時に「ランダムアクセスでない列ではイテレータの移動が線形」と書いている。
  • 規格上の要求は「ソート済み」ではなく partitioned。 ただし任意の値について正しくあるには結局ソート済みが要る、と cppreference 自身が書いている。満たさない場合は undefined behavior。
  • 二分探索は値を探す技術ではなく、境目を絞る技術。 Python の bisect は __eq__ を一度も呼ばず、挿入点を返す。
  • だから配列以外にも掛かる。 git bisect は「narrowing down the range」と書き、「任意の性質が変わったコミット」に使えると明言している。ただし回数の式は公式には無く、実行例の出力(roughly 10 steps)があるだけ。
  • 使うときに確認すべきは回数ではなく前提。境目が1つでなければ、手順は最後まで動いたうえで誤った答えを返す。

自分の現場で確かめる

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

環境:

理解度チェック

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

問題 1 / 3正解: 0

100万件から二分探索で1件まで絞るのに必要な回数と、その根拠はどれですか。

用語集(3語)
partitioned(分割済み)
ある述語について、範囲の前半が false・後半が true に分かれている状態。C++ の二分探索系が要求しているのはこれで、ソート済みより弱い条件。満たさない場合の挙動は未定義と規定されている。
挿入点(insertion point)
その値を入れても順序が崩れない位置。Python の bisect が返すのはこれで、値が存在するかどうかとは別の情報。等しいかどうかの判定(__eq__)は行われない。
ランダムアクセス
何番目の要素でも同じ手間で取り出せる性質。配列は持ち、連結リストは持たない。二分探索の比較回数は log でも、この性質が無いと真ん中へ移動する手間が線形に掛かる。