在此記錄下二分查找的常用模板,包括查找指定數、查找左邊界和右邊界,以後解題就用這個模板。
一、查找指定數(基本的二分搜索)
def binarySearch(nums, target):
left, right = 0, len(nums)-1 # 搜索區間兩邊爲閉
while left <= right: # 注意停止條件,停止條件爲[left, left+1]
mid = left + (right - left) // 2
# 所有情況都寫出來
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1 # 因爲mid已經搜索過
else:
right = mid - 1
return -1
注意點
- 關於
while left <= right
爲什麼要取等號,是取決於left
和right
的初始值,或者說取決於搜索區間是開還是閉的問題。比如,如果left, right = 0, len(nums) - 1
,那麼說明搜索區間是兩端都閉區間,因此循環的停止條件就應該是搜索區間爲空,即[left, left + 1]
。如果不加等號,那麼到[left, left]
就停止了,此時left
沒有被查找過,不正確。我們這裏和下面採用的是兩端都閉的搜索區間。 - 關於爲什麼
left = mid + 1,right = mid - 1
,這也是跟搜索區間有關,因爲我們搜索區間兩端都閉,所以當mid
已經被查找過,那麼下一次當然是mid + 1 和 mid - 1
了。
二、查找左邊界,即第一個等於目標數的位置
def left_bound(nums, target):
"""
[1, 2, 4, 4, 5], target = 4
"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
else:
right = mid - 1
# 最終停止時right在2位置,left在4位置,所以返回left
if left >= len(nums) or nums[left] != target:
return -1
return left
- 採用兩端閉區間作爲搜索區間
- 等於情況的更新,因爲是左邊界,所以更新右端點,
right = mid - 1
- 異常情況判斷:當停止時,
left
超過索引或nums[left] != target
- 最終情況是
right
在1的位置,left
在2的位置,所以返回left
三、查找右邊界,即最後一個等於目標數的位置
def right_bound(nums, target):
"""
[1, 2, 4, 4, 5], target = 4
"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
else:
left = mid + 1
# 最終停止時left在5位置,right在4位置,所以返回left-1
if left >= len(nums) or nums[left - 1] != target:
return -1
return left - 1
四、其他情況
- 查找第一個大於等於目標值的位置,
[1, 2, 4, 4, 6], target = 3
答:相當於查左邊界,把最後的判斷條件nums[left] != target
刪掉即可 - 查找最後一個小於等於目標值的位置,
[1, 2, 4, 4, 6], target = 5
答:相當於查右邊界,把最後的判斷條件nums[left - 1] != target
刪掉即可
五、二分查找具體應用
首先我們要知道,二分查找只適用於有序數組,那麼除了上面我們講的在有序數組中查找目標值以及邊界,拋開有序數組這個枯燥的數據結構,二分查找如何運用到實際的算法問題中呢?當搜索空間有序的時候,就可以通過二分搜索「剪枝」,大幅提升效率。
下面用幾個例題來說明二分查找在搜索空間中的優化求解
875. 愛喫香蕉的珂珂
珂珂喜歡喫香蕉。這裏有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警衛已經離開了,將在 H 小時後回來。珂珂可以決定她喫香蕉的速度 K (單位:根/小時)。每個小時,她將會選擇一堆香蕉,從中喫掉 K 根。如果這堆香蕉少於 K 根,她將喫掉這堆的所有香蕉,然後這一小時內不會再喫更多的香蕉。
珂珂喜歡慢慢喫,但仍然想在警衛回來前喫掉所有的香蕉。
返回她可以在 H 小時內喫掉所有香蕉的最小速度 K(K 爲整數)
先拋開二分查找技巧,想想如何暴力解決這個問題呢?
首先,算法要求的是「H 小時內喫完香蕉的最小速度」,我們不妨稱爲 speed
,請問 speed
最大可能爲多少,最少可能爲多少呢?
顯然最少爲1
,最大爲max(piles)
,因爲一小時最多隻能喫一堆香蕉。那麼暴力解法就很簡單了,只要從 1
開始窮舉到 max(piles)
,一旦發現發現某個值可以在 H
小時內喫完所有香蕉,這個值就是最小速度。
那麼二分查找如何優化呢?
由於我們要求的是最小速度,所以其實就是在搜索範圍[1, max(piles)]
內找到滿足條件的左邊界。我們定義一個函數isValid
作爲二分查找更新的判斷條件,由於我們求左邊界,所以當滿足的時候更新右端點即可。
class Solution:
def minEatingSpeed(self, piles, H):
# 1. 首先可以縮小K的範圍, 最小是1,最大是pile裏面最大的那一堆
maxnn = -1
for i in range(len(piles)):
if piles[i] > maxnn:
maxnn = piles[i]
def isValid(speed):
# import math
time = 0
for j in range(len(piles)):
tmp = piles[j] % speed
if tmp == 0:
this_time = piles[j] // speed
else:
this_time = piles[j] // speed + 1
time += this_time
return time <= H
# 2. 二分查找[1, maxnn]裏滿足isValid的最小值
left, right = 1, maxnn
while left <= right:
mid = left + (right - left) // 2
if isValid(mid): # 滿足的話,縮小右邊界
right = mid - 1
else:
left = mid + 1
return left
1011. 在 D 天內送達包裹的能力
傳送帶上的包裹必須在 D 天內從一個港口運送到另一個港口。
傳送帶上的第 i 個包裹的重量爲 weights[i]。每一天,我們都會按給出重量的順序往傳送帶上裝載包裹。我們裝載的重量不會超過船的最大運載重量。
返回能在 D 天內將傳送帶上的所有包裹送達的船的最低運載能力。
輸入:weights = [1,2,3,4,5,6,7,8,9,10], D = 5
輸出:15
解釋:
船舶最低載重 15 就能夠在 5 天內送達所有包裹,如下所示:
第 1 天:1, 2, 3, 4, 5
第 2 天:6, 7
第 3 天:8
第 4 天:9
第 5 天:10
請注意,貨物必須按照給定的順序裝運,因此使用載重能力爲 14 的船舶並將包裝分成 (2, 3, 4, 5), (1, 6, 7), (8), (9), (10) 是不允許的。
本質上和 Koko 喫香蕉的問題一樣的,首先確定 最小值和最大值分別爲 max(weights)
和 sum(weights)
。然後在該區間內尋找左邊界即可。
class Solution:
def shipWithinDays(self, weights, D):
# 1.首先確定運輸能力的上下界,分別爲所有貨物的和,所有貨物中最重的那個
left, right = max(weights), sum(weights)
# 2. 二分查找
def isValid(p): # 以p能力能否在D天內運送好
days = 0
i = 0
cur_sum = 0 # 當前貨物的總重量
while i < len(weights):
cur_sum += weights[i]
if cur_sum > p:
days += 1
cur_sum = weights[i]
i += 1
if cur_sum != 0:
days += 1 # 最後一波
return days <= D
while left <= right:
mid = left + (right - left) // 2
if isValid(mid):
right = mid - 1
else:
left = mid + 1
return left
所以,經過上面兩個例子,我們發現:對於尋找有序區間內的最優解,我們可以不用窮舉暴力搜索的方式,而是可以轉化成爲二分查找左邊界或右邊界的問題,對於這類問題,使用二分查找的思路和模板可以寫成以下形式:
def findOptim():
# 1. 首先求出解的範圍
min_value, max_value = xx, xx
def isValid():
"""
表示滿足條件的函數
"""
# 2. 區間內使用二分查找
left, right = min_value, max_value
while left <= right:
mid = left + (right - left) // 2
if isValid(mid):
else:
二分查找高效判定子序列
如何判定字符串 s 是否是字符串 t 的子序列(可以假定 s 長度比較小,且 t 的長度非常大)。
s = “abc”, t = “ahbgdc”, return true.
s = “axc”, t = “ahbgdc”, return false.
題目很簡單,也很難想到和二分查找有什麼關聯。首先,通常的雙指針解法是這樣的
def isSubsequence(s, t):
i, j = 0, 0
while i < len(s) and j < len(t):
if s[i] == t[j]:
i += 1
j += 1
else:
j += 1
return i == len(s)
這個解法的時間複雜度是,爲字符串t
的長度。如果僅僅是這個問題,那麼這種解法是最優解。
但是如果給你一系列字符串 s1,s2,...
和字符串t
,你需要判定每個串s
是否是t
的子序列(可以假定 s
較短,t
很長)。如果再用上面的解法,那麼就是對於每一個s
,都按照遍歷一遍t
的方法操作一遍,時間複雜度是$O(mn)。可是當t
串的長度非常大時,時間複雜度就很高了。那麼如何使用二分查找,使得複雜度大大降低呢?
二分查找思路:
對串t
進行預處理,遍歷一遍,將每個字符的下標存放在map中,<key, value> = <s, index>
那麼有了這個map
之後,就可以使用二分查找了。舉例來說,當s=abc
,已經匹配了ab
,只需要去map[c]
中查找第一個比index=3
大的下標即可。因此,對於s
中的每一個字符,只需要去map中用二分查找到對應的左邊界index
,其中左邊界的target
值是上一個字符匹配到在t
中的index
。
這樣的話, 算法複雜度爲。在很大,而相對較小的時候可以大大降低複雜度
def isSubsequence(s, t):
# 預處理,構建字符和下標的字典
map = {} # 字典
for i in range(len(t)):
if t[i] in map.keys():
map[t[i]].append(i)
else:
map[t[i]] = [i]
# 查找第一個比target大的數
def left_bound(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] <= target:
left = mid + 1
elif nums[mid] > target:
right = mid - 1
return left
j = 0 # t中的開始位置
for i in range(len(s)):
# t中沒有s[i]
if s[i] not in map.keys():
return False
pos = left_bound(map[s[i]], j)
# s[i]在t中的index沒有比j還大的
if pos == len(map[s[i]]):
return False
j = pos
return True