基礎二分查詢總結

dotJunz發表於2023-01-17

前言

由於我在學習二分查詢的過程中處於會了忘,忘了複習的狀態,因此總結一套適合自己記憶的模板。建議先看參考資料\(^{[1,2,3]}\),理解二分查詢各種細節的由來。

  1. 二分查詢又死迴圈了?【基礎演算法精講 04】
  2. 手把手帶你撕出正確的二分法 | 二分查詢法 | 二分搜尋法 | LeetCode:704. 二分查詢
  3. 我寫了首詩,讓你閉著眼睛也能寫對二分搜尋

左閉右開的形式:迴圈條件一定是 while(left < right)。由於左閉,所以 left = mid + 1;。由於右開,所以 right = mid;。最後迴圈結束時,left == right

左閉右閉的形式:迴圈條件一定是 while(left <= right)。由於左閉,所以 left = mid + 1;。由於右閉,所以 right = mid - 1;。最後迴圈結束時,left == right + 1

確保上面段話能理解,為了方便記憶,優先採用左閉右開的形式。因為迴圈結束時,left == right,我覺得簡單一點。

基礎的二分查詢

力扣連結:704. 二分查詢

給定一個 n 個元素有序的(升序)整型陣列 nums 和一個目標值 target ,寫一個函式搜尋 nums 中的 target,如果目標值存在返回下標,否則返回 -1。

示例 1:

輸入: nums = [-1,0,3,5,9,12], target = 9
輸出: 4
解釋: 9 出現在 nums 中並且下標為 4

示例 2:

輸入: nums = [-1,0,3,5,9,12], target = 2
輸出: -1
解釋: 2 不存在 nums 中因此返回 -1

左閉右開程式碼實現

class Solution {
    public int search(int[] nums, int target) {
        int left = 0, right = nums.length;  // 定義target在左閉右開的區間裡,即:[left, right)
        while(left < right){  // 因為left == right的時候,在[left, right)是空區間,所以使用小於號
            int mid = left + (right - left) / 2;
            if(nums[mid] < target){
                left = mid + 1;  // target 在右區間,在[mid + 1, right)中
            }else if(nums[mid] > target){  
                right = mid;  // target 在左區間,在[left, mid)中
            }else{  // nums[mid] == target
                return mid;  // 陣列中找到目標值,直接返回下標
            }
        }
        return -1;  // 未找到目標值
    }
}

左閉右閉程式碼實現

class Solution {
    public int search(int[] nums, int target) {
        int left = 0, right = nums.length - 1;  // // 定義target在左閉右開的區間裡,即:[left, right)
        while(left <= right){  // 因為left == right的時候,在[left, right]還有一個元素,所以使用小於等於號
            int mid = left + (right - left) / 2;
            if(nums[mid] < target){
                left = mid + 1;  // target 在右區間,在[mid + 1, right]中
            }else if(nums[mid] > target){
                right = mid - 1;   // target 在左區間,在[left, mid - 1]中
            }else{
                return mid;  // // 陣列中找到目標值,直接返回下標
            }
        }

        return -1;  // 未找到目標值
    }
}

lower_bound 和 upper_bound

lower_bound

lower_bound 含義:

  • 返回第一個大於等於 target 的位置,如果所有元素都小於 target,則返回陣列的長度。
  • 在不改變原有排序的前提下,找到第一個可以插入 target 的位置。

左閉右開程式碼實現

int lower_bound(int[] nums, int target){
    int left = 0, right = nums.length;
    while(left < right){  // 定義target在左閉右開的區間裡,即:[left, right)
        int mid = left + (right - left) / 2;
        if(nums[mid] < target){
            left = mid + 1;  // target 在右區間,在[mid + 1, right)中
        }else{
            right = mid;  // target 在左區間,在[left, mid)中
        }
    }
    return left;  // 此時 left == right,返回 right 也可以
}

對於 nums[mid] == target 的情況:
此時找到一個目標值 target,然而左邊可能還有 target。由於要找的是第一個大於等於 target 的位置,所以應該向左區間繼續查詢,因此與 else 分支合併。

左閉右閉程式碼實現

