二分查找模板及其应用

二分查找模板

二分查找是一个常用的算法,它用于在一个有序数组中查找符合某种条件的元素。二分查找有很多种不同的写法,其中关于区间端点、循环结束条件等细节有很多讲究,很容易写错。下面介绍一种相对来说比较容易理解和记忆的写法。

public int binarySearch(...) {
    int left = ... // 区间左端点
    int right = ... // 区间右端点
    int result = ... // 当前的备选答案,在搜索过程中会不断更新

    // [left, right]为当前搜索区间
    while (left <= right) {
        int mid = left + (right - left) / 2; // 计算区间的中点

        // 检查区间中点mid是否满足要求
        if (check(mid, ...)) {
            result = mid; // mid满足要求,先保存为备选结果
            left = mid + 1; // 如果确定mid就是最终答案,可直接返回mid,否则需要继续搜索左半区间或右半区间
        } else {
            right = mid - 1; // mid不满足要求,将当前搜索区间缩小为左半区间或右半区间
        }
    }

    // 返回最终答案
    return result;
}

关键点:

  • 查找区间使用闭区间[left, right]
  • 循环终止条件为left <= right
  • 计算区间中点mid使用left + (right - left) / 2,而不是(left + right) / 2,因为第二种写法可能会导致溢出
  • 每次循环都要将当前搜索区间减半,根据条件将当前搜索区间[left, right]更新为[left, mid - 1][mid + 1, right]注意不要将区间更新为[left, mid][mid, right],因为当leftright相等时会导致死循环
  • 使用备选答案result记录最终结果,搜索过程中可能会多次更新result

下面用例题来说明如何使用这个模板。

传统二分及其变种

下面是一些简单的二分查找题目,包括最基本的二分查找和一些变种,如元素是否允许重复等。

二分查找

题目链接

给定一个n个元素有序的(升序)整型数组nums和一个目标值target,写一个函数搜索nums中的target,如果目标值存在返回下标,否则返回-1

这是最基本的二分查找算法,由于目标值是唯一的,所以找到后可以直接返回,无需使用备选答案记录。

class Solution {
    public int search(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }

        return -1;
    }
}

在排序数组中查找元素的第一个和最后一个位置

题目链接

给你一个按照非递减顺序排列的整数数组nums,和一个目标值target。请你找出给定目标值在数组中的开始位置和结束位置。

如果数组中不存在目标值target,返回[-1, -1]

本题在最简单的二分查找基础上,增加了“数组元素可重复”这个约束,所以主要有两个算法,firstPos用于查找target在数组nums中第一次出现的下标,lastPos用于查找target在数组nums中最后一次出现的下标。

firstPoslastPos的唯一区别就在于当遇到nums[mid] == target时如何处理。

此时可以将mid作为备选答案,然而并不能确定mid就是最终结果,因为目标值是可重复的,mid左边或右边可能还有等于target的值。

所以在记录完备选答案后,还需要继续搜索左半区间或右半区间来不断更新备选答案。firstPos获取target第一次出现的下标,需要继续搜索左半区间[0, mid - 1]lastPos获取target最后一次出现的下标,需要继续搜索右半区间[mid + 1, nums.length - 1]

class Solution {
    public int[] searchRange(int[] nums, int target) {
        return new int[]{firstPos(nums, target), lastPos(nums, target)};
    }

    // 查找target在数组nums中第一次出现的下标
    private int firstPos(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;
        int result = -1;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                result = mid;
                right = mid - 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }

        return result;
    }

    // 查找target在数组nums中最后一次出现的下标
    private int lastPos(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;
        int result = -1;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                result = mid;
                left = mid + 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }

        return result;
    }
}

搜索插入位置

题目链接

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

何为插入位置?其实就是搜索数组中第一个大于等于target的元素下标。

这里要注意的是,如果数组中不存在大于等于target的元素,应该返回nums.length,因为此时target将会被插入到数组末尾,所以本题result的初始值应该设为nums.length

class Solution {
    public int searchInsert(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;
        int result = nums.length;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] >= target) {
                result = mid;
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return result;
    }
}

