前言
由於我在學習二分查詢的過程中處於會了忘,忘了複習的狀態,因此總結一套適合自己記憶的模板。建議先看參考資料\(^{[1,2,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
,有時候搞不清楚是返回 left
,right
,還是 left - 1
......
這裡有個方便記憶的小技巧,假設 left
和 right
都指向 target
,再看下一步的結果。
比如下面這個例子,target == 3
,lower_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;
的技巧。
假設 left
和 right
都指向 target
,再看下一步的結果。
比如下面這個例子,target == 3
,upper_bound
要求的結果就是紅色的4。
此時 left == right
,根據程式碼,應該執行 left = mid + 1;
這條語句,執行之後,如下圖所示。
此時,left == right + 1
,迴圈結束,結果應該為 left
,所以 return left;
。
可以看到,在左閉右閉的情況下,lower_bound
和 upper_bound
都返回 left
。
lower_bound 和 upper_bound 的聯絡
可以發現,這兩個函式只有 if
判斷為相等的情況不同[6]。為方便記憶,在 if else
只有二分支的情況下,即把相等的情況歸為 if
分支或 else
分支(不是 if ... else if ... else ...
三分支的情況)。
此時,lower_bound
和 upper_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_bound
和 upper_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;
}
}
二分查詢進階
以上是基礎的二分查詢型別,對於進階的題目,把問題轉化成二分查詢是一個難點。
參考資料
- 二分查詢又死迴圈了?【基礎演算法精講 04】
- 手把手帶你撕出正確的二分法 | 二分查詢法 | 二分搜尋法 | LeetCode:704. 二分查詢
- 我寫了首詩,讓你閉著眼睛也能寫對二分搜尋
- C++中的upper_bound和lower_bound區別
- 34. 在排序陣列中查詢元素的第一個和最後一個位置
- 用Java實現C++::std中的upper_bound和lower_bound
以上是我個人的學習心得,能力有限,如有錯誤和建議,懇請批評指正!