[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]

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总是指向前一个数。如果前一个数是我们要找的数,那么只能将rl逼近,触发终止条件;如果后一个数是我们要找的,那么只能将lr逼近,触发终止条件。所以只能用上面的写法,其他写法会导致死循环,永远退出不了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循环。

注意,

  1. 这题右边界取max(nums)即可,当然也可以直接取题干中nums[i]的范围作为左右边界:1 <= nums[i] <= 10^6
  2. 在check(x)==True时,要即时记下答案
  3. 求最小,所以是从大往小逼近,因此在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+1r=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=midl=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/

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]

相关推荐
独行soc16 分钟前
2025年渗透测试面试题总结-腾讯[实习]安全研究员(题目+回答)
linux·安全·web安全·面试·职场和发展·渗透测试
r0ysue_3 小时前
02.上帝之心算法用GPU计算提速50倍
算法·gpu
L_cl3 小时前
【Python 算法零基础 4.排序 ⑦ 桶排序】
数据结构·算法·排序算法
小O的算法实验室4 小时前
2025年AIR SCI1区TOP,多策略增强蜣螂算法MDBO+实际工程问题,深度解析+性能实测
算法·论文复现·智能算法·智能算法改进
花自向阳开10245 小时前
LeetCode hot100-11
数据结构·算法·leetcode
月亮被咬碎成星星5 小时前
LeetCode[404]左叶子之和
算法·leetcode
有梦想的骇客5 小时前
书籍在其他数都出现k次的数组中找到只出现一次的数(7)0603
算法
jiet_h6 小时前
Android Kotlin 算法详解:链表相关
android·算法·kotlin
数据潜水员6 小时前
C#基础语法
java·jvm·算法
天才测试猿7 小时前
接口自动化测试之pytest接口关联框架封装
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·pytest