二分法(二分探索)
概要
ある条件を満たすものの最大値を求めます。
(ソートされた配列に対してある値を探索したいときも上記の方法で出来ます)
使える状況
概要にもあるとおり、「ある条件を満たすものの最大値」を求めるときは二分法(二分探索)で効率的に探索することが出来ます。
説明
今、条件を満たすものの最大値をとします。
このとき、ある数に対して、
- のとき、は条件を満たす。
- のとき、は条件を満たさない。
ということが分かります。表にすると次の通りです。
... | ... | ||||||||
条件を満たすか | ○ | ○ | ○ | ... | ○ | ○ | × | × | ... |
ここで、探索の範囲を「条件を絶対に満たす値」から「条件を絶対に満たさない値」までと限定します。すると、が成り立ちます。
あとは、この条件を満たすように範囲を狭めていくことで、最終的にの値を求めることが出来ます。
ただ、その方法として「が条件を満たすか調べ、満たしていたら次はを調べる」とすると、範囲の大きさによってはかなりの時間を必要としてしまいます(この方法を線形探索といいます)。
それではどうしたら効率よく調べることが出来るかと言うと、この記事タイトルでもある「二分法(二分探索)」を使っていきます。
二分探索では、との間の値を取り、その値が、
- 条件を満たしていたら、にを代入
- 条件を満たしていなかったら、にを代入
します。
そして、またこのときのとの間の値を取って…、と繰り返して範囲を狭めていきます。
最終的にとの差がとなったとき、ちょうど以下が条件を満たし、以上が条件を満たさないことが分かったため、ということがわかります。そのことを以下の表に示します。
before
... | ... | ||||||||
条件を満たすか | ○ | ○ | ○ | ... | ○ | ○ | × | × | ... |
lowとhighの位置 | low | ... | high |
after
... | ... | ||||||||
条件を満たすか | ○ | ○ | ○ | ... | ○ | ○ | × | × | ... |
lowとhighの位置 | ... | low | high | ... |
計算量に関して、線形探索ではとなるところが、二分法(二分探索)ではにまで小さくすることが出来ます。
以上のことをの位置を仮定した簡単な図で書くと次のようになります。
からまでの範囲がを含んだままどんどん狭まっていることがわかりますね。
コード(C++)
//要ヘッダ:functional using ll = long long int; ll BinarySearch(ll low, ll high, std::function<bool(ll)> check){ if(high-low==1){ return low; } ll mid = (high+low)/2; if(check(mid)){ return BinarySearch(mid, high, check); }else{ return BinarySearch(low, mid, check); } }
コードの説明
関数を呼び出すときには、
- 第一引数lowには「条件を絶対に満たす値」
- 第二引数highには「条件を絶対に満たさない値」
- 第三引数checkには「条件の関数」
を指定します。
if(check(mid)){ return BinarySearch(mid, high, check); }else{ return BinarySearch(low, mid, check); }
関数checkでmidについて調べた結果によって、lowかhighのどちらかの値ををmidとして自身の関数を再帰呼び出ししています。
if(high-low==1){ return low; }
また、再帰からの脱出の条件は「lowとhighの差が1のとき」なので、このときlowを返すようにしています。
具体例
入力される非負整数に対し、以下の最大の整数を求める。
「で求められるじゃん!」と思うかもしれませんが、あくまで二分探索の練習なので・・・(簡単でためになる良い問題が思いつかなかった)。
#include <iostream> #include <functional> #include <cmath> #include <iomanip> using ll = long long int; ll BinarySearch(ll low, ll high, std::function<bool(ll)> check){ std::cout << "low|"; std::cout << std::setw(3) << low; std::cout << "--"; std::cout << std::setw(7) << high; std::cout << "|high\n"; if(high-low==1){ return low; } ll mid = (high+low)/2; if(check(mid)){ return BinarySearch(mid, high, check); }else{ return BinarySearch(low, mid, check); } } int main(){ std::ios::sync_with_stdio(false); std::cin.tie(0); ll input; std::cin >> input; auto check = [=](ll n) -> bool { return n*n<=input; }; ll ans = BinarySearch(0, std::pow(2, 20), check); std::cout << "answer = " << ans << "\n"; return 0; }
(計算中のとの値を表示するために二分探索の関数の先頭にいろいろ足してます)
入力例とその出力
入力
100000
出力
low| 0--1048576|high low| 0-- 524288|high low| 0-- 262144|high low| 0-- 131072|high low| 0-- 65536|high low| 0-- 32768|high low| 0-- 16384|high low| 0-- 8192|high low| 0-- 4096|high low| 0-- 2048|high low| 0-- 1024|high low| 0-- 512|high low|256-- 512|high low|256-- 384|high low|256-- 320|high low|288-- 320|high low|304-- 320|high low|312-- 320|high low|316-- 320|high low|316-- 318|high low|316-- 317|high answer = 316
出力結果を見ると、確かにどんどん範囲が狭まっていき、最終的にとの差がになっていることがわかります。
答えも、より、条件を満たすものの中での最大値を求めることが出来ていますね。
備考
問題によっては求める最大値が整数とも限らないので、その場合は「との差が」という部分を「差がほぼ」としたり、各変数の型をdouble型にしたりと、色々工夫が必要だと思います。
そこに関しては、問題を見て適切にコードを書き換えられるようにしておきましょう。
また、冒頭でも述べたように、ソートされた配列について特定の値を調べたいときは、を配列の先頭、を配列の後尾とすることで調べることが出来ます。
ちなみに、を
mid = (high+low)/2;
としていますが、がlong long int型(コード中ではll型)の範囲から溢れてしまう可能性があるような場合は、
より、
mid = low+(high-low)/2;
とすることで解消することが出来ます。
あとがき
実装に関して、上記のコードでは再帰呼び出しをしていますが、whileループで記述しても同様です(その方が速く、メモリも節約できる)。
今回は個人的にイメージしやすい再帰で書いてみました。
文章中では、二分法(二分探索)と書いているところが多いですが、本来二分探索(Binary Search)はソート済みの配列から特定の値を調べるもので、二分法(Bisection method)はある方程式の解を求めるものです。この両者は似て非なるものですが、ある一つの問題に対して、AtCoderの解説スライドでは「二分法」との記載があったり、Twitterでは「二分探索で解いた」という発言も見られるため、両方をまとめて書いておきました。
競プロに関して、この方法はABCのC,D問題、ARCのB問題、yukicoderの★★などでちらほら見かける気がします。
計算量を非常に抑えることが出来るので、ぜひ覚えておきたいアルゴリズムですね。