你这目标值挺能藏啊!(二分搜索)

152 阅读5分钟

一.简介

二分搜索一般运用于搜索有序数组的目标值,属于常规算法,但在细节上是魔鬼。

此外,二分还有左右边界搜索的延伸应用。

二.简单二分搜索

二分搜索的搜索范围有左闭右闭,和左闭右开两种,

2.1左闭右开

先来讲讲左闭右开,也就是搜索区间为[left,right),二分搜索一般应用于数组,搜索范围是数组中还未被遍历过的元素,一开始数组没有一个元素是遍历过的,则搜索范围为 [0,len) (设数组arr的长度为len)。

右区间属于开区间,right下标应该指向已经遍历过的元素,那么在这种情况下while括号里应该写left<right

常见求mid的方式是(left+right)/2,但这种方式存在一个问题,当left或right的值过大时,两者相加可能会越界,所以改用mid=left+(right-left)/2的方式来求mid值。

设要搜索的值为target。

  • arr[mid]<target时,左区间属于闭区间,left下标应该指向没有遍历过的元素,mid下标所对应的元素已经遍历过,所以在这种情况left=mid+1
  • arr[mid]>target时,还是同样的道理,right下标应该指向已经遍历过的元素,mid下标所对应的元素已经遍历过,所以在这种情况right=mid
  • arr[mid]=target时,直接返回mid值即可。

最终代码如下所示。

class Solution {
    public int search(int[] nums, int target) {
        int i=0;
        int j=nums.length;
        //这里i代表left,j代表right
        while (i<j){
            int mid=i+(j-i)/2;
            if (nums[mid]==target){
                return mid;
            }else if (nums[mid]>target){
                j=mid;
            }else {
                i=mid+1;        
            }
        }
        return -1;
    }
}

2.2左闭右闭

左闭右闭和左闭右开的分析思路一致,搜索范围是数组中还未被遍历过的元素,由于一开始数组没有一个元素是遍历过的,则搜索范围为 [0,len-1] (设数组arr的长度为len)。

在左闭右闭的情况下,right下标指向还没遍历过的元素,所以while()里应该写left<=right

  • arr[mid]<target时,左区间属于闭区间,left下标应该指向没有遍历过的元素,mid下标所对应的元素已经遍历过,所以在这种情况left=mid+1
  • arr[mid]>target时,right下标应该指向还没遍历过的元素,mid下标所对应的元素已经遍历过,所以在这种情况right=mid-1
  • arr[mid]=target时,直接返回mid值即可。

最终代码如下所示。

class Solution {
    public int search(int[] nums, int target) {
        int i=0;
        int j=nums.length-1;
        while (i<=j){
            int mid=i+(j-i)/2;
            if (nums[mid]==target){
                return mid;
            }else if (nums[mid]>target){
                j=mid-1;
            }else {
                i=mid+1;        
            }
        }
        return -1;
    }
}

三.二分边界搜索

二分边界搜索是要求在搜索的基础上,返回符合要求的最左边数值(下标最小),或者最右边数值(下标最大)。

不管是左边界还是右边界搜索,主要是针对arr[mid]=target的情况进行修改。

3.1二分左边界搜索

左闭右开的区间是[left,right),由于当搜索到目标值的时候,不是直接返回下标,而是继续向左搜索,代码如下所示。

class Solution {
    public int search(int[] nums, int target) {
        int i=0;
        int j=nums.length-1;
        while (i<=j){
            int mid=i+(j-i)/2;
            if (nums[mid]==target){
                j=mid-1;
            }else if (nums[mid]>target){
                j=mid-1;
            }else {
                i=mid+1;        
            }
        }
        //1
        if(left>=nums.length||nums[left]!=target){
        return -1;
        }
        return left;
    }
}

注释1处代码中,当left越界,或者left下标所对应的元素值不等于target时,返回-1。

可以看到,上面代码中nums[mid]==targetnums[mid]>target是可以合并的,合并之后的代码如下所示。

