二分算法,你思考过这些问题吗?

403 阅读4分钟

**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,这时不需要再进入循环比较了。