核心:
- 二分并不是仅仅用于找数字的,二分的二,其本质是两种不同的状态,通过每次排除一半来逼近两种状态的分界线,在找数字中,我们利用的两种状态就是数字左右两边大小不一样。
二分细节
注意事项:
-
所有情况均为
if else
语句,更加清晰 -
注意防止mid溢出
普通二分查找
在nums中搜索target,找到返回下标,否则返回-1.
int binarySearch(vector<int> nums, int target) {
int l = 0;
int r = nums.size() - 1; // @1
while(l <= r) // @2
{
int m = (r - l) / 2 + l;
if(nums[m] == target) return m; // 找到元素,返回下标
else if(nums[m] < target) l = m + 1; // @3
else if(nums[m] > target) r = m - 1; // @4
}
return -1;
}
首先要明确,二分到最后肯定是不断逼近一个数字的,类比数学上二分找函数零点的思想。
上述的四处@,是四个注意的点,下面分别说一下(为配合上面的代码,建议将代码截图后,一边看解释一边看代码):
-
@1: r是
nums.size()
还是nums.size()-1
?,这个选择其实是自由的,因为我们二分的目标在于,用尽量少的次数去找到目标值,其本质还是基于遍历的思想(不知道这么说是否准确,我的理解是,二分每次可以舍弃一半,但是这一半的舍弃我们确定不会对结果产生影响,所以舍弃,但是不能舍弃掉未知的元素。举个例子,这个nums数组有n个元素,那就必须n个元素都考虑在内,不能从第2个元素开始遍历)。既然要遍历,那么我们这里的r
和l
,也就是在确定遍历的范围。我们知道元素的下标是0
到nums.size()-1
,因此这里选择nums.size()
还是nums.size()-1
无所谓,只要保证搜索区间范围包含所有元素即可。对于选择nums.size()
,那我们的搜索区间就是[l,r)
,选择nums.size()-1
,我们的搜索区间是[l,r]
-
@2:
l <= r
还是l < r
,这里的范围是由上面的搜索区间决定的。-
如果选择
nums.size()
,搜索区间是[l,r)
,那么这里就应该是l < r
,可以用反证法,如果是l <= r
,那么如果一直向r
逼近的话,r
保持不变,l
最终等于r
,就会越界,所以是l < r
。 -
选择
nums.size()-1
,我们的搜索区间是[l,r]
,这里应该是l <= r
,这时不怕越界,如果是l < r
的话,l
最大也就是r - 1
,那么会导致最后一个元素漏掉。 -
对于最后返回-1,
l <= r
,终止条件是l 等于 r + 1
,区间为空,可直接返回;但如果选择l < r
,终止条件是l 等于 r
,但可能会漏掉一个元素(之所以可能,是因为如果l
等于r
等于nums.size()
,那么无所谓,但如果是在内部,那么这个地方就没有顾及到) -
如果数组是
{1, 2, 3, 4, 5, 6, 7}
,目标是3,那么就会找不到(已运行验证(注意这里说的是r=nums.size()
并且l < r
的循环条件)),所以为了防止漏掉,要检查这个地方,即 -
if(l < nums.size() && nums[l] == target) return l; else return -1;
-
-
@3: 3与4是一个问题,放在一块说。有时候是
l = m
,有时候是l = m + 1
,这里还是由于搜索空间决定的,这里左右边界的变化,决定了下一次的搜索空间。对于这个题,我们要保证所有的都遍历过,而现在nums[m]
已经遍历过,那么不再需要遍历,下一次的空间就应该是[l, m - 1]
或者[m + 1, r]
。这是由一开始的搜索空间决定的,如果一开始是[l, r)
,那么经过m后,要保持相同格式,就应该是[l, m)
与[m +1, r)
。
边界二分查找
上面的二分可以查到目标值,但是如果有多个目标值,我们想要第一个或者最后一个的边界值又该怎么办呢?
要在下一次的搜索空间上下功夫:
左边界二分
核心代码:
while(l < r) { // 此处同上,由一开始的区间决定,不再解释,不过边界的一般习惯是 <
int m = (r - l) / 2 + l;
if(nums[m] == target) {
r = m; // @1
} else if(nums[m] > target) {
r = m; // @2,m已经验证过,但这里保持m,是为了保持[l, r)的区间格式
} else if(nums[m] < target) {
l = m + 1; // @3
}
}
if(l < nums.size() && nums[l] == target) return l; // 原因上面已谈及
else return -1;
- @1: 当我们找到一个符合条件的,由于需要找最左边的,不能直接返回,而是缩小上界。那如果只出现一次,将其放在
r
中,但最后返回l
,有影响吗?经验证,没有影响,如果只出现一次,在第一次遇见放到r
中,那么l
会不断逼近直到与r
相等(这也就是循环条件),所以最后返回l
也是可以的。
右边界二分
核心代码:
while(l < r) { // 此处同上,由一开始的区间决定,不再解释,不过边界的一般习惯是<
int m = (r - l) / 2 + l;
if(nums[m] == target) {
l = m + 1; // @1
} else if(nums[m] > target) {
r = m; // 同上
} else if(nums[m] < target) {
l = m + 1; // 同上
}
}
if(l > 0 && nums[l - 1] == target) return l - 1; // @2
else return -1;
在找右侧边界时,即最后一个,那么如果当前的m大了,为保持左闭右开,r = m
,如果m小了,l = m + 1
。
在@1中,可以看到是增大了下届(如果这里不加1,可能会出现死循环,因为m是向下取整,在只有两个数时,无法区分,比如3和4,中点是3,如果3不符合条件则会一直循环),这里的+1对于返回值@2产生了影响,容易看到如果只有一个数字符合,这时候l
又加1,那么应该返回l - 1
。
总结
本文简要介绍了关于二分法在代码实现上的相关细节, 二分法思想比较简单,但是细节很多, 需要多注意。
参考链接
https://zhuanlan.zhihu.com/p/79553968