二分查找常见题目

二分查找一般的常见题目大概我目前遇到的是4类:

1:数组:数组有序、有序数组旋转;三类题目:一类是查询target目标值;另一类是查询最小值;最后一类是查询最小的缺失值;、数组内有无重复元素:数组内有或者没有。以上三类两两组合2*3*2共计12种组合,所以共有12种题目。

2:就是二维数组的查询目标值。具体二维数组的排序方式有2种。

3:利用二分查找进行一些特定题目的优化。

4:将二分查找当作一种经典的方法进行推广以及后面应用。其实这一点才是比较关键的。

1:首先针对第一类问题就是一维数组的直观12种题目的解法。

  • 无旋转、无重复、查询target。最基本的二分查找。
# 最基本的二分查找,切记这是最基本的二分查找
# 注意的是三点:1——while 那里是等于;2——里面更新的是left/right = mid +/- 1,就是注意这里的-1不要
# 丢;3——由nums[mid]和target比较。
left = 0
right = len(nums)
while right >= left:
    mid = (left + right) // 2
    if nums[mid] == target:
         return True
    if nums[mid] < target:
        left = mid + 1  
    else:
        right = mid - 1
  • 无旋转、无重复、查询最小值:没啥难度,直接第一个。
  • 无旋转、无重复、查询最小缺失值:(数组是从0开始的自然数序列,假如数组是[1,2,3],那么最小的缺失值就是0,假如数组是[0,1,2,4]那么缺失值就是3,假如数组是[0,1,2,3,4],那么第一个缺失值就是5)。只有这个对数组有连续的从0开始的自然数序列有要求,上述另外两种情况没有这种特殊的要求,只要数组递增即可。只不过这个题目的递增是一个特殊的递增序列。   

       使用的方法还是二分查找,那么既然是二分查找,就是在最基本的二分查找的基础上进行稍微的改动,改动的无非就是二分查找的那三个注意点,第一个while的等于号不处理,第二个判断条件需要更改为num[i] 是否等于i,等于说明缺失值在右侧,不等于说明在左侧,第三个就是仍然要mid+/-1,所以代码如下。

class Solution:
    def missingNumber(self, nums: List[int]) -> int:
        # 最简单的O(n)的时间复杂度 56%

        # i = 0
        # for value in nums:
        #     if value != i:
        #         return i
        #     i += 1
        # return i

        # 那么因为涉及到排序了,所以肯定考虑二分查找处理
        # 那么二分查找其实基本的框架都一样,就是判断哪里一般会有变化,那么原始的就是左右和中间判断
        # 变化就是判断的条件发生变化,有的多次判断,有的是以一次判断,就像这个就是一次判断,只用中间的值进行判断即可。
        # 本例就是典型的用中间值且一次判断。
        left = 0
        right = len(nums) - 1
        while right >= left:
            mid = (right + left) // 2
            if mid == nums[mid]:
                left = mid + 1
            else:
                right = mid - 1
        return left

 

  • 无旋转、有重复、查目标值。这个和第一点的唯一区别i局势有重复,那么有重复递增数组查目标值是否在里面,方法还是最基本的二分查找,没有任何的变化,注意这个没有变化仅仅针对查询target是否在。如果要想查询最小的index,那就需要在查询到目标值以后往前遍历直到发现最小的index。
  • 无旋转、有重复、查最小值。这个也没难度,就是第一个值。和上边没有重复的操作是一样的。
  • 无旋转、有重复、查最小缺失值。因为数据有重复,所以查询最小缺失值这个就不能使用二分查找了,因为每一个位置的索引已经没有实际的意义了,使用num[i]是否等于i已经不能确定哪个区间段了,因为之所以使用二分的核心目的就是确定有效的区间。所以只能遍历时间复杂度是O(n),可以使用判断语句:if num[i] - num[i-1] > 1: return num[i]-1,当然也可以去重,使用双指针进行去重,如果right != left, left += 1,然后将 right的值放到left里面,然后right += 1,如果 right == left, right += 1.最后得到的left + 1就是有效的长度。之后采用上述的二分查找去查询。时间复杂度比上述那种最基本的判断语句还多一个二分查找,没必要。思来想去,这个只能用O(n)遍历处理,没有别的办法。

