二分查找

27 阅读12分钟

二分查找 Binary Search

二分查找,也叫折半查找算法,它只能查找已经排好序的数据。

二分查找通过比较数组中间的数据与目标数据的大小,可以得知目标数据是在数组的左边还是右边。因此,比较一次就可以把查找范围缩小一半。重复执行该操作就可以找到目标数据,或得出目标数据不存在的结论。

怎么用递归的思想理解二分查找呢?比如在有序的数组 1,2,3,4,5,6,7,8,9,10,11 中查找 2 的位置:

⨳ 要想在 1,2,3,4,5,6,7,8,9,10,11 中查找 2 的位置,必须知道 21,2,3,4,5 中的位置

⨳ 要想知道 21,2,3,4,5 中的位置,必须先知道 21,2 中的位置

⨳ 要想知道 21,2 中的位置,必须先知道 22 中的位置

⨳ 要想知道 22 中的位置,直接返回即可,递归结束

也就是说,二分查找在的过程中就解决了问题,至于仅对应着调用方法弹出栈。

二分查找是很符合常识的查找算法,比如在字典中查找 ji 这个字,如果不看字典的目录,肯定先翻到字典中间,看看中间的汉字拼音是不是以 J 开头,假如翻到的都是 N,那就再翻字典前一半的中间 ... 每次翻书,都将字典的范围缩减一半,直到找到 ji

// 假设 target 在 arr[head, tail] 中
public  int binarySearch(int[] arr, int head, int tail, int target){
    // 空数组,返回 - 1 ,表示没找到
    if(head > tail) {
        return -1;
    }
    // 取区间的中间索引
    int middle = (head + tail) / 2;

    // target 在左区间,问题缩减一半
    if(arr[middle]>target){
        return binarySearch(arr, head,middle-1, target);
    }
    // target 在右区间,问题缩减一半
    else if(arr[middle]<target){
        return binarySearch(arr, middle+ 1, tail, target);
    }
    // target 在中间
    else return middle;
}

二分查找每次比较都使搜索范围缩小一半,时间复杂度为Ο(logn)

假设数据大小是 n,每次查找后数据都会缩小为原来的一半,最坏情况下,直到查找区间被缩小为空,才停止。

被查找区间的变化:n, n2\frac{n}{2}n4\frac{n}{4}n8\frac{n}{8},... n2k\frac{n}{2^{k}} ,...

n2k\frac{n}{2^{k}}=1 时,k的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了k次区间缩小操作,时间复杂度就是O(k)

通过 n2k\frac{n}{2^{k}}=1,我们可以求得 k=log2nlog_{2}^{n},所以时间复杂度就是 O(logn)

唐纳德·克努特(Donald E.Knuth)在《计算机程序设计艺术》的第3卷《排序和查找》中说到:“尽管第一个二分查找算法于1946年出现,然而第一个完全正确的二分查找算法实现直到1962年才出现。”

middle = (head + tail) / 2 //可能整型溢出
middle = head + (tail - head) / 2

非递归实现

二分查找用递归还是很容易理解的,如果用非递归实现呢?

public  int binarySearch(int[] arr,int target){
    // 假设 target 在左闭右闭的区间内 [head, tail] 中
    int head = 0;
    int tail = arr.length-1;

    while(head<=tail){
        // 取区间的中间索引
        int middle = head + (tail-head)/2 ;
        // target 在左区间
        if(arr[middle] > target)
            tail = middle - 1;
        // target 在右区间
        else if(arr[middle] > target)
            head = middle + 1;
        // target 就在中间
        else return middle;
    }
    return -1;
}

代码还是很容易理解的,但就是不好写清楚,主要是因为对区间的定义没有想清楚,上述代码不管是递归还是非递归实现,选取的区间都是 [head, tail](左闭右闭)。

假设每次循环前,target 就在 [head, tail] 区间中 ,所以 while 循环的条件是 head <= tail,而不是 head < tail,因为当 head == tail时,区间中是存在元素的。

同理,当 arr[middle] 大于 target 时, tail 要赋值为 middle - 1 而不是赋值为 middle

如果换一个区间定义:

假设每次循环前,target[head, tail) 区间中 ,那 while 循环的条件是 head < tail,而不是 head <= tail,因为当 head == tail时,区间中是没有元素的。

同理,当 arr[middle] 大于 target 时, tail 要赋值为 middle 而不是赋值为 middle-1

区间的定义就是不变量,那么在循环中坚持根据查找区间的定义来做边界处理,就是循环不变量规则。

上界问题

先看第一道上界问题:查找第一个大于给定值的元素

在有序数组 arr 中查找大于 target 的最小值所在的索引;

比如 [1,3,5,10,25,31,40] 中大于 3 的最小值所在索引为 2

对于这个问题区间怎么定义呢?

假如 head 指向数组第一个元素,tail 指向数组最后一个元素,使用前闭后闭 [head,tail] 定义区间可以吗?

