vlambda博客
学习文章列表

「算法笔记」一文摸秃二分查找

0、引言

经过一段时间的算法学习,我们觉得从二分查找开始再合适不过。


对于二分查找,你是否也对循环条件该怎么写产生焦虑,是否也对要分奇数偶数情况讨论感到抓狂,是否纠结于取mid时到底是向下取整还是向上取整。


别慌,我们和你一起仔细分析二分查找,从二分查找到经典代码到两种变种再到大一统代码,争取吃透二分查找,让天下没有难写的算法。

本文结构:

·什么是二分查找

·二分查找的通用代码以及为什么这么写

·二分查找的应用场景

·本节精简小抄以及所关联的leetcode题号


1、什么是二分查找

我们先讲个通俗易懂的故事。


这天,室友回到宿舍给你展示他新买的AirPods,并让你猜多少钱,你说:“那官网不都写了吗,1246”。室友摇摇头说高了,你再给个数,623。室友说:“怎么可能啊兄弟,我这新的,你搁这腰斩呢。”你没好气的回复道:“623也不是,1246也不是,这中间从623到1246总共622个价格,全部猜一遍猜到天亮吗,劳资还要打王者。” 


室友笑道:“小笨蛋,你不会用二分查找吗?你先猜一半,再猜一半,再猜一半...” 你悟了,先猜 (623 + 1246) / 2 = 934。室友:”高了“。你再猜(934 + 623) / 2 = 778.5。室友摸了摸你的小脑袋:“真聪明,几下就猜中了呢。”


如你所见,这种每次取一半去猜答案,多了就减,少了就加的方法就是二分查找。如果傻乎乎的从1,2,3,4,5...开始遍历查找,要查到猴年马月?


二分查找的思路很简单,但写起来却有诸多的细节可供把玩品味。引用KMP算法发明者的一句话:Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky(虽然二分查找的思路很简单,但是细节要你命)。


看客们若想理解性记忆把控这些细节,请随着我们的思路往下走。


2、通用代码以及Why

def binarySearch(nums, target): left = 0 right = len(nums) - 1 #-------1 while left <= right: #-------2 mid = left + (right - left) // 2 #-------3 if nums[mid] == target: return mid elif nums[mid] < target: left = mid + 1 #-------4 elif nums[mid] > target: right = mid - 1 #-------4   return -1

#1:

left = 0, right = len(nums) - 1实际上是把二分搜索的索引区间限制在闭区间[left, right]


比如对于数组nums=[2,3,5,6,10,11,12,13,15]来说,这样的写法对应的搜索索引区间就是[0,8]


当然,你可能会经常忘记写“-1”,而写成“right = len(nums)”,此时对应的搜索索引区间就是左闭右开区间 [left, right),也即[0,9),注意,是一个右括号。这种写法不是不行,但后续的代码要做出对应的修改,后文会讲。


#2:

left <= right的终止条件是left = right + 1,这很好理解。那如果我忘了写等于号,写成”left < right“,那对应的终止条件是什么呢?


对,就是left = right。那这有什么含义上的区别呢?很简单,画个图你就明白了。


对于[left, right]这种闭区间的情况,在终止条件是left = right + 1,最终搜索结束时,左右指针停留如图所示:

「算法笔记」一文摸秃二分查找

可见,结束时,左右指针可以遍历完数组中的所有元素,二分搜索正确运行。


在终止条件是left = right,最终搜索结束后,左右指针停留如图所示:

可见,在循环结束时,还剩index = left = right这一个索引指向的元素没有被查看,二分搜索漏元素了!


此时,细心的读者会问,那我采用刚刚的[left, right)写法,结合这个left = right的终止条件,不就能遍历数组中的所有元素了嘛?


嘿,我要摸摸你的小脑袋,真聪明。没错,这是对的。在这种情况下,沿用刚刚例子中的数组nums,则此时的right=9,搜索的索引区间为[0,9),那最后停止时,left = right = 9,也就遍历完了索引为0,1,2,3,4,5,6,7,8的元素,即nums中的所有元素,二分搜索算法得以正常运行!


