【基础算法精讲 04】二分法……

12 阅读14分钟

题目:34. 在排序数组中查找元素的第一个和最后一个位置

解法:

获取目标值 target 的左端点索引,并将该值赋值给 start ,获取 (target + 1) 的左端点索引 - 1,并将该值赋值给end,[start,end]也即目标 target 在数组(列表)中的索引区间。如果 start 存在,end 就一定存在,返回 [start, end] 。当 start == len(nums) or nums[start] != target ,说明数组中不存在目标值 target ,返回 [-1,-1] 即可

形式变换:

题目中求 ≥ target 的切片区间,当进行需求变更,有如下三种方式: > target 可转化为 ≥ (target + 1)< target 可转化为 (≥ target) - 1, ≤ target 可转化为 (≥ (target + 1)) - 1

也即

数组元素为整数的情况下,求解不同边界有如下四种情况,情况2、3、4均可以看作情况1的变形:

情况 1、求 '≥ target' 的左边界,按照lower_bound(self, nums, target)进行搜寻即可

情况 2、求 '> target' 的左边界,相当于求'≥ (target + 1)'的左边界,按照lower_bound(self, nums, target + 1)进行搜寻即可
情况 3、求'< target' 的右边界,相当于求'≥ target'的左边界,-1 就是<target的右边界,按照 lower_bound(self, nums, target) - 1 进行搜寻即可
情况 4、求'≤ target' 的右边界,相当于求'≥ (target + 1)'的左边界,-1 就是≤target的右边界,按照lower_bound(self, nums, target + 1) - 1

时间复杂度:O(log n)

空间复杂度:O(1)

代码实现:

class Solution:
    def searchRange(self, nums: List[int], target: int) -> List[int]:
        start = self.lower_bound4(nums, target)
        if start == len(nums) or nums[start] != target: # nums[start] != target 这个条件该怎么理解
            return [-1, -1]
        end = self.lower_bound4(nums, target + 1) - 1
        return [start, end]
    
    # 方法一:闭区间 [left, right]
    def lower_bound(self, nums: List[int], target: int) -> int:
        left, right = 0, len(nums) - 1 # 闭区间 [left, right]
        while left <= right:
            mid = (left + right) // 2 # 向下取整
            if nums[mid] >= target:
                right = mid - 1 # [left, mid - 1]
            else:
                left = mid + 1 # [mid + 1, right]
            
        return right + 1 # 此时 right - 1 = left, 返回left 结果一样
    
    # 方法二:开区间 (left, right)
    def lower_bound2(self, nums: List[int], target: int) -> int:
        left, right = -1, len(nums) # 开区间 (left, right)
        while left + 1 < right:
            mid = (left + right) // 2 # 向下取整
            if nums[mid] >= target:
                right = mid # (left, mid)
            else:
                left = mid # (mid, right)
            
        return right # 或 left + 1

    # 方法三:左闭右开区间:[left, right)
    def lower_bound3(self, nums: List[int], target: int) -> int:
        left, right = 0, len(nums) # 开区间 [left, right)
        while left < right:
            mid = (left + right) // 2 # 向下取整
            if nums[mid] >= target:
                right = mid # [left, mid)
            else:
                left = mid + 1 # [mid + 1, right)
            
        return right # 循环终止条件 left == right, 返回left 或 right均可

    # 方法四:左开右闭区间:(left, right]
    def lower_bound4(self, nums: List[int], target: int) -> int:
        left, right = -1, len(nums) - 1 # 左开右闭区间 (left, right]
        while left < right:
            mid = (left + right + 1) // 2 # 向下取整 -> 向上取整
            if nums[mid] >= target:
                right = mid - 1# (left, mid - 1]
            else:
                left = mid # (mid, right]
            
        return right + 1 # 循环终止条件 left + 1 == right, 返回left + 1 或 right均可   

题目:2529. 正整数和负整数的最大计数

解法:

1、负整数区间需要求解 <0 的右边界,转换为:求解≥0的左边界 - 1(如情况3),也即:lower_bound(self, nums, target) - 1