第一个错误的版本

题目链接

你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。

假设你有n个版本[1, 2, ..., n],你想找出导致之后所有版本出错的第一个错误的版本。

你可以通过调用bool isBadVersion(version)接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用API的次数。

本题实际上是在一个类似[true, true, ..., true, false, false, ..., false]的数组中找到第一个false的下标。

由于必定存在一个错误的版本,所以本题的result可以随意赋初值。

public class Solution extends VersionControl {
    public int firstBadVersion(int n) {
        int left = 0;
        int right = n;
        int result = 0;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (isBadVersion(mid)) {
                result = mid;
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return result;
    }
}

x的平方根

题目链接

给你一个非负整数x,计算并返回x的 算术平方根 。

由于返回类型是整数,结果只保留整数部分,小数部分将被舍去 。

注意:不允许使用任何内置指数函数和算符,例如pow(x, 0.5)或者x ** 0.5

本题实际上是寻找满足 n 2 ≤ x n^2 \leq x n2x的最大的一个 n n n

class Solution {
    public int mySqrt(int x) {
        int left = 0;
        int right = x;
        int result = 0;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            long square = (long) mid * mid;
            if (square == x) {
                return mid;
            } else if (square < x) {
                result = mid;
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }

        return result;
    }
}

二分答案

二分查找还有另一种题型,就是二分答案。这种类型的题并不是在一个有序数组中查找元素,甚至从题目描述中根本看不出与二分查找有任何关系。这种题目一般会包含“xxx的最小值”、“xxx的最大值”、“最多xxx”、“最少xxx”等字眼,如果遇到这些特征,可以尝试往二分答案的方向思考。

假设某个问题的可行解的取值范围是[left, right],我们可以实现一个check(ans)函数来验证ans是否是可行解。如果check函数在区间[left, right]上具有单调性,我们就可以用二分法来搜索最大/最小的可行解。

何为单调性呢?即对于区间[left, right]中的所有元素依次调用check函数,返回结果为[true, true, ..., true, false, false, ..., false][false, false, ...false, true, true, ..., true]

每个小孩最多能分到多少糖果

题目链接

给你一个下标从0开始的整数数组candies。数组中的每个元素表示大小为candies[i]的一堆糖果。你可以将每堆糖果分成任意数量的子堆,但无法再将两堆合并到一起。

另给你一个整数k。你需要将这些糖果分配给k个小孩,使每个小孩分到相同数量的糖果。每个小孩可以拿走至多一堆糖果,有些糖果可能会不被分配。

返回每个小孩可以拿走的最大糖果数目。

假设我们写一个check(n)函数来判断每个小孩是否能拿走n个糖果,那么check就具有单调性。因为如果check(n) == false, 那么对所有大于n的值调用check都会返回false;相应地,如果check(n) == true,那么对所有小于n的值调用check都会返回true。我们要找的就是满足check(n) == true的最大的一个n

最后说一下区间初值如何设置。首先每个小孩最少拿一颗糖果,所以left初始化为1,而每个小孩最多拿走一堆糖果,所以right初始化为糖果堆数量的最大值。然而,区间初值的设置大部分情况下可以不需要这么严谨,对于此题来说,如果实在无法确定区间初值,我们甚至可以直接将区间初值设为[1, Integer.MAX_VALUE]!由于二分查找会让区间大小指数级衰减,所以不必担心这样设置会影响程序的执行效率。

class Solution {
    public int maximumCandies(int[] candies, long k) {
        int left = 1;
        int right = Arrays.stream(candies).max().getAsInt();
        int result = 0;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (check(candies, k, mid)) {
                result = mid;
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }

        return result;
    }

