二分法的邊界條件
“Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky…” — Donald Knuth
每個大學生都可以在抽象上跟你解釋二分法的概念,但是一旦需要寫出正確無誤、考量到邊界條件的實作,大概只有10%左右的工程師能夠做到。儘管現代工具非常方便,只要呼叫bisect,就可以把一串有序列表二分搜尋。但學習正確地寫出二分法,對於鍛鍊程式思維還是非常有幫助。
大多數的二分問題,基本上可以被視為等價於下列三種問題
- 尋找第一個滿足條件的目標
- 尋找最後一個滿足條件的目標
- 只要滿足目標就好,在哪裡不重要
我們可以來看些例子幫助理解。給定一個有序列表
value: [1, 1, 1, 2, 2, 2, 2, 3, 3]
# index: [0 ,1, 2, 3, 4, 5, 6, 7, 8]
大體上可以分成
- 尋找第一個出現的2
- 尋找最後一個出現的2
- 尋找比2大的第一個數
- 尋找比2小的最後一個數
value: [1, 1, 1, 2, 2, 2, 2, 3, 3]
# 尋找第一個出現的2 = 尋找滿足 x >= 2 的第一個x
# [X, X, X, O, O, O, O, O, O]
# ↑
value: [1, 1, 1, 2, 2, 2, 2, 3, 3]
# 尋找找最後一個出現的2 = 尋找滿足 x <= 2 的最後一個數
# [O, O, O, O, O, O, O, X, X]
# ↑
value: [1, 1, 1, 2, 2, 2, 2, 3, 3]
# 尋找 > 2 的第一個數
# [X, X, X, X, X, X, X, O, O]
# ↑
value: [1, 1, 1, 2, 2, 2, 2, 3, 3]
# 尋找 < 2 的最後一個數
# [O, O, O, X, X, X, X, X, X]
# ↑
因此我們可以把大多數的二分法的問題抽象成,給定一個檢定函數,尋找(第一個/最後一個)滿足檢定函數的目標。這裡的檢定函數就是一個檢查元素是O還是X的函數。
常見的二分法寫法
常見的二分法寫法有三種,最大的不同是while條件的定義與lower_bound, upper_bound的範圍。
1. while left <= right
2. while left < right
3. while left + 1 < right
while left <= right 尋找第一個滿足條件的元素
題目是給定一個list, 如果有找到元素則回傳第一個元素出現的位置, 若無則回傳-1。
def findFirstPosition(self, nums, target):
left = 0
right = len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] >= target:
right = mid - 1
else:
left = mid + 1
if left >= len(nums) or nums[left] != target:
return -1
return left
要保證程式碼行為正確無誤,靠的不是鳴人的相信我之術,而是周全的思考每一個邊界條件。
這個寫法的 Loop Invariance 是 left <= right ,加上初始化的區間是0 ~ n-1,代表left, right可能的範圍是0 <= n - 1最後一路縮小到 x <= x, 最後離開迴圈。
每次執行時區間必會縮小
left = mid + 1 和 right = mid - 1 有一個很棒的特性,迴圈每次執行時,範圍都會縮小,想像下列情況。O 代表該元素符合條件, X代表該元素不符合條件。m代表L與R的中點。
- 若符合條件, 則
right = mid - 1 - 若不符合條件, 則
left = mid + 1
[O, O]
# [Lm, R]
# R[L, ] => L停在第一個滿足條件的位置
[X, O]
# [Lm, R]
# [ LRm] => 歸納成[O]
[X, X]
# [Lm R]
# [ LR] => 歸納成[X]]
[O]
# [LRm]
# R[L] => L沒有越界,代表有元素滿足條件
[X]
# [LRm]
# [R]L => L越界, 代表沒有任何元素滿足條件
我們只探討2個元素以下的可能, 原因是對於三個以上的元素, 最後都會收縮歸納成2個以下的元素。
每次迴圈改變區間範圍時,解必然在其中或是區間外的右邊
假定解存在, 我們觀察一下, 如果middle所檢定的元素不是解, 代表解只可能出現在mid之右。因此把left移動到mid之右,確保左邊界移動後,解會在[left, right]區間內。
而如果middle檢定的元素是解,代表解已經存在, 但可能有更好的解,因為我們追求的是最左邊的解。當我們把right = mid - 1時,代表我們想看看right的左邊有沒有更好的解。如果有的話就重複找解的過程,如果新的區間都沒有解,left最後也會移動到right的右邊,也就是解的位置。
假定解不存在,那就會發生left不斷往右移動,最後和右邊界重疊,接著移動到右邊界之右並離開迴圈。此時left已經out of index, 因此需要在離開迴圈時檢查 if left >= len(nums)
最後必然離開迴圈
right = mid - 1, left = mid + 1 確保了left, right位於同一個位置後, 下一輪迴圈會順利中止,因為右邊界會跑到左邊(或是左邊界會跑到右邊)
歸納
- 每次執行時區間必會縮小
- 每次迴圈改變區間範圍時,解必然在其中或是區間外的右邊
- 最後必然離開迴圈
而離開迴圈後, L恰好會落在區間外側之右,因此檢查L是否出界,若不出界則L就是滿足檢定函數的解。
但滿足檢定函數並不代表滿足題意,題目要求的是「尋找第一個出現的target」, 而我們的檢定函式做的是尋找第一個滿足 x >= target 的數,有可能x都不是target,所以得做一個額外的判斷 or nums[left] != target
while left < right 尋找第一個滿足條件的元素
Talk is cheap, show me the code
def findFirstPosition(self, nums, target):
left = 0
right = len(nums)
while left < right:
mid = (left + right) // 2
if nums[mid] >= target:
right = mid
else:
left = mid + 1
if left >= len(nums) or nums[left] != target:
return -1
return left
這次的寫法和剛剛的不太一樣,我們可以發現
- 迴圈條件改成 left < right
- right 涵蓋了 n 而不是 n - 1
- right 的更新改成 mid 而不是 mid - 1
每次執行時區間必會縮小,且必然能夠結束迴圈
(left + right) // 2 會讓中點偏左。中點絕對不會出現在right。因此right = mid必然會讓區間縮小。left = mid + 1 也必然會讓區間縮小。直到最後left == right離開迴圈為止。