搞定所有的二分查找

Posted 做个精致男孩

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了搞定所有的二分查找相关的知识,希望对你有一定的参考价值。

        乍一看二分查找其实挺简单的,思想也简单。感觉很多问题都这样,自己觉得很简单,做到题目一直出bug,最后没能在规定的时间内做出来,然后自己抱怨。归根到底是眼高手低,基础不扎实。

        最常见的二分查找是这样的:

def biSearch(arr,left,right,target):    while(left<=right):         mid = left + (right-left)//2         if arr[mid] == target:          return mid         if arr[mid] < target:             left = mid+1         else:          right = mid-1     # 跳出循环则就是找不到这个元素     return -1

        以前觉得写到这就差不多了。二分法基本也就这样了。

        但是这个写法的弊端还是挺多的。

优点:

        找到目标元素直接返回。想法直接。

缺点:

  •  如果数组内多个元素重复,找到左边界和右边界不容易。

  •  退出循环的时候,left和right的值不相等,得思考退出时的left和right究竟是什么。


        综上,这种方法很多情况下并不推荐。

        底下有另一种思路来写二分法。排除的思想。

        每次排除一半的不符合要求的元素。再从剩下的另一半中如此往复。最后会返回一个元素。再判断这个元素是否符合要求。代码如下:

def biSearch(arr,left,right,target):    while(left<right): mid = left + (right-left)//2         # 排除一半的元素         if arr[mid] < target:         # 否则目标元素在右半边数组中(不包含mid)             left = mid+1         else:         # 否则target就在左半边数组中(包含mid)          right = mid              # 跳出循环则就是left == right,还要判断这个元素是否为目标元素     return arr[left]==target 

优点:

  • 代码简洁明了。只有两种可能,target在左边或者target在右边。

  • 思路明确,每次排除一半,最后只会剩下一个再判断。

  • 最重要的其实还是数组有重复元素时候找左右边界方便。


底下举几个例子层层递进讲解。



Example 1:


这是最最简单最最基础的二分查找了。

def search(nums,target): left = 0        right = len(nums)-1        while(left<right):            mid = left+(right-left)//2            # 每次排除一半,如果数组左边不满足则舍弃左边 if nums[mid]<target:            # 说明目标值在右边,nums[mid]也不是目标值,所以left=mid+1 left = mid+1 else:            # 说明目标值在左边,nums[mid]可能为在mid,所以要包括mid right = mid return left if nums[left]==target else -1


Example 2:

搞定所有的二分查找

这题是有重复元素的序数组查找左边界和右边界。如果是之前传统的二分查找写法不太好找边界。

其实找左边界就是之前代码的写法,参考Example1:

搞定所有的二分查找

搞定所有的二分查找

                                            

        仔细品品,重点在 if nums[mid]<target  这句判断,如果nums[mid]恰好为target,那么会排除右边的一半。边界不断地向左收紧,比如最后只有[8,8]元素了,那么最后边界会往左边的8缩进。退出循环时left = right = 左边界。

        写到这边可以想想右边界怎么找,其实思想也是一样的。就是不断地排除左边的数组,边界往右边缩进。判断改成,if nums[mid]<=target 则不断地排除左边的,边界往右边走。

 

搞定所有的二分查找

def getRightBoundary(nums,target): left = 0 right = len(nums)-1 print('Find Right Boundary:')    while(left<right):
mid = left+(right-left)//2        if (nums[mid]<=target): left = mid else: right = mid-1 return right if nums[left]==target else -1

至此,以后有序数组的二分查找边界应该是小case。


Example3:


搞定所有的二分查找

二分查找不一定只能用在有序数组上,也可以运用在部分有序数组上,比如这题。遇到这题第一反应应该也是二分查找。但是这边的nums[mid]不一定是中位数。时刻记住二分查找的思想是排除一半。

此时有两种思路。

  • nums[mid]和nums[left]比看能不能去除一半不符合的。

  • nums[mid]和nums[right]比看能不能去除一半不符合的。


这两种思路都是可以的。

搞定所有的二分查找

(图片参考力扣 liweiwei1439。)

可以看到不管旋转点在哪边,只要判断mid和left或者right的值就知道,mid落在哪边的有序数组中。

