{ }codelper

学ぶ › Craykon

アルゴリズム · 28

二分探索

整列された範囲を半分ずつ捨てて、値や、条件を最初に満たす位置を見つけます。

二分探索は、整列されたリストの真ん中を見て、答えがありえない半分を捨てることを繰り返します。
下の例は、低い順に並んだ点数から、質問された点数 x ごとに x 以上の最初の位置を探します。
その位置の番号は x より低い点数の個数でもあるので、その数と、x 点があれば yes、なければ no を表示します。

lowerBound(a, x){    lo = 0; // 답은 lo부터 hi까지 가운데 어딘가    hi = a.個数;    間 (lo < hi)    {        mid = 数学.商(lo + hi, 2);        もし (a[mid] < x)        {            lo = mid + 1; // mid와 그 왼쪽은 모두 x보다 작아요        }        でなければ        {            hi = mid; // mid와 그 오른쪽은 모두 x 이상이에요        }    }    返す lo; // x 이상인 첫 자리(없으면 a.개수)} first = コンソール.数読み();n = first[0];scores = コンソール.数読み();それぞれ (x の中 コンソール.数読み()){    pos = lowerBound(scores, x); // x보다 낮은 점수의 개수이기도 해요    found = pos < n && scores[pos] == x;    コンソール.書く(pos + " " + (found ? "yes" : "no"));}
stdin
7 5
40 55 55 70 82 82 95
55 60 82 100 10
stdout
1 yes
3 no
4 yes
7 no
0 no

ポイント

  • リストが整列されていれば、真ん中の値一つで半分を捨てられます。
    真ん中が x より小さければその左もすべて x より小さく、真ん中が x 以上ならその右もすべて x 以上だからです。
  • 例の lower_bound は x 以上の最初の位置を返します。
    その位置の値が x と同じなら x があるということで、同じ値がいくつもあればその先頭です。
    そういう値がなければ、リストの長さ n を返します。
  • 繰り返しのあいだずっと守られる約束があります:lo の左はすべて x より小さく、hi から右はすべて x 以上です。
    最初(lo = 0、hi = n)は両側に何もないので成り立ち、lo = mid + 1 と hi = mid はこの約束を壊しません。
    だから lo と hi が出会った位置が答えです。
  • lo < hi のあいだ、mid はいつも hi より小さくなります。
    そのため lo = mid + 1 でも hi = mid でも範囲が 1 以上縮み、必ず終わります。
  • 1 回で半分になるので、100 万個でも多くて 20 回しか見ません。
    点数 1 万個に質問 1 万個を私たちのジャッジで計ると、先頭から一つずつ数える Python の解法は 3 秒以上かかり、二分探索は約 0.02 秒でした。
  • 値を探すためだけのものではありません。
    前のほうはすべて偽で、ある位置から先はすべて真になる条件なら、条件が最初に真になる位置を同じ方法で探せます。
    例の条件は「値が x 以上である」です。

言語ごとの書き方

Pythonmid = (lo + hi) // 2。
組み込みは import bisect — bisect.bisect_left(a, x) が x 以上の最初の位置(例の関数と同じ)、bisect.bisect_right(a, x) が x より大きい最初の位置
JavaScriptconst mid = Math.floor((lo + hi) / 2); — / は小数になるので、必ず切り捨てます。
組み込みの二分探索はないので自分で書きます
TypeScriptJavaScript と同じです
Javaint mid = lo + (hi - lo) / 2;。
組み込みの Arrays.binarySearch(a, x) は、あればその位置、なければ -(入る位置) - 1 を返します(例の点数で 60 は -4)。
同じ値がいくつもあると、そのうちどの位置が返るかは決まっていません(ここでは 82 は先頭の 4 ではなく 5)。
リストは Collections.binarySearch
C#int mid = lo + (hi - lo) / 2;。
組み込みの Array.BinarySearch(a, x) は、なければ負の数を返し、~ を付けると入る位置になります(60 は -4、~(-4) は 3)。
同じ値がいくつもあると先頭とは限りません(82 は 5)。
リストは list.BinarySearch(x)
C++#include <algorithm> の std::lower_bound(v.begin(), v.end(), x) - v.begin() が例の関数と同じです。
x より大きい最初の位置は std::upper_bound、あるかどうかだけなら std::binary_search
Cint mid = lo + (hi - lo) / 2;。
<stdlib.h> の bsearch(&x, a, n, sizeof a[0], cmp) は同じ値のアドレスか NULL しか返さないので、「x 以上の最初の位置」は探せません。
例のように自分で書きます
Craykonmid = 数学.商(lo + hi, 2); — / は小数になり、a[1.5] は「[ ] の中には整数の番号を書いてください」と言って止まります。
組み込みの二分探索はないので自分で書きます

よくある間違い

lo = mid で狭めたら終わらない

a = [10, 20, 30]
lo, hi = 0, 2  # 20 以下の最後の位置を探す
while lo < hi:
    mid = (lo + hi) // 2
    if a[mid] <= 20:
        lo = mid
    else:
        hi = mid - 1
print(lo)

→ 終わりません。lo = 1、hi = 2 で mid がまた 1 になり、範囲が縮みません

lo = mid を使う形では、真ん中を切り上げにします(mid = (lo + hi + 1) // 2)。
すると 1 を表示して終わります。
いくつもの形を混ぜず、例の形(lo = mid + 1、hi = mid)一つを身につけておくのが安全です。

真ん中を / 2 だけで求めたJavaScript · TypeScript

const a = [10, 20, 30];
let lo = 0, hi = a.length;
while (lo < hi) {
  const mid = (lo + hi) / 2;
  if (a[mid] < 25) lo = mid + 1;
  else hi = mid;
}
console.log(lo);

→ 1 — 答えは 2 です。最初の mid が 1.5 で、a[1.5] はエラーにならず undefined なので、1,077 周回ったあげく間違った位置を返します

Math.floor((lo + hi) / 2) で切り捨てます。
すると 2 になります。

(lo + hi) / 2 があふれたJava · C# · C · C++

int lo = 1500000000, hi = 2000000000;
int mid = (lo + hi) / 2;
System.out.println(mid);

→ -397483648 — lo + hi が int の上限(約 21 億)を超えて負の数になりました。C# も同じ値になります。C・C++ では int があふれると何が起こるか決まっていません(未定義動作)。私たちのジャッジでは同じ値になりましたが、いつもそうだと思ってはいけません

int mid = lo + (hi - lo) / 2; と書けば 1750000000 になります。
配列の番号ではまれですが、1 から 20 億のあいだで答えになる数を半分ずつ狭めて探すときには、すぐに出会います。

言語ごとの違い
二分探索が正しいのは、整列されたリストだけです。
整列されていない [30, 10, 20] から 30 を bisect_left で探すと、エラーなしで 3(ないという意味)が返ります。
始まりの値も一つの差が大事です。
例で hi を n ではなく n - 1 から始めると、[10, 20, 30] からどの値よりも大きい 99 を探したとき、3 ではなく 2 になります。

練習問題