在此记录下二分查找的常用模板,包括查找指定数、查找左边界和右边界,以后解题就用这个模板。
一、查找指定数(基本的二分搜索)
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