上一篇 文章 我们介绍了按值二分,但这个知识点的题型的变化很多,而且大部分情况下都是以难题的形式出现。因此想要很好的掌握还需多多练习,这次我们再来看一道按值二分的题目,希望能加深你对这个概念的理解。
给定一个包含 n 个整数的数组,找到最大平均值的连续子序列,且长度大于等于 k。并输出这个最大平均值。
示例:
输入:nums = [1,12,-5,-6,50,3], k = 4
输出:12.75
解释:子数组 [12,-5,-6,50]
满足长度的条件,并且有最大的平均值 12.75
提醒
返回答案和正确答案之间的差别,也就是精确值要小于 10^-5
给定一个数组,要求出这个数组的一个子数组,这个子数组的长度必须大于或等于 K,而且子数组中所有元素的平均值在所有符合条件(长度大于等于 K)的子数组中是最大的。按常规思路,我们可能会首先从数组本身入手,把所有的子数组都算一遍,时间复杂度是 O(n^2),会超时。这道题应该对一个线索产生警觉,就是精确值,给这个精确值的意义何在?真的是像我们中学时代做数学题一样,是为了最后表示方便吗?很多按值二分的题目都会给出一个精确值,目的是让你二分到一定程度的时候可以退出循环。一般在用二分解这类问题的时候,我们的思路一定要从最后的答案去倒推整个过程。思路如下:
由第二点可知,子数组的平均值肯定是在数组中最小和最大元素的值之间。第三点是重点,我们可以用最小和最大元素的值作为二分的 start 和 end,然后每次用二分中点值去到数组中找,看一下这个值是小了还是大了,如果数组中存在符合条件的子数组的平均值比这个值要大,那么说明这个值小了,我们要找的最大平均值比现在的二分中点要大,因此,我们移动 start 指针去缩小范围,反之,二分中点大了,我们需要移动 end 指针缩小范围。
另外提及一点的是,在数组中求平均值这个过程也有技巧,我们只需要将子数组中的所有元素和我们当前取到的二分中点做差,然后加起来看是否大于 0 即可。
(a1 + a2 + a3 + a4 + ... + an) / n = avg
(a1 + a2 + a3 + a4 + ... + an) = avg * n
(a1 - avg) + (a2 - avg) + ... + (an - avg) = 0
如果 (a1 - avg) + (a2 - avg) + ... + (an - avg) > 0,说明当前选的 avg 是小于实际的平均值的
如果 (a1 - avg) + (a2 - avg) + ... + (an - avg) < 0,说明当前选的 avg 是大于实际的平均值的
这样子做的话可以很好的利用 数组前缀和 的特性,关于数组前缀和,我们有机会介绍,它是解决子数组问题的一个非常有用的技巧。
很明显,答案的范围在数组中的最大元素和数组中的最小元素之间,我们可以通过遍历得到这个范围
然后,我们在这个范围上进行二分
每次,我们利用二分中点的值去数组里面查看是否存在符合条件并大于或等于该值的子数组,如果是的话,移动头指针,如果不是,移动尾指针
慢慢地,你会发现,我们在逐步地逼近最后的答案,而且每次二分中点之间的差在慢慢缩小
什么时候停止二分呢?答案是,当上一次的二分中点和本次的二分中点的差别小于 10^(-5) 时,我们就退出二分,原因是此时我们可搜索的范围已经很小了,二分中点已经足够逼近答案了。
class Solution {
public double findMaxAverage(int[] nums, int k) {
if (nums == null || nums.length < k) {
return 0;
}
// 遍历找到平均值的范围,最大值就是数组中最大的元素,最小值就是数组中最小的元素
int minValue = Integer.MAX_VALUE, maxValue = Integer.MIN_VALUE;
for (int i : nums) {
minValue = Math.min(i, minValue);
maxValue = Math.max(i, maxValue);
}
// errorRate 表示的是误差值,由于最后的答案是浮点数,没有办法精确到一个固定的值
// 但我们可以把答案控制在一定的误差范围内,这里设定的是 10^(-5)
// 如果当前二分中点和上一次二分中点的差别小于 10^(-5),说明可二分的区间很小,我们已经极度逼近答案,此时退出循环
double errorRate = Integer.MAX_VALUE;
double l = minValue, r = maxValue;
double result = minValue;
while (errorRate >= 1e-5) {
double mid = (l + r) / 2.0;
// 看看数组中是否有符合条件的子数组的值大于或等于此时的二分中点
// 如果有,说明答案大于或等于此时的中点,移动头指针,缩小二分范围
// 如果没有,说明答案小于此时的中点,移动尾指针,缩小二分范围
if (haveSolutionOrNot(nums, mid, k)) {
l = mid;
} else {
r = mid;
}
// 计算此时的误差值
errorRate = Math.abs(result - mid);
result = mid;
}
return result;
}
private boolean haveSolutionOrNot(int[] nums, double mid, int k) {
double sum = 0.0;
for (int i = 0; i < k; ++i) {
sum += nums[i] - mid;
}
if (sum >= 0.0) {
return true;
}
double prevSum = 0.0, minSum = 0.0;
for (int i = k; i < nums.length; ++i) {
sum += nums[i] - mid;
prevSum += nums[i - k] - mid;
minSum = Math.min(minSum, prevSum);
if (sum >= minSum) {
return true;
}
}
return false;
}
}
这道题目的时间复杂度其实和数组当中的元素值有关,是 O(nlogS)
,这里的 S 是数组中的最大值和最小值的差距。你可能会问,当 S 很大的时候,那么复杂度不是很高吗?要知道 log 级别的复杂度是一个完全不同的概念,O(logS) 可能直接看表达式不够形象,我举个例子你就懂了,假设 S 是 4294967296,如果是 O(S) 时间的话,最差情况下你需要去找 4294967296 次,如果是 O(logS),最差情况下你只需要去找 32 次。当然了,换成是浮点数可能会高一些,但是也不会高太多。
这道题目的重点是按值二分的解题思路,记得一定要从最后的答案去倒推整个过程,不管是浮点数还是整数,谨记二分的过程就是排除法。只不过是,浮点数我们最后没有办法得到一个准确的值,我们需要设定一个精确值,当搜索范围小于这个精确值的时候,即可退出二分。