#3:

取中点值的标准写法,这必须记住。因为直接写成mid = (left + right) // 2会导致溢出。


#4:

很好理解,因为我们的初始搜索区间是[left, right],算出mid,且mid不是我们要的target后,下一个搜索区间是[left, mid - 1][mid + 1, right]。不包含mid是因为mid已经被检验过了,不是我们想要的答案。那么,显而易见,当mid大于target时,要让 right = mid - 1以进入mid左边的搜索区间。相应的,当mid大于target时,要让left = mid + 1以进入mid右边的搜索区间。


至此,经典的二分搜索算法我们已经剖析完成。但这个算法已经完美了吗?


具体而言,对于nums = [1,2,2,2,3,4]的情况,含有多个重复元素,且2刚好就是我们要找的target。那此时题目要我返回最左边的2,该怎么办?要我返回最右边的2,又该怎么办?


根据这种差别,我们将二分搜索算法分为“最左目标二分查找”,以及“最右目标二分查找”。


2.1 最左目标二分查找

按惯例,先上标准代码:右划查看注释

def binarySearch(nums, target): left = 0 right = len(nums) while left < right: mid = left + (right - left) // 2 if nums[mid] == target:           right = mid                  #--------1 elif nums[mid] < target: left = mid + 1 elif nums[mid] > target:           right = mid                  #--------2 return right

#1:

可以注意到,标准代码采取了左闭右开区间[left, right)和终止条件left = right的写法。这段代码之所以能找到最左目标,精髓就在这个#1,每次找到target进行更新时,mid都将区间分为”[left, mid), mid, [mid + 1, right)”,令right = mid就可以顺利的进入左边的区间。


更形象的,这个”mid)” 就像卡身位一样将搜索卡入左边的搜索区间。同理,若mid不等于(或大于)我们要找的target,也是将区间通过”mid)”的写法卡入左边更小的搜索区间。


更更详细一点,有读者可能还是想问这里为什么不像上一段代码一样写成right = mid - 1。好,那我们假设#1改成right = mid - 1,那每次更新时,搜索区间就会被划分成[left, mid - 1), mid , [mid + 1, right)。注意,“mid - 1)” 和“mid”之间还有一个”[mid - 1]” 没有被检验过,因此就出现了漏检的情况。


读者理解了#1处,也就很容里理解#2处了。


#*:

有人可能会问,我能不能用 right = len(nums) - 1, 终止条件: left = right + 1的写法来写最左目标二分查找呢。


先说结论,经过我们的尝试,建议不要这样做。那我们一起看看这样做会出现什么情况。代码如下:右划查看注释

def binarySearch(nums, target): left = 0 right = len(nums) - 1 while left <= right: mid = left + (right - left) // 2 if nums[mid] == target:           right = mid                  #--------1 elif nums[mid] < target: left = mid + 1 elif nums[mid] > target:           right = mid                  #--------2 return right

如果读者亲自运行这段代码就会发现,最后会一直死循环。


原因很简单,因为每次更新,mid将搜索区间划分成[left, mid], [mid + 1, right]。当循环到到后面,搜索区间就会变成[left, 最左target(也等于mid等于right)],因为mid = left + (right - left) // 2向下取整,所以mid取到挨着最左target的左边的值(其实此时,mid和left取值相同),此值小于最左target,根据第9行代码,此时left要+1。这就是问题所在,这left + 1不就等于right,不就等于最左target了嘛?那我的下一个mid不就是 (自己+自己) / 2还等于自己,那mid也不更新,right也不更新,left也不更新,也就是left永远<=right,跳不出循环。


所以,我们不介意这么写。


2.2 最右目标二分查找

废话不多说,上代码:右划查看注释

def binarySearch(nums, target): left = 0 right = len(nums) while left < right: mid = left + (right - left) // 2 if nums[mid] == target:           left = mid + 1             #--------1 elif nums[mid] < target: left = mid + 1 elif nums[mid] > target: right = mid    return left - 1                    #--------2

