【演算法】二分三步走

Nemo&發表於2021-03-28

據查,醫書有服用響豆的方法,響豆就是槐樹果實在夜裡爆響的,這種豆一棵樹上只有一個,辨認不出來。取這種豆的方法是,在槐樹剛開花時,就用絲網罩在樹上,以防鳥雀啄食。結果成熟後,縫製許多布囊貯存豆莢。夜裡用來當枕頭,沒有聽到聲音,便扔掉。就這麼輪著枕,肯定有一個囊裡有爆響聲。然後把這一囊的豆類又分成幾個小囊裝好,夜裡再枕著聽。聽到響聲再一分為二,裝進囊中枕著聽。這麼分下去到最後只剩下兩顆,再分開枕聽,就找到響豆了。

二分查詢

十個二分九個錯,該演算法被形容 "思路很簡單,細節是魔鬼"。第一個二分查詢演算法於 1946 年出現,然而第一個完全正確的二分查詢演算法實現直到 1962 年才出現。下面的二分查詢,其實是二分查詢裡最簡單的一個模板,在後面的文章系列裡,我將逐步為大家講解二分查詢的其他變形形式。

適用場景

注意,絕大部分「在遞增遞減區間中搜尋目標值」的問題,都可以轉化為二分查詢問題。

即 適用於在有序集合中搜尋特定值

關鍵詞:有序

基本概念

二分查詢是電腦科學中最基本、最有用的演算法之一。它描述了在有序集合中搜尋特定值的過程。一般二分查詢由以下幾個術語構成:

  • 目標 Target —— 你要查詢的值

  • 索引 Index —— 你要查詢的當前位置

  • 左、右指示符 Left,Right —— 我們用來維持查詢空間的指標

  • 中間指示符 Mid —— 我們用來應用條件來確定我們應該向左查詢還是向右查詢的索引

在最簡單的形式中,二分查詢對具有指定左索引和右索引的連續序列進行操作。我們也稱之為查詢空間。二分查詢維護查詢空間的左、右和中間指示符,並比較查詢目標;如果條件不滿足或值不相等,則清除目標不可能存在的那一半,並在剩下的一半上繼續查詢,直到成功為止。

【演算法】二分三步走

舉例說明:比如你需要找1-100中的一個數字,你的目標是用最少的次數猜到這個數字。你每次猜測後,我會說大了或者小了。而你只需要每次猜測中間的數字,就可以將餘下的數字排除一半。

【演算法】二分三步走

不管我心裡想的數字如何,你在7次之內都能猜到,這就是一個典型的二分查詢。每次篩選掉一半資料,所以我們也稱之為 折半查詢。一般而言,對於包含n個元素的列表,用二分查詢最多需要log2n步。

【演算法】二分三步走

當然,一般題目不太可能給你一個如此現成的題型,讓你上手就可以使用二分,所以我們需要思考,如何來構造一個成功的二分查詢。大部分的二分查詢,基本都由以下三步組成:

  • 預處理過程(大部分場景就是對未排序的集合進行排序)

  • 二分查詢過程(找到合適的迴圈條件,每一次將查詢空間一分為二)

  • 後處理過程(在剩餘的空間中,找到合適的目標值)

二分三步走

一般參考條件:
總結一下一般實現的幾個條件:

  • 初始條件: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;
}

遞迴實現

遞迴實現:這裡二分法的遞迴,其實可以叫做分治法

// 明確分解策略:大問題=從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. 愛吃香蕉的珂珂

珂珂喜歡吃香蕉。這裡有 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 的平方根

實現 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. 第一個錯誤的版本

你是產品經理,目前正在帶領一個團隊開發新的產品。不幸的是,你的產品的最新版本沒有通過質量檢測。由於每個版本都是基於之前的版本開發的,所以錯誤的版本之後的所有版本都是錯的。

假設你有 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;
}

我們自然是採用二分的思想,來進行查詢。舉個例子,比如我們版本號對應如下:
【演算法】二分三步走

如果中間的mid如果是錯誤版本,那我們就知道 mid 右側都不可能是第一個錯誤的版本。那我們就令 right = mid,把下一次搜尋空間變成[left, mid],然後自然我們很順利查詢到目標。
【演算法】二分三步走

根據分析,程式碼如下:

//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

統計一個數字在排序陣列中出現的次數。

示例 1:

輸入: nums = [5,7,7,8,8,10], target = 8
輸出: 2

示例 2:

輸入: nums = [5,7,7,8,8,10], target = 6
輸出: 0

遍歷答案

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;
    }
}

相關文章