1. 程式人生 > >【演算法】二分三步走

【演算法】二分三步走

> 據查,醫書有服用響豆的方法,響豆就是槐樹果實在夜裡爆響的,這種豆一棵樹上只有一個,辨認不出來。取這種豆的方法是,在槐樹剛開花時,就用絲網罩在樹上,以防鳥雀啄食。結果成熟後,縫製許多布囊貯存豆莢。夜裡用來當枕頭,沒有聽到聲音,便扔掉。就這麼輪著枕,肯定有一個囊裡有爆響聲。然後把這一囊的豆類又分成幾個小囊裝好,夜裡再枕著聽。聽到響聲再一分為二,裝進囊中枕著聽。這麼分下去到最後只剩下兩顆,再分開枕聽,就找到響豆了。 # 二分查詢 > 十個二分九個錯,該演算法被形容 "思路很簡單,細節是魔鬼"。第一個二分查詢演算法於 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;