本文介绍算法题中常见的二分算法。二分算法的模板框架并不复杂,但是初始左右边界的取值以及左右边界如何向中间移动,往往让我们头疼。本文根据博主自己的刷题经验,总结出四类题型,熟记这四套模板,可以应付大部分二分算法题。
简言之,这四类题型分别是:
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)即可,当然也可以直接取题干中numsi的范围作为左右边界:
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