class Solution {
    public int search(int[] nums, int target) {
        int i=0;
        int j=nums.length-1;
        while (i<=j){
            int mid=i+(j-i)/2;
            if (nums[mid]>=target){
                j=mid-1;
            }else {
                i=mid+1;        
            }
        }
        //1
        if(left>=nums.length||nums[left]!=target){
        return -1;
        }
        return left;
    }

3.2二分右边界搜索

二分右边界搜索和二分左边界搜索的分析过程类似,这里就不再赘述,简单贴下代码。

class Solution {
    public int search(int[] nums, int target) {
        int i=0;
        int j=nums.length-1;
        while (i<=j){
            int mid=i+(j-i)/2;
            if (nums[mid]<=target){
                i=mid+1;
            }else {
                j=mid-1;
            }
        }
        //1
        if(right<0||nums[right]!=target){
        return -1;
        }
        return right;
    }

四.二分边界搜索的延伸应用

前面代码块注释1处都对越界情况进行了判定,需要注意的是,这个越界情况是可以根据题意灵活改变的,下面的题目爱吃香蕉的珂珂就是一个例子。

是的,这道题是用二分边界搜索解。

先来定义一个时间函数time(),设速度为v,time(v)即为计算在某速度下吃完所有香蕉的时间。

    private int time(int[] piles,int v){
        int res=0;
        for (int i=0;i<piles.length;i++){
            res+=piles[i]/v;
            if (piles[i]%h!=0){
                //剩下的香蕉不需要吃一小时也当做要吃一小时
                res++;
            }
        }
        return res;
    }

我们先把题目换一换,假设现在有一个V[]数组(V代表Velocity速度),该数组元素为{1,3,5,9,10},求该数组中能能在H小时吃完所有香蕉的最小速度。

不难看出,变换后的题目是在搜索左边界,那么我们同样可以用二分边界搜索去解决,左闭右闭。

  • 当time(v[mid])>H,即吃完所有香蕉的所需要时间大于H,不符合题意,速度要加快,left=mid+1。
  • 当time(v[mid])<H,即吃完所有香蕉的所需要时间小于H,符合题意,但题目求的是最小速度,也就是左边界,还需要往左搜索,right=mid-1。
  • 当time(v[mid])=H,即吃完所有香蕉的所需要时间刚好等于H,符合题意,但题目求的是最小速度,也就是左边界,还需要往左搜索,right=mid-1。

现在把数组换成{1,2,3,4..MAX_VALUE},MAX_VALUE的值是不管堆有多少个香蕉,珂珂总是能在一小时吃完的速度,如下图所示,堆中香蕉的最大数量是10^9个。

  • 当搜索区间是左闭右开时,MAX_VALUE的值取10^9+1。
  • 当搜索区间是左闭右闭,MAX_VALUE的值取10^9。

最终代码如下所示。

class Solution {
    public int minEatingSpeed(int[] piles, int h) {
        if(piles==null||h<=0){
            return -1;
        }
        //搜索区间为左闭右开
        int left=1;
        int right=1000000000+1;
        while (left<right){
            int mid=left+(right-left)/2;
            int temp=count(piles,mid);
            if (temp<=h){
                right=mid;
            }else {
                left=mid+1;
            }
        }
        //1
        return left;
    }
    private int count(int[] piles,int h){
        //计算时间
        //h为珂珂吃香蕉的速度
        int res=0;
        for (int i=0;i<piles.length;i++){
            res+=piles[i]/h;
            if (piles[i]%h!=0){
                res++;
            }
        }
        return res;
    }
}

需要注意的是,注释1处没有像往常一样进行越界判断,而是直接返回left,这是因为题目不需要吃完香蕉的时间刚好等于h,只需要无限接近于h即可。

题目是分析完了,但核心问题还没有解决,那就是为什么这道题可以用二分边界搜索?

先来分析计算吃香蕉所需时间的函数time(v),其图像如下图所示,可以发现,其呈现出了一种单调性,在一定范围内,速度越快,所需时间越短,但存在一定的”瓶颈期“,也就是图中横线所在位置。

而我们要求的就是上图虚线所对应的值,横线对应的所有值即为满足题目要求的速度,在这种情况下,不就是在进行左边界搜索吗?

当题目呈现出了像爱吃香蕉的珂珂一样的函数单调性,就可以尝试使用二分边界搜索进行解题。

最后再来总结一下此种类型的题目的做题步骤。

  1. 确定函数f(x)
  2. 确定搜索范围,也就是确定最小值,最大值。这两个值取决于题意的同时,也与搜索范围采用左闭右开还是采用左闭右闭区间有关。

参考资料

  1. 《labuladong的算法秘籍》