int lower_bound(int[] nums, int target){
    int left = 0, right = nums.length - 1;
    while(left <= right){  // 定義target在左閉右閉的區間裡,即:[left, right]
        int mid = left + (right - left) / 2;
        if(nums[mid] < target){
            left = mid + 1;  // target 在右區間,在[mid + 1, right]中
        }else{
            right = mid - 1;  // target 在左區間,在[left, mid - 1]中
        }
    }
    return left;  // 此時 left == right + 1
}

這裡和左閉右開形式不同,左閉右開 left == right,不用糾結。這裡 left == right + 1,有時候搞不清楚是返回 leftright,還是 left - 1......

這裡有個方便記憶的小技巧,假設 leftright 都指向 target,再看下一步的結果。

比如下面這個例子,target == 3lower_bound 要求的結果就是紅色的3。

基礎二分查詢總結

此時 left == right,根據程式碼,應該執行 right = mid - 1; 這條語句,執行之後,如下圖所示。

基礎二分查詢總結

此時,left == right + 1,迴圈結束,結果應該為 left,所以 return left;

upper_bound

upper_bound 含義:

  • 返回第一個大於 target 的位置,如果所有元素都小於等於 target,則返回陣列的長度。
  • 在不改變原有排序的前提下,找到最後一個可以插入 target 的位置。

左閉右開程式碼實現

int upper_bound(int[] nums, int target){
    int left = 0, right = nums.length;
    while(left < right){  // 定義target在左閉右開的區間裡,即:[left, right)
        int mid = left + (right - left) / 2;
        if(nums[mid] <= target){
            left = mid + 1;  // target 在右區間,在[mid + 1, right)中
        }else{
            right = mid;  // target 在左區間,在[left, mid)中
        }
    }
    return left;  // 此時 left == right,返回 right 也可以
}

對於 nums[mid] == target 的情況:
此時找到一個目標值 target。由於要找的是第一個大於 target 的位置,所以應該向右區間繼續查詢,所以與 if 分支合併。

左閉右閉程式碼實現

int upper_bound(int[] nums, int target){
    int left = 0, right = nums.length - 1;
    while(left <= right){  // 定義target在左閉右閉的區間裡,即:[left, right]
        int mid = left + (right - left) / 2;
        if(nums[mid] <= target){
            left = mid + 1;  // target 在右區間,在[mid + 1, right]中
        }else{
            right = mid - 1;  // target 在左區間,在[left, mid - 1]中
        }
    }
    return left;  // 此時 left == right + 1
}

lower_bound 類似,說一下記憶 return left; 的技巧。

假設 leftright 都指向 target,再看下一步的結果。

比如下面這個例子,target == 3upper_bound 要求的結果就是紅色的4。

基礎二分查詢總結

此時 left == right,根據程式碼,應該執行 left = mid + 1; 這條語句,執行之後,如下圖所示。

基礎二分查詢總結

此時,left == right + 1,迴圈結束,結果應該為 left,所以 return left;

可以看到,在左閉右閉的情況下,lower_boundupper_bound 都返回 left

lower_bound 和 upper_bound 的聯絡

可以發現,這兩個函式只有 if 判斷為相等的情況不同[6]。為方便記憶,在 if else 只有二分支的情況下,即把相等的情況歸為 if 分支或 else 分支(不是 if ... else if ... else ... 三分支的情況)。

此時,lower_boundupper_bound 可以透過在 if 分支判斷語句中增刪 = 互相轉化。

另外,upper_bound 可以直接複用 lower_bound
對於非遞減整數陣列,\(>x\) 等價於 \(\geq x+1\)[1]upper_bound 求第一個大於 target 的位置,就等價於 lower_bound 求第一個大於等於 target + 1 的位置。

因此,upper_bound 的另一種寫法

int upper_bound(int[] nums, int target){
    return lower_bound(nums, target + 1);
}

所以,只要記 lower_bound 的程式碼就好了。

力扣相關題目

35. 搜尋插入位置

力扣連結:35. 搜尋插入位置

給定一個排序陣列和一個目標值,在陣列中找到目標值,並返回其索引。如果目標值不存在於陣列中,返回它將會被按順序插入的位置。

你可以假設陣列中無重複元素。

示例 1:

