二分查找详解【Python】

在此记录下二分查找的常用模板,包括查找指定数、查找左边界和右边界,以后解题就用这个模板。

一、查找指定数(基本的二分搜索)

def binarySearch(nums, target):
  left, right = 0, len(nums)-1  # 搜索区间两边为闭
  while left <= right:  # 注意停止条件,停止条件为[left, left+1]
    mid = left + (right - left) // 2
    # 所有情况都写出来
    if nums[mid] == target:
      return mid
    elif nums[mid] < target:
      left = mid + 1  # 因为mid已经搜索过
	else:
      right = mid - 1
   return -1

注意点

  1. 关于while left <= right为什么要取等号,是取决于leftright的初始值,或者说取决于搜索区间是开还是闭的问题。比如,如果left, right = 0, len(nums) - 1,那么说明搜索区间是两端都闭区间,因此循环的停止条件就应该是搜索区间为空,即[left, left + 1]。如果不加等号,那么到[left, left]就停止了,此时left没有被查找过,不正确。我们这里和下面采用的是两端都闭的搜索区间。
  2. 关于为什么 left = mid + 1,right = mid - 1,这也是跟搜索区间有关,因为我们搜索区间两端都闭,所以当mid已经被查找过,那么下一次当然是mid + 1 和 mid - 1了。

二、查找左边界,即第一个等于目标数的位置

def left_bound(nums, target):
	"""
	[1, 2, 4, 4, 5], target = 4
	"""
    left, right = 0, len(nums) - 1
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] < target:
            left = mid + 1
        elif nums[mid] > target:
            right = mid - 1
        else:
            right = mid - 1
    # 最终停止时right在2位置,left在4位置,所以返回left
    if left >= len(nums) or nums[left] != target:
        return -1
    return left
  1. 采用两端闭区间作为搜索区间
  2. 等于情况的更新,因为是左边界,所以更新右端点,right = mid - 1
  3. 异常情况判断:当停止时,left超过索引或nums[left] != target
  4. 最终情况是right在1的位置,left在2的位置,所以返回left

三、查找右边界,即最后一个等于目标数的位置

def right_bound(nums, target):
	"""
	[1, 2, 4, 4, 5], target = 4
	"""
    left, right = 0, len(nums) - 1
    while left <= right:
        mid = left + (right - left) // 2
        if nums[mid] < target:
            left = mid + 1
        elif nums[mid] > target:
            right = mid - 1
        else:
            left = mid + 1
        #  最终停止时left在5位置,right在4位置,所以返回left-1
    if left >= len(nums) or nums[left - 1] != target:
        return -1
    return left - 1

四、其他情况

  1. 查找第一个大于等于目标值的位置,[1, 2, 4, 4, 6], target = 3
    答:相当于查左边界,把最后的判断条件nums[left] != target删掉即可
  2. 查找最后一个小于等于目标值的位置,[1, 2, 4, 4, 6], target = 5
    答:相当于查右边界,把最后的判断条件nums[left - 1] != target删掉即可

五、二分查找具体应用

首先我们要知道,二分查找只适用于有序数组,那么除了上面我们讲的在有序数组中查找目标值以及边界,抛开有序数组这个枯燥的数据结构,二分查找如何运用到实际的算法问题中呢?当搜索空间有序的时候,就可以通过二分搜索「剪枝」,大幅提升效率。
下面用几个例题来说明二分查找在搜索空间中的优化求解

875. 爱吃香蕉的珂珂