2、正整数区间需要求解 ≥1 的左边界,带入函数即可,也即:lower_bound(self, nums, 1)

也即:

数组元素为整数的情况下,求解不同边界可以遵循如下规则:
情况 1、求 '≥ target' 的左边界,按照lower_bound(self, nums, target)进行搜寻即可
情况 2、求 '> target' 的左边界,相当于求'≥ (target + 1)'的左边界,按照lower_bound(self, nums, target + 1)进行搜寻即可
情况 3、求'< target' 的右边界,相当于求'≥ target'的左边界,-1 就是<target的右边界,按照 lower_bound(self, nums, target) - 1 进行搜寻即可
情况 4、求'≤ target' 的右边界,相当于求'≥ (target + 1)'的左边界,-1 就是≤target的右边界,按照lower_bound(self, nums, target + 1) - 1

时间复杂度:O(log n)

空间复杂度:O(1)

代码实现:基础写法要会,但更要关注二分法!

class Solution:
    def maximumCount(self, nums: List[int]) -> int:
        # 正整数区间:找到 >0 的位置,[right, len(nums)]
        left_bound_index = self.lower_bound(nums, 1)
        left_value = len(nums) - left_bound_index

        # 负整数区间:找到 <0 的位置,[-1, left]
        right_bound_index = self.lower_bound(nums, 0) - 1
        right_value = right_bound_index + 1
        return max(left_value, right_value)


    def lower_bound(self, nums: List[int], target) -> int:
        left, right = 0, len(nums) - 1 # 闭区间 [left, right]
        while left <= right: 
            mid = (left + right) // 2 # 向下取整
            if nums[mid] >= target:
                right = mid - 1
            else: # nums[mid] < target
                left = mid + 1
        return right + 1 # 终止条件 right < left,此时返回left 或 right + 1均可


暴力解法:
class Solution:
    def maximumCount(self, nums: List[int]) -> int:
        # 负整数 求右边界 right
        # 正整数 求左边界 left
        right = cnt0 = 0
        left = len(nums) - 1
        for i in range(len(nums)):
            if nums[i] < 0:
                right = i
            elif nums[i] == 0:
                cnt0 += 1
            else:
                left = i
                break
        if cnt0 == len(nums):
            return 0
        
        return max(right + 1, len(nums) - left)

题目:

解法:

时间复杂度:O(n log n),接近10^6,时间复杂度测评一般内置是10^7~10^8.

空间复杂度:O(1)

代码实现:


# 方法一:暴力解法,超时
class Solution:
    def successfulPairs(self, spells: List[int], potions: List[int], success: int) -> List[int]:
        # 两层for 循环
        pairs = [0] * len(spells)

        for i, x in enumerate(spells):
            cnt = 0
            for j, y in enumerate(potions):
                if x * y >= success:
                    cnt += 1
            pairs[i] = cnt
        return pairs

# 方法二:for 循环 + 二分法(前提是排好序)
class Solution:
    def successfulPairs(self, spells: List[int], potions: List[int], success: int) -> List[int]:
        # 整体思路:for循环 + 二分查找
        # 定义数组 pairs
        pairs = [0] * len(spells)
        # 数组排序,二分查找的前提
        potions.sort()
        for i, x in enumerate(spells):
            # 二分查找 potions,找到符合条件的左边界 right + 1 / left
            left, right = 0, len(potions) - 1 # 闭区间 [left, right]
            while left <= right:
                mid = (left + right) // 2
                if x * potions[mid] >= success:
                    right = mid - 1
                else:
                    left = mid + 1
            # 左边界 right + 1 / left
            pairs[i] = len(potions) - (right + 1)
        return pairs

题目:1385. 两个数组间的距离值

解法:

暴力:略

for循环遍历 + 二分查找(推荐)。问题是|arr1[i]-arr2[j]| <= d ,可转化为arr2[j] 是否存在在区间[arr1[i] - d, arr1[j] + d],不存在ans += 1,获取区间 ≥ arr1[i] - d的最小值y,如果不存在该最小值i == len(arr2),或者最小值y 大于区间最大值 arr[i] + d, 则结果进行 +1

