二分查找是一个常用的算法,它用于在一个有序数组中查找符合某种条件的元素。二分查找有很多种不同的写法,其中关于区间端点、循环结束条件等细节有很多讲究,很容易写错。下面介绍一种相对来说比较容易理解和记忆的写法。
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]
,因为当left
与right
相等时会导致死循环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
中最后一次出现的下标。
firstPos
和lastPos
的唯一区别就在于当遇到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
的 算术平方根 。由于返回类型是整数,结果只保留整数部分,小数部分将被舍去 。
注意:不允许使用任何内置指数函数和算符,例如
pow(x, 0.5)
或者x ** 0.5
。
本题实际上是寻找满足 n 2 ≤ x n^2 \leq x n2≤x的最大的一个 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
小时内吃掉所有香蕉的最小速度k
(k
为整数)。
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;
}
}
题目链接
传送带上的包裹必须在
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;
}
}
题目链接
给你一个整数数组
bloomDay
,以及两个整数m
和k
。现需要制作
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;
}
}