二分查找

767 阅读6分钟

实际写过二分查找的人应该都有体会到细节是魔鬼,连计算机领域的泰斗都说过:Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky。

目前刷题时遇到好几个二分查找的题,一开始是处处碰壁。看大神的题解和博客找到一个比较好的模板,可以解决常见的二分查找问题。趁着这个机会自己再梳理总结下,免得以后再采坑。

参考资料:特别好用的二分查找法模板(第 2 版)

模板说明

核心思想:把待搜索的目标元素放在最后判断,每一次循环排除掉不存在目标元素的区间,目的依然是确定下一轮搜索的区间。

step1:确定左右边界,即left和right的初始值。

step2:查找中通常只写两个分支(排除中位数和不排除中位数),并且先写容易识别的分支(通常是排除中位数的分支)。

step3:根据分支逻辑确定mid为左中位数((left+right)//2)还是右中位数((left+right+1)//2),依据是确保不陷入死循环。

step4:退出查询循环时left=right,根据具体问题确定后处理步骤。

二分查找题目汇总

正用-直接套模板

704. 二分查找

35. 搜索插入位置

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

旋转数组的

33. 搜索旋转排序数组

81. 搜索旋转排序数组 II

变形用-分支比较的目标值取数组值

162. 寻找峰值

变形用-分支比较的目标值计算而得

287. 寻找重复数

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

变形用-待描述

4. 寻找两个正序数组的中位数

正用-直接套模板

704. 二分查找

题目描述:

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

题解:

easy级别,直接套用模板即可。注意while循环的判断条件是left<right,这保证在结束循环时left等于right,简化后处理,不需要再做过多的逻辑判断。

class Solution:
    def search(self, nums: List[int], target: int) -> int:
        #step1:确定左右边界
        #左右边界一般固定为0和len(nums)
        left, right = 0, len(nums)
        while left<right: #二分查找的固定语句
            #step3:根据分支确定mid取左中位数还是右中位数,依据是不陷入死循环
            mid = (left+right)>>1
            #step2:只写两个分支,并且先写容易识别的分支
            #当nums[mid]比目标值小时,可以确定target不在[left, mid]间, 因此left=mid+1
            if nums[mid]<target:
                #print(left, right)
                left = mid+1
            else:
                #print(left, right)
                right = mid
        #step4:退出循环时left=right, 根据实际情况做后处理
        if left==len(nums) or nums[left]!=target:
            return -1
        else:
            return left

35. 搜索插入位置

题目描述:

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

题解:

easy级别,直接套用模板即可。

class Solution:
    def searchInsert(self, nums: List[int], target: int) -> int:
        # step1: 确认左右边界
        # 由于本题的结果有可能是len(nums), 所以右边界必须取len(nums)
        left, right = 0, len(nums)
        while left<right:
            # step3:确定位数据取值
            mid = (left+right)>>1  #左中位数
            # step2:只写两个分支,并且先写容易识别的分支
            # 由于当target不存在时, 需要返回其应该插入的位置
            # 因此只有在nums[mid]<target, 能确定target不能插入[left,mid]上
            if nums[mid]<target:
                left = mid + 1
            else:
                right = mid
        # step4: 不需要后处理, 直接返回索引即可
        return left

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

题目描述:

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值,返回 [-1, -1]

题解:

题目的要求等价于求target在有序数组中的左右边界

左边界的求解要求只有在很明确的情况下才移动左边界,即在nums[mid]<target时可以确定左边界不在[left,mid]中(对应left=mid+1);

右边界的求解要求只有在很明确的情况下才移动右边界,即在nums[mid]>target时可以确定右边界不在[mid,right](对right=mid-1)。

class Solution:
    def searchRange(self, nums: List[int], target: int) -> List[int]:
        def leftEdge(nums, target):
            if len(nums)==0:
                return -1
            left = 0
            right = len(nums)-1
            while left<right:
                mid = left+(right-left)//2 
                if nums[mid]<target:
                    left = mid+1  #只有在很明确的条件下才做-1+1移位
                else:
                    right = mid   
            return left if nums[left]==target else -1
                
        def rightEdge(nums, right):
            if len(nums)==0:
                return -1
            left = 0
            right = len(nums)-1
            while left<right:
                mid = left+(right-left+1)//2  #取右中位数,免得如下陷入死循环
                if nums[mid]>target:
                    right = mid-1  #只有在很明确的条件下才做-1+1移位
                else:
                    left = mid
            return left if nums[left]==target else -1
        
        return [leftEdge(nums, target), rightEdge(nums, target)]
                

旋转数组使用二分查询

33. 搜索旋转排序数组

题目描述:

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] 。

搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。

假设数组中不存在重复的元素。

题解:

本题的难点在于分支的设定,由于是旋转数组,两个分支不能完全包住,需要增加分支。但是整体思想依然是先写容易识别的分支,也就是排除中位数的分支,此时分两种场景。

case1:左分支有序且target不在左分支,即nums[left]<nums[mid]nums[left]>target or nums[mid]<target此时left=mid+1

case2:左分支存在旋转点且target不在左分支,即nums[left]>nums[mid]nums[left]<target <nums[mid],此时left=mid+1。

otherwise:right=mid。

由于nums不存在重复数据,因此不需要考虑nums[left]==nums[mid]的场景。

class Solution:
    def search(self, nums: List[int], target: int) -> int:
        if len(nums)==0:
            return -1
        #step1:确定左右边界
        #由于target可能不存在,因此right=len(nums)
        left, right = 0, len(nums)-1
        while left<right:
            #step3:根据分支逻辑确定mid取左中位数还是右中位数,依据是不陷入死循环
            mid = (left+right)>>1
            #step2:只写两个分支,并且先写容易识别分支(例外)
            #左分支有序,且target不在左分支的范围内
            if nums[left]<nums[mid] and (nums[left]>target or nums[mid]<target):
                left = mid+1
            #左分支无序,且target不在左分支范围内
            elif nums[left]>nums[mid] and (nums[mid]<target<nums[left]):
                left = mid+1
            else:
                right = mid
        #step4:退出循环时left=right,根据具体问题做后处理
        if nums[left]!=target:
            return -1
        else:
            return left

81. 搜索旋转排序数组 II

题目描述:

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

例如,数组 [0,0,1,2,2,5,6] 可能变为 [2,5,6,0,0,1,2] 。

编写一个函数来判断给定的目标值是否存在于数组中。若存在返回 true,否则返回 false。

题解:

这个题目和33题的区别在于本题中nums中可能存在重复数据。这导致分支判断中当nums[left]==nums[mid]时无法确定往那边移动,这时还需要再判断

case1:左分支有序且target不在左分支,即nums[left]<nums[mid]nums[left]>target or nums[mid]<target此时left=mid+1

case2:左分支存在旋转点且target不在左分支,即nums[left]>nums[mid]nums[left]<target <nums[mid],此时left=mid+1。

case3:nums[left]==nums[mid]

case3.1:nums[left]==target,则直接返回。

case3.2:否则只能确定left不是target,left右移一位。

otherwise:right=mid

class Solution:
    def search(self, nums: List[int], target: int) -> bool:
        if len(nums)==0:
            return False
        # step1:确定左右边界
        left, right = 0, len(nums)-1
        while left<right:
            # step3:确定中位数是取左中位数还是右中位数
            mid = (left+right)>>1
            # step2:先确定容易识别的分支
            # 本题选择什么条件下一定在右分支查询
            # case1:左分支有序,且target不在左分支
            if nums[left]<nums[mid] and (nums[left]>target or nums[mid]<target):
                left = mid+1
            # case2:左分支无序,且target不在左分支
            elif nums[left]>nums[mid] and (nums[mid]<target<nums[left]):
                left = mid+1
            elif nums[left]==nums[mid]: #由于可能有重复数据,所以必须考虑该场景
                if nums[left]==target:
                    return True
                else:
                    left = left+1
            else:
                right = mid
        return nums[left]==target

变形用-分支比较的目标值取数组值

162. 寻找峰值

题目描述:

峰值元素是指其值大于左右相邻值的元素。给定一个输入数组 nums,其中nums[i] ≠ nums[i+1],找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回任何一个峰值所在位置即可。你可以假设 nums[-1] = nums[n] = -∞

题解:

我们对二分查找进行一点修改。首先从数组 nums 中找到中间的元素mid。若该元素恰好位于降序序列或者一个局部下降坡度中(通过将nums[i]与右侧比较判断),则说明峰值会在本元素的左边。于是,我们将搜索空间缩小为mid的左边(包括其本身),并在左侧子数组上重复上述过程。

若该元素恰好位于升序序列或者一个局部上升坡度中(通过将nums[i]与右侧比较判断),则说明峰值会在本元素的右边。于是,我们将搜索空间缩小为mid的右边,并在右侧子数组上重复上述过程。

就这样,我们不断地缩小搜索空间,直到搜索空间中只有一个元素,该元素即为峰值元素。

class Solution:
    def findPeakElement(self, nums: List[int]) -> int:
        # 二分查找, 判断mid和右侧数值大小
        # 如果mid大, 说明峰值在左侧, right左移
        # 如果mid小, 说明峰值在右侧, left右移
        # step1, 确定左右边界
        left, right = 0, len(nums)-1
        while left<right:
            # step3: 确定中位数是取左中位数还是右中位数
            mid = (left+right)>>1
            # step2: 先写容易识别的分支
            if nums[mid]<nums[mid+1]:
                left = mid+1
            else:
                right = mid
        return left