輸入: nums = [1,3,5,6], target = 5
輸出: 2

示例 2:

輸入: nums = [1,3,5,6], target = 2
輸出: 1

示例 3:

輸入: nums = [1,3,5,6], target = 7
輸出: 4

解法一
直接應用 lower_bound

class Solution {
    public int searchInsert(int[] nums, int target) {
        return lower_bound(nums, target);
    }

    int lower_bound(int[] nums, int target){
        int left = 0, right = nums.length;
        while(left < right){  // 定義target在左閉右開的區間裡,即:[left, right)
            int mid = left + (right - left) / 2;
            if(nums[mid] < target){
                left = mid + 1;  // target 在右區間,在[mid + 1, right)中
            }else{
                right = mid;  // target 在左區間,在[left, mid)中
            }
        }
        return left;  // 此時 left == right,返回 right 也可以
    }
}

解法二
透過 upper_bound 轉化

class Solution {
    public int searchInsert(int[] nums, int target) {
        int pos = upper_bound(nums, target);
        if(pos == 0 || nums[pos - 1] != target) return pos;  // target 不存在
        return pos - 1;  // target 存在,前一個位置就是 target
    }

    int upper_bound(int[] nums, int target){
        int left = 0, right = nums.length;
        while(left < right){  // 定義target在左閉右開的區間裡,即:[left, right)
            int mid = left + (right - left) / 2;
            if(nums[mid] <= target){
                left = mid + 1;  // target 在右區間,在[mid + 1, right)中
            }else{
                right = mid;  // target 在左區間,在[left, mid)中
            }
        }
        return left;  // 此時 left == right,返回 right 也可以
    }
}

直接記解法一就行了,解法二隻是證明 upper_bound 也可以做,因為 lower_boundupper_bound 本來就有轉化關係。

34. 在排序陣列中查詢元素的第一個和最後一個位置

力扣連結:34. 在排序陣列中查詢元素的第一個和最後一個位置

給定一個按照升序排列的整數陣列 nums,和一個目標值 target。找出給定目標值在陣列中的開始位置和結束位置。

如果陣列中不存在目標值 target,返回 [-1, -1]。

示例 1:

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

示例 2:

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

示例 3:

輸入:nums = [], target = 0
輸出:[-1,-1]

思路
第一個位置:就是 lower_bound 函式的含義。
最後一個位置:如果 target 存在的話,第一個大於 target 的位置減一就是 target 的最後一個位置。

程式碼實現

class Solution {
    public int[] searchRange(int[] nums, int target) {
        int start = lower_bound(nums, target);
        if(start == nums.length || nums[start] != target) return new int[]{-1, -1};  // target 不存在
        int end = upper_bound(nums, target) - 1;
        return new int[]{start, end};
    }

    int lower_bound(int[] nums, int target){
        int left = 0, right = nums.length;
        while(left < right){  // 定義target在左閉右開的區間裡,即:[left, right)
            int mid = left + (right - left) / 2;
            if(nums[mid] < target){
                left = mid + 1;  // target 在右區間,在[mid + 1, right)中
            }else{
                right = mid;  // target 在左區間,在[left, mid)中
            }
        }
        return left;  // 此時 left == right,返回 right 也可以
    }

    int upper_bound(int[] nums, int target){
        return lower_bound(nums, target + 1);
    }
}

69. x 的平方根

力扣連結:69. x 的平方根

給你一個非負整數 x ,計算並返回 x 的 算術平方根

由於返回型別是整數,結果只保留 整數部分 ,小數部分將被 捨去

注意: 不允許使用任何內建指數函式和算符,例如 pow(x, 0.5) 或者 x ** 0.5 。

示例 1:

輸入:x = 4
輸出:2

示例 2:

輸入:x = 8
輸出:2
解釋:8 的算術平方根是 2.82842..., 由於返回型別是整數,小數部分將被捨去。

思路
這其實就是一個 upper_bound 問題,對於 x = 8,二分割槽間應該在 [0,8],我們要在這些數的平方中找到第一個大於8的數,它左邊的那個數的平方根就是答案。如下圖所示,找到9(是第一個大於8的數),左邊4的平方根2就是答案。

基礎二分查詢總結

