算法学习笔记——常用技巧:双指针(二分搜索)

二分搜索基本框架

下面是基本代码框架,后面所有写法都是它的变形

def binarySearch(arr: list, target: int) -> int:
    left = 0
    right = ...
    while ...:
        mid = (left + right) // 2
        if arr[mid] == target:
            ...
        elif arr[mid] < target:
            left = ...
        elif arr[mid] > target:
            right = ...
    return ...
  • 这里用elif语句列举出所有情况,以便思路清晰,弄懂后可以用else写出更简化的版本
  • 如果所用语言有整型溢出问题,那么mid的计算改为 mid = left + ( right - left) // 2,防止溢出
  • 下面有两种不同写法:while left <= rightwhile left < right,两种写法是等效的,主要区别在于,搜索区间是[left,right]还是[left,right)

二分搜索逻辑框架

初始化:left = 0right = len(arr) - 1
这决定了搜索区间[left,right]
因而循环条件是while left <= right
同时也决定了每次更改搜索区间的方式是left = mid + 1right = mid - 1

1.基本二分搜索

用于在有序数组中寻找某个数的下标

只要找到了目标值,即if arr[mid] == target,直接返回下标mid即可
如果while循环终止,此时left = right + 1,相当于搜索区间为空,意味着没找到,返回-1

def binarySearch(arr: list, target: int) -> int:
    """传入升序的有序数组,返回target的的下标(有多个只返回在中间的那一个),找不到返回-1"""
    left = 0
    right = len(arr) - 1
    while left <= right:
        mid = (left + right) // 2
        if arr[mid] == target:  # 找到目标数字
            return mid
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid - 1
    return -1

这个算法用于数组中每个元素只出现一次的情况
如果目标数字出现多次,返回的是“靠中间”的那个下标

2.寻找左侧边界的二分搜索

用于在有序数组中搜索某个数第一次出现的下标

只需修改两处代码:

def left_bound(arr: list, target: int) -> int:
    """传入升序的有序数组,返回target的第一次出现的下标,找不到返回-1"""
    left = 0
    right = len(arr) - 1
    while left <= right:
        mid = (left + right) // 2
        if arr[mid] == target:
            # 不返回,收缩右边界,锁定左边界
            right = mid - 1
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid - 1
    # 如果找到,则left为答案,也可能找不到,返回-1
    if left >= len(arr) or arr[left] != target:  # 小心left越界
        return -1
    # 省略上面的判断,则返回值的意义是:数组中有多少元素的值小于target
    return left

该方法能够搜索左侧边界的原理:找到target后不是立即返回,而是缩小搜索区间的上限,在[left,mid-1]中继续搜索,即不断向左收缩,从而锁定左侧边界

要注意的是,while结束的条件是left==right+1,因此left可能越界(如果target比arr中所有数都大),最终要检查越界情况

3.寻找右侧边界的二分搜索

用于在有序数组中搜索某个数最后一次出现的下标

同样只需修改两处代码:

def right_bound(arr: list, target: int) -> int:
    """传入升序的有序数组,返回target的最后一次出现的下标,找不到返回-1"""
    left = 0
    right = len(arr) - 1
    while left <= right:
        mid = (left + right) // 2
        if arr[mid] == target:
            # 不返回,收缩左边界,锁定右边界
            left = mid + 1
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid - 1
    # 如果找到,则left为答案,也可能找不到,返回-1
    if right < 0 or arr[right] != target:  # 小心right越界
        return -1
    return right

该方法能够搜索右侧边界的原理:找到target后不是立即返回,而是增大搜索区间的下限,在[mid+1,right]中继续搜索,即不断向右收缩,从而锁定右侧边界

要注意的是,while结束的条件是left-1==right,因此right可能越界(如果target比arr中所有数都小),最终要检查越界情况

另外一种常见的写法

对于上面2、3点的左侧边界、右侧边界的二分搜索,另一种常见的写法是while left < right,这相当于搜索区间[left,right)左闭右开区间

逻辑如下:
初始化:left = 0right = len(arr)
这决定了搜索区间是[left,right)
因而循环条件是while left < right(当left == right,搜索区间为空,循环终止)
同时也决定了每次更改搜索区间的方式是left = mid + 1right = mid

搜索左侧边界的思路同上,即不断收缩搜索区间的上限
搜索右侧边界时的思路也同上,但是返回答案时略有不同:由于收缩搜索区间下限时必须left = mid + 1,所以最终无论返回left或right(此处while结束时两者相等)时,必须减1

def left_bound_2(arr: list, target: int) -> int:
    """写法2"""
    left = 0
    right = len(arr)
    while left < right:
        mid = (left + right) // 2
        if arr[mid] == target:
            # 不返回,收缩右边界,锁定左边界
            right = mid
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid
    # 如果找到,则left为答案,也可能找不到,返回-1
    if left >= len(arr) or arr[left] != target:  # 小心left越界
        return -1
    # 省略上面的判断,则返回值的意义是:数组中有多少元素的值小于target
    return left