# 下面开始讨论有旋转的。

先说明一下,凡是旋转类题目,旋转后数组由于进行了旋转,那么旋转之后的数组如下图1:所以对于这类型问题的处理一般都是画出图,然后确定中间值的位置,2处,然后针对不同的问题确定不同的目标位置。                                                       图一 

图二             

图三                                                                                                         

  • 有旋转、无重复、查目标值。此时,最基本的还是二分查找,依据二分查找的目的,二分查找的根本目的就是确定有序的区间,那么同时依据最简单的二分查找的基本情况,我们知道首先需要确定mid的位置,之后确定target的位置。由于旋转数组,mid的位置就有2处,如下图二。依据最基本的二分查找算法,此时需要比较target和mid值的大小关系,首先考虑mid比target小,此时,target就有3个位置,如图三,那么我们发现图三的t1、t3都是在不同的mid的右侧,对于这两个t,我们都只需要将Left = mid + 1,这两种变化是固定的;只有对于t2,我们发现是将Righ = mid - 1,所以这就区分开了。当mid < target时候, if target >= left and left > mid: right=mid-1;否则,left = mid + 1。(和目标比的时候一定注意等于号,3个两两进行比较,最开始是mid和target比较,之后是target和left比较,最后一个是left和mid比较,中间这个比较注意等于号即可)。反之,当target比mid小的话,如图三。发现当target <= left and mid > right: left = mid + 1, else right = mid - 1。(注意这里比较的是right)。
    # 下面就是上面if的合并优化版本,是目前时间效率最高的。
            if nums == []:
                return -1
            left = 0
            right = len(nums) - 1
            while right >= left:
                mid = (left + right) // 2
                if nums[mid] == target:
                    return mid
                if nums[mid] < target:
                    if nums[mid] < nums[left] and nums[left] <= target:
                        right = mid - 1
                    else:
                        left = mid + 1
                else:
                    if nums[mid] > nums[right] and nums[left] > target:
                        left = mid + 1
                    else:  
                        right = mid - 1              
            return -1

    所以这个的核心就是在基础二分查找框架不变的基础上去通过三个位置的比较确定有序的区间

  • 有旋转、无重复、查最小值。这个既然是排序数组,仍然可以利用二分查找进行处理。由于旋转数组其实里面暗含3种可能,多以绝对不能用这个值与前面的值和后面的值进行比较。那么该如何做呢?(下面的图就是图四)还是由于是旋转数组,还是首先确定mid的位置,mid还是2个位置,如果mid在左大侧,那么此时left一定在最左侧,然而right就位置不确定了,right可能在左大侧mid右侧,如图四的1,也可能在右小侧,如图四的2。如果mid 在小侧,此时left又有2种情况,left在左小侧,如图四的3,left在大侧,如图四的4。我们发现当情况是1、3的时候直接return left就好了,为什么要写成R = mid呢?因为情况4是R = mid,为了代码的统一我们发现return left 可以放慢节奏,写成R = mid,去逐步逼近left。那么另外的一个点就是为什么情况二是Left = mid + 1,而情况4就是R = mid,却不减1了呢?就是因为情况二L < mid,那么mid一定不是最小值,所以可以跨过mid这个值;但是情况4不同,情况4里面mid < R,那么就有可能mid就是大侧小侧的分界点,所以不能跨过,这就是一个减去一另外一个不加一的区别,以及为什么代码统一的区别。经过上述的代码优化如下:

    i, j = 0, len(numbers) - 1
    while i < j:
        m = (i + j) // 2
        if numbers[m] > numbers[j]: i = m + 1
        elif numbers[m] < numbers[j]: j = m
        else: j -= 1
    return numbers[i]

                                                               

