[leetcode] 二分算法
本文介绍算法题中常见的二分算法。二分算法的模板框架并不复杂,但是初始左右边界的取值以及左右边界如何向中间移动,往往让我们头疼。本文根据博主自己的刷题经验,总结出四类题型,熟记这四套模板,可以应付大部分二分算法题。
简言之,这四类题型分别是:
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]
# 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)//2if nums[mid]>=t:r=midelse:l=mid+1return ll1=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]
# 2563. 统计公平数对的数目
class Solution:def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:nums.sort()n=len(nums)ans=0for 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-lowreturn ans
bisect_left 和 bisect_right 支持给定区间内的搜索。
# 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 0return queries
bisect_left可以像sorted函数一样,接收lambda函数指定在多维列表中的某一维上搜索。
上面这些题绝大多数都是题目给出两个数组,对其中一个数组做一些处理,然后针对另外一个数组中的每个元素,在处理过的数组中二分搜索。
下面几题都是在键值对上做二分搜索,方法巧妙:
1146. 快照数组 [Medium]
981. 基于时间的键值存储 [Medium]
3508. 设计路由器 [Medium]
# 1146. 快照数组
class SnapshotArray:def __init__(self, length: int):self.cur_snap_id=0self.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+=1return self.cur_snap_id-1def 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]
# 1283. 使结果不超过阈值的最小除数
class Solution:def smallestDivisor(self, nums: List[int], threshold: int) -> int:def check(x):s=0for y in nums:s+=ceil(y/x)if s>threshold:return Falseelse:return True# 求什么,二分什么l,r=1,max(nums)ans=0while l<=r:mid=(l+r)//2if check(mid):r=mid-1ans=midelse:l=mid+1return 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]
# 2226. 每个小孩最多能分到多少糖果
class Solution:def maximumCandies(self, candies: List[int], k: int) -> int:if sum(candies)<k:return 0def check(x):s=0for c in candies:s+=c//xif s>=k:return Trueelse:return Falsel,r=1,max(candies)ans=1while l<=r:mid=(l+r)//2if check(mid):l=mid+1ans=midelse:r=mid-1return 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]
# 668. 乘法表中第 K 小的数
class Solution:def findKthNumber(self, m: int, n: int, k: int) -> int:def check(x):cnt=0for i in range(1,m+1):cnt+=min(x//i,n)return cnt>=kl,r=1,m*nans=1while l<=r:mid=(l+r)//2if check(mid):r=mid-1ans=midelse:l=mid+1return ans
可以看到,求第K小,就是在check(x)函数中,满足当前x的数量>=k就返回True。
未完成(因为这类题目很多用堆会更简便,所以后面没有多刷):
793. 阶乘函数后 K 个零
373. 查找和最小的 K 对数字
1439. 有序矩阵中的第 k 个最小数组和
786. 第 K 个最小的质数分数
3116. 单面值组合的第 K 小金额
3134. 找出唯一性数组的中位数
2040. 两个有序数组的第 K 小乘积
2386. 找出数组的第 K 大和
1508. 子数组和排序后的区间和
3520. 逆序对计数的最小阈值
1918. 第 K 小的子数组和
下面两个部分,灵神是单独拎出来的,个人觉得和求最小和求最大本质上没什么区别,所以没有刷,后面有时间再刷:
4) 最小化最大值
本质是二分答案求最小。二分的 mid 表示上界。
410. 分割数组的最大值
2064. 分配给商店的最多商品的最小值
1760. 袋子里最少数目的球
1631. 最小体力消耗路径
2439. 最小化数组中的最大值
2560. 打家劫舍 IV
778. 水位上升的泳池中游泳
2616. 最小化数对的最大差值
3419. 图的最大边权的最小值
2513. 最小化两个数组中的最大值
3399. 字符相同的最短子字符串 II
LCP 12. 小张刷题计划
774. 最小化去加油站的最大距离
5) 最大化最小值
本质是二分答案求最大。二分的 mid 表示下界。
3281. 范围内整数的最大得分
2517. 礼盒的最大甜蜜度
1552. 两球之间的磁力
2812. 找出最安全路径
2528. 最大化城市的最小电量
3449. 最大化游戏分数的最小值
3464. 正方形上的点之间的最大距离
1102. 得分最高的路径
1231. 分享巧克力
三、山脉数组
山脉数组,顾名思义,就是说数组中至少存在一个值,其值严格大于左右相邻值的元素。要求去寻找这个峰值。
162. 寻找峰值
1901. 寻找峰值 II
852. 山脉数组的峰顶索引
1095. 山脉数组中查找目标值 1827
class Solution:def findPeakElement(self, nums: List[int]) -> int:# 因为左右边界都有可能是山峰,所以左闭右闭l,r=0,len(nums)-1while l<r:mid=(l+r)//2# 往高处走,总能找到山峰if nums[mid]<nums[mid+1]:l=mid+1else:r=midreturn 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. 寻找旋转排序数组中的最小值
154. 寻找旋转排序数组中的最小值 II
33. 搜索旋转排序数组
81. 搜索旋转排序数组 II
# 153. 寻找旋转排序数组中的最小值
class Solution:def findMin(self, nums: List[int]) -> int:l,r=0,len(nums)-1while l<r:mid=(l+r)//2if nums[mid]<nums[r]:r=midelse:l=mid+1return 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,是面试中的常客,所以需要反复练习掌握。
具体题解可以参考:https://leetcode.cn/problems/median-of-two-sorted-arrays/solutions/3660794/deepseekti-jie-by-elk-r-xscq/
class Solution:def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:if len(nums1)>len(nums2):nums1,nums2=nums2,nums1m,n=len(nums1),len(nums2)l,r,half=0,m,(m+n+1)//2while l<=r:i=(l+r)//2j=half-i# j=half-i# =(m+n+1)//2-i# >=(m+n+1)//2-m# >=(n+1)//2-m//2# >=0 (n>=m)# 所以不用判断j是否大于0if i<m and nums1[i]<nums2[j-1]:l=i+1elif i>0 and nums1[i-1]>nums2[j]:r=i-1else: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_leftif 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 的平方根
74. 搜索二维矩阵
240. 搜索二维矩阵 II
2476. 二叉搜索树最近节点查询
278. 第一个错误的版本
374. 猜数字大小
222. 完全二叉树的节点个数
未完成
1539. 第 k 个缺失的正整数
540. 有序数组中的单一元素
1064. 不动点
702. 搜索长度未知的有序数组
2936. 包含相等值数字块的数量
1060. 有序数组中的缺失元素
1198. 找出所有行中最小公共元素
1428. 至少有一个 1 的最左端列
1533. 找到最大整数的索引
2387. 行排序矩阵的中位数
302. 包含全部黑色像素的最小矩形