2021-09-20-二分查找yyds

二分查找

什么是二分查找?二分查找就是不断的找当前序列的中点,通过与二段性比较,如果满足,则解必然在另一半的序列中,将序列一分为二,在另一半中继续搜索,如此往复,因此每次减少一般的搜索空间,达到Ologn的时间复杂度。

二段性

二分查找最重要的就是必须拥有二段性,什么是二段性?必须序列的前一部分满足一个性质,后一部分不满足,因此我们可以通过二分查找找到两个性质转变的中间点。
举个例子,在s=[1,2,3,4,5]中查找一个数字target=4,即我们可以发现在s中有二段性,一部分是大于target的一部分是小于target的,因此我们要找到满足性质转换的那个点,也就是target。
还有比如检验问题,一个序列,在某个点检验失败后,后面的点全部是失败,让我们找第一个失败的点,这个也是有二段性,因为第一个失败点之前都是成功的,后面都是失败的,因此两段性质不同。
对具有二段性质的序列,我们可以使用二分查找,时间复杂度可以达到O(logn)

二分查找代码及逻辑

二分查找的代码可以有很多写法,在这里我们只介绍两种,一种是靠左,一种靠右
靠左的意思是,在选择中点的时候,如果长度是偶数,那么中点选在靠左的位置,这个时候,如果出现重复的target,那么靠左的代码最终选中的是相同target中最左边那个。靠右同理
首先是靠右:

l=0
r=n-1
while l<r:
	mid=(l+r+1)>>1
	if 是否满足二段性中其中一段:
		r=mid-1 
	else:
		l=mid

因为是靠右选的,如果选中的答案不满足性质,那么只考虑mid前面半部分

靠左:

l=0
r=n-1
while l<r:
	mid=(l+r)>>1
	if 是否满足二段性中其中一段:
		l=mid+1 
	else:
		r=mid

因为是靠左选的,如果选中的答案不满足性质,那么只考虑mid右边半部分
那么二分查找代码最终找到的位置为l=r

双闭区间:

def searchInsert(self, nums: List[int], target: int) -> int:
        # 经典二分搜索
        # 搜索区间为左闭右闭,nums无重复元素!
        left = 0
        right = len(nums) - 1
        while left <= right: # 左闭右闭区间的允许小于等于号,因为要搜索左右指向同一个位置的数
            mid = (left+right)//2
            if nums[mid] == target:
                return mid
            elif nums[mid] > target: # 说明中值偏大,在左边搜
                right = mid - 1
            elif nums[mid] < target: # 说明中值偏小,在右边搜
                left = mid + 1       
        return left

例题实战

1. 在一个升序数组nums中寻找target,如果找到返回target的位置,找不到,返回-1

 解法:经典的二分查找做法,有序数组,因此考虑二分
class Solution(object):
    def search(self, nums, target):
        """
        :type nums: List[int]
        :type target: int
        :rtype: int
        """
        l=0
        r=len(nums)-1
        while l<r:
            mid=(l+r+1)>>1
            if nums[mid]<=target:
                l=mid
            else:
                r=mid-1
        if nums[r]==target:
            return r
        else:
            return -1

此处我们的判断二段性体现在,选中的中点数字如果大于target,那么target只能出现在中点左边,因此r=mid-1,在左半边继续搜索

