Jon Bentley:“ 90%以上的程序员无法正确无误的写出二分查找代码。”
Jon Bentley:“ 在贝尔实验室和IBM 的时候都出过这道考题。那些专业的程序员都认为自己写出了正确的程序。于是,我们花了半个钟头来用测试用例验证他们的代码。一百多人中有90%的程序员写的程序中有bug。”
一个更惊人的事实是,二分查找算法的论文在1946年就发表了,但第一个没有错误的程序直到1962年才写出来。
直到今日,字节跳动等大公司的面试也经常考查二分查找。虽然二分查找的思路非常简单,就是通过不断通过有序数组的中间项与查找target 进行比较,可以排除数组一半元素,范围缩小一半。就这样反复比较,反复缩小范围,最终就会在数组中找到target,时间复杂度为logN。
思路看起来很简单,但是大厂多次考察的原因是什么呢?
面试者非常容易写错二分查找的边界条件,尤其对于二分查找变种题型。
这篇文章教会你写边界条件的通用思考方式,适用所有变种题型,不需要死记硬背。学会之后,面试前再看一遍,保证顺利bug-free。
01 分治
二分法是分治(divide and conqer)的一种特例,本质上是为了将大问题分解成更小的问题,而二分法是恰好将问题规模减少为原来的一半。
我们拿到一道新题,第一想法不是想能不能用二分法解决,而是想能不能用分治法解决,将大问题拆解成子问题解决?
如果整个问题是有序的,并且子问题恰好能将规模减少为原来的一半,思路就导向了二分法。
如果发现子问题是有冗余的,思路就导向了动态规划,详细见刷题有术--动态规划 必备知识
二分法一些明显标志:有序数组;区间范围,单调性逼近。
02. 二分查找
二分查找也叫二分搜索,有递归和非递归两种写法。面试推荐非递归写法,因为方便确定边界条件写法。
-
思路:
搜索过程从有序数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。
-
递归写法:
int binarySearch(int *array, int left, int right, int value)
{
if (left > right) return -1;
int mid = left + (right-left)/2;
if (array[mid] == value) {
return mid;
} else if (array[mid]> value) {
return binarySearch(array, left, mid -1, value);
} else if (array[mid]< value) {
return binarySearch(array, mid+1, right, value);
}
}
-
非递归写法:
int binarySearch(int *array, int left, int right, int value)
{
while (left<=right) //边界条件,适时而变
{
int middle = left + (right-left)/2;
if (array[middle]>value)
{
right =middle-1;
}
else if(array[middle]<value)
{
left=middle+1;
}
else
return middle;
}
return -1
}
注意点:
-
要点1. middle写法
int middle = left + (right-left)/2 这个写法与int middle = (left + right)/2 的结果是一样的,但是当数组长度很大的时候,left + right 可能超出int 范围,所以写成left+ (right-left)/2 是非常专业的!
-
要点2. 如果找不到target,target 插入位置在哪里?
当升序排列时,当没有查找到元素时,left的位置是应插入该元素的位置。
如果没有找到目标元素,那么left一定停在恰好比目标大的index上,right一定停在恰好比目标小的index上。
-
要点3. 结束条件 & left/right 指针如何移动
left/right 指针不同的移动方式,对应不同的while 循环结束条件。while循环条件到底是left < right 还是 left<=right,边界条件如何确定?
只需要考虑,当left 与 right 紧邻时到下一次循环的过程,能否「搜索到left 或者right 为target 的情况」,以及「搜索会不会死循环」。
当left, right 如上图紧邻时,middle 是等于left,如果target 恰恰是等于right,则进入array[middle]<value分支,left 会加一,变成如下状态。
如果while 循环条件是写成left < right,没有等于号,这时就无法进入循环,无法检验middle 是否是target,从而返回-1 的错误答案。
所以结论就是,无论 结束条件 和 left/right 指针如何移动,都要验证最后两次循环过程,能否「搜索到left 或者right 为target 的情况」,以及「搜索会不会死循环」
03. 变型
二分搜索法要求数组是有序的,而有一些变种题目,数组部分区间单调。
比如,字节跳动大厂常考的面试题“搜索旋转排序数组”:
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1。
由于数组旋转之后,整体不是严格有序,但是由于只旋转了一次,所以能保证middle 指针的一侧是严格有序的,需要分为Middle左侧有序和右侧有序两种情况。
通过判断target 是否在单调区间内,从而将子问题规模缩小为原来的一半。
int solute(vector<int> nums, int target)
{
int left = 0;
int right = nums.size()-1;
while(left <= right)
{
int middle = left+(right-left)/2;
if (nums[middle]==target)
{
return middle;
}
else if (nums[middle]>= nums[left])
{
if (nums[left]<=target && target< nums[middle])
{
right = middle-1;
}
else
{
left = middle+1;
}
}
else {
if (nums[middle]< target && target<=nums[right])
{
left=middle+1;
}
else
{
right = middle-1;
}
}
}
return -1;
}
04.二分答案法
经常有这样的问题,求满足限制的最近距离最大能是多少,即求符合条件的最小值里的最大值,这种问题有个解法叫二分答案法。
不知道的答案也能二分查找?
是的,关键在于这个答案是可以判断是不是符合条件的。
-
算法思想:
以求最小值的最大值(最小值最大化)为例,尝试一个可能的答案,如果这个答案符合题目条件,那么它肯定是“最小”(可行解),但不一定是“最大”(最优解)。
然后我们换个更大的可能答案,如果也符合条件,那这个新可行解就更优,不断重复即可。怎么找呢?这时就该二分上场了。
-
二分答案法前提:
1.答案区间上下限确定,即最终答案在哪个范围是容易知道的。
2.检验某值是否可行非常简单,即给你个值,你能很容易的判断是不是符合题目要求。(不好找解但是能判断解是否符合条件)
3.可行解满足区间单调性,即若x是可行解,则不用搜索大于x或者小于x的那一侧了,只用在小于x或者大于x的一侧搜索答案。
-
写法
1. 有一个check函数检测解是否符合题意
2. 二分写法
-
最小值最大化
int left = min_ans, right = max_ans;
while (left < right) { // 不能是left<=right 了,因为这时进入循环中middle就超出left到right范围了
int mid = left + (right-left)/2+1; //+1避免 r == l + 1 时mid一直等于l,从而死循环
if (check(mid)) //符合条件返回True
left = mid;
else
right = mid - 1;
}
if (check(left))// 可能r一直逼近,最后r=l,l没动过退出的循环,所以要检测下l
return left; // 最后l是解
希望答案尽可能大,所以我们需要确保左区间Left点符合题目条件(最小),至于Right是否符合条件是不确定的,首先判断Middle点符合与否,符合则将Left移到Middle点,维持了Left的True属性,也减小了所要的最小值所在区间。如果不符合,没办法在保持Left的True属性情况下移动Left,那就移动Right。
如何设计 结束条件 & left/right 指针如何移动呢?
这就用到了第二章中要点三的内容:
只需要考虑,当left 与 right 紧邻时,能不能进入循环继续搜索,「搜索到left 或者right 为target 的情况」,以及「搜索完了会不会死循环」。
由于left 可能一直保持在mid 不变,所以防止死循环,mid = left + (right-left)/2+1;并且while条件不能是left<=right 了,因为当left等于right这时进入循环后middle由于有加一就超出left到right范围了。
-
最大值最小化
int left = min_ans, right = max_ans;
while (left < right) {
int mid = (left + right) / 2;
if (check(mid)) //符合条件返回True
right = mid;
else
left = mid + 1;
}
if (check(right))//可能l一直逼近,最后l=r,r没动过退出的循环,所以要检测下r
return right;//最后r是解
按同样道理分析,维持Right的True属性即可。
这里的mid就不需要加1了,因为当left 和right紧邻时,mid指向left,下一步要么right移到mid,要么left 加1,所以不会死循环。
while里的条件不需要left <= right 取等号,因为left == right 的情况是上一步left 和right紧邻,并且left 移到了mid+1 才会造成了left==right,这时不需要再进入循环比较了。