变形用-分支比较的目标值取数组值

287. 寻找重复数

题目描述:

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

  1. 不能更改原数组(假设数组是只读的)。
  2. 只能使用额外的 O(1) 的空间。
  3. 时间复杂度小于 O(n2)

题解: 分支比较中目标值是统计数组中有多少数字小于当前的mid。

class Solution:
    def findDuplicate(self, nums: List[int]) -> int:
        #nums的取值范围在1到n, 每次选1个数mid遍历nums找到小于该数的个数cnt
        #如果cnt大于1半,说明重复数字比mid小
        #否则说明重复数据比mid大
        #可以用二分查找缩减搜索范围
        # step1:确认左右边界
        left, right = 1, len(nums)-1
        while left<right:
            # step3:确定中位数取左中位数还是右中位数
            mid = (left+right)>>1
            cnt = 0
            for v in nums:
                if v<=mid:
                    cnt += 1
            # step2:
            # 写分支, 通常写两个分支
            # 先写容易识别的分支
            if cnt>mid:
                right = mid
            else:
                left = mid+1
        return left

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

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

题解:整个二维数组中 左上角为最小值l,右下角为最大值r,任取一个数 mid 满足 l<=mid<=r,那么矩阵中不大于 mid 的数,肯定全部分布在矩阵的左上角,统计其数量为cnt。

对于第k小的数,不妨假设答案为 x,那么可以知道 l<=x<=r,这样就确定了二分查找的上下界。

每次对于「猜测」的答案 mid,计算矩阵中有多少数不大于 mid

  • 如果数量不少于k,那么说明最终答案 x 不大于 mid;

  • 如果数量少于k,那么说明最终答案 x 大于 mid。

    这样我们就可以计算出最终的结果 x了。

class Solution:
    def kthSmallest(self, matrix: List[List[int]], k: int) -> int:
        # 统计矩阵中小于等于目标值的数字个数
        def get_less_mid_count(matrix, target, n):
            # 从左下角开始找
            r,c = n-1,0
            cnt = 0
            while r>=0 and c<n:
                # 如果当前元素小于等于目标值,则需要右移一列
                if matrix[r][c]<=target:
                    cnt += r+1
                    c += 1
                # 否则需要上移1个位置
                else:
                    r -= 1
            return cnt

        # 二分查找, 目标值在最小的matrix[0][0]和最大的matrix[n-1][n-1]之间
        n = len(matrix)
        # step1:确定左右边界
        left, right = matrix[0][0], matrix[n-1][n-1]
        while left<right:
            # step3:确定中位数取值
            mid = (left+right)>>1
            # 对本题的特殊处理
            cnt = get_less_mid_count(matrix, mid, n)
            # step2:写两个分支,先写容易识别的分支
            if cnt<k:
                left = mid+1
            else:
                right = mid

变形用-待描述

4. 寻找两个正序数组的中位数

题目描述:

给定两个大小为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。

请你找出这两个正序数组的中位数,并且要求算法的时间复杂度为 O(log(m + n))。

你可以假设 nums1 和 nums2 不会同时为空

题解:

由于要求时间复杂对为O(log(m + n)),首先考虑二分查找。两个数组的左中位数在合并数组的索引是是left_mid=(m+n)>>1,右中位数的索引是right_mid=(m+n+1)>>1。这个题目转换为求nums1和nums2中第k大的数。由于数列是有序的,其实我们完全可以一半儿一半儿的排除。假设我们要找第 k 小数,我们可以每次循环排除掉 k/2 个数。

class Solution:
    def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
        
        def getKth(nums1, s1, e1, nums2, s2, e2, k):
            len1 = e1-s1+1
            len2 = e2-s2+1
            if len1>len2:  #确保nums1的长度小于nums2
                return getKth(nums2, s2, e2, nums1, s1, e1, k)
            if len1==0:  #当更短的nums1,长度为0时,直接从nums2中取数
                return nums2[s2+k-1]
            if k==1:  #当k为1时,取两数组起始的小值
                return min(nums1[s1], nums2[s2])
            idx_1 = s1+min(len1,k//2)-1  #每次只取k的一半
            idx_2 = s2+min(len2,k//2)-1  #
            if nums1[idx_1]>nums2[idx_2]:
                return getKth(nums1, s1, e1, nums2, idx_2+1, e2, k-(idx_2-s2+1))
            else:
                return getKth(nums1, idx_1+1, e1, nums2, s2, e2, k-(idx_1-s1+1))
        
        len1 = len(nums1)
        len2 = len(nums2)
        left = (len1+len2+1)//2
        right = (len1+len2+2)//2
        return (getKth(nums1, 0, len1-1, nums2, 0, len2-1, left)+getKth(nums1, 0, len1-1, nums2, 0, len2-1, right))/2