2. 假设你有 n 个版本 [1, 2, …, n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。

解法,即一个版本是错的,后面的版本都是错的,让我们找第一个出错的版本,很明显是有二段性,因此考虑二分查找
class Solution:
    def firstBadVersion(self, n):
        """
        :type n: int
        :rtype: int
        """
        l=0
        r=n-1
        while l<r:
            mid=(l+r)>>1
            if isBadVersion(mid+1):
                r=mid
            else:
                l=mid+1
        return r+1

此处我们判断二段的依据在于,我们要找的是第一个错误的版本,因此选择靠左的做法,判断中点是否是错误的,如果是,那么当前点也有可能是第一个错误的,因此从包含该点的左边搜索,因此r=mid
注意:代码中我们用的是0-n-1的下标,因此最后输出的版本要加1,中间判断的版本也要+1

那么为什么不能选择靠右呢? 这里我们考虑,因为要找的是第一个错误的版本,因此在判断是否错误的时候本身的答案是要继续搜索的,如果选择了靠右的代码,那么序列不能被均匀的一分为二,速度会下降,如果选择靠右,那么我们的二段性条件要改变,即要找的点应该是第一个错误的点前面那个点,也就是最后一个成功的点。

3. 统计一个数字在排序数组中出现的次数。 nums非递减

一个经典的二分做法,这道题就用到了我们的性质了,我们可以考虑靠左靠右同时使用, 分别找到相应的target的最左边和最右边,然后相减可以得到target的出现次数

class Solution(object):
    def search(self, nums, target):
        """
        :type nums: List[int]
        :type target: int
        :rtype: int
        """
        if len(nums)==0:
            return 0
        l=0
        r=len(nums)-1
        while l<r:
            mid=(l+r+1)>>1
            if nums[mid]<=target:
                l=mid
            else:
                r=mid-1
        if nums[r]!=target:
            return 0
        right=r

        l=0
        r=len(nums)-1
        while l<r:
            mid=(l+r)>>1
            if nums[mid]<target:
                l=mid+1
            else:
                r=mid

        return right-l+1

本题没什么可讲的,二段性那个条件可以画图试试就好了,确保分的时候均匀即可

4. 一个长度为n-1的递增排序数组中的所有数字都是唯一的,并且每个数字都在范围0~n-1之内。在范围0~n-1内的n个数字中有且只有一个数字不在该数组中,请找出这个数字。

解法,这道题很明显也是二分查找,但是二段性条件比较难想,首先我们要知道n-1的长度数组在0-n-1的数据范围且递增,缺了一个数,那么我们很轻易知道,缺失数字前面一部分的数字是和下标相同的,后面一部分和下标不同,因此考虑二分

class Solution(object):
    def missingNumber(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
    

        l=0
        r=len(nums)-1
        if nums[0]==1:
            return 0
        
        while l<r:
            mid=(l+r+1)>>1
            if nums[mid]!=mid:
                r=mid-1
            else:
                l=mid
        return nums[r]+1

首先对0缺失要单独考虑,因为最终算到的缺失点是数组边界之外,因此如果0缺失(即第一位是1),那么不管nums再长,直接返回0
我们的二段性条件找的是缺失的数字左边的那个数字,因此如果nums[mid]!=mid的时候,当前中点已经是错误点了,因此考虑r=mid-1,也就是前面一部分。最终我们找到的是缺失的数字左边那个数字,因此需要+1

5. 整数数组 nums 按升序排列,数组中的值 互不相同 。

在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。

给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 

解法,本题比较难,首先我们需要找到旋转点,因为旋转点两边各自有序
那么首先我们考虑二分去找旋转点,怎么找呢?二段性体现在哪?例如[1,2,3,4,5,6,7]数组在某个点旋转之后变成[5,6,7,1,2,3,4]那么我们发现旋转点是5,但是最终对两段有序分割的点是1,那么我们发现1这个点前面的5,6,7,因为是旋转之后的,所以必定是大于等于5的,同时我们发现1234必定是小于5的(因为原数组中数值各不相同)因此二段性体现在,一部分nums[i]>=nums[0] (此处是已经旋转之后的数组)另一部分相反。因此我们首先二分找到旋转点。代码:

class Solution(object):
    def search(self, nums, target):
        """
        :type nums: List[int]
        :type target: int
        :rtype: int
        """
        
        slen=len(nums)
        if slen == 0:
            return -1
        if slen ==1:
            if nums[0]==target:
                return 0
            else:
                return -1

        l=0
        r=slen-1
        while l<r:
            mid=(l+r+1)>>1
            if nums[mid]>nums[0]:
                l=mid
            else:
                r=mid-1

此时我们的l=r=旋转点前面一个点,也就是数组中最大的元素
因此我们需要对target判断是属于这个点前面还是后面一部分

		if target>=nums[0]:
		            l=0		      
		        else:
		            l=l+1
		            r=slen-1

如果target是大于nums[0]的话,那么属于左边一部分,应该是[0-r],此时的r是最大元素所在位置
否则的话从[l+1,slen-1]搜索
最后一遍二分不用我多说了吧?有序数组中找一个target

		while l<r:
            mid=(l+r+1)>>1
            if nums[mid]<=target:
                l=mid
            else:
                r=mid-1

        if nums[r]==target:
            return r
        else:
            return -1

寻找峰值

在这里插入图片描述
题目让我们实现一个 O(logn) 算法,这是对使用「二分」的强烈暗示。

和往常的题目一样,我们应当从是否具有「二段性」来考虑是否可以进行「二分」。

不难发现,如果 在确保有解的情况下,我们可以根据当前的分割点 mid 与左右元素的大小关系来指导 l或者 r 的移动。

假设当前分割点 mid 满足关系 num[mid] > nums[mid + 1] 的话,一个很简单的想法是 num[mid]可能为峰值,而 nums[mid + 1] 必然不为峰值,于是让 r = mid,从左半部分继续找峰值。

估计不少同学靠这个思路 AC 了,只能说做法对了,分析没对。

上述做法正确的前提有两个:

对于任意数组而言,一定存在峰值(一定有解);

  • 二分不会错过峰值。
  • 我们分别证明一下。

证明 1 :对于任意数组而言,一定存在峰值(一定有解)

根据题意,我们有「数据长度至少为 1」、「越过数组两边看做负无穷」和「相邻元素不相等」的起始条件。

我们可以根据数组长度是否为 1 进行分情况讨论:

数组长度为 1,由于边界看做负无穷,此时峰值为该唯一元素的下标;

数组长度大于 1,从最左边的元素 nums[0] 开始出发考虑:

如果 nums[0] > nums[1],那么最左边元素 nums[0] 就是峰值(结合左边界为负无穷);
如果 nums[0] < nums[1],由于已经存在明确的 nums[0] 和 nums[1] 大小关系,我们将 nums[0]看做边界, nums[1]看做新的最左侧元素,继续往右进行分析:
如果在到达数组最右侧前,出现 nums[i] > nums[i + 1],说明存在峰值位置 i(当我们考虑到 nums[i],必然满足 nums[i]大于前一元素的前提条件,当然前一元素可能是原始左边界);
到达数组最右侧,还没出现 nums[i] > nums[i + 1],说明数组严格递增。此时结合右边界可以看做负无穷,可判定 nums[n - 1]为峰值。
综上,我们证明了无论何种情况,数组必然存在峰值。

证明 2 :二分不会错过峰值

其实基于「证明 1」,我们很容易就可以推理出「证明 2」的正确性。

整理一下由「证明 1」得出的推理:如果当前位置大于其左边界或者右边界,那么在当前位置的右边或左边必然存在峰值。

换句话说,对于一个满足 nums[x] > nums[x - 1] 的位置,xx 的右边一定存在峰值;或对于一个满足 nums[x] > nums[x + 1] 的位置,xx 的左边一定存在峰值。

因此这里的「二段性」其实是指:在以 mid为分割点的数组上,根据 nums[mid] 与 nums[mid±1] 的大小关系,可以确定其中一段满足「必然有解」,另外一段不满足「必然有解」(可能有解,可能无解)。

如果不理解为什么「证明 2」的正确性可以由「证明 1」推导而出的话,可以重点看看「证明 1」的第 2 点的证明。

至此,我们证明了始终选择大于边界一端进行二分,可以确保选择的区间一定存在峰值,并随着二分过程不断逼近峰值位置。

class Solution(object):
    def findPeakElement(self, nums):
        """
        :type nums: List[int]
        :rtype: int
        """
        slen=len(nums)
        left=0
        right=slen-1
        while left<right:
            mid=(left+right)>>1
            if nums[mid]>nums[mid+1]:
                right=mid
            else:
                left=mid+1
        return right


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值