def right_bound_2(arr: list, target: int) -> int:
    """写法2"""
    left = 0
    right = len(arr)
    while left < right:
        mid = (left + right) // 2
        if arr[mid] == target:
            # 不返回,收缩左边界,锁定右边界
            left = mid + 1
        elif arr[mid] < target:
            left = mid + 1
        elif arr[mid] > target:
            right = mid
    # 由于收缩搜索区间下限时left = mid + 1,所以最终返回right(或left,两者相等)时,必须减1
    # 如果找到,则(right-1)为答案,也可能找不到,返回-1
    if right - 1 < 0 or arr[right - 1] != target:  # 小心right越界
        return -1
    return right - 1

应用

  1. 二分搜索最普遍的应用场景是在有序数组中搜索元素
  2. 稍作推广,只要问题的搜索空间有序时,对连续空间的线性搜索可以用二分搜索
  3. 最后将看到,搜索空间有二段性,(左边部分的空间与右边部分的空间有截然不同的特点,而分隔点就是所需的目标点),也可用二分搜索

总结:一般的思路过程是,先写出暴力解法,然后发现搜索空间的有序性/连续性/二段性,进而考虑通过二分搜索优化效率

搜索空间有序

LeetCode 1011. 在 D 天内送达包裹的能力
传送带上的第 i 个包裹的重量为 weights[i] ,货物不可分割且必须按顺序运输,货船每天能运输一定量的货物
现在需要在D天内运输所有货物,求船的最低运载能力

分析:

  • 货物不可分割,每日运载量下限至少为max(weights),(每天运一件),最大为sum(weights),(一天内运完)
  • 朴素的做法是,从下限开始线性搜索,找到的第一个符合条件的运载量就是答案,但注意到搜索空间线性连续、有序,因此用二分搜索效率更高
  • 注意细节:找最低运载量,应该是搜索左侧边界的二分搜索
class Solution:
    def shipWithinDays(self, weights: List[int], days: int) -> int:
        # 货物不可分割,每日运载量下限至少为max(weights),(每天运一件),最大为sum(weights),(一天内运完)
        # 搜索空间是连续递增的,使用二分搜索(注意求下限,应该是搜索左侧边界的二分搜索)
        def canFinish(capacity):
            """判断运载量为capacity时,能否在D天内完成任务"""
            i = 0  # 当前运输的货物下标
            for day in range(days):
                today_cap = capacity
                while today_cap - weights[i] >= 0:  # 还能装载,就多装一点
                    today_cap -= weights[i]
                    i += 1
                    if i == L:
                        return True
            return False

        L = len(weights)
        l, r, = max(weights), sum(weights)
        while l <= r:
            mid = (l + r) >> 1
            if canFinish(mid):
                # 找到符合要求的承载,先收缩右边界
                r = mid - 1
            else:
                l = mid + 1
        return l

类似的题目还有 LeetCode 875. 爱吃香蕉的珂珂

搜索空间有二段性

LeetCode 540. 有序数组中的单一元素
有序数组中,每个元素都会出现两次,唯有一个数仅出现一次,找出该元素的值,要求解决方案必须满足 O(log n) 时间复杂度和 O(1) 空间复杂度

  • 数组是有序的,那么容易得出这样的规律:
    成对的元素中,第一个必为偶数下标,第二个为奇数下标
  • 然而,唯一的单个元素将数组拆为两段:
    左侧满足上述规律,右侧的规律翻转(第一个必为奇数下标,第二个为偶数下标)
  • 因此可利用二段性进行二分搜索:如果判断当前位于“左半部分”,搜索区间向右收缩;当前位于“右半部分”,搜索区间向左收缩
class Solution:
    def singleNonDuplicate(self, nums: List[int]) -> int:
        # 有序数组,成对的元素中,第一个必为偶数下标,第二个为奇数下标
        # 单个元素出现后,结论翻转
        L = len(nums)
        if L == 1:
            return nums[0]
        l, r = 0, L - 1
        while l <= r:
            mid = (l + r) >> 1
            if mid + 1 < L and nums[mid] == nums[mid + 1]:
                if not mid & 1:  # 第一个为偶数下标
                    l = mid + 2
                else:  # 第一个为奇数下标
                    r = mid - 1

            elif mid >= 0 and nums[mid] == nums[mid - 1]:
                if mid & 1:  # 第二个为奇数下标
                    l = mid + 1
                else:  # 第二个为偶数下标
                    r = mid - 2

            else:  # 不等于前/后,找到目标
                return nums[mid]

另外,在LeetCode 887. 鸡蛋掉落中也可以用二分搜索提高效率:
我们要求两条图线的交点,它把搜索空间分为两段:“左侧”是图线1高于图线2,“右侧”是图线2高于图线1,因此也可用二分搜索
详见动态规划:高楼扔鸡蛋
在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值