聊一聊二分查詢法

王子發表於2020-09-15

前言

二分查詢法是什麼,難道是我們剛入行時候寫的搜尋演算法嗎?

還記得我們剛入行,接觸演算法的時候,一般都會從氣泡排序、二分查詢開始入手演算法,那小夥伴們會不會覺得這個演算法太容易了,沒有必要用一篇文章來講解呢。

如果你有這樣的疑問,那麼王子問大家幾個問題,看大家能否很容易的就回答的上。

你清楚二分查詢法一般用於哪些查詢場景嗎?

你清楚迴圈終止條件嗎?

什麼時候使用<=,什麼時候使用<這些你都清楚嗎?

本文就與小夥伴們一起探討幾個最常用的二分查詢場景:尋找一個數、尋找左側邊界、尋找右側邊界。

並對裡面的實現細節做一個仔細的分析。

尋找一個數(最基本的二分查詢法)

這個場景是最簡單的場景,也是大家最熟悉的入門演算法,即在陣列中搜尋一個指定的數字,如果存在返回索引,如果不存在返回-1.

直接看程式碼:

int binarySearch(int[] nums, int target) {
    int left = 0; 
    int right = nums.length - 1; // 注意

    while(left <= right) { // 注意
        int mid = left+(right - left) / 2;
        if(nums[mid] == target)
            return mid; 
        else if (nums[mid] < target)
            left = mid + 1; // 注意
        else if (nums[mid] > target)
            right = mid - 1; // 注意
        }
    return -1;
}

 針對於上邊的程式碼,我們探討幾個問題。

 

1.為什麼while迴圈中用的是<=而不是<?

因為我們初始化的right=nums.length-1,而不是nums.length。這兩者的區別就是前者表示的是閉區間查詢[left,right],後者表示的是開區間查詢[left,right)。

因為我們選擇的是[left,right]閉區間查詢,所以while中的條件就是left<=right.

那麼什麼時候應該退出迴圈呢?當然是找到目標值了。

        if(nums[mid] == target)
            return mid; 

如果沒有找到,就會通過while迴圈條件終止,並返回-1,而這個while迴圈中的條件,用大白話來講就是代表搜尋的區間是空的

while(left <= right)的終止條件是 left == right + 1,寫成區間的形式就是 [right + 1, right],這種情況已經把陣列中所有該尋找的值尋找過了,所以直接返回 -1 是沒有問題的。

while(left < right)的終止條件是 left == right,寫成區間的形式就是 [right, right],這時候搜尋區間非空,還有一個數 right,但此時 while 迴圈終止了。也就是說索引 right被漏掉了,沒有進行判斷,如果這時候直接返回 -1 就可能出現錯誤。

這種錯誤我們只要在下邊做些改動就可以解決。

//...
while(left < right) {
    // ...
}
return nums[left] == target ? left : -1;

 

2.為什麼 left = mid + 1,right = mid - 1?

其實只要我們明白了剛才討論的搜尋區間原則,理解這個並不難。

我們的搜尋區間是[left,right],那麼當我們發現mid不是目標值的時候,應該怎麼繼續查詢呢?當然是去查詢[left,mid-1]或者是[mid+1,right]了,這個應該好理解吧。

所以如果我們nums[mid]<target的時候,代表要找的值在[mid+1,right]這一區間中,所以就有了left=mid+1。right=mid-1同理。

 

3.這樣的寫法是否可以解決所有二分查詢的問題?

答案是不能的,如果給我們的nums=[1,3,3,3,4],target=3,由於是在中間二分查詢,那返回的結果就是2。

但是如果我們要得到左側邊界和右側邊界,這種寫法就不能實現了。

接下來我們分別說明左側邊界和右側邊界的寫法。

 

左側邊界問題

首先我們要明白什麼是左側邊界。

其實左側邊界可以理解成,這個陣列中所有小於目標值的數字有幾個,還是拿nums=[1,3,3,3,4],target=3來舉例,它的左側邊界就是1,白話解釋就是所有小於3的數字有1個。如果target=5,它的左側邊界就是5,也就是陣列的長度,白話解釋就是所有小於5的數字有5個。

好了,我們直接看程式碼:

int left_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0;
    int right = nums.length; // 注意

    while (left < right) { // 注意
        int mid = left+( right - left ) / 2;
        if (nums[mid] == target) {
            right = mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid; // 注意
        }
    }
    return left;
}

 1.為什麼沒有返回 -1 的操作?如果 nums 中不存在 target 這個值,怎麼辦?

上邊我們給大家白話解釋了什麼是左側邊界,那麼什麼時候會返回-1呢?就是沒有找到目標數字的時候才會返回-1.