珂珂喜欢吃香蕉。这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 H 小时后回来。珂珂可以决定她吃香蕉的速度 K (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 K 根。如果这堆香蕉少于 K 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。
珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。
返回她可以在 H 小时内吃掉所有香蕉的最小速度 K(K 为整数)

先抛开二分查找技巧,想想如何暴力解决这个问题呢?
首先,算法要求的是「H 小时内吃完香蕉的最小速度」,我们不妨称为 speed,请问 speed最大可能为多少,最少可能为多少呢?
显然最少为1,最大为max(piles),因为一小时最多只能吃一堆香蕉。那么暴力解法就很简单了,只要从 1开始穷举到 max(piles),一旦发现发现某个值可以在 H 小时内吃完所有香蕉,这个值就是最小速度。

那么二分查找如何优化呢?
由于我们要求的是最小速度,所以其实就是在搜索范围[1, max(piles)]内找到满足条件的左边界。我们定义一个函数isValid作为二分查找更新的判断条件,由于我们求左边界,所以当满足的时候更新右端点即可。

class Solution:
    def minEatingSpeed(self, piles, H):
        # 1. 首先可以缩小K的范围, 最小是1,最大是pile里面最大的那一堆
        maxnn = -1
        for i in range(len(piles)):
            if piles[i] > maxnn:
                maxnn = piles[i]

        def isValid(speed):
            # import math
            time = 0
            for j in range(len(piles)):
                tmp = piles[j] % speed
                if tmp == 0:
                    this_time = piles[j] // speed
                else:
                    this_time = piles[j] // speed + 1
                time += this_time
            return time <= H
        # 2. 二分查找[1, maxnn]里满足isValid的最小值
        left, right = 1, maxnn
        while left <= right:
            mid = left + (right - left) // 2
            if isValid(mid):  # 满足的话,缩小右边界
                right = mid - 1
            else:
                left = mid + 1

        return left
1011. 在 D 天内送达包裹的能力

传送带上的包裹必须在 D 天内从一个港口运送到另一个港口。
传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。
返回能在 D 天内将传送带上的所有包裹送达的船的最低运载能力。
输入:weights = [1,2,3,4,5,6,7,8,9,10], D = 5
输出:15
解释:
船舶最低载重 15 就能够在 5 天内送达所有包裹,如下所示:
第 1 天:1, 2, 3, 4, 5
第 2 天:6, 7
第 3 天:8
第 4 天:9
第 5 天:10
请注意,货物必须按照给定的顺序装运,因此使用载重能力为 14 的船舶并将包装分成 (2, 3, 4, 5), (1, 6, 7), (8), (9), (10) 是不允许的。

本质上和 Koko 吃香蕉的问题一样的,首先确定 最小值和最大值分别为 max(weights)sum(weights)。然后在该区间内寻找左边界即可。

class Solution:
    def shipWithinDays(self, weights, D):
        # 1.首先确定运输能力的上下界,分别为所有货物的和,所有货物中最重的那个
        left, right = max(weights), sum(weights)

        # 2. 二分查找
        def isValid(p):  # 以p能力能否在D天内运送好
            days = 0
            i = 0
            cur_sum = 0  # 当前货物的总重量
            while i < len(weights):
                cur_sum += weights[i]
                if cur_sum > p:
                    days += 1
                    cur_sum = weights[i]
                i += 1
            if cur_sum != 0:
                days += 1  # 最后一波
            return days <= D

        while left <= right:
            mid = left + (right - left) // 2
            if isValid(mid):
                right = mid - 1
            else:
                left = mid + 1

        return left

所以,经过上面两个例子,我们发现:对于寻找有序区间内的最优解,我们可以不用穷举暴力搜索的方式,而是可以转化成为二分查找左边界或右边界的问题,对于这类问题,使用二分查找的思路和模板可以写成以下形式:

def findOptim():
	# 1. 首先求出解的范围
	min_value, max_value = xx, xx  
	def isValid():
		"""
		表示满足条件的函数
		"""
	# 2. 区间内使用二分查找
	left, right = min_value, max_value
	while left <= right:
		mid = left + (right - left) // 2
		if isValid(mid):
		else:
	
二分查找高效判定子序列

如何判定字符串 s 是否是字符串 t 的子序列(可以假定 s 长度比较小,且 t 的长度非常大)。

s = “abc”, t = “ahbgdc”, return true.
s = “axc”, t = “ahbgdc”, return false.

题目很简单,也很难想到和二分查找有什么关联。首先,通常的双指针解法是这样的

def isSubsequence(s, t):
	i, j = 0, 0
	while i < len(s) and j < len(t):
		if s[i] == t[j]:
			i += 1
			j += 1
		else:
			j += 1
	return i == len(s)

这个解法的时间复杂度是 O ( n ) O(n) O(n) n n n为字符串t的长度。如果仅仅是这个问题,那么这种解法是最优解。
但是如果给你一系列字符串 s1,s2,...和字符串t,你需要判定每个串s是否是t的子序列(可以假定 s较短,t很长)。如果再用上面的解法,那么就是对于每一个s,都按照遍历一遍t的方法操作一遍,时间复杂度是 O ( m n ) O(mn) O(mn)。可是当t串的长度非常大时,时间复杂度就很高了。那么如何使用二分查找,使得复杂度大大降低呢?
二分查找思路:
对串t进行预处理,遍历一遍,将每个字符的下标存放在map中,<key, value> = <s, index>
在这里插入图片描述
那么有了这个map之后,就可以使用二分查找了。举例来说,当s=abc,已经匹配了ab,只需要去map[c]中查找第一个比index=3大的下标即可。因此,对于s中的每一个字符,只需要去map中用二分查找到对应的左边界index,其中左边界的target值是上一个字符匹配到在t中的index

这样的话, 算法复杂度为 O ( m l o g n ) O(mlogn) O(mlogn)。在 n n n很大,而 m m m相对较小的时候可以大大降低复杂度

def isSubsequence(s, t):
    # 预处理,构建字符和下标的字典
    map = {}  # 字典
    for i in range(len(t)):
        if t[i] in map.keys():
            map[t[i]].append(i)
        else:
            map[t[i]] = [i]

    # 查找第一个比target大的数
    def left_bound(nums, target):
        left, right = 0, len(nums) - 1
        while left <= right:
            mid = left + (right - left) // 2
            if nums[mid] <= target:
                left = mid + 1
            elif nums[mid] > target:
                right = mid - 1
        return left

    j = 0  # t中的开始位置
    for i in range(len(s)):
        # t中没有s[i]
        if s[i] not in map.keys():
            return False
        pos = left_bound(map[s[i]], j)
        # s[i]在t中的index没有比j还大的
        if pos == len(map[s[i]]):
            return False
        j = pos
    return True
287. 寻找重复数

给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。

示例 1:
输入: [1,3,4,2,2]
输出: 2

说明:
不能更改原数组(假设数组是只读的)。
只能使用额外的 O(1) 的空间。
时间复杂度小于 O(n2) 。
数组中只有一个重复的数字,但它可能不止重复出现一次。

这道题的难点在于有两个限制

  1. 不能更改原数组(假设数组是只读的)
  2. 只能使用额外的 O(1) 的空间。

如果没有这两个限制,容易想到的方法有:

  1. 使用哈希表判重,这违反了限制 2;
  2. 将原始数组排序,排序以后,重复的数相邻,即找到了重复数,这违反了限制 1;
  3. 用原地哈希,时间复杂度只有O(n),但违反了限制1。

思路

这道题要求我们查找的数是一个整数,并且给出了这个整数的范围(在 1 1 1 n n n 之间,包括 1 1 1 n n n),并且给出了一些限制,于是可以使用二分查找法定位在一个区间里的整数;我们的目标就是使用二分法从[1, n]里面找到这个重复元素。

二分法的思路是先猜一个数(有效范围[left, right]里的中间数 mid),然后统计原始数组中小于等于这个中间数的元素的个数 cnt,如果 cnt 严格大于 mid,(注意我加了着重号的部分「小于等于」、「严格大于」)。根据抽屉原理,重复元素就在区间 [left, mid]里。

    def findDuplicate(self, nums):
        """
        做法:
        因为给定了区间,数字在[1,n]中,所以可以使用二分
        我们的目标就是使用二分法从[1,n]中选取到重复元素
        那么思路就是对每次猜测的数mid,都去遍历原数组,计算小于等于mid的数的个数,如果个数严格大于mid,说明重复的数在[left, mid]中
        """
        left, right = 1, len(nums) - 1  # 猜测区间为[left, right]
        while left < right:  # left == right,就代表找到了重复元素
            mid = left + (right - left) // 2

            # 计算小于等于mid的个数
            cnt = 0
            for num in nums:
                if num <= mid:
                    cnt += 1

            if cnt > mid:
                right = mid  # 严格大于, 代表重复的元素在[left, mid]内
            elif cnt < mid:
                left = mid + 1  # 小于,代表重复的元素在(mid,right]内
            else:
                left = mid + 1  # 等于,说明mid也不是重复元素,代表重复的元素在(mid,right]内

        return left
378. 有序矩阵中第K小的元素

给定一个 n x n 矩阵,其中每行和每列元素均按升序排序,找到矩阵中第 k 小的元素。
请注意,它是排序后的第 k 小元素,而不是第 k 个不同的元素。

matrix = [
[ 1, 5, 9],
[10, 11, 13],
[12, 13, 15]
],
k = 8,
返回 13。

这道题用「堆」的解法是很容易想到的,时间复杂度为 O ( n l o g k ) O(nlogk) O(nlogk),其中 n n n是所有元素的个数, l o g k logk logk是维护一个大小为 k k k的堆的复杂度。但是这种做法没有利用题目所给二维矩阵每行、每列都是有序的特点,因此不是一种好的做法。

思路

同上面一道题类似,我们知道当前这个二维有序矩阵从左上角到右下角递增,所以值域为[matrix[0][0],matrix[-1][-1]],那么我们的目标就是在这个值域中通过「二分查找」找到目标值,使得目标值是矩阵中第 k k k小的元素。二分查找的判断条件和上题类似,如何判断当前「猜测的数mid」是否为第 k k k小的数,就可以转化为计算矩阵中小于等于该数的个数——利用二维有序数组行列有序的特性,可以从左下角开始遍历到右上角,以线性复杂度计算得到。

所以对于值域[matrix[0][0],matrix[-1][-1]]内的每个猜测的数mid,都去计算矩阵中「小于等于」mid的个数:

  1. 如果个数大于 k k k,说明第 k k k小的数小于等于mid,移动边界right=mid
  2. 如果个数恰好等于 k k k,说明第 k k k小的数小于等于mid,移动边界right=mid
  3. 如果个数小于 k k k,说明第 k k k小的数严格大于mid,移动边界left=mid + 1
    def kthSmallest(self, matrix, k):
        """
        对值域[matrix[0][0],matrix[-1][-1]]进行二分查找
        我们可以通过有序矩阵的特点,从左下角到右上角以线性遍历,来得到对于任意一个值x,矩阵中有多少数num不大于它
        那么二分查找的判断逻辑就是:
        1)当算出的数量大于k,说明我们要的目标值小于当前的mid
        2)当算出的数量小于k,说明我们要的目标值大于当前的mid
        3)当算出的数量等于k,说明我们要的目标值小于等于当前的mid,因为mid不一定在矩阵中出现,所以当等于的时候也要左移右边界
        时间复杂度O(nlog(r-l)),二分查找进行log(r-l)次,每次Log(n)复杂度
        """

        n = len(matrix)

        def calculate(mid):
            """
            计算矩阵中小于等于mid的数有多少个
            """
            count = 0
            i, j = n - 1, 0  # 左下角作为起点
            while i >= 0 and j < n:
                if matrix[i][j] <= mid:
                    count += i + 1  # 当前这列的上面都是符合条件的
                    j += 1  # 右移
                else:
                    i -= 1
            return count

        left, right = matrix[0][0], matrix[-1][-1]
        while left < right:
            mid = left + (right - left) // 2
            if calculate(mid) >= k:  # 当大于等于的时候,当前这个数有可能是满足条件的,所以right=mid
                right = mid
            else:  # 当小于的时候,当前这个数是不满足条件的(也就是说这个数不是第k小的数,充其量是第k-1小的数)
                # 所以left直接右移到mid+1
                left = mid + 1

        return left
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值