[head,tail] 的中间值 middle

⨳ 如果 middle 指向的值小于等于 target,则 [head,middle] 中的值一定都小于 target ,都不符合条件,下一次遍历区间为 [middle+1,tail]

⨳ 如果 middle 指向的值大于 target,则 [middle+1,tail] 中的元素都大于 target ,但 middle 指向的值更符合条件,下一次遍历区间为 [head,middle]

这样定义貌似是可以的,[head,middle] + [middle+1,tail] 一个元素也没有遗漏掉。

递归或循环终止条件呢?

⨳ 如果数组中存在最小值,当 [head,tail] 缩减到只有一个元素时,即 head==tail时 ,该元素肯定是大于 target 的最小值;

⨳ 如果数组中不存在最小值,也就是所有元素都小于target时,[head,tail] 也会缩减到没有元素,即 head > tail 时。

这就很难受了,终止条件冲突,当 head > tail 时,肯定会经过 head==tail,所以要在终止条件上作区分:

// 在有序数组 `arr` 中查找大于 `target` 的最小值所在的索引
public int upper(int[] arr,int target){
    int head = 0;
    int tail = arr.length-1;
    return upper(arr,head,tail,target);
}


public int upper(int[] arr,int head,int tail,int target){
    if(head >= tail){ // 数组中至多一个元素
        return arr[head]>target?head:-1;
    }
    // 取区间的中间索引
    int middle = (head + tail) / 2;
    // `[head,middle]` 中的值一定都小于 `target`,不符合条件
    if(arr[middle] <= target){
        return upper(arr, middle+ 1, tail, target);
    }
    // `middle` 指向的值更符合条件
    else{
        return upper(arr, head,middle, target);
    }
}

再看一道上界问题:查找第一个大于等于给定值的元素

在有序数组 arr 中查找 target

▪ 如果 target 存在,返回其最小的索引;

▪ 如果 target 不存在,返回大于 target 的最小值所在的索引。

这道题改两个符号就完事了,

public int upper(int[] arr,int head,int tail,int target){
    if(head >= tail){ 
     // return arr[head]>target?head:-1;
        return arr[head]>=target?head:-1;
    }
    int middle = (head + tail) / 2;
 // if(arr[middle] <= target)
    if(arr[middle] < target){ =
        return upper(arr, middle+ 1, tail, target);
    }
    else{
        return upper(arr, head,middle, target);
    }
}

如果这道题变一下呢?如果 target 存在,返回其最大的索引,也就是查找最后一个等于给定值的元素所在索引。

这更好办了:

⨳ 先使用第一道上界问题的解法,找到大于 target 的最小值所在的索引 index

index-- ,如果此时 index 指向的元素等于 target ,则其就是指向 target 的最大的索引;如果此时 index 指向的元素不等于 target,则 target 不存在。

下界问题

先看第一道下界问题:查找最后一个小于给定值的元素

