AI summary
type
status
date
slug
summary
category
tags
icon
password
先给大家讲个笑话乐呵一下:
有一天阿东到图书馆借了 N 本书,出图书馆的时候,警报响了,于是保安把阿东拦下,要检查一下哪本书没有登记出借。阿东正准备把每一本书在报警器下过一下,以找出引发警报的书,但是保安露出不屑的眼神:你连二分查找都不会吗?
于是保安把书分成两堆,让第一堆过一下报警器,报警器响,这说明引起报警的书包含在里面;于是再把这堆书分成两堆,把第一堆过一下报警器,报警器又响,继续分成两堆……
最终,检测了 logN 次之后,保安成功的找到了那本引起警报的书,露出了得意和嘲讽的笑容。于是阿东背着剩下的书走了。
从此,图书馆丢了 N - 1 本书。
🥲
笑点解析:使用二分搜索要求序列有序。
二分查找并不简单,Knuth 大佬(发明 KMP 算法的那位)都说二分查找:思路很简单,细节是魔鬼。到底要给 mid 加一还是减一,while 里到底用 <= 还是 <。你要是没有正确理解这些细节,写二分肯定就是玄学编程,有没有 bug 只能靠菩萨保佑,谁写谁知道。
本文就来探究几个最常用的二分查找场景:寻找一个数、寻找左侧边界、寻找右侧边界。而且,我们就是要深入细节,分析这些细节的差异以及出现这些差异的原因,保证你能灵活准确地写出正确的二分查找算法。
另外再声明一下,对于二分搜索的每一个场景,本文还会探讨多种代码写法,目的是为了让你理解出现这些细微差异的本质原因,最起码你看到别人的代码时不会懵逼。实际上这些写法没有优劣之分,你喜欢哪种就用哪种好了。
其中 ... 标记的部分,就是可能出现细节问题的地方,当你见到一个二分查找的代码时,首先注意这几个地方。后文用实例分析这些地方能有什么样的变化。
另外提前说明一下,计算 mid 时需要防止溢出,代码中 left + (right - left) / 2 就和 (left + right) / 2 的结果相同,但是有效防止了 left 和 right 太大,直接相加导致溢出的情况。

寻找一个数

基本的二分搜索。这个场景是最简单的,可能也是大家最熟悉的,即搜索一个数,如果存在,返回其索引,否则返回 -1。

704. 二分查找

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target  ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1
代码如下:
  • 为什么 while 循环的条件是 <= 而不是 <
答:因为初始化 right 的赋值是 nums.length - 1,即最后一个元素的索引,而不是 nums.length
这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间 [left, right],后者相当于左闭右开区间 [left, right)。因为索引大小为 nums.length 是越界的,所以我们把 right 这一边视为开区间。
我们这个算法中使用的是前者 [left, right] 两端都闭的区间。这个区间其实就是每次进行搜索的区间
什么时候应该停止搜索呢?当然,找到了目标值的时候可以终止:if(nums[mid] == target)
但如果没找到,就需要 while 循环终止,然后返回 -1。那 while 循环什么时候应该终止?搜索区间为空的时候应该终止,意味着你没得找了,就等于没找到嘛。
while(left <= right) 的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。
while(left < right) 的终止条件是 left == right,写成区间的形式就是 [right, right],或者带个具体的数字进去 [2, 2]这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说区间 [2, 2] 被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。
当然,如果你非要用 while(left < right) 也可以,我们已经知道了出错的原因,就打个补丁好了:return nums[left] == target ? left : -1
  • 为什么是 left = mid + 1right = mid - 1
答:这也是二分查找的一个难点,不过只要你能理解前面的内容,就能够很容易判断。
刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即 [left, right]。那么当我们发现索引 mid 不是要找的 target 时,下一步应该去搜索哪里呢?
当然是去搜索区间 [left, mid-1] 或者区间 [mid+1, right] 对不对?因为 mid 已经搜索过,应该从搜索区间中去除
  • 此算法有什么缺陷?
