# 二分搜尋法 Binary Search
如何在一堆東西裡面尋找我想要的東西呢?相向大家都有相似的疑問吧?
這章節會介紹兩個常用的搜尋演算法
## 線性搜尋法 Linear Search
找東西最直觀的方法就是把所有東西掃過一遍,這樣就可以找到想要的內容了
而這種方法就叫做線性搜尋法
線性搜尋法很簡單,對於一個陣列,只需要一個一個比較是否相同即可
```cpp
int arr[7] = {1, 4, 8, 13, 42, 89, 101};
int target = 42; // 目標值
for(int i = 0; i < 7; i++){
if(arr[i] == target) // 判斷 arr[i] 是否為 target
cout << "Found!!\n";
}
```
時間複雜度: $O(n)$
**但是這樣就太慢了!!**
## 二分搜尋法 Binary Search
### 介紹
二分搜尋法的精神就是把一個序列剖半、比較、再剖半、再比較...直到找到答案為止
在以下影片中,目標是尋找一區間中的紅線,黑線代表中線
那們會執行以下流程:
1. 藉由左右界找到中線位置
2. 發現紅線是在中線的右半邊
3. 捨棄左半邊不看(將新左界設在中線的位置)
4. 找出新的中線位置
5. 發現紅線在中線的左半邊
6. 捨棄右半邊不看(將新右界設在中線的位置)
7. 找出新的中線位置
8. 發現紅線剛好在中線上(結束!!)
{%youtube kBncwQWVcj8 %}
---
### 舉例
假設有一陣列 $arr[7] = \{1, 4, 8, 13, 42, 89, 101\}$,目標是希望找到 $42$
在這其中,我們可以使用三個指針 $L, M, R$ 來維護
{%youtube oxuBieClpi4 %}
---
### 實作
二分搜尋法需要先準備好一個「有序」的序列與一個目標值
最後會回傳目標值在序列中的位置
```cpp
int arr[7] = {1, 4, 8, 13, 42, 89, 101};
int target = 42, n = 7;
int l = 0, r = n - 1; // 搜尋 [l, r] 區間
while (l <= r) {
int m = (l + r) / 2;
if (arr[m] == target){
cout << "Found target";
break;
}
else if (arr[m] < target)
l = m + 1;
else (arr[m] > target)
r = m - 1;
}
```
詳細流程如下:
```mermaid
flowchart TD
st[宣告左界 l = 0、右界 r = n-1]:::declear
cnd{l <= r}:::condition
dp1["宣告 m = (l + r) / 2"]:::declear
cnd1{"arr[m] 等於 目標"}:::if
cnd2{"arr[m] 小於 目標"}:::if
cnd3{"arr[m] 大於 目標"}:::if
c1["找到答案"]:::st
c5["離開迴圈"]:::st
c4["沒找到答案"]:::st
c2["l = m + 1"]:::st
c3["r = m - 1"]:::st
c4 --> c5
c1 --> c5
cnd -->|No| c4
st --> cnd --> |Yes| dp1
dp1 --> cnd1
dp1 --> cnd2
dp1 --> cnd3
cnd1 --> c1
cnd2 --> c2 --> cnd
cnd3 --> c3 --> cnd
classDef declear stroke:#00f, fill:#fff
classDef condition stroke:#0f0, fill:#fff
classDef if stroke:#f00, fill:#fff
classDef st stroke:#000, fill:#fff
```
PS : 實作時如果遇到無限迴圈,可以檢查是否維護好邊界或是終止條件
## 單調性
二分搜尋法基本上符合單調性的任何東西都可以使用
單調性基本上就是對於一個函數 $f(x)$:
當 $x$ 越大 $f(x)$ 也會越大 或者 當 $x$ 越大 $f(x)$ 也會越小
<img src="https://res.cloudinary.com/bend/f_auto/shikakutimes/s3/bend-image/1645534943.png" style="margin: 0 auto; display: block; width: 400px">
<p class="text-center">圖源: <a href="https://manabitimes.jp/math/1289">https://manabitimes.jp/math/1289</a></p>
想當然一個排序好的陣列,或是說一個序列也會具有單調性
除此之外,像是坐標系、時間軸、走訪起來有序的樹狀資料結構...等等,也可能具有單調性,可以試試二分搜尋法
## 二分搜尋法的限制
- 二分搜尋法的時間複雜度是 $O(log~n)$
- 如果不符合單調性,就無法執行二分搜尋法,所以要記得先排序過
- 值得注意的是,快速排序法的複雜度大概是 $O(n~log~n)$
因此排序 + 二分搜的複雜度也是 $O(n~log~n)$
如果發現要搜尋的東西不是具有單調性的函數,而是一個具有「極值」的拋物線函數,那麼就可以使用[三分搜尋法](https://hackmd.io/@ShanC/ternary-search)
## C++ 內建二分搜尋法
C++ 其實有內建的二分搜尋法,分別是 `lower_bound` 與 `upper_bound`
在比賽時也比較推薦使用這兩個函數,可以省去很多 debug 時間
### lower_bound()
lower_bound() 函數可以找到一有序陣列中第一個「大於或等於」目標值的記憶體位置
如果要找的數大於所有的數字,則回傳陣列末尾
由於是找記憶體位置,所以實作時要記得檢掉原本陣列 $[0]$ 的位置
#### 範例1:
```cpp
int arr[7] = {1, 4, 8, 13, 42, 89, 101};
int target = 42, ans;
ans = lower_bound(arr, arr + 7, target) - arr; // 記得減 arr
cout << target << " 在陣列位置: " << ans << endl;
// arr 對於 C/C++ 而言其實是陣列位置
```
#### 輸出1:
```cpp
42 在陣列位置: 4
```
<p><br></p>
#### 範例2:
```cpp
int arr[7] = {1, 4, 8, 13, 42, 89, 101};
int target = 500, ans;
ans = lower_bound(arr, arr + 7, target) - arr; // 記得減 arr
cout << target << " 在陣列位置: " << ans << endl;
```
#### 輸出2:
```cpp
500 在陣列位置: 7
```
---
### upper_bound()
upper_bound() 函數可以找到一有序陣列中第一個「大於」目標值的記憶體位置
用法與 `lower_bound()` 雷同
#### 範例:
```cpp
int arr[7] = {1, 4, 8, 13, 42, 89, 101};
int target = 42, ans;
ans = upper_bound(arr, arr + 7, target) - arr; // 記得減 arr
cout << target << " 在陣列位置 + 1: " << ans << endl;
```
#### 輸出:
```cpp
42 在陣列位置 + 1: 5
```
## 例題說明
來源: [2019-2020 ACM-ICPC Brazil Subregional Programming Contest Problem M](https://codeforces.com/gym/102346/problem/M)
### 題目
這題要吃爆米花,共 $C$ 人分享 $N$ 桶爆米花,每人每一秒只能吃 $T$ 粒爆米花
告訴你第 $i$ 桶有 $P_{i}$ 粒爆米花,並且要符合以下規則:
- 每個人要挑選「連續」的幾桶爆米花吃
- 一桶爆米花只能被同一人吃完
問他們最快要花多少時間才能吃完所有爆米花
### 限制
- $1 \leq N,C \leq 10^{5}$
- $1 \leq T \leq 50$
- $1 \leq P_{i} \leq 10^{4}$
### 題解
首先,由於每個人要選擇「連續」的爆米花桶
因此可以發現這其實就是把這 $N$ 桶爆米花分割成 $C$ 個區間
測資一就是這樣切割:
<img src="https://hackmd.io/_uploads/SJRjo7NGJl.png" style="margin: 0 auto; display: block; width: 400px">
我們可以來觀察一下:
- 根據輸出結果,因為每秒吃 $4$ 粒爆米花,如果花 $4$ 秒的時間,每個人各自可以吃 $16$ 粒
這樣分割區間的話,三個人分別要吃 $13$、$13$、$7$ 粒爆米花,時間絕對來的及
- 試著想想看,如果今天給這三人 $5$ 秒的時間,可以怎麼分割區間呢?
因為每秒吃 $4$ 粒爆米花,如果花 $5$ 秒的時間,每個人各自可以吃 $20$ 粒
這樣分割區間的話三個人分別要吃 $16$、$17$、$0$ 顆
會發現這樣有人沒分到,所以是 $2$ 個區間
- 試著想想看,如果今天給這三人 $3$ 秒的時間,可以怎麼分割區間呢?
因為每秒吃 $4$ 粒爆米花,如果花 $3$ 秒的時間,每個人各自可以吃 $12$ 粒
這樣分割區間的話三個人分別要吃 $5$、$11$、$10$ 顆,剩下 $7$ 顆吃不完
會發現這樣會有 $4$ 個區間(不是我要的答案)
可以歸納出三點:
- 當我代入**越大**的時間,分割區間**越少**塊
- 當我代入**越小**的時間,分割區間**越多**塊
- 代入的時間最少 $1$ 秒,最多 $\sum P{i}$ 秒 (即所有爆米花一人獨享)
寫成不等式: $1 \leq 代入的時間\leq \sum P{i}$
可以發現子結論 **具有單調性!!!!!**
因此只要對**時間**做二分搜尋
每次搜尋確定可以分割的塊數**小於或等於**人數 (小於的話就是有人分 $0$ 粒),就記錄答案
### 程式實作
```cpp
int check(int x){
x *= t; // 算每人各可以吃幾粒
int cut = 1, total = 0;
for(int i = 0; i < n; i++){ // 找的貪心一點
if(pc[i] > x) // 如果這桶爆米花比我能吃的總數還多
return 1e9; // 那就我就算分割無限多塊也吃不完
if(total + pc[i] <= x)
total += pc[i];
else // 多增加一個區間
total = pc[i], cut++;
}
return cut;
}
signed main(){
/*****略*****/
int l = 1, r = 1e9 / t, mid;
while(l <= r){
mid = (l + r) / 2;
int x = check(mid);
if(x <= c)
r = mid - 1, ans = mid; // 記得紀錄答案
else
l = mid + 1;
}
/*****略*****/
}
```
PS : 根據經驗法則,有時候二分搜尋也有可能是一種「逼近」答案的技巧
## 題目練習
~~二分搜尋法是 APCS 的常客喔~~
[Zerojudge c575. APCS 2017-0304-4基地台](https://zerojudge.tw/ShowProblem?problemid=c575)
[Zerojudge d732. 二分搜尋法](https://zerojudge.tw/ShowProblem?problemid=d732) (裸題)
[ABC 231_C Counting 2](https://atcoder.jp/contests/abc231/tasks/abc231_c?lang=en) (可以想成裸題)
[Zerojudge e616 Aggressive cows](https://zerojudge.tw/ShowProblem?problemid=e616)
[Zerojudge f581. APCS 3. 圓環出口](https://zerojudge.tw/ShowProblem?problemid=f581) (前綴和 + 二分搜)
[Zerojudge i401. APCS 3. 雷射測試](https://zerojudge.tw/ShowProblem?problemid=i401)
[CSES Concert Tickets](https://cses.fi/problemset/task/1091/) (資料結構 + 二分搜)
[LeetCode First Bad Version](https://leetcode.com/problems/first-bad-version/description/) (猜數字裸題)
[LeetCode Find the Duplicate Number](https://leetcode.com/problems/find-the-duplicate-number/description/) (其實可以不需要二分搜,但是題目要)
[CSES Factory Machines](https://cses.fi/problemset/task/1620) (對時間二分搜)
[ABC 319_D Minimum Width](https://atcoder.jp/contests/abc319/tasks/abc319_d?lang=en) (可以轉化成例題的區間分割問題)
[CSES Array Division](https://cses.fi/problemset/task/1085)
[CSES Sum of Three Values](https://cses.fi/problemset/task/1641) (排序 + 窮舉 + 二分搜)
[LeetCode Koko Eating Bananas](https://leetcode.com/problems/koko-eating-bananas/description/) (用二分搜猜速率)
----
## 參考資料
- [海大競程 - search algorithm & 實作misc](https://hackmd.io/@LeeShoWhaodian/Byyg2J6byl#/)
- [geeksforgeeks - Binary Search Algorithm – Iterative and Recursive Implementation](https://www.geeksforgeeks.org/binary-search/)
----
> [ShanC 程式競賽筆記](https://hackmd.io/@ShanC/B1ouGxqcC)
> 作者: ShanC
> 更新: 2024/10/4 (颱風天寫的)