在有序数组 arr 中查找小 target` 的最大值所在的索引;

比如 [1,3,5,10,25,31,40] 中小于 10 的最大值所在索引为 2

还是使用 [head,tail] 前闭后闭区间进行处理:

⨳ 如果 middle 指向的值大于等于 target,则 [middle,tail] 中的值一定都不符合条件,下一次遍历区间为 [head,middle-1]

⨳ 如果 middle 指向的值小于 target,则 [head,middle+1,tail] 中的元素都小于 target ,但 middle 指向的值更符合条件,下一次遍历区间为 [middle,tail]

这道题和查找第一个大于给定值的元素处理逻辑相反:

public int lower(int[] arr,int target){
    int head = 0, tail = arr.length-1;
    return lower(arr,head,tail,target);
}
public int lower(int[] arr,int head,int tail,int target){
    if(head >= tail){
        return arr[head]<target?head:-1;
    }
    int middle = (head+tail+1) / 2;
    if(arr[middle]>= target){
        return lower(arr, head, middle-1, target);
    }
    // `middle` 指向的值更符合条件
    else{
        return lower(arr, middle,tail, target);
    }
}

注意,不再是使用 (head + tail) / 2 取中 ,而是 (head+tail+1) / 2

为啥呢?

因为运算符 / 会向下取整,假设 head+1=tail,则 middle = (head + tail) / 2 = (2*head+1) / 2 = head ,也就是说 当 headtail 相邻的时候,中间值还是 head

如果此时 arr[middle] 小于 target,则 lower(arr, middle,tail, target) 会一直调用,区间不会缩小。

(head + tail) / 2 改为 (head+tail+1) / 2,是让 middle 取值向上取整。

大家可以分析一下,为什么寻找上界,使用向下取整是可以的。

下界问题,还有查找最后一个小于等于给定值的元素所在索引...这里就不赘述了。

无论二分查找怎么变,只要定义好递归区间,确认好终止条件,更新好区间上下界,就不会容易写错的。

下面看几道力扣上面的题。

爱吃香蕉的珂珂

leetcode.cn/problems/ko…

珂珂喜欢吃香蕉。这里有 n 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 h 小时后回来。

珂珂可以决定她吃香蕉的速度 k (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 k 根。如果这堆香蕉少于 k 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。  

珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。

返回她可以在 h 小时内吃掉所有香蕉的最小速度 kk 为整数)。

举例 piles = [3,6,7,11], h = 8

⨳ 假设 k=3 即每小时吃 3根香蕉,那吃第 0 堆需要 1 小时,吃第 1 堆需要 2 小时,吃第 2 堆需要 3 小时,吃第 3 堆需要 4 小时,共计 10 小时,不符合要求;

⨳ 假设 k=4 即每小时吃 4根香蕉,那吃第 0 堆需要 1 小时,吃第 1 堆需要 2 小时,吃第 2 堆需要 2 小时,吃第 3 堆需要 3 小时,共计 8 小时,符合要求;

由题可知每小时吃的(k)越少,用时越长,每小时吃的(k)越多,用时越短。

最少的k可设为 1,最多的k可设为最多的那一堆香蕉个数 Max(piles),在 1Max(piles) 之间选择最小符合条件的 k 即可。

class Solution {
    public int minEatingSpeed(int[] piles, int h) {
        int min_k = 1 ; // 每小时最少吃的香蕉数
        int max_k = piles[0]; // 每小时最多吃的香蕉数
        for(int i=0;i<piles.length;i++){
            if(piles[i]>max_k)  max_k = piles[i];
        }
        return minEatingSpeed(piles,h,min_k,max_k);
    }
    // 递归调用
    private int minEatingSpeed(int[] piles, int h,int min_k,int max_k){
        if(min_k>=max_k){
            return piles.length > h?-1:min_k;
        }
        int mid_k = (min_k+max_k)/2;
        int mid_h = getHourByK(piles,mid_k);
        // mid_k 符合条件,就往少了吃
        if(mid_h <= h){
            return minEatingSpeed(piles,h,min_k,mid_k);
        // mid_k 不符合条件,就往多了吃
        }else{
            return minEatingSpeed(piles,h,mid_k+1,max_k);
        }
    }

    // 根据 速度 k,获取吃完的小时数
    private int getHourByK(int[] piles,int k){
        int h = 0;
        for(int i= 0;i<piles.length;i++){
            h = h + (piles[i] % k == 0? piles[i]/k:piles[i]/k+1 );
        }
        return h;
    }
}

在D天内送达包裹的能力

leetcode.cn/problems/ca…

传送带上的包裹必须在 days 天内从一个港口运送到另一个港口。

传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量(weights)的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。

返回能在 days 天内将传送带上的所有包裹送达的船的最低运载能力。

举例 weights = [1,2,3,4,5,6,7,8,9,10], days = 5

⨳ 假设船的最低运载能力为 10,那第一天可以运 1,2,3 这三个包裹,第二天可以运 4,5,第三天可以运 6,第四天可以运 7 ,第五天可以运 8,第六天可以运 9,第七天可以运 10,一共花费 7 天,不符合条件。

⨳ 假设船的最低运载能力为 15,那第一天可以运 1, 2, 3, 4, 5 这三个包裹,第二天可以运 6, 7,第三天可以运 8,第四天可以运 9 ,第五天可以运 10,符合条件。

可以说,这道题和爱吃香蕉的珂珂一模一样,设最低运载能力为k,最少的k可设为数组中最大的元素Max(weights),最多的k可设为数组的和 Sum(weights),在 Max(weights)Sum(weights) 之间选择最小符合条件的 k 即可。

public int shipWithinDays(int[] weights, int days) {
    int min_k = weights[0]; // 船的最低运载能力的最小值
    int max_k = 0; // 船的最低运载能力的最大值
    for(int i=0;i<weights.length;i++){
        max_k+=weights[i];
        if(weights[i]>min_k) min_k=weights[i];
    }
    return shipWithinDays(weights,days,min_k,max_k);
}

// 递归调用
private int shipWithinDays(int[] weights, int days, int min_k, int max_k) {
    if(min_k>=max_k){
        return days<1?-1:min_k; // 如果一天都运不走最大的货物,那就是期限 days 给的太小了
    }

    int mid_k = (min_k+max_k) /2;
    int mid_d = getDaysByK(weights,mid_k);
    // mid_d 符合条件,还可以再小一点
    if(mid_d<=days){
        return shipWithinDays(weights,days,min_k,mid_k);
    }else{
        return shipWithinDays(weights,days,mid_k+1,max_k);
    }
}

private int getDaysByK(int[] weights,int k){
    int days = 0;
    int cur_k = 0;
    for(int i=0;i<weights.length;i++){
        // 传送带还可以装货
        if(cur_k+weights[i]<=k){
            cur_k+=weights[i];
        }else{
            days++;
            cur_k = weights[i];
        }
    }
    days++;
    return days;
}