二分搜索基本框架
下面是基本代码框架,后面所有写法都是它的变形
def binarySearch(arr: list, target: int) -> int:
left = 0
right = ...
while ...:
mid = (left + right) // 2
if arr[mid] == target:
...
elif arr[mid] < target:
left = ...
elif arr[mid] > target:
right = ...
return ...
- 这里用
elif
语句列举出所有情况,以便思路清晰,弄懂后可以用else
写出更简化的版本 - 如果所用语言有整型溢出问题,那么mid的计算改为
mid = left + ( right - left) // 2
,防止溢出 - 下面有两种不同写法:
while left <= right
和while left < right
,两种写法是等效的,主要区别在于,搜索区间是[left,right]
还是[left,right)
二分搜索逻辑框架
初始化:
left = 0
、right = len(arr) - 1
这决定了搜索区间是[left,right]
因而循环条件是while left <= right
同时也决定了每次更改搜索区间的方式是left = mid + 1
和right = mid - 1
1.基本二分搜索
用于在有序数组中寻找某个数的下标
只要找到了目标值,即if arr[mid] == target
,直接返回下标mid即可
如果while循环终止,此时left = right + 1
,相当于搜索区间为空,意味着没找到,返回-1
def binarySearch(arr: list, target: int) -> int:
"""传入升序的有序数组,返回target的的下标(有多个只返回在中间的那一个),找不到返回-1"""
left = 0
right = len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target: # 找到目标数字
return mid
elif arr[mid] < target:
left = mid + 1
elif arr[mid] > target:
right = mid - 1
return -1
这个算法用于数组中每个元素只出现一次的情况
如果目标数字出现多次,返回的是“靠中间”的那个下标
2.寻找左侧边界的二分搜索
用于在有序数组中搜索某个数第一次出现的下标
只需修改两处代码:
def left_bound(arr: list, target: int) -> int:
"""传入升序的有序数组,返回target的第一次出现的下标,找不到返回-1"""
left = 0
right = len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
# 不返回,收缩右边界,锁定左边界
right = mid - 1
elif arr[mid] < target:
left = mid + 1
elif arr[mid] > target:
right = mid - 1
# 如果找到,则left为答案,也可能找不到,返回-1
if left >= len(arr) or arr[left] != target: # 小心left越界
return -1
# 省略上面的判断,则返回值的意义是:数组中有多少元素的值小于target
return left
该方法能够搜索左侧边界的原理:找到target后不是立即返回,而是缩小搜索区间的上限,在[left,mid-1]
中继续搜索,即不断向左收缩,从而锁定左侧边界
要注意的是,while结束的条件是left==right+1
,因此left可能越界(如果target比arr中所有数都大),最终要检查越界情况
3.寻找右侧边界的二分搜索
用于在有序数组中搜索某个数最后一次出现的下标
同样只需修改两处代码:
def right_bound(arr: list, target: int) -> int:
"""传入升序的有序数组,返回target的最后一次出现的下标,找不到返回-1"""
left = 0
right = len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
# 不返回,收缩左边界,锁定右边界
left = mid + 1
elif arr[mid] < target:
left = mid + 1
elif arr[mid] > target:
right = mid - 1
# 如果找到,则left为答案,也可能找不到,返回-1
if right < 0 or arr[right] != target: # 小心right越界
return -1
return right
该方法能够搜索右侧边界的原理:找到target后不是立即返回,而是增大搜索区间的下限,在[mid+1,right]
中继续搜索,即不断向右收缩,从而锁定右侧边界
要注意的是,while结束的条件是left-1==right
,因此right可能越界(如果target比arr中所有数都小),最终要检查越界情况
另外一种常见的写法
对于上面2、3点的左侧边界、右侧边界的二分搜索,另一种常见的写法是while left < right
,这相当于搜索区间是[left,right)
左闭右开区间
逻辑如下:
初始化:left = 0
、right = len(arr)
这决定了搜索区间是[left,right)
因而循环条件是while left < right
(当left == right
,搜索区间为空,循环终止)
同时也决定了每次更改搜索区间的方式是left = mid + 1
和right = mid
搜索左侧边界的思路同上,即不断收缩搜索区间的上限
搜索右侧边界时的思路也同上,但是返回答案时略有不同:由于收缩搜索区间下限时必须left = mid + 1
,所以最终无论返回left或right(此处while结束时两者相等)时,必须减1
def left_bound_2(arr: list, target: int) -> int:
"""写法2"""
left = 0
right = len(arr)
while left < right:
mid = (left + right) // 2
if arr[mid] == target:
# 不返回,收缩右边界,锁定左边界
right = mid
elif arr[mid] < target:
left = mid + 1
elif arr[mid] > target:
right = mid
# 如果找到,则left为答案,也可能找不到,返回-1
if left >= len(arr) or arr[left] != target: # 小心left越界
return -1
# 省略上面的判断,则返回值的意义是:数组中有多少元素的值小于target
return left
def right_bound_2(arr: list, target: int) -> int:
"""写法2"""
left = 0
right = len(arr)
while left < right:
mid = (left + right) // 2
if arr[mid] == target:
# 不返回,收缩左边界,锁定右边界
left = mid + 1
elif arr[mid] < target:
left = mid + 1
elif arr[mid] > target:
right = mid
# 由于收缩搜索区间下限时left = mid + 1,所以最终返回right(或left,两者相等)时,必须减1
# 如果找到,则(right-1)为答案,也可能找不到,返回-1
if right - 1 < 0 or arr[right - 1] != target: # 小心right越界
return -1
return right - 1
应用
- 二分搜索最普遍的应用场景是在有序数组中搜索元素
- 稍作推广,只要问题的搜索空间有序时,对连续空间的线性搜索可以用二分搜索
- 最后将看到,搜索空间有二段性,(左边部分的空间与右边部分的空间有截然不同的特点,而分隔点就是所需的目标点),也可用二分搜索
总结:一般的思路过程是,先写出暴力解法,然后发现搜索空间的有序性/连续性/二段性,进而考虑通过二分搜索优化效率
搜索空间有序
LeetCode 1011. 在 D 天内送达包裹的能力
传送带上的第 i 个包裹的重量为 weights[i] ,货物不可分割且必须按顺序运输,货船每天能运输一定量的货物
现在需要在D天内运输所有货物,求船的最低运载能力
分析:
- 货物不可分割,每日运载量下限至少为max(weights),(每天运一件),最大为sum(weights),(一天内运完)
- 朴素的做法是,从下限开始线性搜索,找到的第一个符合条件的运载量就是答案,但注意到搜索空间线性连续、有序,因此用二分搜索效率更高
- 注意细节:找最低运载量,应该是搜索左侧边界的二分搜索
class Solution:
def shipWithinDays(self, weights: List[int], days: int) -> int:
# 货物不可分割,每日运载量下限至少为max(weights),(每天运一件),最大为sum(weights),(一天内运完)
# 搜索空间是连续递增的,使用二分搜索(注意求下限,应该是搜索左侧边界的二分搜索)
def canFinish(capacity):
"""判断运载量为capacity时,能否在D天内完成任务"""
i = 0 # 当前运输的货物下标
for day in range(days):
today_cap = capacity
while today_cap - weights[i] >= 0: # 还能装载,就多装一点
today_cap -= weights[i]
i += 1
if i == L:
return True
return False
L = len(weights)
l, r, = max(weights), sum(weights)
while l <= r:
mid = (l + r) >> 1
if canFinish(mid):
# 找到符合要求的承载,先收缩右边界
r = mid - 1
else:
l = mid + 1
return l
类似的题目还有 LeetCode 875. 爱吃香蕉的珂珂
搜索空间有二段性
LeetCode 540. 有序数组中的单一元素
有序数组中,每个元素都会出现两次,唯有一个数仅出现一次,找出该元素的值,要求解决方案必须满足 O(log n) 时间复杂度和 O(1) 空间复杂度
- 数组是有序的,那么容易得出这样的规律:
成对的元素中,第一个必为偶数下标,第二个为奇数下标 - 然而,唯一的单个元素将数组拆为两段:
左侧满足上述规律,右侧的规律翻转(第一个必为奇数下标,第二个为偶数下标) - 因此可利用二段性进行二分搜索:如果判断当前位于“左半部分”,搜索区间向右收缩;当前位于“右半部分”,搜索区间向左收缩
class Solution:
def singleNonDuplicate(self, nums: List[int]) -> int:
# 有序数组,成对的元素中,第一个必为偶数下标,第二个为奇数下标
# 单个元素出现后,结论翻转
L = len(nums)
if L == 1:
return nums[0]
l, r = 0, L - 1
while l <= r:
mid = (l + r) >> 1
if mid + 1 < L and nums[mid] == nums[mid + 1]:
if not mid & 1: # 第一个为偶数下标
l = mid + 2
else: # 第一个为奇数下标
r = mid - 1
elif mid >= 0 and nums[mid] == nums[mid - 1]:
if mid & 1: # 第二个为奇数下标
l = mid + 1
else: # 第二个为偶数下标
r = mid - 2
else: # 不等于前/后,找到目标
return nums[mid]
另外,在LeetCode 887. 鸡蛋掉落中也可以用二分搜索提高效率:
我们要求两条图线的交点,它把搜索空间分为两段:“左侧”是图线1高于图线2,“右侧”是图线2高于图线1,因此也可用二分搜索
详见动态规划:高楼扔鸡蛋