那麼什麼時候算是沒有找到這個數字呢,一共有兩種情況,一種情況是目標數字大於陣列中的每個數字,這種情況下,left=right=nums.length。另一種情況是目標數字小於陣列中的每個數字,也就是left=right=0,而且nums[0]!=target。

明白了上邊的道理,我們就可以更改程式碼如下:

while (left < right) {
    //...
}
// target 比所有數都大
if (left == nums.length) return -1;
// 類似之前演算法的處理方式
return nums[left] == target ? left : -1;

2為什麼 left = mid + 1,right = mid ?

這個問題其實很好解釋,我們之前的區間是[left,right]閉區間,而我們現在的區間是[left,right),所以當我們發現nums[mid]<target的時候,代表目標值在[left,mid)中,所以right=mid,而nums[mid]>target的時候,代表目標值在[mid+1,right)中,所以left=mid+1.

3.如何解釋nums[mid]==target的時候,right=mid

其實這個就是解決左側邊界的主要判斷條件。我們找到target的時候不是直接返回索引的,而是繼續縮小範圍並向左收縮的,也就是縮小[left,mid)的範圍,所以就有了right=mid的寫法。其實明白了這裡,右側邊界也是同理,向右收縮搜尋範圍即可,寫成left=mid+1就可以了。

 

右側邊界

有了左側邊界的經驗,我們再來看右側邊界的問題就很容易了,直接看程式碼:

int right_bound(int[] nums, int target) {
    if (nums.length == 0) return -1;
    int left = 0, right = nums.length;

    while (left < right) {
        int mid = left+(right-left) / 2;
        if (nums[mid] == target) {
            left = mid + 1; // 注意
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid;
        }
    }
    if (left == nums.length) return -1;
    return nums[left-1] == target ? (left-1) : -1;// 注意
}

程式碼中已經標註了與左側邊界不同的地方。

1 left=mid+1剛才我們已經解釋過了,不再說明。

2 為什麼返回的值是left-1,而不是left

其實這個問題也好解釋,因為本來我們要返回的值是mid,而在左側邊界問題中,最終結果的時候left=mid=right,所以返回left或者right都可以

而在右側邊界問題中,我們的left=mid+1=right,所以mid=left-1。

所以我們的返回值就是left-1了。

 

擴充套件

為了讓大家對二分查詢法印象更深刻,王子給大家分享一道擴充套件的題目演算法,求x的平方根,題目如下:

 

 

 這道題目其實也是可以用二分查詢法做出來的。

求x的平方根,我們可以把搜素區間粗略的設定為[0,x],在這個區間中進行二分查詢鎖定目標就可以了。

那麼什麼時候算是找到了目標呢,肯定是mid*mid==target,或者是mid*mid<target,而且(mid+1)*(mid+1)>target,這兩種情況下mid就是我們想要的結果。

有了思路,我們來看程式碼:

    public int mySqrt(int x) {
        // result用於儲存結果
        int left = 0, right = x, result = -1;
        while (left <= right) {
            int mid = left + (right - left) / 2;
            // 這裡一定要轉換成long型別,否者如果數字過大會變成負數,影響結果
            if ((long) mid * mid <= x) {
                result = mid;
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return result;
    }

這裡我們引入中間變數result來儲存想要的結果。

值得注意的就是mid*mid一定要強轉成long,因為如果數字過大超過int的範圍會變成負數,影響最終答案。

 

總結

至此,幾個最常用的二分查詢場景:尋找一個數、尋找左側邊界、尋找右側邊界,王子與大家就討論完畢了。

通過本文,小夥伴們應該對於二分查詢法的細節有了更深一步的瞭解。就算遇到二分查詢法的變形,也可以運用這種解題思維獨立思考並解決問題了。

看完本文,大家可以去LeeCode的二分查詢法專題中看一看,會很容易看懂其中的原理。

小夥伴們去試一試吧,入果覺得本篇文章對你有所幫助,記得關注哦。

 

往期文章推薦:

中介軟體專輯:

什麼是訊息中介軟體?主要作用是什麼?

常見的訊息中介軟體有哪些?你們是怎麼進行技術選型的?

你懂RocketMQ 的架構原理嗎?

聊一聊RocketMQ的註冊中心NameServer

Broker的主從架構是怎麼實現的?

RocketMQ生產部署架構如何設計

RabbitMQ和Kafka的高可用叢集原理

RocketMQ的傳送模式和消費模式

演算法專輯:

和同事談談Flood Fill 演算法

詳解股票買賣演算法的最優解(一)

詳解股票買賣演算法的最優解(二)

相關文章