二分查詢常見套路與分析

Gevin發表於2021-03-19

enter image description here

注:

  1. 二分查詢的思路很簡單,但具體寫起來,很容易在細節上搞錯,本文目標是總結常見的二分查詢寫法細節的套路

  2. 本文程式碼Java寫的,為了標明二分法程式碼中每種情況的業務邏輯和可讀性,大部分程式碼沒有做邏輯合併和程式碼最佳化

  3. 本文預設nums陣列是按升序排列的

  4. 隨著對二分查詢理解的深入,本文內容不定期更新,也會補充演算法題來做練手

  5. 本文同步發表於我的公眾號(年更)

1. 思路和程式碼框架

這就是所謂簡單的部分,二分查詢無非是對於一個排好序的陣列,透過檢查陣列中間位置元素值與target的大小,縮小陣列的長度範圍,直到找到target,或達到迴圈退出條件後,做近一步判斷並返回結果。

其程式碼框架如下:

public int binarySearch(int[] nums, int target) {
    int left = ..., right = ...;
    while(...) {
        int mid = (left + right) / 2;
        if(nums[mid] == target) {
            ...
        } else if (nums[mid] > target) {
            right = ...;
            ...
        } else if (nums[mid] < target) {
            left = ...;
            ...
        }
    }

}

二分查詢容易出錯的地方,是上述程式碼中省略號處應該怎麼寫才合適,比如到底是<=還是<,是left = mid 還是left = mid + 1,是right = mid還是right = mid - 1等,正確的寫法是這幾個語句恰當的組合,否則都沒法透過全部用例測試。

本文將從最基本的在一個有序陣列中找到target出發,說明幾種常見的正確組合寫法。

2. 如何在一個有序陣列中找到target

2.1 經典寫法

最經典的寫法如下:

public int binarySearch(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while(left <= right) {
        int mid = (left + right) / 2;
        if(nums[mid] == target) {
            return mid;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        }
    }
    return -1;
}

經典的寫法由於大家都熟悉,所以會忽視條件細節,所以這裡再對細節做一個說明:

  1. 查詢的區間範圍是[0, nums.length - 1],左右全閉
  2. 每次縮小範圍時,由於mid已經檢查過了,所以縮小範圍時可以把mid去掉,且縮小後的範圍,不論是[left, mid - 1]還是[mid + 1, right],也都是左右全閉
  3. 退出條件是left <= right,即退出時,left = right + 1,且每個元素都已經檢查過一遍

FAQ:

1. 縮小範圍時,寫成left = mid 或 right = mid,有沒有問題?

不行,可能會出現死迴圈的bug。

二分查詢,如果一直沒找到target,那麼最後範圍會縮小到2個元素,即[left, right],且num[left] < num[right]。

<1> 對於left = mid 會出現bug的情況是,如果target就是nums的最後一個元素,那麼範圍縮小到[left, right]後,繼續往下執行程式碼的結果為:

mid = (left + right) / 2 = left, 
nums[mid] = nums[left] < target, 
left = mid = left

所以如果不是left = mid + 1的話,範圍會定格在[left, right]兩個元素上,且在迴圈到條件範圍內,會一直死迴圈下去;

<2> 對於right = mid,可能會出現bug的情況為,如果target不在陣列中,且target 在nums陣列區間範圍內,那麼程式碼還是會執行到只剩left, right兩個元素,此時nums[left] < target < nums[right],這樣還會有下一輪迴圈到執行,即:

left = mid + 1 = right,
mid = (left + right) / 2 = right,
nums[mid] = nums[right] > target,
right = mid = right

所以如果不是right = mid - 1,也會死迴圈下去(迴圈條件:while(left <= right))

2. 若迴圈退出條件寫成 left < right,有什麼問題?

如果條件為left < right,則當left = right時就退出了,這樣就少查了一個元素,如果這個元素就是target的話,出bug了

另外,明白了這一點,其實迴圈退出條件寫成left < right也沒關係,此時漏掉的元素就是left(也是right),再加幾行程式碼對這個元素單獨處理就好了,如:

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

3. 經典寫法有什麼侷限?

如果陣列中有重複元素,且target就是重複的元素,該寫法找到的只是其中某個,如果我們需要明確找到第一個或最後一個的話,就搞不定了

2.2 更巧妙的寫法

注:這裡所謂“更巧妙”的寫法,只是為了說明這是另一種思路,在本節的經典場景下,與經典寫法本質區別不大

public int binarySearch(int[] nums, int target) {
    int left = 0, right = nums.length;       // #1
    while(left < right) {                   // #2
        int mid = (left + right) / 2;
        if(nums[mid] == target) {
            return mid;
        } else if (nums[mid] > target) {
            right = mid;                   // #3
        } else if (nums[mid] < target) {
            left = mid + 1;
        }
    }
    return -1;
}