    // 是否能够让k个小孩每人拿到n个糖果
    private boolean check(int[] candies, long k, int n) {
        long cnt = 0;
        for (int x : candies) {
            cnt += x / n;
        }
        return cnt >= k;
    }
}

爱吃香蕉的珂珂

题目链接

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

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

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

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

class Solution {
    public int minEatingSpeed(int[] piles, int h) {
        int left = 1;
        int right = Arrays.stream(piles).max().getAsInt();
        int result = 0;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (check(piles, h, mid)) {
                result = mid;
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return result;
    }

    // 如果每小时吃k根香蕉,是否能在h小时之内吃完所有香蕉
    private boolean check(int[] piles, int h, int k) {
        long t = 0;
        for (int p : piles) {
            t += (p % k == 0 ? p / k : p / k + 1);
        }
        return t <= h;
    }
}

在D天内送达包裹的能力

题目链接

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

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

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

本题的check方法的含义为:判断当船的运载能力为capacity时,是否能在days天内运送完所有包裹。使用与上题类似的方法可证明check函数具有单调性。

下面的题目不再详细解释,留给读者自行思考。

class Solution {
    public int shipWithinDays(int[] weights, int days) {
        int left = Arrays.stream(weights).max().getAsInt();
        int right = Arrays.stream(weights).sum();
        int result = 0;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (check(weights, days, mid)) {
                result = mid;
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return result;
    }

    // 当船的运载能力为capacity时,是否能在days天内运送完所有包裹
    private boolean check(int[] weights, int days, int capacity) {
        int cnt = 1;
        int sum = 0;

        for (int w : weights) {
            if (w > capacity) {
                return false;
            }
            if (sum + w <= capacity) {
                sum += w;
            } else {
                cnt++;
                sum = w;
            }
        }

        return cnt <= days;
    }
}

制作m束花所需的最少天数

题目链接

给你一个整数数组bloomDay,以及两个整数mk

现需要制作m束花。制作花束时,需要使用花园中相邻的k朵花 。

花园中有n朵花,第i朵花会在bloomDay[i]时盛开,恰好可以用于一束花中。

请你返回从花园中摘m束花需要等待的最少的天数。如果不能摘到m束花则返回-1

class Solution {
    public int minDays(int[] bloomDay, int m, int k) {
        int left = 1;
        int right = Arrays.stream(bloomDay).max().getAsInt();
        int result = -1;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (check(bloomDay, m, k, mid)) {
                result = mid;
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return result;
    }

    // 等待day天是否可以摘m束花
    private boolean check(int[] bloomDay, int m, int k, int day) {
        int flower = 0;
        int bouquet = 0;

        for (int bd : bloomDay) {
            if (bd <= day) {
                flower++;
                if (flower == k) {
                    bouquet++;
                    flower = 0;
                }
            } else {
                flower = 0;
            }
        }

        return bouquet >= m;
    }
}

完成所有工作的最短时间

题目链接

给你一个整数数组jobs,其中jobs[i]是完成第i项工作要花费的时间。

请你将这些工作分配给k位工人。所有工作都应该分配给工人,且每项工作只能分配给一位工人。工人的工作时间是完成分配给他们的所有工作花费时间的总和。请你设计一套最佳的工作分配方案,使工人的最大工作时间得以最小化。

返回分配方案中尽可能最小的最大工作时间。

class Solution {
    public int minimumTimeRequired(int[] jobs, int k) {
        jobs = Arrays.stream(jobs).boxed().sorted(Comparator.reverseOrder()).mapToInt(n -> n).toArray();
        int left = 1;
        int right = Arrays.stream(jobs).reduce(0, Integer::sum);
        int result = 0;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (check(jobs, k, mid)) {
                result = mid;
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        }

        return result;
    }

    // 是否可以在time时间内让所有工人把所有工作做完
    private boolean check(int[] jobs, int k, int time) {
        return dfs(jobs, new int[k], time, 0);
    }

    // 使用dfs遍历所有分配方案
    private boolean dfs(int[] jobs, int[] sum, int time, int index) {
        if (index == jobs.length) {
            return true;
        }

        for (int i = 0; i < sum.length; i++) {
            if (sum[i] + jobs[index] <= time) {
                sum[i] += jobs[index];
                if (dfs(jobs, sum, time, index + 1)) {
                    sum[i] -= jobs[index];
                    return true;
                }
                sum[i] -= jobs[index];
            }
        }

        return false;
    }
}

你可能感兴趣的:(算法,leetcode,算法,数据结构,leetcode)