魔鬼的二分查找
原理
二分查找并不简单,Knuth 大佬(发明 KMP 算法的那位)都说二分查找:思路很简单,细节是魔鬼。很多人喜欢拿整型溢出的 bug 说事儿,但是二分查找真正的坑根本就不是那个细节问题,而是在于到底要给 mid
加一还是减一,while 里到底用 <=
还是 <
。
你要是没有正确理解这些细节,写二分肯定就是玄学编程,有没有 bug 只能靠菩萨保佑。
框架
1 | int binarySearch(int[] nums, int target) { |
举例
寻找一个数
这个场景是最简单的,肯能也是大家最熟悉的,即搜索一个数,如果存在,返回其索引,否则返回 -1。
1 | int binarySearch(int[] nums, int target) { |
1、为什么 while 循环的条件中是 <=,而不是 <?
答:因为初始化 right
的赋值是 nums.length - 1
,即最后一个元素的索引,而不是 nums.length
。
这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间 [left, right]
,后者相当于左闭右开区间 [left, right)
,因为索引大小为 nums.length
是越界的。
我们这个算法中使用的是前者 [left, right]
两端都闭的区间。这个区间其实就是每次进行搜索的区间。
什么时候应该停止搜索呢?当然,找到了目标值的时候可以终止:
1 | 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
,写成区间的形式就是 [left, right]
,或者带个具体的数字进去 [2, 2]
,这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间 [2, 2]
被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。
当然,如果你非要用 while(left < right)
也可以,我们已经知道了出错的原因,就打个补丁好了:
1 | //... |
总的来说,如果一开始的right是length - 1,那么你需要让left = right 这种情况也被搜索一遍,如果一开始right是length,left = right这种临界情况其实已经超出了数组的有效范围
2、为什么 left = mid + 1
,right = mid - 1
?我看有的代码是 right = mid
或者 left = mid
,没有这些加加减减,到底怎么回事,怎么判断?
答:这也是二分查找的一个难点,不过只要你能理解前面的内容,就能够很容易判断。
刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即 [left, right]
。那么当我们发现索引 mid
不是要找的 target
时,下一步应该去搜索哪里呢?
当然是去搜索 [left, mid-1]
或者 [mid+1, right]
对不对?因为 mid
已经搜索过,应该从搜索区间中去除。
3、此算法有什么缺陷?
答:至此,你应该已经掌握了该算法的所有细节,以及这样处理的原因。但是,这个算法存在局限性。
比如说给你有序数组 nums = [1,2,2,2,3]
,target
为 2,此算法返回的索引是 2,没错。但是如果我想得到 target
的左侧边界,即索引 1,或者我想得到 target
的右侧边界,即索引 3,这样的话此算法是无法处理的。
这样的需求很常见,你也许会说,找到一个 target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了。
寻找左侧边界的二分查找
1 | int left_bound(int[] nums, int target) { |
1、为什么 while 中是 <
而不是 <=
?
答:用相同的方法分析,因为 right = nums.length
而不是 nums.length - 1
。因此每次循环的「搜索区间」是 [left, right)
左闭右开。
while(left < right)
终止的条件是 left == right
,此时搜索区间 [left, left)
为空,所以可以正确终止。
2、为什么没有返回 -1 的操作?如果 nums
中不存在 target
这个值,怎么办?
答:因为要一步一步来,先理解一下这个「左侧边界」有什么特殊含义:
对于这个数组,算法会返回 1。这个 1 的含义可以这样解读:nums
中小于 2 的元素有 1 个。
比如对于有序数组 nums = [2,3,5,7]
, target = 1
,算法会返回 0,含义是:nums
中小于 1 的元素有 0 个。
再比如说 nums = [2,3,5,7], target = 8
,算法会返回 4,含义是:nums
中小于 8 的元素有 4 个。
综上可以看出,函数的返回值(即 left
变量的值)取值区间是闭区间 [0, nums.length]
,所以我们简单添加两行代码就能在正确的时候 return -1:
1 | while (left < right) { |
3、为什么 left = mid + 1
,right = mid
?和之前的算法不一样?
答:这个很好解释,因为我们的「搜索区间」是 [left, right)
左闭右开,所以当 nums[mid]
被检测之后,下一步的搜索区间应该去掉 mid
分割成两个区间,即 [left, mid)
或 [mid + 1, right)
。
4、为什么该算法能够搜索左侧边界?
答:关键在于对于 nums[mid] == target
这种情况的处理:
1 | if (nums[mid] == target) |
可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right
,在区间 [left, mid)
中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
5、为什么返回 left
而不是 right
?
答:都是一样的,因为 while 终止的条件是 left == right
。
6、能不能想办法把 right
变成 nums.length - 1
,也就是继续使用两边都闭的「搜索区间」?这样就可以和第一种二分搜索在某种程度上统一起来了。
答:当然可以,只要你明白了「搜索区间」这个概念,就能有效避免漏掉元素,随便你怎么改都行。下面我们严格根据逻辑来修改:
因为你非要让搜索区间两端都闭,所以 right
应该初始化为 nums.length - 1
,while 的终止条件应该是 left == right + 1
,也就是其中应该用 <=
:
1 | int left_bound(int[] nums, int target) { |
因为搜索区间是两端都闭的,且现在是搜索左侧边界,所以 left
和 right
的更新逻辑如下:
1 | if (nums[mid] < target) { |
由于 while 的退出条件是 left == right + 1
,所以当 target
比 nums
中所有元素都大时,会存在以下情况使得索引越界:
因此,最后返回结果的代码应该检查越界情况:
1 | if (left >= nums.length || nums[left] != target) |
至此,整个算法就写完了,完整代码如下:
1 | int left_bound(int[] nums, int target) { |
这样就和第一种二分搜索算法统一了,都是两端都闭的「搜索区间」,而且最后返回的也是 left
变量的值。只要把住二分搜索的逻辑,两种形式大家看自己喜欢哪种记哪种吧。
寻找右侧边界的二分查找
1 | int right_bound(int[] nums, int target) { |
原文链接: 二分法详解
二分法变种(2021.04.08更新)
今天的leetcode每日一题是一道二分法的题目,但是不同于上面讲的这些二分法,在这里更新记录一下
这是题目链接:https://leetcode-cn.com/problems/find-minimum-in-rotated-sorted-array/
这里我就直接使用官方的题解来解答,只不过会对这个题解加以更加详细的解释
一个不包含重复元素的升序数组在经过旋转之后,可以得到下面可视化的折线图:
其中横轴表示数组元素的下标,纵轴表示数组元素的值。图中标出了最小值的位置,是我们需要查找的目标。
我们考虑数组中的最后一个元素 xx:在最小值右侧的元素(不包括最后一个元素本身),它们的值一定都严格小于 xx;而在最小值左侧的元素,它们的值一定都严格大于 xx。因此,我们可以根据这一条性质,通过二分查找的方法找出最小值。
在二分查找的每一步中,左边界为low,右边界为high,区间的中点为pivot,最小值就在该区间内。我们将中轴元素nums[pivot] 与右边界元素 nums[high] 进行比较,可能会有以下的三种情况:
第一种情况是nums[pivot]<nums[high]。如下图所示,这说明 nums[pivot] 是最小值右侧的元素,因此我们可以忽略二分查找区间的右半部分。
第二种情况是nums[pivot]>nums[high]。如下图所示,这说明 nums[pivot] 是最小值左侧的元素,因此我们可以忽略二分查找区间的左半部分。
由于数组不包含重复元素,并且只要当前的区间长度不为 1,pivot 就不会与 high 重合;而如果当前的区间长度为 11,这说明我们已经可以结束二分查找了。因此不会存在 nums[pivot]=nums[high] 的情况。
1 | var findMin = function(nums) { |
几个问题
看完上面那些解释,大家可能觉得自己明白了,但是还有几个问题我们需要思考一下:
搜索区间是什么
根据原理那部分所讲,由于high = nums.length - 1,所以搜索区间是[low, high]
为什么是low < high 的时候才执行,也就是说为什么low = high的时候不需要判断
这是因为当low = high的时候,区间长度为1,那最小值就是这个唯一值,所以直接返回就好
为什么是比较pivot和high而不是low
左、中、右三个位置的值相比较,有以下几种情况:
左值 < 中值, 中值 < 右值 :没有旋转,最小值在最左边,可以收缩右边界
1
2
3右
中
左左值 > 中值, 中值 < 右值 :有旋转,最小值在左半边,可以收缩右边界
1
2
3左
右
中左值 < 中值, 中值 > 右值 :有旋转,最小值在右半边,可以收缩左边界
1
2
3中
左
右左值 > 中值, 中值 > 右值 :单调递减,不可能出现
1
2
3左
中
右我们可以分析一下上面几种情况,比较中值和右值只分两种情况就好。中值大于左值还要再分两种情况
为什么缩小区间时,high = pivot,而 low = pivot + 1
因为当
nums[pivot] < nums[high]
时,这个nums[pivot]
还有可能是最小值,所以pivot仍然在搜索区间内。而当nums[pivot] > nums[high]
,nums[pivot]
不可能是最小值