目标变量和另一个变量有相关关系(一般而言是线性关系),目标变量的性质不好推测,但是另一个变量的性质相对容易推测。这样的问题的判别函数通常会写成一个函数的形式。
例题一
分析:题目中说,最小速度 K 是一个整数,并且我们知道这个 K 不可能无限大,珂珂一个小时能吃的香蕉最多为一整堆香蕉的个数。因此 K 是一个有范围的整数。如果我们做多了二分查找的问题,就知道这样的问题可以考虑使用二分查找去做,事实上,这道问题的解题思路就是我们在上一节介绍过的「二分答案」。
根据题意,我们不难分析出,如果珂珂每个小时吃的香蕉数越少,那么她吃完所有香蕉的耗时就越多;相应地,如果珂珂每个小时吃的香蕉数越多,那么她吃完所有香蕉的耗时就越少。
题目要我们找的最小速度,而限制是 H (吃完香蕉的所有时间)。因此我们就可以根据「吃完香蕉的所有时间」的限制,通过二分搜索的办法,得到最小的速度。
查找的是最小速度。由于题目限制了珂珂一个小时之内只能选择一堆香蕉吃,因此速度最大值就是这几堆香蕉中,数量最多的那一堆。速度的最小值是 11,还是因为珂珂一个小时之内只能选择一堆香蕉吃,因此:「每堆香蕉吃完的耗时 = 这堆香蕉的数量 / 珂珂一小时吃香蕉的数量」。根据题意,这里的 / 在不能整除的时候,需要上取整。
先根据珂珂每小时吃掉的香蕉数,计算吃完所有香蕉的耗时,再根据下面的逻辑逼近到最小速度 a :
- 如果耗时大于 H ,则说明速度太慢了,下一轮搜索的时候需要从 a + 1 开始;
如果耗时小于等于 H ,则说明速度或者刚好合适,或者太慢了,下一轮搜索的时候速度至多是 a (这里不能把速度 a 的值排除掉)
public class Solution {
public int minEatingSpeed(int[] piles, int H) {
int maxVal = 1;
for (int pile : piles) {
maxVal = Math.max(maxVal, pile);
}
// 速度最小的时候,耗时最长
int left = 1;
// 速度最大的时候,耗时最短
int right = maxVal;
while (left < right) {
int mid = left + (right - left) / 2;
if (calculateSum(piles, mid) > H) {
// 耗时太多,说明速度太慢了,下一轮搜索区间在 [mid + 1, right]
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
/**
* 如果返回的小时数严格大于 H,就不符合题意
*
* @param piles
* @param speed
* @return 需要的小时数
*/
private int calculateSum(int[] piles, int speed) {
int sum = 0;
for (int pile : piles) {
// 上取整可以这样写
sum += (pile + speed - 1) / speed;
}
return sum;
}
}
复杂度分析:
- 时间复杂度:O(N log max(piles))O(Nlogmax(piles)),这里 N 表示数组 piles 的长度。我们在 [1, max{piles}] 里使用二分查找定位最小速度,而每一次执行判别函数的时间复杂度是 O(N);
- 空间复杂度:O(1),算法只使用了常数个临时变量。
例题二
分析:
- 数组中的元素全部非负,可能有 0;
- 分成 m 个非空的连续子数组,这一句话中非空且连续是很重要的,如果没有连续我们设计的算法就会有所改变;
- 各自和的最大值最小,这一句话的意思是:对分割的每一个数组求和的最大值,根据题目的示例,让我们输出的是,这个最大值在某一种最优分割下,最小值是多少。这种分割不需要我们描述出来,但是题目要求我们输出这种最优分割的各个组里,组内所有元素的和的最大值。
题目要我们找在最优分割下,各个组的和的最大值,在最小情况下是多少,这个值是一个整数,极端情况下,所有元素被分在一组,因此,这个整数的最大值是数组里所有元素的和,而最小值就是数组里所有元素的最小值。(请大家体会,这里题目中说元素都是非负数的原因。)这样的场景就是求一个有范围的整数,可以考虑使用「二分答案」。
由于子数组必须是原始数组的连续子数组,并且所有元素非负,那么一个元素一定会属于一个区间,我们可以这样设计算法,假设当前分在同一个组里的所有的元素的和为 a。分割的策略是:从左到右,做加和,只要是加和 小于等于 a 就分为一组,一旦严格大于 a ,则另起一组。于是有下面的 单调性。
- 如果 a 的值很小,分割的组数就会很多;
- 如果 a 的值很大,分割的组数就会很少。
即:各个组内元素的和的最大值 a 与分割的组数 M (为了与题目中的 m 区分,这里用 M) 是单调递减的关系;利用这个单调递减的关系,使用二分查找算法,调整 a 的值,把 M 逼近到 m 。
public class Solution {
public int splitArray(int[] nums, int m) {
int max = 0;
int sum = 0;
// 计算「子数组各自的和的最大值」的上下界
for (int num : nums) {
max = Math.max(max, num);
sum += num;
}
// 使用「二分查找」确定一个恰当的「子数组各自的和的最大值」,使得它对应的「子数组的分割数」恰好等于 m
int left = max;
int right = sum;
while (left < right) {
int mid = left + (right - left) / 2;
int splits = split(nums, mid);
if (splits > m) {
// 如果分割数太多,说明「子数组各自的和的最大值」太小,此时需要将「子数组各自的和的最大值」调大
// 下一轮搜索的区间是 [mid + 1, right]
left = mid + 1;
} else {
// 下一轮搜索的区间是上一轮的反面区间 [left, mid]
right = mid;
}
}
return left;
}
/***
*
* @param nums 原始数组
* @param maxIntervalSum 子数组各自的和的最大值
* @return 满足不超过「子数组各自的和的最大值」的分割数
*/
private int split(int[] nums, int maxIntervalSum) {
// 至少是一个分割
int splits = 1;
// 当前区间的和
int curIntervalSum = 0;
for (int num : nums) {
// 尝试加上当前遍历的这个数,如果加上去超过了「子数组各自的和的最大值」,就不加这个数,另起炉灶
if (curIntervalSum + num > maxIntervalSum) {
curIntervalSum = 0;
splits++;
}
curIntervalSum += num;
}
return splits;
}
}
复杂度分析:
- 时间复杂度:O(Nlog∑nums),这里 N 表示输入数组的长度,∑nums 表示输入数组的和,代码在[max(nums),∑nums] 区间里使用二分查找找到目标元素,而每一次判断分支需要遍历一遍数组,时间复杂度为 O(N);
- 空间复杂度:O(1) ,只使用到常数个临时变量。