注:
二分查詢的思路很簡單,但具體寫起來,很容易在細節上搞錯,本文目標是總結常見的二分查詢寫法細節的套路
本文程式碼Java寫的,為了標明二分法程式碼中每種情況的業務邏輯和可讀性,大部分程式碼沒有做邏輯合併和程式碼最佳化
本文預設nums陣列是按升序排列的
隨著對二分查詢理解的深入,本文內容不定期更新,也會補充演算法題來做練手
本文同步發表於我的公眾號(年更)
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;
}
經典的寫法由於大家都熟悉,所以會忽視條件細節,所以這裡再對細節做一個說明:
- 查詢的區間範圍是[0, nums.length - 1],左右全閉
- 每次縮小範圍時,由於mid已經檢查過了,所以縮小範圍時可以把mid去掉,且縮小後的範圍,不論是[left, mid - 1]還是[mid + 1, right],也都是左右全閉
- 退出條件是
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;
}
說明:
- 這段程式碼與上一節有3個區別,分別在
#1
、#2
和#3
處 - 這裡
right = nums.length
而非right = nums.length - 1
,且迴圈退出條件是left < right
,意味著這裡的搜尋區間為[0, nums.length),左閉右開,而後面right = mid
,同樣是左閉右開,既沒把已經查過的mid包含到新範圍中,也保證了新範圍沒有漏掉未查的元素 - 迴圈退出條件是
left < right
,即left == right
時就退出了,由於right是“右開”的位置,此時其實已經到達右邊界了,這裡如果寫的是left <= right
,則可能程式碼執行到迴圈條件邊界時,陣列已經越界了,這裡不用死記硬背 - 由於“右開”,每輪迴圈時,都不會檢索nums[right]的資料,而nums[right]其實一直是被本來迴圈之前的迴圈處理的
問題
1. 能不能寫right = mid - 1
?
不建議,因為“右開”,若right = mid - 1
,會丟失對mid - 1元素的檢查,這樣還得對這個元素單獨檢查處理
2. 能不能寫left = mid
?
不能,因為“左閉”,直覺上就不合適,而且如果出現target比nums陣列最後一個元素還大的情況,會出現上一節分析過的bug
2.3 兩種方法的對比總結
- 迴圈條件
left <= right
意味著左右全閉,其搜尋範圍始終是[left, right],初始值分別為left = 0, right = nums.length - 1,搜尋到最後只剩left, right兩個元素時,會進行最後一輪搜尋(left==right
)確認最後一個元素nums[right]是否為target; - 迴圈條件
left < right
意味著左閉右開,其搜尋範圍始終是[left, right),初始值分別為left = 0, right = nums.length,搜尋到最後只剩left, right兩個元素時,其實只有left一個元素未檢查了,此時執行mid=(left + right)/2
後,nums[mid]=nums[left]完成對left的檢查,迴圈就結束 - 迴圈程式碼塊內二分的邏輯中,必須是
left = mid + 1
,否則搜尋範圍縮小到只有left、right兩個元素時,可能會進入死迴圈 - 兩個迴圈條件都可以,關鍵是下面二分程式碼中匹配恰當的邏輯,左右全閉時,必須
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數字中的情況,這個分三種情況考慮:
- target大於nums中最後一個元素,此時target 不在陣列中,二分查詢結束後,left = right = nums.length
- target在nums的區間範圍之內,此時target在陣列中,二分查詢結束後,nums[left] != target
- 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的思路不太一致了。不過可以繼續填坑:
- 如果收縮範圍中不包含target,我們在幾輪查詢並跳出後,此時left = right + 1,這時,如果nums[left] == target,則left就是第一個,另外,對於target不在nums區間範圍內的情況,也要單獨處理一下
- 如果收縮範圍中包含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左移改成右移即可,對查詢結果的處理也類似,不過處理細節上要繞一些,再單獨說明一下:
- 要注意跳出迴圈時,left = right = mid + 1了,所以結果要返回target索引為left - 1
- target比nums第一個元素還小,則跳出迴圈時,left == 0,target的實際索引
left - 1
超出左界 - target比nums最後一個元素還大時,跳出迴圈時,left = right = nums.length,nums[left - 1] != target
- 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. 參考資料
本文主要借鑑了以下內容,部分程式碼也源自於此:
- 詳解二分查詢演算法
- 極客時間,王爭老師的《資料結構與演算法之美》專欄,可以掃下面的二維碼購買閱讀
注:轉載本文,請與Gevin聯絡
歡迎關注我的微信公眾賬號