一文弄懂二分查找

481 阅读2分钟

问题引入

  先从下面“类二分查找”代码中找出正确的二分查找代码:

int search(int[] nums,int target){
    int low=0,high=nums.length;
    while(low < high){
        int mid = low + (high-low)/2;
        if(nums[mid] < target){
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    return left;
}
int search(int[] nums,int target){
    int low=0,high=nums.length-1;
    while(low < high){
        int mid = low + (high-low)/2;
        if(nums[mid] < target){
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    return left;
}
int search(int[] nums,int target){
    int low=0,high=nums.length;
    while(low <= high){
        int mid = low + (high-low)/2;
        if(nums[mid] < target){
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    return left;
}
int search(int[] nums,int target){
    int low=0,high=nums.length;
    while(low < high){
        int mid = low + (high-low)/2;
        if(nums[mid] <= target){
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    return left;
}

image.png
感觉就像是徐锦江老师和雷神一样,这些代码不能说有点相似,只能说是一模一样。。。
二分查找是每个学过数据结构的同学都知道的,思想也很简单,在一个有序数组里查找某元素,由于数组有序,每次用中间元素和查找元素比较,可以淘汰掉一半的区间。但是写起来感觉就不是很好写了,整体框架和上面四段代码一致,但是细节太多,比方说:

  1. high初始化为nums.length还是nums.length-1?
  2. while里是low<high还是low<=high?
  3. nums[mid]<target还是nums[mid]<=target?
  4. low = mid还是low=mid+1? high=mid还是mid-1?
  5. 最后return left还是right?
    按以上几个条件排列组合,似乎可以写出十几种看起来很相似但不知道对不对的二分代码😒
    二分的写法和理解方法很多,不一而足,就不一一说了,这里给出一种二分查找的写法以及理解方法(只考虑升序情况)。

前提

  本文所述的二分查找返回一个下标值,使得在此位置插入新元素后列表仍然有序。(所谓插入,就是将插入位置及之后的元素全部向后移动一位,而原插入位置则赋值为新元素)这个位置是一定存在的,但不一定是唯一的。倘若数组nums的长度为n,本文给出的二分查找函数返回结果是[0,n]。

image.png
在如下图的数组里对4进行二分查找: 5b9854c796607e1252a70e4035a80fa.jpg
由上图,我们定义lower_bound为第一个插入元素后数组仍有序的下标,upper_bound为最后一个插入元素后数组仍有序的下标。听起来有些拗口,说人话就是lower_bound是第一个大于等于target的元素的下标,upper_bound是第一个大于target的元素的下标。此图里,lower_bound为2,upper_bound为5。
再举个栗子,在同一个数组里查找9,可以看出数组里所有元素都比9小,所以lower_bound和upper_bound一样,都是8,也就是在位置8插入元素9,可以保证数组仍然有序。 1cd4a38fee9d7f4be04ead3341f2464.jpg

lower_bound

直接上代码

int search(int[] nums,int target){
    int low=0,high=nums.length;
    while(low < high){
        int mid = low + (high-low)/2;
        if(nums[mid] < target){
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    return left;
}

  这是查找lower_bound的代码,也就是第一个大于等于target的代码,由于target可能大于数组里所有数,所以high初始化为nums.length。我们先看这部分

if(nums[mid] < target){
    low = mid + 1;
}

由于我们要找的是第一个大于等于target的位置,那么如果nums[mid] < target,那可以确定mid以及mid之前的元素都小于target(nums升序),也就是说low左边(0,1....mid)全都是不符合条件的解,所以直接令low = mid + 1,low其实就是控制不符合的解,再看high的部分

else {
    high = mid;
}

else也就是nums[mid] >= target,此时,将high更新为mid,由于我们找的是第一个大于等于target的元素,而nums[mid] >= target,也就是说mid一定是大于等于target的坐标集合里的,但是是不是第一个不知道。令high = mid,high其实代表的就是可能可以的解。再看循环条件:

while(low < high)

这个循环的终止条件为low >= high,在本文的情境下,每一轮mid都更新为low + (high-low)/2,也就是(low + high)/2,并且是向下取整,这么写是为了防止溢出,这都是老生常谈的事情了,就不解释了。然后每次low更新为mid + 1,或者high更新为mid。不难证明,在这个情景下,这个循环走到最后一定是low == high,不会有low>high的情况。
结合上面对low和high的代码更新的分析,low左边的都是不符合条件的解,可以忽略,而high代表的是满足条件的解,但不知道是不是第一个。那么循环终止时,low和high相等,high就是low,也就是说high左边的元素都不符合条件,而high又是符合条件的解,那么high就是符合条件的第一个解。
综上,low就是控制不符合条件的解,low左边的元素都不符合条件,high则是记录符合条件的解,但不一定是第一个,low不断向右,high不断向左,当low和high重合时,那么我们就找到了符合条件的第一个解。

upper_bound

现在要找的是第一个大于target的元素,不符合条件也就是nums[mid] <= target,根据上面总结的low控制不符合条件解,high控制符合条件解的思想,代码写出来就非常简单了

int search(int[] nums,int target){
    int low=0,high=nums.length;
    while(low < high){
        int mid = low + (high-low)/2;
        if(nums[mid] <= target){
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    return left;
}

只要把low的更新条件由nums[mid] < target改为nums[mid] <= target即可。
有人说了,不是low控制不符合条件,high控制符合条件吗?那我直接让low的更新条件为nums[mid]!=target不就结了。这样是不行的,因为之所以可以用low控制不符合条件的解,就是因为数组有序,nums[mid]小于或小于等于target,那它前面的也肯定小于等于target,但是你用!=来控制,nums[mid]不等于target,并不能代表它前面的元素就不等于target。所以还是得老老实实写正常的控制条件。

查找元素出现位置

现在来看二分查找最朴素的应用,查找数组中某数的位置,可以直接调用lower_bound的代码,由于lower_bound找的是第一个大于等于target的位置,即使元素不在数组里,也会有结果,另外,有可能target比数组里所有数都大,此时代码会返回数组的长度。所以如果查找元素出现位置,只需将lower_bound代码返回值如下改动:

if(low == nums.length||nums[low]!=target)//数组里没这个元素
    return -1;
return low  //否则,数组里有这个元素,第一个大于等于target的位置也就是第一个等于target的位置

一些细节问题

本文最前面提到了一些细节问题,一一回答。这些细节都是针对本文的写法,对其它写法可能不适用

  1. high初始化为nums.length还是nums.length-1? high初始化为nums.length,因为有可能target比数组所有的数都大,所以nums.length也是一个可能符合条件的位置。
  2. while里是low<high还是low<=high? low<high,因为要确保循环结束时,low == high
  3. nums[mid]<target还是nums[mid]<=target? 这个因题目而异,主要看题目要求什么,不符合条件的情况是什么
  4. low = mid还是low=mid+1? high=mid还是mid-1? low = mid + 1,low控制的是不符合条件的解,mid也是不符合条件的;high = mid,因为high控制的是符合条件的解
  5. 最后return low还是high? 都一样
    下面用这种思想来做几个题。

查找元素的左右边界

看一下这道题:
34. 在排序数组中查找元素的第一个和最后一个位置
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值 target,返回 [-1, -1]
示例 1:

输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]

其实就是求upper_bound-1和lower_bound。

public int[] searchRange(int[] nums, int target) {
    if(nums.length==0)
        return new int[]{-1,-1};
    int low=0,high=nums.length;
    //寻找lower_bound
    while(low<high){
        int mid = low + (high-low)/2;
        if(nums[mid]<target){
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    //pos1记录lower_bound
    int pos1 = low;
    //重置low和high,进行下一次循环,寻找upper_bound
    low=0;
    high=nums.length;
    //寻找upper_bound
    while(low<high){
        int mid = low + (high-low)/2;
        if(nums[mid]<=target){
            low = mid + 1;
        }
        else {
            high = mid;
        }
    }
    //此时的low就是upper_bound
    if (low== pos1)
        return new int[]{-1,-1};
    return new int[]{pos1,low-1};
    }

题解里的判断数是否在数组里出现的条件很麻烦,其实只要判断下lower_bound和upper_bound是不是相同就行了,如果target在数组里出现了,这两个数的值肯定是不一样的,如果没出现,那么nums[mid]<=target里的=就失效了,相当于两次循环是一个东西,返回自然是一样的。 image.png
这个代码也经过了力扣官方几百个用例的检验,应该问题不大。

第一个错误的版本

278. 第一个错误的版本
你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。
假设你有 n 个版本 [1, 2, ..., n],你想找出导致之后所有版本出错的第一个错误的版本。
你可以通过调用 bool isBadVersion(version) 接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。
示例 1:

输入: n = 5, bad = 4
输出: 4
解释:
调用 isBadVersion(3) -> false 
调用 isBadVersion(5-> true 
调用 isBadVersion(4-> true
所以,4 是第一个错误的版本。

直接上代码:

public class Solution extends VersionControl {
    public int firstBadVersion(int n) {
        //新的二分查找,太好用了
        int low =1,high=n;
        while(low<high){
            int mid = low + (high-low)/2;
            if (!isBadVersion(mid)) {
                low = mid + 1; //low向右压缩,对不符合条件的解进行淘汰
            }
            else {
                high = mid; //high向左压缩,记录符合条件的解
            }
        }
        return high;
    }
}

用本文介绍的思想,我们要找的是第一个错误的版本,那么当isBadVersion(version)为true时,此时的version是符合条件的解,更新high,但不一定是第一个,所以还需要low来控制不符合的解。当循环结束时,low = high,就找到了第一个符合条件的解。很容易写出代码 image.png
代码也是经过了检验的。

总结

本文给出了一种二分查找的写法,并给出了如何理解。当然,二分查找的写法和理解方法有很多,本文只是其中一种。比方说Java的Arryas.binarySearch

private static int binarySearch0(int[] a, int fromIndex, int toIndex,int key) {
    int low = fromIndex;
    int high = toIndex - 1;

    while (low <= high) {
        int mid = (low + high) >>> 1;
        int midVal = a[mid];

        if (midVal < key)
            low = mid + 1;
        else if (midVal > key)
            high = mid - 1;
        else
            return mid; // key found
    }
    return -(low + 1);  // key not found.
}

还是挺不一样的,大家有兴趣可以研究一下有啥不一样。