# 思路:for循环 + 二分查找(排序)
# 问题:|arr1[i]-arr2[j]| <= d 
# 转化为:arr2[j] 是否存在∈ [arr1[i] - d, arr1[i] + d],如果不存在,符合题意 ans += 1

时间复杂度:O(n log m)

空间复杂度:O(1)

代码实现:

# 方法一:暴力解法,两层for循环
class Solution:
    def findTheDistanceValue(self, arr1: List[int], arr2: List[int], d: int) -> int:
        # 暴力解法,两层for循环
        cnt = 0
        for i, x in enumerate(arr1):
            flag = 0
            for j, y in enumerate(arr2):
                if abs(x - y) <= d:
                    flag = 1
                    break
            if flag == 0:
                cnt += 1
            else:
                continue
        return cnt
        
# 方法二:for循环 + 二分查找 (推荐)
class Solution:
    def findTheDistanceValue(self, arr1: List[int], arr2: List[int], d: int) -> int:
        # for循环 + 二分查找(排序)
        # 问题:|arr1[i]-arr2[j]| <= d 
        # 转化为:arr2[j] 是否存在∈ [arr1[i] - d, arr1[i] + d],如果不存在,符合题意 ans += 1
        arr2.sort()
        ans = 0
        for x in arr1:
            # 获取arr2中arr1[i] - d,也即 x-d 的最小值的左边界
            i = bisect_left(arr2, x - d)
            if i == len(arr2) or arr2[i] > x + d:
                ans += 1
        return ans

题目:2080. 区间内查询数字的频率

解法:

1、创建空字典,底层是列表的形式
2、遍历将元素值作为key,索引作为value存储到字典中,遍历是有序的,则每个元素对应的索引也是有序的(二分查找的前提)
3、在query()中获取value的列表,返回元素为value的在[left, right]的元素个数,
    bisect_right 筛选的是>value时的下标,
    bisect_left 筛选的是 ≥value的左边界的索引,
    两个索引之差即为value值在[left,right]区间出现的次数

时间复杂度:O(log n)

空间复杂度:O(1)

代码实现:

class RangeFreqQuery:

    def __init__(self, arr: List[int]):
        pos = defaultdict(list) # 创建空字典,内部是列表的形式
        for i, x in enumerate(arr):
            pos[x].append(i) # 将元素值为x的索引下标存储到pos中,由于按顺序遍历,索引列表天然有序(这是二分查找的前提!)
        self.pos = pos # # self.pos 属于对象实例,每个对象有自己的 pos

    def query(self, left: int, right: int, value: int) -> int:
        a = self.pos[value] # 获取值为value的列表a,a中存储着在arr中的所有索引(有序);如果value不存在,返回空列表[] 
        # 求value 在区间[left,right]的索引个数
        # bisect_left(a, left) → 第一个 >= left 的位置 
        # bisect_right(a, right) → 第一个 > right 的位置 
        # 两者相减 = 索引在 [left, right] 范围内的个数
        return bisect_right(a, right) - bisect_left(a, left) 

# Your RangeFreqQuery object will be instantiated and called as such:
# obj = RangeFreqQuery(arr)
# param_1 = obj.query(left,right,value)

题目:2563. 统计公平数对的数目

解法:for循环 + 二分查找(提前排序,且排序不影响结果)

时间复杂度:O(nlog n)

空间复杂度:O(1)

代码实现:

# for循环 + 二分查找
class Solution:
    def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:
        # 排序不影响结果,加法满足交换定律
        # for + 二分查找
        ans = 0
        nums.sort()
        n = len(nums)
        for i in range(n):
            x = nums[i]
            j = i + 1
            ans += bisect_right(nums, upper - x, j) - bisect_left(nums, lower - x, j)
        return ans


方法一:暴力解法,超时,(10^5)^2,远大于平台每秒执行的时间复杂度能力:10^7~10^8class Solution:
    def countFairPairs(self, nums: List[int], lower: int, upper: int) -> int:
        ans = 0
        for i in range(len(nums) - 1):
            x = nums[i]
            for j in range(i + 1, len(nums)):
                y = nums[j]
                s = x + y
                if s >= lower and s <= upper:
                    ans += 1
        return ans