程式碼
直接應用 upper_bound,下面的程式碼會超出記憶體限制,但是方便我們理解它和 upper_bound 的關係。

class Solution {
    public int mySqrt(int x) {
        int[] nums = new int[x + 1];
        for(int i = 0; i <= x; i++) nums[i] = (i + 1) * (i + 1);
        int res = upper_bound(nums, x);
        return res;
    }

    int lower_bound(int[] nums, int target){
        int left = 0, right = nums.length;
        while(left < right){  // 定義target在左閉右開的區間裡,即:[left, right)
            int mid = left + (right - left) / 2;
            if(nums[mid] < target){
                left = mid + 1;  // target 在右區間,在[mid + 1, right)中
            }else{
                right = mid;  // target 在左區間,在[left, mid)中
            }
        }
        return left;  // 此時 left == right,返回 right 也可以
    }

    int upper_bound(int[] nums, int target){
        return lower_bound(nums, target + 1);
    }
}

左閉右開程式碼
由於 x + 1 可能溢位,所以要用 long

class Solution {
    public int mySqrt(int x) {
        long left = 0, right = (long) x + 1;  //左閉右開,所以是[0,x+1)
        while(left < right){
            long mid = left + (right - left) / 2;
            if(f(mid) <= x){
                left = mid + 1;
            }else{
                right = mid;
            }
        }

        return (int)(left - 1);
    }

    long f(long x){  // 計算x的平方
        return (long) x * x;
    }
}

左閉右閉程式碼

class Solution {
    public int mySqrt(int x) {
        int left = 0, right = x;  //左閉右閉,所以是[0,x]
        while(left <= right){
            int mid = left + (right - left) / 2;
            if(f(mid) <= x){
                left = mid + 1;
            }else{
                right = mid - 1;
            }
        }

        return left - 1;
    }

    long f(int x){  // 計算x的平方
        return (long) x * x;
    }
}

367. 有效的完全平方數

力扣連結:367. 有效的完全平方數

給你一個正整數 num 。如果 num 是一個完全平方數,則返回 true ,否則返回 false 。

完全平方數 是一個可以寫成某個整數的平方的整數。換句話說,它可以寫成某個整數和自身的乘積。

不能使用任何內建的庫函式,如  sqrt 。

示例 1:

輸入:num = 16
輸出:true
解釋:返回 true ,因為 4 * 4 = 16 且 4 是一個整數。

示例 2:

輸入:num = 14
輸出:false
解釋:返回 false ,因為 3.742 * 3.742 = 14 但 3.742 不是一個整數。

左閉右開程式碼
由於 x + 1 可能溢位,所以要用 long

class Solution {
    public boolean isPerfectSquare(int num) {
        long left = 1, right = num + 1;  //左閉右開,所以是[0,x+1)
        while(left < right){
            long mid = left + (right - left) / 2;
            long square = mid * mid;
            if(square < num){
                left = mid + 1;
            }else if(square > num){
                right = mid;
            }else{
                return true;
            }
        }

        return false;
    }
}

左閉右閉程式碼

class Solution {
    public boolean isPerfectSquare(int num) {
        int left = 1, right = num;  //左閉右閉,所以是[0,x]
        while(left <= right){
            int mid = left + (right - left) / 2;
            long square = (long) mid * mid;
            if(square < num){
                left = mid + 1;
            }else if(square > num){
                right = mid - 1;
            }else{
                return true;
            }
        }

        return false;
    }
}

二分查詢進階

以上是基礎的二分查詢型別,對於進階的題目,把問題轉化成二分查詢是一個難點。

參考資料

  1. 二分查詢又死迴圈了?【基礎演算法精講 04】
  2. 手把手帶你撕出正確的二分法 | 二分查詢法 | 二分搜尋法 | LeetCode:704. 二分查詢
  3. 我寫了首詩,讓你閉著眼睛也能寫對二分搜尋
  4. C++中的upper_bound和lower_bound區別
  5. 34. 在排序陣列中查詢元素的第一個和最後一個位置
  6. 用Java實現C++::std中的upper_bound和lower_bound

以上是我個人的學習心得,能力有限,如有錯誤和建議,懇請批評指正!

相關文章