在此记录下二分查找的常用模板,包括查找指定数、查找左边界和右边界,以后解题就用这个模板。
一、查找指定数(基本的二分搜索)
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)
这个解法的时间复杂度是
O
(
n
)
O(n)
O(n),
n
n
n为字符串t
的长度。如果仅仅是这个问题,那么这种解法是最优解。
但是如果给你一系列字符串 s1,s2,...
和字符串t
,你需要判定每个串s
是否是t
的子序列(可以假定 s
较短,t
很长)。如果再用上面的解法,那么就是对于每一个s
,都按照遍历一遍t
的方法操作一遍,时间复杂度是
O
(
m
n
)
O(mn)
O(mn)。可是当t
串的长度非常大时,时间复杂度就很高了。那么如何使用二分查找,使得复杂度大大降低呢?
二分查找思路:
对串t
进行预处理,遍历一遍,将每个字符的下标存放在map中,<key, value> = <s, index>
那么有了这个map
之后,就可以使用二分查找了。举例来说,当s=abc
,已经匹配了ab
,只需要去map[c]
中查找第一个比index=3
大的下标即可。因此,对于s
中的每一个字符,只需要去map中用二分查找到对应的左边界index
,其中左边界的target
值是上一个字符匹配到在t
中的index
。
这样的话, 算法复杂度为 O ( m l o g n ) O(mlogn) O(mlogn)。在 n n n很大,而 m m m相对较小的时候可以大大降低复杂度
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
287. 寻找重复数
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
示例 1:
输入: [1,3,4,2,2]
输出: 2
说明:
不能更改原数组(假设数组是只读的)。
只能使用额外的 O(1) 的空间。
时间复杂度小于 O(n2) 。
数组中只有一个重复的数字,但它可能不止重复出现一次。
这道题的难点在于有两个限制
- 不能更改原数组(假设数组是只读的)
- 只能使用额外的 O(1) 的空间。
如果没有这两个限制,容易想到的方法有:
- 使用哈希表判重,这违反了限制 2;
- 将原始数组排序,排序以后,重复的数相邻,即找到了重复数,这违反了限制 1;
- 用原地哈希,时间复杂度只有O(n),但违反了限制1。
思路:
这道题要求我们查找的数是一个整数,并且给出了这个整数的范围(在
1
1
1和
n
n
n 之间,包括
1
1
1 和
n
n
n),并且给出了一些限制,于是可以使用二分查找法定位在一个区间里的整数;我们的目标就是使用二分法从[1, n]
里面找到这个重复元素。
二分法的思路是先猜一个数(有效范围[left, right]
里的中间数 mid
),然后统计原始数组中小于等于这个中间数的元素的个数 cnt
,如果 cnt
严格大于 mid
,(注意我加了着重号的部分「小于等于」、「严格大于」)。根据抽屉原理,重复元素就在区间 [left, mid]
里。
def findDuplicate(self, nums):
"""
做法:
因为给定了区间,数字在[1,n]中,所以可以使用二分
我们的目标就是使用二分法从[1,n]中选取到重复元素
那么思路就是对每次猜测的数mid,都去遍历原数组,计算小于等于mid的数的个数,如果个数严格大于mid,说明重复的数在[left, mid]中
"""
left, right = 1, len(nums) - 1 # 猜测区间为[left, right]
while left < right: # left == right,就代表找到了重复元素
mid = left + (right - left) // 2
# 计算小于等于mid的个数
cnt = 0
for num in nums:
if num <= mid:
cnt += 1
if cnt > mid:
right = mid # 严格大于, 代表重复的元素在[left, mid]内
elif cnt < mid:
left = mid + 1 # 小于,代表重复的元素在(mid,right]内
else:
left = mid + 1 # 等于,说明mid也不是重复元素,代表重复的元素在(mid,right]内
return left
378. 有序矩阵中第K小的元素
给定一个 n x n 矩阵,其中每行和每列元素均按升序排序,找到矩阵中第 k 小的元素。
请注意,它是排序后的第 k 小元素,而不是第 k 个不同的元素。
matrix = [
[ 1, 5, 9],
[10, 11, 13],
[12, 13, 15]
],
k = 8,
返回 13。
这道题用「堆」的解法是很容易想到的,时间复杂度为 O ( n l o g k ) O(nlogk) O(nlogk),其中 n n n是所有元素的个数, l o g k logk logk是维护一个大小为 k k k的堆的复杂度。但是这种做法没有利用题目所给二维矩阵每行、每列都是有序的特点,因此不是一种好的做法。
思路:
同上面一道题类似,我们知道当前这个二维有序矩阵从左上角到右下角递增,所以值域为[matrix[0][0],matrix[-1][-1]]
,那么我们的目标就是在这个值域中通过「二分查找」找到目标值,使得目标值是矩阵中第
k
k
k小的元素。二分查找的判断条件和上题类似,如何判断当前「猜测的数mid
」是否为第
k
k
k小的数,就可以转化为计算矩阵中小于等于该数的个数——利用二维有序数组行列有序的特性,可以从左下角开始遍历到右上角,以线性复杂度计算得到。
所以对于值域[matrix[0][0],matrix[-1][-1]]
内的每个猜测的数mid
,都去计算矩阵中「小于等于」mid
的个数:
- 如果个数大于
k
k
k,说明第
k
k
k小的数小于等于
mid
,移动边界right=mid
- 如果个数恰好等于
k
k
k,说明第
k
k
k小的数小于等于
mid
,移动边界right=mid
- 如果个数小于
k
k
k,说明第
k
k
k小的数严格大于
mid
,移动边界left=mid + 1
def kthSmallest(self, matrix, k):
"""
对值域[matrix[0][0],matrix[-1][-1]]进行二分查找
我们可以通过有序矩阵的特点,从左下角到右上角以线性遍历,来得到对于任意一个值x,矩阵中有多少数num不大于它
那么二分查找的判断逻辑就是:
1)当算出的数量大于k,说明我们要的目标值小于当前的mid
2)当算出的数量小于k,说明我们要的目标值大于当前的mid
3)当算出的数量等于k,说明我们要的目标值小于等于当前的mid,因为mid不一定在矩阵中出现,所以当等于的时候也要左移右边界
时间复杂度O(nlog(r-l)),二分查找进行log(r-l)次,每次Log(n)复杂度
"""
n = len(matrix)
def calculate(mid):
"""
计算矩阵中小于等于mid的数有多少个
"""
count = 0
i, j = n - 1, 0 # 左下角作为起点
while i >= 0 and j < n:
if matrix[i][j] <= mid:
count += i + 1 # 当前这列的上面都是符合条件的
j += 1 # 右移
else:
i -= 1
return count
left, right = matrix[0][0], matrix[-1][-1]
while left < right:
mid = left + (right - left) // 2
if calculate(mid) >= k: # 当大于等于的时候,当前这个数有可能是满足条件的,所以right=mid
right = mid
else: # 当小于的时候,当前这个数是不满足条件的(也就是说这个数不是第k小的数,充其量是第k-1小的数)
# 所以left直接右移到mid+1
left = mid + 1
return left