题目:875. 爱吃香蕉的珂珂

解法:

AI释义:www.qianwen.com/share/chat/…

时间复杂度:O(log n)

空间复杂度:O(1)

代码实现:

class Solution:
    def minEatingSpeed(self, piles: List[int], h: int) -> int:
        n = len(piles)
        left = 0 # 恒为 False
        right = max(piles) # 恒为 True
        while left + 1 < right: # 开区间不为空
            mid = (left + right) // 2
            if sum((p - 1) // mid for p in piles) <= h - n:
                right = mid # 循环不变量:恒为 True
            else:
                left = mid # 循环不变量:恒为 False
        return right

题目:2187. 完成旅途的最少时间

解法:见代码实现

时间复杂度:O(log n)

空间复杂度:O(1)

代码实现:

class Solution:
    def minimumTime(self, time: List[int], totalTrips: int) -> int:
        min_t = min(time) 
        left = min_t - 1 # 循环不变量:sum >= totalTrips 恒为 False
        right = min_t * totalTrips # 循环不变量:sum >= totalTrips 恒为 True
        while left + 1 < right: # 开区间 (left, right) 不为空
            mid = (left + right) // 2
            if sum(mid // t for t in time) >= totalTrips:
                right = mid # 缩小二分区间为 (left, mid)
            else:
                left = mid # 缩小二分区间为 (mid, right)
        # 终止循环,此时 left 等于 right - 1
        # sum(left) < totalTrips 且 sum(right) >= totalTrips
        return right

题目:275. H 指数 II

解法:见代码实现

时间复杂度:O(log n)

空间复杂度:O(1)

代码实现:

class Solution:
    def hIndex(self, citations: List[int]) -> int:
        # 在开区间 (left, right) 内问询
        left = 0 # 恒为 True
        right = len(citations) + 1 # 恒为 False

        while left + 1 < right: # 区间不为空
            # 循环不变量:
            # left 的回答一定为:是
            # right 的回答一定为:否
            mid = (left + right) // 2
            # 引用次数最多的 mid 篇论文,引用次数均 >= mid
            if citations[-mid] >= mid:
                left = mid # 询问范围缩小到 (mid, right)
            else:
                right = mid # 询问范围缩小到 (left, mid)
        # 根据循环不变量,left 现在是最大的回答为 是 的数
        return left

题目:2861. 最大合金数

解法:见代码实现

时间复杂度:O(nklog n)

空间复杂度:O(1)

代码实现:

class Solution:
    def maxNumberOfAlloys(self, n: int, k: int, budget: int, composition: List[List[int]], stock: List[int], cost: List[int]) -> int:
        # 二分搜索边界:left最小1个,right最大2×10^8个,ans记录答案
        left, right, ans = 1, 2 * 10 ** 8, 0
        
        # 闭区间二分搜索,left>right时结束
        while left <= right:
            # 当前尝试制造的合金数量
            mid = (left + right) // 2
            
            # 标记是否存在机器能在预算内制造mid个合金
            valid = False
            
            # 遍历每台机器,检查是否可行
            for i in range(k):
                # 当前机器制造mid个合金的总花费
                spend = 0
                
                # 遍历每种金属,计算花费
                for j, (composition_j, stock_j, cost_j) in enumerate(zip(composition[i], stock, cost)):
                    # 花费 += 需购买数量 × 单价(库存足够则不买)
                    spend += max(composition_j * mid - stock_j, 0) * cost_j
                
                # 当前机器花费不超过预算,说明可行
                if spend <= budget:
                    valid = True
                    break  # 找到一台可行机器即可,无需继续检查 # 只要有一台机器i,满足制造mid个合金即可,不需要要几个机器的数量
            
            # 根据可行性调整搜索范围
            if valid:
                ans = mid          # 更新答案
                left = mid + 1     # 尝试更多合金
            else:
                right = mid - 1    # 减少合金数量
        
        # 返回最大可制造合金数
        return ans

题目:2439. 最小化数组中的最大值

解法:见代码实现

时间复杂度:O(log n)

空间复杂度:O(1)

代码实现:

class Solution:
    def minimizeArrayValue(self, nums: List[int]) -> int:
        '''
        题目核心规则:
        - 只能将 nums[i] 的值转移给 nums[i - 1] (从右向左)
        - 不能将 nums[i-1] 的值转移给 nums[i] (从左向右)
        - 目标:让数组中最大值尽可能小
        '''
        # 方法一:二分搜索 + 检查函数
        def check(limit: int) -> bool:
            '''
            检查是否能让所有元素都不超过 limit
            思路:从右向左遍历,把超过limit的值向左转移
            '''
            extra = 0 # 需要向左转移的多余值
            
            for i in range(len(nums) - 1, -1, -1): # 从倒数第二个元素开始,从右向左遍历
                current = nums[i] + extra # 当前位置的实际值(包含从右边转移过来的)

                if current > limit: # 超过 limit, 多出的部分向左转移
                    extra = current - limit
                else:
                    extra = 0 # 没超过,不需要转移
            return extra == 0
        
        # 二分搜索框架
        left, right = 0, max(nums) # 答案范围:0 到 最大值
        while left <= right:
            mid = (left + right) // 2
            if check(mid): # mid 可行,尝试更小的值
                right = mid - 1
            else: # mid 不可行,需要更大的值
                left = mid + 1
        return right
        
        
class Solution:
    def minimizeArrayValue(self, nums: List[int]) -> int:
        '''
        贪心解法:前缀和平均值

        核心思想L:
        - 前 i 个元素的总和是固定的(只能从右向左转移)
        - 前 i 个元素的平均值就是这部分的理论最小值
        - 答案 = 所有前缀平均值中的最大值
        '''
        ans = 0
        prefix_sum = 0

        for i, num in enumerate(nums):
            prefix_sum += num # 累加前缀和

            # 前i+1个元素的平均值(向上取整)
            # (prefix_sum + i) // (i + 1) 等价于 ceil(prefix_sum / (i + 1))
            avg = (prefix_sum + i) // (i + 1)

            # 更新答案
            ans = max(ans, avg)
        return ans

题目:2517. 礼盒的最大甜蜜度

解法:见代码实现

时间复杂度:O(nlogn+nlogC),其中 n 是数组 price 的长度,C 是数组 price 的最大值与最小值之差。排序的时间是 O(nlogn),二分查找的次数是 O(logC),每次查找的时间是 O(n)。

空间复杂度:O(logn),其中 n 是数组 price 的长度。为排序的空间复杂度。

代码实现:

class Solution:
    def maximumTastiness(self, price: List[int], k: int) -> int:
        def f(d: int) -> int:
            cnt = 1 # 计数器, 至少选第一个糖果
            pre = price[0] # 第一个价格(甜度),赋值给pre 记录上一个选中的糖果价格(初始化第一个)
            for p in price: # 遍历所有糖果价格
                if p - pre >= d: # 如果当前糖果与上一个选中的糖果价格差 >= 目标甜蜜度 d
                    cnt += 1 # 可以选当前糖果
                    pre = p # 更新上一个选中的糖果价格
            return cnt # 返回能选出的糖果总数

        price.sort() # 第一步:对价格数组排序,排序后便于贪心选择,从小到大依次考虑
        left = 0 # 第二步:确定二分搜索的边界 ,left为最小可能的甜蜜度(0 表示可以选价格相同的糖果)
        right = (price[-1] - price[0]) // (k - 1) # 最大可能的甜蜜度上界,总间隔为 right

        while left <= right: # 第三步:二分搜索寻找最大可行甜蜜度
            mid = (left + right) // 2 # 计算中间值作为当前尝试的甜蜜度
            if f(mid) >= k: # 检查甜蜜度为 mid 的甜蜜度时,能否选出至少 k 个糖果
                left = mid + 1 # 可行:当前甜蜜度可以达到,尝试更大的甜蜜度
            else:
                right = mid - 1 # 不可行,当前甜蜜度太高,需要降低
        return right # 返回最大甜蜜度,循环结束时,right 是最后一个满足 f(right) >= k 的值