說明:

  1. 這段程式碼與上一節有3個區別,分別在 #1#2#3
  2. 這裡right = nums.length 而非right = nums.length - 1,且迴圈退出條件是left < right,意味著這裡的搜尋區間為[0, nums.length),左閉右開,而後面right = mid,同樣是左閉右開,既沒把已經查過的mid包含到新範圍中,也保證了新範圍沒有漏掉未查的元素
  3. 迴圈退出條件是left < right,即left == right時就退出了,由於right是“右開”的位置,此時其實已經到達右邊界了,這裡如果寫的是left <= right,則可能程式碼執行到迴圈條件邊界時,陣列已經越界了,這裡不用死記硬背
  4. 由於“右開”,每輪迴圈時,都不會檢索nums[right]的資料,而nums[right]其實一直是被本來迴圈之前的迴圈處理的

問題

1. 能不能寫right = mid - 1

不建議,因為“右開”,若right = mid - 1,會丟失對mid - 1元素的檢查,這樣還得對這個元素單獨檢查處理

2. 能不能寫left = mid

不能,因為“左閉”,直覺上就不合適,而且如果出現target比nums陣列最後一個元素還大的情況,會出現上一節分析過的bug

2.3 兩種方法的對比總結

  1. 迴圈條件 left <= right 意味著左右全閉,其搜尋範圍始終是[left, right],初始值分別為left = 0, right = nums.length - 1,搜尋到最後只剩left, right兩個元素時,會進行最後一輪搜尋(left==right)確認最後一個元素nums[right]是否為target;
  2. 迴圈條件 left < right 意味著左閉右開,其搜尋範圍始終是[left, right),初始值分別為left = 0, right = nums.length,搜尋到最後只剩left, right兩個元素時,其實只有left一個元素未檢查了,此時執行mid=(left + right)/2後,nums[mid]=nums[left]完成對left的檢查,迴圈就結束
  3. 迴圈程式碼塊內二分的邏輯中,必須是left = mid + 1,否則搜尋範圍縮小到只有left、right兩個元素時,可能會進入死迴圈
  4. 兩個迴圈條件都可以,關鍵是下面二分程式碼中匹配恰當的邏輯,左右全閉時,必須 right = mid - 1;左閉右開時,最好right = mid,避免額外的處理邏輯

3. 找陣列中重複數字target第一個?

3.1 基於經典寫法

在經典的寫法中,當nums[mid] == target時,就退出了,此時無非判斷mid位置是否為target的第一個,修改起來也很簡單,只要再判斷nums[mid - 1] == target就好了,如果nums[mid - 1] < target,則mid就是第一個,否則向左收縮搜尋範圍,即right = mid - 1 即可。程式碼如下:

public int binarySearchFirst(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = (left + right) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else {
            if (mid == 0 || nums[mid - 1] < target) {
                return mid;
            } else {
                right = mid - 1;
            }
        }
    }
    return -1;
}

3.2 基於更巧妙的寫法

2.2節的演算法,由於nums[mid] == target時,執行 right = mid,且迴圈終止條件時left == right,所以如果target在nums陣列中,按該演算法找到的target,就是重複數字的第一個。因此,我們只需額外處理一下target不在nums數字中的情況,這個分三種情況考慮:

  1. target大於nums中最後一個元素,此時target 不在陣列中,二分查詢結束後,left = right = nums.length
  2. target在nums的區間範圍之內,此時target在陣列中,二分查詢結束後,nums[left] != target
  3. target小雨nums中第一個元素,此時target不在陣列中,二分查詢結束後,left = 0,且nums[left] != target,可以與上一種情況合併

因此,最終演算法為:

public int binarySearchFirst(int[] nums, int target) {
    int left = 0, right = nums.length;
    while (left < right) {
        int mid = (left + right) / 2;
        if (nums[mid] == target) {
            right = mid;
        } else if (nums[mid] > target) {
            right = mid;
        } else if (nums[mid] < target) {
            left = mid + 1;
        }
    }
    // 迴圈結束後,按照上面分析的left的情況,返回恰當結果
    if (left == nums.length) {
        return -1;
    } else if (nums[left] != target) {
        return -1;
    } else {
        return left;
    }

    // 把上面壞味道的程式碼寫到一行中更好
    // return left == nums.length ? -1 : (nums[left] == target ? left : -1 );
}

3.3 問題探討

3.2的思路,能否借鑑到經典寫法裡去?

不建議,思路不太順,坑填不上就是bug。

3.2 的思路,是基於左閉右開區間範圍的,如果借鑑過去,在左右全閉的區間範圍內,迴圈條件得寫成while(left <= right),我們第2章節也探討了在經典寫法裡right = mid可能存在的bug,所以right = mid - 1也不能改,只能當nums[mid] == target時,把收縮範圍也改成right = mid - 1,但這時,收縮的範圍中不能保證還有target,這已經與3.2的思路不太一致了。不過可以繼續填坑:

  1. 如果收縮範圍中不包含target,我們在幾輪查詢並跳出後,此時left = right + 1,這時,如果nums[left] == target,則left就是第一個,另外,對於target不在nums區間範圍內的情況,也要單獨處理一下
  2. 如果收縮範圍中包含target,則經過幾輪迴圈後,無非2中情況:
    • 2.1 收縮的範圍中不包含target,迴歸情況1
    • 2.2 收縮範圍後,left = mid, right = mid - 1 < left,直接到達退出迴圈的條件,也迴歸到情況1