所以我们发现对于旋转数组的思路就是:确定mid然后开始考虑各种情况从而写出代码,最后进行优化。最后这一步非常重要。

  • 有旋转、无重复、查缺失值。这个就不要求连续了,就是一个排序的数组旋转,查询第一个缺失的自然数。这个题目的话二分查找没有意义了,处理不了,那么办法就是先去掉负数,对负数不用管,所以其实排序数组旋转对于缺失值的查找,本身的旋转排序就没有意义了。那么到底是什么处理办法呢?如果此时先排序,时间复杂度就是O(nlogn),然后再次遍历的话时间复杂度是O(n),因为不连续,所以此时没法像最开始那样使用二分查找,所以只能遍历查询,所以总体而言时间复杂度是O(nlogn)。那么有没有就是O(n)的办法呢?那么我们就是避免排序,避免排序我们发现一个办法,就是不断的交换,从0开始遍历,然后num[0] 和num[num[0]]交换,依据这个不断的交换,直到num[0] == 0,就不交换了,防止出现死循环;同时判断被交换以后的那个位置是否发生变换,如果没有变化,也要就是退出,防止出现死循环,这样内部不断交换,外部从0到len(num),看起来是两重循环时间复杂度是O(n^2),但其实是只要内部交换了,外面就只是遍历本质上不交换了,所以时间复杂度就是O(n),所以还是利用 j = num[i]这个操作不断进行的本质上还是避免数组直接操作,而是应该尽可能的使用交换

 

  • 有旋转、有重复、查目标值target。对于这种题目,就是要避免重复值造成的影响,所以每次二分循环进来以后都需要先进行2个循环,以排除重复值的影响。1:就是在上面的代码基础上加了下面的2个循环,如下面的第一个代码。2:因为外面有while,所以我们可以不加while,只要将其变成if就可以了,这个代码就不写了。最后验证发现将两个while换成if效率将无比的高,代码下面第二个。同时注意+2个continue。但是其实我们发现只要第一个if就可以了,因为如果mid和right相等,那么此时right -= 1,左移动将会改变下一次mid的位置,所以下面的if有没有无所谓,mid本身随上面就变了。所以最好的代码如下第二个。同时不用写mid != right,所以相比于第一种方式简单多了。所以以后对于重复类的数据,直接加一句 if mid == right:right -= 1即可。
    # 下面加两个while
            if nums == []:
                return -1
            left = 0
            right = len(nums) - 1
            while right >= left:
                mid = (left + right) // 2
                if nums[mid] == target:
                    return mid
                # 加两个while循环,切记排除相等的那种情况
                while nums[mid] == nums[right] and right != mid:
                     right -= 1
                while nums[mid] == nums[left] and left != mid:
                     right += 1
                if nums[mid] < target:
                    if nums[mid] < nums[left] and nums[left] <= target:
                        right = mid - 1
                    else:
                        left = mid + 1
                else:
                    if nums[mid] > nums[right] and nums[left] > target:
                        left = mid + 1
                    else:  
                        right = mid - 1              
            return -1
     
    # 下面加两个while
            if nums == []:
                return -1
            left = 0
            right = len(nums) - 1
            while right >= left:
                mid = (left + right) // 2
                if nums[mid] == target:
                    return mid
                # 加两个while循环,切记排除相等的那种情况
                if nums[mid] == nums[right]:
                     right -= 1
                     continue
                if nums[mid] < target:
                    if nums[mid] < nums[left] and nums[left] <= target:
                        right = mid - 1
                    else:
                        left = mid + 1
                else:
                    if nums[mid] > nums[right] and nums[left] > target:
                        left = mid + 1
                    else:  
                        right = mid - 1              
            return -1
     
     

     

  • 有旋转、有重复、查最小值。办法就是在最上面代码上加上if mid == right: right -= 1完事了。
    class Solution:
        def findMin(self, nums: List[int]) -> int:
            left = 0
            right = len(nums) - 1
            while right >= left:
                mid = (left + right) // 2
                if nums[right] == nums[mid]:
                    right -= 1
                    continue
                if nums[mid] < nums[right]:
                    right = mid
                elif nums[mid] > nums[right]:
                    left = mid + 1
            return nums[left]

     

  • 有旋转、有重复、查最小缺失值。方法可以先去重,去重之后做法与上面的等同,不断的交换查到即可。也不是二分查找,办法不变。

