本文介绍算法题中常见的二分算法。二分算法的模板框架并不复杂,但是初始左右边界的取值以及左右边界如何向中间移动,往往让我们头疼。本文根据博主自己的刷题经验,总结出四类题型,熟记这四套模板,可以应付大部分二分算法题。
简言之,这四类题型分别是:
1.手动实现lower_bound/upper_bound,python中是bisect_left/bisect_right;
2.二分答案;
3.山脉数组;
4.旋转数组。
下面详细介绍这四种题型,介绍中的题单来自leetcode 灵神题单:https://leetcode.cn/discuss/post/3579164/ti-dan-er-fen-suan-fa-er-fen-da-an-zui-x-3rqn/
一、手动实现二分库函数
实现方法有三种,1)闭区间写法;2)左闭右开区间写法;3)开区间写法。这里的开闭区间指的是左右边界的取值是否包括数组的左右端点。三种写法可以参考灵神题解:https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/solutions/1980196/er-fen-cha-zhao-zong-shi-xie-bu-dui-yi-g-t9l9/。具体选用哪一种写法,根据个人喜好。博主使用左闭右开写法较多,所有下面的代码都是左闭右开的写法。
34. 在排序数组中查找元素的第一个和最后一个位置 [Medium]
35. 搜索插入位置 [Simple]
704. 二分查找 [Simple]
744. 寻找比目标字母大的最小字母 [Simple]
2529. 正整数和负整数的最大计数 [Simple]
python
# 34. 在排序数组中查找元素的第一个和最后一个位置
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
def lower_bound(t):
l,r=0,len(nums)
while l<r:
mid=(l+r)//2
if nums[mid]>=t:
r=mid
else:
l=mid+1
return l
l1=lower_bound(target)
if l1==len(nums) or nums[l1]!=target:
return [-1,-1]
l2=lower_bound(target+1)
return [l1,l2-1]
# 库函数写法
l=bisect_left(nums,target)
r=bisect_right(nums,target)
if l==r:
return [-1,-1]
return [l,r-1]
这里有几个容易混淆的点,
1.为什么是l<r
不是l<=r
?
- while结束的条件是
l==r
,且当l==r
时,不会再去执行while中的逻辑。我们的右边界是len(nums),是不能索引的。所以只能用l<r
,如果用l<=r
,那么就会访问下标len(nums),导致越界。
2.为什么是nums[mid]>=t
,不是nums[mid]>t
?为什么是r=mid
,不是r=mid-1
?为什么是l=mid+1
,不是l=mid
?
- 对于各种类型的二分算法题,我们只需要考虑当二分到只剩两个数时,怎么写可以找到目标且退出while循环 。只剩两个数时,这个时候mid总是指向前一个数。如果前一个数是我们要找的数,那么只能将
r
向l
逼近,触发终止条件;如果后一个数是我们要找的,那么只能将l
向r
逼近,触发终止条件。所以只能用上面的写法,其他写法会导致死循环,永远退出不了while循环。
下面的题目将直接使用二分库函数,必要时需要将原数组先排序。
2300. 咒语和药水的成功对数 [Medium]
1385. 两个数组间的距离值 [Simple]
2389. 和有限的最长子序列 [Simple]
1170. 比较字符串最小字母出现频次 [Medium]
2080. 区间内查询数字的频率 [Medium]
3488. 距离最小相等元素查询 [Medium]
2563. 统计公平数对的数目[Medium]
2070. 每一个查询的最大美丽值 [Medium]
1818. 绝对差值和 [Medium]
911. 在线选举 [Medium]
658. 找到 K 个最接近的元素 [Medium]
1150. 检查一个数是否在数组中占绝大多数 [Simple]
python
# 2563. 统计公平数对的数目
class Solution:
def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:
nums.sort()
n=len(nums)
ans=0
for i,x in enumerate(nums):
# 不要用这种写法,bisect_left(nums[i:],lower-x),会超时
low=bisect_left(nums,lower-x,i+1,n)
high=bisect_right(nums,upper-x,i+1,n)
ans+=high-low
return ans
bisect_left 和 bisect_right 支持给定区间内的搜索。
python
# 2070. 每一个查询的最大美丽值
class Solution:
def maximumBeauty(self, items: List[List[int]], queries: List[int]) -> List[int]:
items.sort(key=lambda x:x[0])
for i in range(1,len(items)):
items[i][1]=max(items[i-1][1],items[i][1])
for i,q in enumerate(queries):
# bisect_right可以加lambda参数
h=bisect_right(items,q,key=lambda x:x[0])
queries[i]=items[h-1][1] if h else 0
return queries
bisect_left可以像sorted函数一样,接收lambda函数指定在多维列表中的某一维上搜索。
上面这些题绝大多数都是题目给出两个数组,对其中一个数组做一些处理,然后针对另外一个数组中的每个元素,在处理过的数组中二分搜索。
下面几题都是在键值对上做二分搜索,方法巧妙:
1146. 快照数组 [Medium]
981. 基于时间的键值存储 [Medium]
3508. 设计路由器 [Medium]
python
# 1146. 快照数组
class SnapshotArray:
def __init__(self, length: int):
self.cur_snap_id=0
self.history=defaultdict(list)
def set(self, index: int, val: int) -> None:
self.history[index].append((self.cur_snap_id,val))
def snap(self) -> int:
self.cur_snap_id+=1
return self.cur_snap_id-1
def get(self, index: int, snap_id: int) -> int:
k=bisect_right(self.history[index],snap_id,key=lambda x:x[0])
return 0 if not k else self.history[index][k-1][1]
未完成:
LCP 08. 剧情触发时间 [Medium]
1182. 与目标颜色间的最短距离 [Medium]
2819. 购买巧克力后的最小相对损失 [Medium]
1287. 有序数组中出现次数超过 25% 的元素 [Simple]
二、二分答案
这类题型,我们能够通过题目得出答案的范围,然后在答案的范围内做二分搜索,最终找出确切的答案。
首先,根据题目确定答案的左右边界,也就是答案的最小值和最大值,如果不能快速的确定答案的最小值和最大值,可以直接用题目中的边界条件,例如:-105, 105。然后,对答案范围做二分搜索,判断给定的中间值是否满足题干要求。这里,我们要定义一个check(x)函数,x即每次二分的中间值,check(x)==True即满足题干要求。这类题目的难点就在于,如何实现check(x)函数。
1) 求最小:
1283. 使结果不超过阈值的最小除数 [Medium]
2187. 完成旅途的最少时间 [Medium]
1011. 在 D 天内送达包裹的能力 [Medium]
875. 爱吃香蕉的珂珂 [Medium]
3296. 移山所需的最少秒数 [Medium]
475. 供暖器 [Medium]
2594. 修车的最少时间 [Medium]
1482. 制作 m 束花所需的最少天数 [Medium]
避免浮点数:
1870. 准时到达的列车最小时速 [Medium]
3453. 分割正方形 I [Medium]
python
# 1283. 使结果不超过阈值的最小除数
class Solution:
def smallestDivisor(self, nums: List[int], threshold: int) -> int:
def check(x):
s=0
for y in nums:
s+=ceil(y/x)
if s>threshold:
return False
else:
return True
# 求什么,二分什么
l,r=1,max(nums)
ans=0
while l<=r:
mid=(l+r)//2
if check(mid):
r=mid-1
ans=mid
else:
l=mid+1
return ans
跟第一类题型一样,可能我们又会有疑问,
1.为什么while的条件是l<=r
,不是l<r
?
- 在这类题型中,l和r分别是答案的最小值和最大值,它们都是有可能成为最终答案的,所以它们都可能进while循环执行逻辑,因此条件是
l<=r
。如果是l<r
,那么当l==r
时,触发结束条件,就没机会进while循环执行逻辑了。
2.为什么是r=mid-1
,不是r=mid
?
- 跟第一类题型一样,我们只需要考虑只剩两个数时的情况。只剩两个数时,mid总是取前一个数。如果前一个数是最终答案,那么
r=mid-1
,此时r<l
,触发结束条件,循环结束;如果后一个数是最终答案,那么l=mid+1
,此时l==r
,再进一次while循环,执行逻辑跟刚才一样,触发结束条件,循环结束。其他写法可能会导致死循环,无法退出while循环。
注意,
- 这题右边界取max(nums)即可,当然也可以直接取题干中nums[i]的范围作为左右边界:
1 <= nums[i] <= 10^6
- 在check(x)==True时,要即时记下答案
- 求最小,所以是从大往小逼近,因此在check(x)==True时,右边界变小。
未完成:
3048. 标记所有下标的最早秒数 I [Medium]
2604. 吃掉所有谷子的最短时间 [Hard]
2702. 使数字变为非正数的最小操作次数 [Hard]
2) 求最大:
求最大跟求最小框架基本一样,唯一的区别在于,求最大是在check(x)==True时,左边界变大。
2226. 每个小孩最多能分到多少糖果 [Medium]
275. H 指数 II [Medium]
2982. 找出出现至少三次的最长特殊子字符串 II [Medium]
2576. 求出最多标记下标 [Medium]
1898. 可移除字符的最大数目 [Medium]
1802. 有界数组中指定下标处的最大值 [Medium]
1642. 可以到达的最远建筑 [Medium]
python
# 2226. 每个小孩最多能分到多少糖果
class Solution:
def maximumCandies(self, candies: List[int], k: int) -> int:
if sum(candies)<k:
return 0
def check(x):
s=0
for c in candies:
s+=c//x
if s>=k:
return True
else:
return False
l,r=1,max(candies)
ans=1
while l<=r:
mid=(l+r)//2
if check(mid):
l=mid+1
ans=mid
else:
r=mid-1
return ans
未完成:
2861. 最大合金数 [Medium]
3007. 价值和小于等于 K 的最大数字 [Medium]
2141. 同时运行 N 台电脑的最长时间 [Hard]
2258. 逃离火灾 [Hard]
2071. 你可以安排的最多任务数目 [Hard]
LCP 78. 城墙防线 [Medium]
1618. 找出适应屏幕的最大字号 [Medium]
1891. 割绳子 [Medium]
2137. 通过倒水操作让所有的水桶所含水量相等 [Medium]
644. 子数组最大平均数 II [Hard]
3143. 正方形中的最多点数 [Medium]
1648. 销售价值减少的颜色球 [Medium]
3) 第K小/大
此类问题,本质还是二分答案。只不过在check(x)函数中,返回值都是跟K比较。
- 第 k 小等价于:求最小的 x,满足 ≤x 的数至少有 k 个。
- 第 k 大等价于:求最大的 x,满足 ≥x 的数至少有 k 个。
668. 乘法表中第 K 小的数 [Hard]
378. 有序矩阵中第 K 小的元素 [Medium]
719. 找出第 K 小的数对距离 [Hard]
878. 第 N 个神奇数字 [Hard]
1201. 丑数 III [Medium]
python
# 668. 乘法表中第 K 小的数
class Solution:
def findKthNumber(self, m: int, n: int, k: int) -> int:
def check(x):
cnt=0
for i in range(1,m+1):
cnt+=min(x//i,n)
return cnt>=k
l,r=1,m*n
ans=1
while l<=r:
mid=(l+r)//2
if check(mid):
r=mid-1
ans=mid
else:
l=mid+1
return ans
可以看到,求第K小,就是在check(x)函数中,满足当前x的数量>=k就返回True。
未完成(因为这类题目很多用堆会更简便,所以后面没有多刷):
793. 阶乘函数后 K 个零 [Hard]
373. 查找和最小的 K 对数字 [Medium]
1439. 有序矩阵中的第 k 个最小数组和 [Hard]
786. 第 K 个最小的质数分数 [Medium]
3116. 单面值组合的第 K 小金额 [Hard]
3134. 找出唯一性数组的中位数 [Hard]
2040. 两个有序数组的第 K 小乘积 [Hard]
2386. 找出数组的第 K 大和 [Hard]
1508. 子数组和排序后的区间和 [Medium]
3520. 逆序对计数的最小阈值 [Medium]
1918. 第 K 小的子数组和 [Medium]
下面两个部分,灵神是单独拎出来的,个人觉得和求最小和求最大本质上没什么区别,所以没有刷,后面有时间再刷:
4) 最小化最大值
本质是二分答案求最小。二分的 mid 表示上界。
410. 分割数组的最大值 [Hard]
2064. 分配给商店的最多商品的最小值 [Medium]
1760. 袋子里最少数目的球 [Medium]
1631. 最小体力消耗路径 [Medium]
2439. 最小化数组中的最大值 [Medium]
2560. 打家劫舍 IV [Medium]
778. 水位上升的泳池中游泳 [Hard]
2616. 最小化数对的最大差值 [Medium]
3419. 图的最大边权的最小值 [Medium]
2513. 最小化两个数组中的最大值 [Medium]
3399. 字符相同的最短子字符串 II [Hard]
LCP 12. 小张刷题计划 [Medium]
774. 最小化去加油站的最大距离 [Hard]
5) 最大化最小值
本质是二分答案求最大。二分的 mid 表示下界。
3281. 范围内整数的最大得分 [Medium]
2517. 礼盒的最大甜蜜度 [Medium]
1552. 两球之间的磁力 [Medium]
2812. 找出最安全路径 [Medium]
2528. 最大化城市的最小电量 [Hard]
3449. 最大化游戏分数的最小值 [Hard]
3464. 正方形上的点之间的最大距离 [Hard]
1102. 得分最高的路径 [Medium]
1231. 分享巧克力 [Hard]
三、山脉数组
山脉数组,顾名思义,就是说数组中至少存在一个值,其值严格大于左右相邻值的元素。要求去寻找这个峰值。
162. 寻找峰值 [Medium]
1901. 寻找峰值 II [Medium]
852. 山脉数组的峰顶索引 [Medium]
1095. 山脉数组中查找目标值 [Hard]
python
# 162. 寻找峰值
class Solution:
def findPeakElement(self, nums: List[int]) -> int:
# 因为左右边界都有可能是山峰,所以左闭右闭
l,r=0,len(nums)-1
while l<r:
mid=(l+r)//2
# 往高处走,总能找到山峰
if nums[mid]<nums[mid+1]:
l=mid+1
else:
r=mid
return l
注意,
1.为什么while的条件是l<r
,不是l<=r
?
l<r
,表明l==r
时触发结束条件,不用执行while内逻辑。我们知道,当l==r
时,说明已经从左右两边逼近到山峰了,没有必要再去执行while内逻辑了,可以结束循环了。如果l<=r
,那么就会死循环了。
2.山脉数组题型,总是相邻元素相比较nums[mid]<nums[mid+1]
3.为什么是l=mid+1
,r=mid
?
- 我们还是考虑只剩两个元素的情况:mid总是指向前一个数。前一个数小于后一个数,那么向后移动,即
l=mid+1
;否则,向前移动,即r=mid
。其他写法可能会导致死循环。
四、旋转数组
旋转数组是指一个已经正序的数组,从最后一个元素开始,依次挪到数组的最前面,一共挪k次。要求找到旋转之后的数组中的最小值。
旋转数组跟山脉数组解法相似,区别在于旋转数组是一直拿中间值跟右端点值比较。
153. 寻找旋转排序数组中的最小值 [Medium]
154. 寻找旋转排序数组中的最小值 II [Hard]
33. 搜索旋转排序数组 [Medium]
81. 搜索旋转排序数组 II [Medium]
python
# 153. 寻找旋转排序数组中的最小值
class Solution:
def findMin(self, nums: List[int]) -> int:
l,r=0,len(nums)-1
while l<r:
mid=(l+r)//2
if nums[mid]<nums[r]:
r=mid
else:
l=mid+1
return nums[l]
旋转数组其实就是由两部分正序的数组拼成,且前半部分数组的最小值大于后半部分数组的最大值。利用这一点,通过不断地比较中间值和右端点值,判断中间值是在前半部分还是后半部分。如果中间值小于右端点值,说明在后半部分,那么右边界往左移;否则,说明在前半部分,那么左边界往右移。
同样地,我们再来分析一下,
1.为什么while条件是l<r
?不是l<=r
?
- 和山脉数组一样,当l==r时,我们已经找到了最小值,可以退出while循环了,没必要在进while循环执行一遍逻辑;
2.为什么是r=mid
,l=mid+1
?不是r=mid-1
,不是l=mid
?
- 同样地,我们只需要考虑只剩两个元素的情况。mid总是指向前面一个元素。如果最小值是前面一个元素,那么
r=mid
,此时l=r
且都指向最小值,触发退出while循环条件。如果最小值是后面一个元素,那么l=mid+1
,此时l=r
且都指向最小值,退出循环。如果l=mid
,那么就会死循环,无法退出while循环。
五、Hot:
4. 寻找两个正序数组的中位数 [Hard]
为什么把这题单独拎出来呢?嘿嘿,因为这题的方法确实不好归纳到上面四种题型中去。另外就是这题是Hot100,是面试中的常客,所以需要反复练习掌握。
python
# 4. 寻找两个正序数组的中位数
class Solution:
def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
if len(nums1)>len(nums2):
nums1,nums2=nums2,nums1
m,n=len(nums1),len(nums2)
l,r,half=0,m,(m+n+1)//2
while l<=r:
i=(l+r)//2
j=half-i
# j=half-i
# =(m+n+1)//2-i
# >=(m+n+1)//2-m
# >=(n+1)//2-m//2
# >=0 (n>=m)
# 所以不用判断j是否大于0
if i<m and nums1[i]<nums2[j-1]:
l=i+1
elif i>0 and nums1[i-1]>nums2[j]:
r=i-1
else:
if i==0:
max_left=nums2[j-1]
elif j==0:
max_left=nums1[i-1]
else:
max_left=max(nums1[i-1],nums2[j-1])
if (m+n)%2:
return max_left
if i==m:
min_right=nums2[j]
elif j==n:
min_right=nums1[i]
else:
min_right=min(nums1[i],nums2[j])
return (max_left+min_right)/2
六、其他:
69. x 的平方根 [Simple]
74. 搜索二维矩阵 [Medium]
240. 搜索二维矩阵 II [Medium]
2476. 二叉搜索树最近节点查询 [Medium]
278. 第一个错误的版本 [Simple]
374. 猜数字大小 [Simple]
222. 完全二叉树的节点个数 [Simple]
未完成
1539. 第 k 个缺失的正整数 [Simple]
540. 有序数组中的单一元素 [Medium]
1064. 不动点 [Simple]
702. 搜索长度未知的有序数组 [Medium]
2936. 包含相等值数字块的数量 [Medium]
1060. 有序数组中的缺失元素 [Medium]
1198. 找出所有行中最小公共元素 [Medium]
1428. 至少有一个 1 的最左端列 [Medium]
1533. 找到最大整数的索引 [Medium]
2387. 行排序矩阵的中位数 [Medium]
302. 包含全部黑色像素的最小矩形 [Hard]