由此,填坑後的程式碼如下,看上去與3.2的程式碼有點像,但邏輯卻不完全一致,而且如果沒有3.2打底,這個寫法可能更難想清楚。

public int binarySearchFirst(int[] nums, int target) {
    int left = 0, right = nums.length;
    while (left <= right) {
        int mid = (left + right) / 2;
        if (nums[mid] < target) {
            left = left + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            right = mid - 1;
        }
    }

    /*
    if (left == nums.length) {
        return -1;
    } else if (nums[left] != target) {
        return -1;
    } else {
        return left;
    }
    */
    return left == nums.length ? -1 : (nums[left] == target ? left : -1);
}

4. 找陣列中重複數字target最後一個?

4.1 基於經典寫法

與3.1 思路完成一致,程式碼如下:

public int binarySearchLast(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = (left + right) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            if (mid == nums.length - 1 || nums[mid + 1] > target) {
                return mid;
            } else {
                left = mid + 1;
            }
        }
    }
    return -1;
}

4.2 基於更巧妙的寫法

繼續沿用3.2的思路反過來就可以了。找target的最後一個,把順著mid左移改成右移即可,對查詢結果的處理也類似,不過處理細節上要繞一些,再單獨說明一下:

  1. 要注意跳出迴圈時,left = right = mid + 1了,所以結果要返回target索引為left - 1
  2. target比nums第一個元素還小,則跳出迴圈時,left == 0,target的實際索引left - 1超出左界
  3. target比nums最後一個元素還大時,跳出迴圈時,left = right = nums.length,nums[left - 1] != target
  4. target在nums的區間範圍內且target不在nums中時,跳出迴圈時,nums[left - 1] != target

最後,演算法程式碼如下:

public int binarySearchLast(int[] nums, int target) {
    int left = 0, right = nums.length;
    while (left < right) {
        int mid = (left + right) / 2;
        if (nums[mid] == target) {
            left = mid + 1;
        } else if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid;
        }
    }

    return left == 0 ? -1 : (nums[left - 1] == target ? left - 1 : -1);
}

4.3 問題探討

4.2 的思路,能否借鑑到經典寫法裡去?

也不建議,硬寫的話,思路可以參考3.3章節,程式碼略。

5. 總結與擴充套件

5.1 總結

以上內容寫了很多細節,為便於理解和記憶,總結如下:

1. 二分查詢的資料區間範圍有左右全閉和左閉右開兩種,其範圍和迴圈條件如下,不能混搭:

(1)左右全閉,[0, nums.length - 1], while(left <= right)

(2)左閉右開,[0, nums.length], while(left < right)

2. 對於右邊界的收縮,對於左閉右開,必須right = mid,對於左右全閉,建議right = mid - 1,這不僅是在邏輯上保持一致,而且是避免不必要的bug

3. 對於左邊界的擴張,同樣的,需要寫left = mid + 1

4. 對於最簡單的二分查詢,由於nums[mid] == target時就退出了,最簡單最不易寫錯,而其他複雜情況,都會執行到只剩left、right兩個元素後,再做最後一次mid的計算、判斷才結束,這裡是容易出錯的地方

5. mid的取值是偏向left一側的,一輪迴圈結束後,由於left = mid + 1,所以左右全閉,退出迴圈時,left = right + 1,左閉右開時,left = right,找最後一個等於target的元素時,由於nums[mid] = target,所以這裡要返回left - 1,即mid,這個也是容易出錯的地方

6. 搞清楚了二分查詢的特點和容易出錯的地方,兩種寫法是可以相互借鑑的

5.2 擴充套件

二分查詢還有兩類常見的查詢需求,在搞明白兩種寫法的基礎上,也都可以寫出經典、巧妙和混搭三種寫法了。本文不在提供全部寫法,大家可以自己練練手。

5.2.1 查詢第一個大於等於target的元素

(1)經典寫法

public int binarySearch(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = (left + right) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (mid == 0 || nums[mid - 1] < target) {
            return mid;
        } else {
            right = mid - 1;
        }
    }
    return -1;
}

(2) 巧妙寫法

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

5.2.2 查詢最後一個小於等於target的元素

(1)經典寫法

public int binarySearch(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = (left + right) / 2;
        if (nums[mid] > target) {
            right = mid - 1;
        } else if (mid == nums.length - 1 || nums[mid + 1] > target) {
            return mid;
        } else {
            left = mid + 1;
        }
    }
    return -1;
}

(2)巧妙寫法

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

6. 演算法題

後續補充

7. 參考資料

本文主要借鑑了以下內容,部分程式碼也源自於此:

  1. 詳解二分查詢演算法
  2. 極客時間,王爭老師的《資料結構與演算法之美》專欄,可以掃下面的二維碼購買閱讀


注:轉載本文,請與Gevin聯絡




如果您覺得Gevin的文章有價值,就請Gevin喝杯茶吧!

|

歡迎關注我的微信公眾賬號

二分查詢常見套路與分析

相關文章