综上所述:1:对于旋转类处理就是确定mid位置,确定taregt位置或者right/left位置并且写出全部代码,最后进行合并优化。2:对于旋转后有重复的处理就是让right -= 1就ok了。所以旋转类的其实就是查target以及查min_value总共四个代码外加上面的基本的二分查找和查找缺失值+数组旋转,一共7个代码,记住即可。

第二类题目就是二维数组的查找

  • 1:第一条规则就是每一行从左到右依次递增,第二条规则就是下面的每一行开头的比上一行最后一个都要大。所以处理这类型target的目标查询的操作方法就是将其认为一个m*n的一个递增的序列,然后按照一维的去查。
    left = 0
    right = m * n - 1
    while right >= left:
        mid = (left + right) // 2
        row = mid // n
        line = mid % n
        if nums[row][line] == target:
            return True
        if nums[row][line] < target:
            right = mid -1
        else:
            left = mid + 1
    return True

     

  • 2:这个题目叫做排序矩阵:给定M×N矩阵,每一行、每一列都按升序排列,寻找target。这个题目和上面的题目不一样,这个是每一行每一列都是有序,那么我们能知道的是从右上角开始,如果当前元素比这个元素大没说明这一行他就不用看了,i += 1;如果比这个元素小,那么这一列就不用看了,所以依据这种规则,将其不断的缩小到左下角,这就是思路。可以发现核心的还是发现数据之间的区分,不断的变换,这个题目的行列交替变换缩小范围给了我们一种很好的思路去处理这总问题
    class Solution:
        """
        关于二维矩阵的排序查找,一种是这个,另外的一种是下一行比上一行大的这种查找
    
        """
        def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
            # return sum([1 if target in value else 0 for value in matrix]) > 0
    
            # 下面的方法和上面的执行时间基本一致
            # 写代码一定要注意边界条件,但是要放在最后去考虑
            # 时间复杂度是O(m+n),这就是典型的缩小范围逼近法,不是二分的办法
            if matrix == []:
                return False
            left = 0
            right = len(matrix[0]) - 1
            while right >=0 and left < len(matrix):
                if matrix[left][right] == target:
                    return True
                elif matrix[left][right] < target:
                    left += 1
                else:
                    right -= 1
            return False

     

第三类题目就是利用二分的数学问题

  • 1:就是快速幂算法的递归版本,思路比较简单,虽然递归会用到栈使得空间,但是可实现挺好的。2:求平方根,left = 0,right = x,求的是pow(x, 0.5),我们确定上界是x,这样去找比pow(x,0.5)的最大整数。3:两数相除不使用//,*,%。那么很自然想到用不断的减法,但是如果被除数是323244243242,除数是1,不断的减,太慢了。我们还是使用快速幂的思想,先减1,在减2,在减4,在减8,不断的上去,然后直到两个数加起来<被除数,然后上下的继续从减1、2、4不断的迭代,代码如下:力扣的29.
    class Solution:
        def divide(self, dividend: int, divisor: int) -> int:
            """
            """
            # 最直接的想法就是循环-,但是直接不断的被减数会超时间,所以我们每次减的时候不能都减一个被减数
    
            """
            思路就是按照指数级别上升,第一次减1个被减数,第二次减2个,第三次减4个,依次按照指数级别上;
            本质是二分的思想,记住这是减的一种优化策略。
            """
            # 下面就是要注意取值范围,注意这个特例
            if dividend == -pow(2,31) and divisor == -1:
                return pow(2, 31)-1
    
            dividend_int = int(dividend)
            divisor_int = int(divisor)
            fu_1 = 1 if dividend_int > 0 else 0
            fu_2 = 1 if divisor_int > 0 else 0
            
            # 下面这个递归函数是核心的东西
            def dfs(jie):
                if jie < abs(divisor_int):
                    return 0
                much = 1
                tb = abs(divisor_int)
                while tb + tb + tb + tb <= jie:
                    much += much
                    tb += tb
                    much += much
                    tb += tb
                return much + dfs(jie - tb)
            much = dfs(abs(dividend_int))
            return much if fu_1 ^ fu_2 == 0 else -much

     

最后一类就是将二分+快速幂+排序当作一个套装算法,便于以后不断的去迭代使用。说白了就是在记住上述提醒的基础上将二分当作一个技巧去使用。

 

 

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值