答:至此,你应该已经掌握了该算法的所有细节,以及这样处理的原因。但是,这个算法存在局限性。
比如说给你有序数组 nums = [1,2,2,2,3]target 为 2,此算法返回的索引是 2,没错。但是如果我想得到 target 的左侧边界,即索引 1,或者我想得到 target 的右侧边界,即索引 3,这样的话此算法是无法处理的。
这样的需求很常见,你也许会说,找到一个 target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了
我们后续的算法就来讨论这两种二分查找的算法。

寻找左侧边界

比如输入 [4,6,8,8,8,10], target = 8,left_bound 返回索引 2。
  • 为什么 while 中是 < 而不是 <=
  • 为什么是 left = mid + 1 和 right = mid
左闭右开区间。
这里先要说一个搜索左右边界和上面这个算法的一个区别,也是很多读者问的:刚才的 right 不是 nums.length - 1 吗,为啥这里非要写成 nums.length 使得「搜索区间」变成左闭右开呢
因为对于搜索左右侧边界的二分查找,这种写法比较普遍。
  • target 不存在时返回什么?
如果 nums 中不存在 target 这个值,计算出来的这个索引含义是什么?如果我想让它返回 -1,怎么做?
这是一个很好且很重要的问题,你把这个地方理解了,在二分搜索的实际应用场景中就不会懵逼。
直接说结论:如果 target 不存在,搜索左侧边界的二分搜索返回的索引是大于 target 的最小索引
为什么?
left_bound 的这个行为有一个好处。比方说现在让你写一个 floor 函数,就可以直接用 left_bound 函数来实现:
如果想让 target 不存在时返回 -1 其实很简单,在返回的时候额外判断一下 nums[left] 是否等于 target 就行了,如果不等于,就说明 target 不存在。需要注意的是,访问数组索引之前要保证索引不越界:
其实上面的 if 中 left < 0 这个判断可以省略,因为对于这个算法,left 不可能小于 0。你看这个算法执行的逻辑,left 初始化就是 0,且只可能一直往右走,那么只可能在右侧越界、不过我这里就同时判断了,因为在访问数组索引之前保证索引在左右两端都不越界是一个好习惯,没有坏处。另一个好处是让二分的模板更统一,降低你的记忆成本,因为等会儿寻找右边界的时候也有类似的出界判断。
  • 为什么该算法能够搜索左侧边界?
关键在于对于 nums[mid] == target 这种情况的处理:right = mid
可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的
  • 为什么返回 left 而不是 right
答:都是一样的,因为 while 终止时是 left == right
  • 能否统一成两端都闭的搜索区间?
当然可以,完整代码如下,自己分析分析:
这样就和第一种二分搜索算法统一了,都是两端都闭的「搜索区间」,而且最后返回的也是 left 变量的值。只要把住二分搜索的逻辑,两种形式大家看自己喜欢哪种记哪种吧。

寻找右侧边界的二分查找

与上面同理。
  • 为什么返回 left - 1
为什么最后返回 left - 1 而不像左侧边界的函数,返回 left?而且我觉得这里既然是搜索右侧边界,应该返回 right 才对。
答:首先,while 循环的终止条件是 left == right,所以 left 和 right 是一样的,你非要体现右侧的特点,返回 right - 1 好了。
至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在锁定右边界时的这个条件判断:
因为 left=mid+1 ,所以 mid=left-1 。因为我们对 left 的更新必须是 left = mid + 1,就是说 while 循环结束时,nums[left] 一定不等于 target 了,而 nums[left-1] 可能是 target ,如下图所示:
notion image
  • 如果 target 不存在时返回什么?
和前面讲的 left_bound 相反:如果 target 不存在,搜索右侧边界的二分搜索返回的索引是小于 target 的最大索引
如果你想在 target 不存在时返回 -1,很简单,只要在最后判断一下 nums[left-1] 是不是 target 就行了,类似之前的左侧边界搜索,做一点额外的判断即可。
当然,此算法也可以转为左闭右闭区间,完整代码如下:
当然,由于 while 的结束条件为 right == left - 1,所以你把上述代码中的 left - 1 都改成 right 也没有问题

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

给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。
用上面的两个函数秒了:
理解本文能保证你写出正确的二分查找的代码,但实际题目中不会直接让你写二分代码,我会在 二分搜索的运用 和 二分搜索算法经典习题 中进一步讲解如何把二分思维运用到更多算法题中。