我们以第二种思路为例子。

        如果nums[mid]<nums[right]:说明nums[mid:right]都是有序数组的,如果target在这其中二分查找即可。(nums[mid]<=target<=nums[right])。如果target在左边的无序数组中,只能慢慢缩小边界查找,每次右边界减少1进行试探。即right = mid-1。

        同理如果nums[mid]>nums[right]:说明nums[left:mid]有序,如果target在左半边,二分查找即可。(nums[left]<=target<=nums[mid])。如果target在右边的无序数组中,只能左边界+1去试探,即left = mid+1。

def search(self, nums: List[int], target: int) -> int: if not nums : return -1 left = 0 right = len(nums)-1 while(left<right):            mid = left+(right-left+1)//2 if nums[mid]<nums[right]: if nums[mid]<=target<=nums[right]: left = mid else:                    right -= 1 elif nums[mid]>nums[right]: if nums[left]<=target<=nums[mid]: right = mid else: left = mid+1 else:                # 这边边界要好好调试代码,有可能陷入死循环,                # 打印出left,right,mid就知道问题在哪了。 if nums[right]==target: return right right -= 1 return left if nums[left]==target else -1

(如果数组中有重复元素,也是这么解决的并无区别)


Example 4:

搞定所有的二分查找

再来看这种题。。中等难度。1分钟AC了。理解了思路原理,统统很easy。所以好好体会挖掘一题,举一反三胜过盲目做十道题。

简单说下思路,如果左边有序,则最小值一定在右边。如果右边有序则最小值一定在左边。就是这么简单。。


def findMin(self, nums: List[int]) -> int: left = 0 right = len(nums)-1 while(left<right): mid = left+(right-left)//2 if nums[mid]<nums[right]: right = mid else: left = mid+1 return nums[left]


Example5:

搞定所有的二分查找

话不多说,这种困难的题也轻松一分钟AC。。重复元素唯一要考虑的就是nums[mid]和nums[right]相等的时候,这时候不知道右边数组是不是递增的,此时让right-=1,右边界向左收缩,直到能判断右边数组到底是不是递增的即可。

搞定所有的二分查找

Example6:

        下面这题有一点难度,运用到了贪心算法+二分查找。其实这些算法拆开来都并不是特别难。只是要灵活运用,还是要多做题。比如遇到有序数组要插入,肯定要用二分查找,线性查找就是二五仔。

        这题可以用dp做时间复杂度是O(n^2)这里就不讲了。主要讲下贪心+二分。时间复杂度是O(nlogn)。每个元素插入的复杂度是logn,一共n个元素。

        这边我们维护一个tail数组。对于任意长度i,tail[0:i]代表长度为i且结尾最小的上升子序列。例如i=2代表长度为2,且结尾最小的序列,这样的序列有很多例如[2,5],[2,3],[5,7],[3,7]等等,但是相同长度的子序列,最后一个数肯定是越小越好的。题中就是tail[0:2]就是[2,3],如果i=3呢,tail就是[2,3,7]。

        那如何维护一个tail呢。

  • 当新进来的num[i]>tail[-1],说明当前数字加进来一定可以构成更长的子序列,直接加入数组。

  • 如果nums[i] 已经在tail中了就不需要考虑了,直接跳过。

  • 如果nums[i]<tail[-1]时,替换掉tail中第一个大于nums[i]的数字,例如,此时的tail为[2,5],nums[i]=3,在tail中找到第一个大于3的数字,是5,则替换掉5,tail变为[2,3]。

所以我们维护的tail一直是递增的序列,每次新进来的数字要替换掉第一个大于它的数。有序加插入,这显然是二分查找来维护tail。


        至此,以后遇到二分查找的问题,应该5分钟内AC掉。。掌握了思路,二分查找还是非常简单的。主要就是细节比较麻烦,还是可能出问题,多调试,多总结。

       祝进步。

以上是关于搞定所有的二分查找的主要内容,如果未能解决你的问题,请参考以下文章

Golang 二分查找算法

我作了首诗,保你闭着眼睛也能写对二分查找

二分查找来查找旋转数组

二分查找+所有细节保姆级详解

二分查找为什么让面试者挂的这么惨?

算法专题(01)二分查找(03) 简单LeetCode 278