#1:

同样的,能让这段代码找到最右target的精髓也在此处。如我们在最左目标查找中分析的那样,每次更新时,mid将搜索区间划分成[left, mid), mid ,[mid + 1, right)。当mid小于或等于我们的target时,要让left = mid + 1才能顺利的进入右边的搜索区间,去不断的寻找最右target。但!也正是因为left = mid + 1,所以才会出现#2处这样的代码。放在下一段分析。


#2:

仔细想想,当mid = 最右target时,我们又让left = mid + 1,此时left = right,循环终止。但left已经指向了最右target的下一个元素,因此要返回left - 1。


2.3 大一统二分查找

经过上面三种代码的熏陶,你是否已经所有所思,但还是感觉记忆起来需要费点儿劲,需要分清楚while的终止条件,还要分清楚right什么时候等于len(nums)什么时候等于len(nums) - 1。没关系,我们在此给出一种万能代码。


查找目标:右划查看注释

def binarySearch(nums, target): left = 0 right = len(nums) - 1 while left + 1 < right:       mid = left + (right - left)//2 if nums[mid] == target: return mid elif nums[mid] < target: left = mid elif nums[mid] > target: right = mid if nums[right] == target: return right if nums[left] == target: return left return -1

最左目标查找:右划查看注释

def binarySearch(nums, target): left = 0 right = len(nums) - 1 while left + 1 < right:       mid = left + (right - left)//2 if nums[mid] == target: left = mid elif nums[mid] < target: left = mid elif nums[mid] > target: right = mid if nums[right] == target: return right if nums[left] == target: return left return -1

最右目标查找:右划查看注释

def binarySearch(nums, target): left = 0 right = len(nums) - 1 while left + 1 < right:       mid = left + (right - left)//2 if nums[mid] == target: right = mid elif nums[mid] < target: left = mid elif nums[mid] > target: right = mid   if nums[left] == target:        #-------1 return left   if nums[right] == target:       #-------2 return right   return -1


代码很统一,读者到这里应该能理解,这种写法的搜索区间是闭区间[left, right],那么终止条件是什么呢?


没错,就是left + 1 = right,当循环终止时,left和right挨着,这其中的一个就是我们想要的答案,无论是单纯的目标,最左目标还是最右目标。需要注意的是,当循环结束时,多做了两个判断#1和#2。画个图来告诉你为什么要多两个判断(以最右目标查找为例):

如图所示,最后终止时,left和right指针无论是如黑色所示还是如红色所示,其中之一指向的就是我们要的target,那么我们按从右向左的顺序,即先判断right再判断left,不就可以找出最右的目标了嘛。这样看来,最左目标查找的写法是不是也一目了然?


虽然大一统代码很容易记忆,但面试中遇到二分查找,考察的恰恰就是这些细节问题。因此,不能光记忆大一统代码,前面的代码也要做到能自由切换。


3、应用场景

二分查找最明显的特点就是将搜索的复杂度从遍历搜索O(n)降到O(logn),要知道,logn是一个非常牛逼的数量级。比如2的32次方,大概等于42亿,如果遍历的话,要算到哪年去?但使用logn,也就是算32次而已。


二分查找只是一种折半的思想,通过我们的刷题观察,凡是题目中对时间复杂度出现logn的要求,都可以应用这种折半思想。由此可见,二分查找的应用十分广泛,这也是为什么我们从二分查找开始。


但需要注意的是,二分查找只适用于顺序表,也就是数组,且应用于有序(递增or递减)的数据。


4、精简小抄及leetcode

笔记至此,我们能完全掌握二分查找了。一起到leetcode拿下对应的习题吧:

leetcode:35. 搜索插入位置 


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


如果你觉得我们的文章还不错的话,不妨点个关注收藏转发什么的,你的支持就是我们坚持开源,免费,的最大动力,感谢🙏


- END -