【演算法】二分三步走
阿新 • • 發佈:2021-03-28
> 據查,醫書有服用響豆的方法,響豆就是槐樹果實在夜裡爆響的,這種豆一棵樹上只有一個,辨認不出來。取這種豆的方法是,在槐樹剛開花時,就用絲網罩在樹上,以防鳥雀啄食。結果成熟後,縫製許多布囊貯存豆莢。夜裡用來當枕頭,沒有聽到聲音,便扔掉。就這麼輪著枕,肯定有一個囊裡有爆響聲。然後把這一囊的豆類又分成幾個小囊裝好,夜裡再枕著聽。聽到響聲再一分為二,裝進囊中枕著聽。這麼分下去到最後只剩下兩顆,再分開枕聽,就找到響豆了。
# 二分查詢
> 十個二分九個錯,該演算法被形容 "思路很簡單,細節是魔鬼"。第一個二分查詢演算法於 1946 年出現,然而第一個完全正確的二分查詢演算法實現直到 1962 年才出現。下面的二分查詢,其實是二分查詢裡最簡單的一個模板,在後面的文章系列裡,我將逐步為大家講解二分查詢的其他變形形式。
## 適用場景
注意,絕大部分**「在遞增遞減區間中搜索目標值」**的問題,都可以轉化為二分查詢問題。
即 適用於**在有序集合中搜索特定值**
關鍵詞:有序
## 基本概念
二分查詢是電腦科學中最基本、最有用的演算法之一。它描述了**在有序集合中搜索特定值的過程**。一般二分查詢由以下幾個術語構成:
- 目標 Target —— 你要查詢的值
- 索引 Index —— 你要查詢的當前位置
- 左、右指示符 Left,Right —— 我們用來維持查詢空間的指標
- 中間指示符 Mid —— 我們用來應用條件來確定我們應該向左查詢還是向右查詢的索引
在最簡單的形式中,二分查詢對具有指定左索引和右索引的**連續序列**進行操作。我們也稱之為**查詢空間**。二分查詢維護查詢空間的左、右和中間指示符,並比較查詢目標;如果條件不滿足或值不相等,則清除目標不可能存在的那一半,並在剩下的一半上繼續查詢,直到成功為止。
![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210308093750835-184855006.gif)
舉例說明:比如你需要找1-100中的一個數字,你的目標是用**最少的次數**猜到這個數字。你每次猜測後,我會說大了或者小了。而你只需要每次猜測中間的數字,就可以將餘下的數字排除一半。
![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210308084126911-555844562.png)
不管我心裡想的數字如何,你在7次之內都能猜到,這就是一個典型的二分查詢。每次篩選掉一半資料,所以我們也稱之為 **折半查詢**。一般而言,對於包含n個元素的列表,用二分查詢最多需要log2n步。
![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210308084135913-1802551192.png)
當然,一般題目不太可能給你一個如此現成的題型,讓你上手就可以使用二分,所以我們需要思考,如何來構造一個成功的二分查詢。大部分的二分查詢,基本都由以下三步組成:
- 預處理過程(大部分場景就是對未排序的集合進行排序)
- 二分查詢過程(找到合適的迴圈條件,每一次將查詢空間一分為二)
- 後處理過程(在剩餘的空間中,找到合適的目標值)
# 二分三步走
一般參考條件:
總結一下一般實現的幾個條件:
- **初始條件:**`left = 0, right = length - 1`
- **迴圈條件:**`left <= right`
- **終止:**`left > right`
- **向左查詢:**`right = mid - 1`
- **向右查詢:**`left = mid + 1`
## 1. 明確左右邊界
1.**明確左右邊界:**一般左邊界是陣列的起始下標 `left = 0`,右邊界的陣列的結束下標 `right = nums.length - 1`
## 2. 確立中間索引
2.**確立中間索引:**一般是正中間向下取整,即 `mid = (left + right) / 2`,不過為了防止 left + right 溢位記憶體,我們一般採用 `mid = left + (right - left) / 2` 是一樣的效果噢,只不過 right - left 肯定不會溢位記憶體。
## 3. 完成二分劃分
3.**完成二分劃分:**
**向左查詢:**`right = mid - 1`;
**向右查詢:**`left = mid + 1`
# 實現方式
## 一般實現
瞭解了二分查詢的過程,我們對二分查詢進行**一般實現**(這裡給出一個Java版本,比較正派的程式碼,沒有用一些縮寫形式)
> **注意:**迴圈條件`while (low <= high)`與快速排序`while (low < high)`的不同的,
因為我們的二分法需要查詢元素是否滿足條件,當 low == high 時,我們也需要判斷元素是否滿足條件,不滿足條件依舊不能返回;
而快速排序就不一樣了,我們僅僅是需要劃分陣列,將陣列分為一小一大兩部分,當 low == high 時,我們不需要判斷是否滿足條件了,直接劃分即可。
```
//JAVA
public int binarySearch(int[] array, int des) {
int low = 0, high = array.length - 1;
while (low <= high) { // 與快速排序的low < high區分開來
int mid = low + (high - low) / 2; // 防止 high + low 溢位記憶體
if (des == array[mid]) {
return mid;
} else if (des < array[mid]) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
```
> **注意:**上面的程式碼,mid 使用 `low + (high - low) / 2` 的目的,是防止 `high + low` 溢位記憶體。如果不溢位的話,其實是和 `(high + low) / 2` 一樣的效果。
為什麼說是一般實現?
1. **根據邊界的不同(開閉區間調整),有時需要彈性調整low與high的值,以及迴圈的終止條件。**
2. 根據元素是否有重複值,以及是否需要找到重複值區間,有時需要對原演算法進行改進。
那上面我們說了,一般二分查詢的過程分為:預處理 - 二分查詢 - 後處理,上面的程式碼,就沒有後處理的過程,因為在每一步中,你都檢查了元素,如果到達末尾,也已經知道沒有找到元素。
總結一下一般實現的幾個條件:
- **初始條件:**`left = 0, right = length - 1`
- **迴圈條件:**`left <= right`
- **終止:**`left > right`
- **向左查詢:**`right = mid - 1`
- **向右查詢:**`left = mid + 1`
請大家記住這個模板原形,在後面的系列中,我們將介紹二分查詢其他的模板型別。
## 延伸實現
### 記錄滿足條件的元素
特殊一點:滿足條件就記錄一次,直到最後一次,就是我們滿足條件的最後答案
```
//JAVA
public class Solution extends VersionControl {
public int firstBadVersion(int n) {
int left = 1;
int right = n;
int res = n; // 用來記錄滿足條件的答案
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (isBadVersion(mid)) {
// 滿足條件就記錄覆蓋一次,直到最後一次,就是我們滿足條件的最後答案
res = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return res;
}
}
```
---
### 陣列中必定存在滿足條件的元素時的優化
可以使用此優化的情況:如果我們不需要判斷最終 `left == right` 時是否滿足條件
- 可以確定如果最後找到元素就一定滿足條件
- 只需要找到最接近的元素
- 我們確定滿足條件的元素一定在陣列中
如果我們不需要判斷最終 `left == right` 時是否滿足條件,可以確定如果最後找到元素就一定滿足條件,或者只需要找到最接近的元素,或者我們確定滿足條件的元素一定在陣列中,我們就可以優化為以下程式碼,不需要判斷最後 left == right 時是否滿足條件:
```
//JAVA
public int firstBadVersion(int n) {
int left = 1;
int right = n;
while (left < right) { // 我們不需要審查 left == right 時的場景,因為滿足條件的元素必然在陣列中,所以我們也不需要記錄滿足條件的元素結果答案
int mid = left + (right - left) / 2;
if (isBadVersion(mid)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
```
## 遞迴實現
**遞迴實現**:這裡二分法的遞迴,其實可以叫做[分治法](https://www.cnblogs.com/blknemo/p/14281768.html)
```
// 明確分解策略:大問題=從n個元素中找到最大的數字並返回,折半分解,小問題=從2個元素比較大小找到最大數字並返回。
int f(int[] nums, int l, int r) {
// 尋找最小問題:最小問題即是隻有一個元素的時候
if (l > = r) {
return nums[l];
}
// 使用分解策略
int lMax = f(nums, l, (l+r)/2);
int rMax = f(nums, (l+r)/2+1, r);
// 解決次小問題:比較兩個元素得到最大的數字
return lMax > rMax ? lMax : rMax;
}
```
## 思考問題
注意,絕大部分「在遞增遞減區間中搜索目標值」 的問題,都可以轉化為二分查詢問題。並且,二分查詢的題目,基本逃不出三種:找特定值,找大於特定值的元素(上界),找小於特定值的元素(下界)。
而根據這三種,程式碼又最終會轉化為以下這些問題:
- low、high 要初始化為 0、n-1 還是 0、n 又或者 1,n?
- 迴圈的判定條件是 low < high 還是 low <= high?
- if 的判定條件應該怎麼寫?
- if 條件正確時,應該移動哪邊的邊界?
- 更新 low 和 high 時,mid 如何處理?
處理好了上面的問題,自然就可以順利解決問題。
# 一點建議
> 我拉出來講這道題的原因,絕對不是說你會了,知道怎麼樣做了就可以了。我是希望通過本題,各位去深度思考二分法中幾個元素的建立過程,比如 **Left 和 Right 我們應該如何去設定**,如本題中 Right 既可以設定為 x 也可以設定為 x/2;又比如 **mid 值該如何計算**。大家一定要明確 mid 的真正含義有兩層,第一:大部分題目最後的 mid 值就是我們要找的目標值 第二:我們通過 mid 值來收斂搜尋空間。
那麼問題來了,如何可以徹底掌握二分法?初期我並不建議大家直接去套模板,這樣意義不是很大,因為套模板很容易邊界值出現錯誤(當然,也可能我的理解還不夠深入,網上有很多建議是去直接套模板的)我的建議是:去思考二分法的本質,瞭解其通過收斂來找到目標的內涵,對每一個二分的題目都進行深度剖析,多分析別人的答案。你得知道,每一個答案,背後都是對方的思考過程。從這些過程中抽繭剝絲,最終留下的,才是二分的精髓。也只有到這一刻,我認為才可以真正的說一句掌握了二分。畢竟模板的目的,也是讓大家去思考模板背後的東西,而不是模板本身。
# 例項
## [875. 愛吃香蕉的珂珂](https://leetcode-cn.com/problems/koko-eating-bananas/)
珂珂喜歡吃香蕉。這裡有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警衛已經離開了,將在 H 小時後回來。
珂珂可以決定她吃香蕉的速度 K (單位:根/小時)。每個小時,她將會選擇一堆香蕉,從中吃掉 K 根。如果這堆香蕉少於 K 根,她將吃掉這堆的所有香蕉,然後這一小時內不會再吃更多的香蕉。
珂珂喜歡慢慢吃,但仍然想在警衛回來前吃掉所有的香蕉。
返回她可以在 H 小時內吃掉所有香蕉的最小速度 K(K 為整數)。
示例 1:
輸入: piles = [3,6,7,11], H = 8
輸出: 4
示例 2:
輸入: piles = [30,11,23,4,20], H = 5
輸出: 30
示例 3:
輸入: piles = [30,11,23,4,20], H = 6
輸出: 23
### 答案
做題思路:
1. 我們需要一個方法來判斷速度為k時能否在h小時內吃完堆
2. 由於我們不能判斷k的大小,那就一個一個遞增去試試,去找到一個合適的值;(滿足了我們二分法的適用場景)
3. 然後我們想到,如果是**遞增有序**的話,我們可以直接用二分法查詢
```
class Solution {
public int minEatingSpeed(int[] piles, int h) {
// 1. 我們需要一個方法來判斷速度為k時能否在h小時內吃完堆
// 2. 由於我們不能判斷k的大小,那就一個一個遞增去試試,如果是遞增有序的話,我們可以直接用二分法查詢
// 這是第一版左右界限,我們可以優化一下
// int left = 1;
// int right = Integer.MAX_VALUE; // 這裡得用最大的數,因為測試示例很大
// 第二版左右界限,右界限我們可以取 香蕉個數的最大值max
int left = 1;
int right = 0;
for (int i = 0 ; i < piles.length; i++) {
right = piles[i] > right? piles[i] : right;
}
int index = 0; // 用來記錄可以吃完的速度,滿足條件就記錄一次,直到最後一次,就是我們的答案
while (left <= right) {
// 1. 如果可以吃完,那就向左邊找找
// 2. 如果不能,那就右邊
// int mid = (left + right) / 2; // 使用這個有可能導致 left + right 溢位
int mid = left + (right - left) / 2;
if (f(piles, mid, h)) {
index = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return index;
}
// 1. 我們需要一個方法來判斷速度為k時能否在h小時內吃完堆
public boolean f(int[] piles, int k, int h) {
int time = 0;
for (int i = 0; i < piles.length; i++) {
// 我的向上取整方法:
// 1. 如果能整除,那就直接除法算時間
// 2. 如果不能整除,那就先除法再+1
// if (piles[i] % k == 0) {
// time += piles[i] / k;
// } else {
// time += piles[i] / k + 1;
// }
// 別人的向上取整方法:
// 可以看到,其實就是加了一個 k - 1,再除以 k,也就是說加了一個大於 0.5,小於 1 的數,向上取整
time += (piles[i] + k - 1) / k;
}
return h > = time;
}
}
```
## [69. x 的平方根](https://leetcode-cn.com/problems/sqrtx)
實現 int sqrt(int x) 函式。
計算並返回 x 的平方根,其中 x 是非負整數。
由於返回型別是整數,結果只保留整數的部分,小數部分將被捨去。
示例 1:
輸入: 4
輸出: 2
示例 2:
輸入: 8
輸出: 2
說明: 8 的平方根是 2.82842...,
由於返回型別是整數,小數部分將被捨去。
### 答案
1. 這裡我們很容易就想到暴力破解,從 0 開始遞增一個一個看是不是滿足 res * res == x,直到查詢到一個數滿足我們的條件
2. 既然是遞增有序的,我們使用二分法來分解查詢
```
class Solution {
public int mySqrt(int x) {
// 如果不能使用平方根函式的話,那就只有使用 res * res == x 來計算了,我們最簡單可以使用暴力破解來尋找res(即 一個一個找)
// for (int i = 1; i <= x / 2; i++) {
// if ((i * i < x && (i + 1) * (i + 1) > x) || i * i == x) {
// return i;
// }
// }
// return x;
// 遺憾的是,暴力破解在驗證2147483647的時候超時了,只能換一個查詢方法了
// 平方根的整數部分必然 ans * ans <= x,所以滿足此條件的ans都有可能是我們需要的
int l = 0;
int r = x;
int ans = -1; // 用來儲存我們滿足條件的答案,每次滿足條件都儲存一次,直到最後一次。
while (l <= r) {
int mid = l + (r - l) / 2;
if ((long) mid * mid <= x) {
ans = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
return ans;
}
}
```
## [278. 第一個錯誤的版本](https://leetcode-cn.com/problems/first-bad-version)
你是產品經理,目前正在帶領一個團隊開發新的產品。不幸的是,你的產品的最新版本沒有通過質量檢測。由於每個版本都是基於之前的版本開發的,所以錯誤的版本之後的所有版本都是錯的。
假設你有 n 個版本 [1, 2, ..., n],你想找出導致之後所有版本出錯的第一個錯誤的版本。
你可以通過呼叫 bool isBadVersion(version) 介面來判斷版本號 version 是否在單元測試中出錯。實現一個函式來查詢第一個錯誤的版本。你應該儘量減少對呼叫 API 的次數。
示例:
給定 n = 5,並且 version = 4 是第一個錯誤的版本。
呼叫 isBadVersion(3) -> false
呼叫 isBadVersion(5) -> true
呼叫 isBadVersion(4) -> true
所以,4 是第一個錯誤的版本。
### 答案
> 這個題目還是相當簡單的....我拿出來講的原因,是因為我的開發生涯中,真的遇到過這樣一件事。當時我們做一套算薪系統,算薪系統主要複雜在業務上,尤其是銷售的薪資,設計到數百個變數,並且還需要考慮異動(比如說銷售A是團隊經理,但是下調到B團隊成為一名普通銷售,然後就需要根據A異動的時間,來切分他的業績組成。同時,最噁心的是,普通銷售會影響到其團隊經理的薪資,團隊經理又會影響到營業部經理的薪資,一直到最上層,影響到整個大區經理的薪資構成)要知道,當時我司的銷售有近萬名,每個月異動的人就有好幾千,這是非常非常複雜的。然後我們遇到的問題,就是同一個月,有幾十個團隊找上來,說當月薪資計算不正確(放在個人來講,有時候差個幾十塊,別人也是會來找的)最後,在一陣漫無目的的排查之後,我們採用二分的思想,通過切變數,最終切到錯誤的異動邏輯上,進行了修正。
回到本題,我們當然可以一個版本一個版本的進行遍歷,直到找到最終的錯誤版本。但是如果是這樣,還講毛線呢。。。
```
//JAVA
public int firstBadVersion(int n) {
for (int i = 1; i < n; i++) {
if (isBadVersion(i)) {
return i;
}
}
return n;
}
```
我們自然是採用二分的思想,來進行查詢。舉個例子,比如我們版本號對應如下:
![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210308131034574-1484394796.png)
如果中間的mid如果是錯誤版本,那我們就知道 mid 右側都不可能是第一個錯誤的版本。那我們就令 right = mid,把下一次搜尋空間變成[left, mid],然後自然我們很順利查詢到目標。
![](https://img2020.cnblogs.com/blog/1542615/202103/1542615-20210308131045029-305140544.png)
根據分析,程式碼如下:
```
//JAVA
public int firstBadVersion(int n) {
int left = 1;
int right = n;
while (left < right) {
int mid = left + (right - left) / 2;
if (isBadVersion(mid)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
```
額外補充:請大家習慣這種返回left的寫法,保持程式碼簡潔的同時,也簡化了思考過程,何樂而不為呢。
當然,程式碼也可以寫成下面這個樣子(是不是感覺差點意思?)
```
//JAVA
public class Solution extends VersionControl {
public int firstBadVersion(int n) {
int left = 1;
int right = n;
int res = n;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (isBadVersion(mid)) {
res = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return res;
}
}
```
## [劍指 Offer 53 - I. 在排序陣列中查詢數字 I](https://leetcode-cn.com/problems/zai-pai-xu-shu-zu-zhong-cha-zhao-shu-zi-lcof/)
統計一個數字在排序陣列中出現的次數。
示例 1:
```
輸入: nums = [5,7,7,8,8,10], target = 8
輸出: 2
```
示例 2:
```
輸入: nums = [5,7,7,8,8,10], target = 6
輸出: 0
```
### 遍歷答案
```java
class Solution {
public int search(int[] nums, int target) {
// 順序遍歷
int num = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] == target) {
num++;
} else if (nums[i] > target) {
break;
}
}
return num;
}
}
```
### 二分法答案
```
// 可以試試二分法
class Solution {
public int search(int[] nums, int target) {
// 分別二分查詢 targettarget 和 target - 1target−1 的右邊界,將兩結果相減並返回即可。
return helper(nums, target) - helper(nums, target - 1);
}
// helper() 函式旨在查詢數字 tartar 在陣列 numsnums 中的 插入點 ,且若陣列中存在值相同的元素,則插入到這些元素的右邊。
int helper(int[] nums, int tar) {
int i = 0, j = nums.length - 1;
while(i <= j) {
int m = (i + j) / 2;
if(nums[m] <= tar) i = m + 1;
else j = m - 1;
}
return i;