1. 从题目到模型:这道题到底在考什么
1.1 题目核心需求拆解
“Best Cow Fences”这个标题,直译过来是“最好的奶牛围栏”,但放到算法竞赛的语境里,它其实是一道非常经典的二分答案 + 前缀和的综合应用题。题目的原始描述大致是这样的:给定一个长度为 n 的正整数序列,要求找出一个长度不小于 L 的连续子段,使得这个子段的平均值最大,输出这个最大平均值乘以 1000 后取整的结果。
我第一次看到这道题的时候,第一反应是“这不就是个滑动窗口求最大平均值吗”,但仔细一想,窗口长度不固定,而且要求长度不小于 L,这就让问题变得有意思了。如果只是固定长度,那直接前缀和加滑动窗口就完事了,但“不小于 L”这个条件,意味着我们需要在所有可能的长度中寻找最优解,暴力枚举所有子段的话,时间复杂度是 O(n²),对于 n 达到 10⁵ 级别的数据规模来说,显然会超时。
所以这道题的核心考点就两个:第一,你能不能想到用二分答案来把“求最大平均值”这个优化问题转化为“判断是否存在一个平均值不小于某个值的子段”这个判定问题;第二,你能不能利用前缀和在 O(n) 的时间内完成这个判定。这两个点结合起来,就是这道题的精髓所在。
1.2 为什么选择二分答案
很多新手看到“最大值”或者“最小值”这类字眼,第一反应是贪心或者动态规划,但这道题不太一样。贪心在这里行不通,因为平均值这个指标不具备贪心选择性质——你没法说“当前选最大的数就一定能让平均值最大”,因为后面可能跟着一个很小的数把平均值拉下来。动态规划呢?状态定义会很麻烦,因为你需要记录当前子段的长度和总和,状态空间太大。
二分答案的思路是这样的:我们不去直接求最大平均值,而是猜一个平均值 x,然后判断“是否存在一个长度不小于 L 的子段,其平均值大于等于 x”。如果存在,说明真正的最大平均值至少是 x,我们可以把下界抬高;如果不存在,说明真正的最大平均值小于 x,我们需要把上界降低。这样不断缩小范围,最终就能逼近真正的最大值。
这个思路之所以有效,是因为判定问题比优化问题更容易解决。判断“是否存在平均值 ≥ x 的子段”这件事,可以通过一个巧妙的转化变成“是否存在长度不小于 L 的子段,其和 ≥ 0”,而这个问题用前缀和可以在 O(n) 时间内搞定。
1.3 判定问题的数学转化
假设我们要判断是否存在一个长度不小于 L 的子段,其平均值 ≥ x。设这个子段为 a[i+1] 到 a[j],长度为 j-i ≥ L,那么条件就是:
(a[i+1] + a[i+2] + ... + a[j]) / (j - i) ≥ x
两边同时乘以 (j - i),得到:
a[i+1] + a[i+2] + ... + a[j] ≥ x * (j - i)
把右边的项移到左边:
(a[i+1] - x) + (a[i+2] - x) + ... + (a[j] - x) ≥ 0
也就是说,如果我们把原序列中每个数都减去 x,得到一个新序列 b[k] = a[k] - x,那么问题就变成了:是否存在一个长度不小于 L 的子段,其和大于等于 0。
这个转化是整道题最关键的一步。它把“平均值”这个带有除法的指标,变成了“和”这个纯加法的指标,从而可以用前缀和来高效处理。
2. 前缀和与判定算法的细节实现
2.1 前缀和数组的构建
对于转化后的序列 b,我们构建前缀和数组 sum,其中 sum[0] = 0,sum[k] = b[1] + b[2] + ... + b[k]。那么子段 a[i+1] 到 a[j] 的和就可以表示为 sum[j] - sum[i]。
我们要判断的是:是否存在 j - i ≥ L,使得 sum[j] - sum[i] ≥ 0,即 sum[j] ≥ sum[i]。
这里有一个很重要的细节:i 和 j 的范围。i 可以从 0 到 n-L,j 可以从 i+L 到 n。我们需要在遍历 j 的过程中,维护一个“在当前位置之前,满足 j - i ≥ L 的所有 i 中,sum[i] 的最小值”。为什么是最小值?因为只要存在一个 i 使得 sum[i] ≤ sum[j],就说明找到了一个满足条件的子段。所以我们希望 sum[i] 越小越好,这样更容易满足 sum[j] ≥ sum[i]。
2.2 O(n) 判定的具体流程
具体的判定流程是这样的:
- 初始化 min_sum = sum[0] = 0,这里的 min_sum 表示在当前位置之前,所有满足距离条件的前缀和中的最小值。
- 从 j = L 开始遍历到 n。
- 在每次遍历中,先更新 min_sum:min_sum = min(min_sum, sum[j - L])。注意这里更新的是 sum[j-L] 而不是 sum[j],因为我们要保证 i 和 j 之间的距离至少为 L。
- 然后判断 sum[j] - min_sum ≥ 0 是否成立。如果成立,说明存在一个长度不小于 L 的子段,其平均值 ≥ x,返回 true。
- 如果遍历完所有 j 都没有找到满足条件的子段,返回 false。
这个流程的时间复杂度是 O(n),因为只需要一次遍历。结合二分答案的 O(log C) 次判定(C 是答案的范围),总时间复杂度是 O(n log C),对于 n = 10⁵ 的数据规模来说,完全可以在 1 秒内跑完。
2.3 二分边界的确定与精度控制
二分答案的边界怎么定?下界 lo 可以设为 0,因为题目中所有数都是正整数,平均值不可能是负数。上界 hi 可以设为序列中的最大值,因为平均值不可能超过最大值。但更稳妥的做法是设 hi 为一个足够大的数,比如 10⁶ 或者 10⁹,具体取决于题目中数的范围。
精度控制是这道题容易翻车的地方。题目要求输出“最大平均值乘以 1000 后取整”,这意味着我们需要精确到小数点后三位。如果二分的时候精度不够,最后取整的结果可能会差 1。我的经验是,二分的时候直接对整数进行二分,也就是说,我们二分的不是平均值本身,而是“平均值乘以 1000”这个整数。这样最后直接输出二分的下界或者上界就可以了,完全避免了浮点数精度问题。
具体来说,如果题目要求输出平均值乘以 1000 后取整,我们可以把二分范围设为 [0, max_val * 1000],每次判定的时候用 mid / 1000.0 作为猜测的平均值。这样二分结束时,lo 就是答案。或者更简单一点,直接在实数域上二分,设置一个足够小的精度比如 1e-5,最后输出 floor(hi * 1000) 或者 floor(lo * 1000)。但实数二分有个坑:如果精度设得不够小,可能会在边界上出错。所以我个人更推荐整数二分的方法。
3. 完整代码实现与逐行解析
3.1 核心代码框架
下面是我常用的代码框架,用 C++ 实现,因为算法竞赛中 C++ 的效率和精度控制都比较好:
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; int n, L; double a[MAXN], sum[MAXN]; bool check(double x) { for (int i = 1; i <= n; i++) { sum[i] = sum[i-1] + a[i] - x; } double min_sum = 0; for (int j = L; j <= n; j++) { min_sum = min(min_sum, sum[j-L]); if (sum[j] - min_sum >= 0) return true; } return false; } int main() { cin >> n >> L; double max_val = 0; for (int i = 1; i <= n; i++) { cin >> a[i]; max_val = max(max_val, a[i]); } double lo = 0, hi = max_val; while (hi - lo > 1e-5) { double mid = (lo + hi) / 2; if (check(mid)) lo = mid; else hi = mid; } cout << (int)(hi * 1000) << endl; return 0; }3.2 关键代码段解析
check 函数是整道题的核心。它接收一个猜测的平均值 x,然后判断是否存在满足条件的子段。注意这里我用了 double 类型的 sum 数组,因为减去 x 之后可能会出现小数。sum[i] 表示前 i 个数减去 x 之后的前缀和。
在 check 函数中,min_sum 初始化为 0,对应的是 sum[0]。然后从 j = L 开始遍历,每次先更新 min_sum,再判断 sum[j] - min_sum 是否大于等于 0。这里有一个细节:为什么 min_sum 更新的是 sum[j-L] 而不是 sum[j-L+1]?因为我们要保证子段的长度至少为 L,也就是说,如果子段是从 i+1 到 j,那么 j - i ≥ L,即 i ≤ j - L。所以当遍历到 j 的时候,所有满足条件的 i 中,最大的 i 是 j - L,对应的前缀和是 sum[j-L]。我们需要在遍历过程中不断把新的 sum[j-L] 纳入 min_sum 的考虑范围。
二分部分,lo 初始为 0,hi 初始为序列中的最大值。每次取 mid,如果 check(mid) 返回 true,说明存在平均值至少为 mid 的子段,所以真正的最大值在 [mid, hi] 之间,把 lo 抬高到 mid;否则真正的最大值在 [lo, mid] 之间,把 hi 降低到 mid。循环条件是 hi - lo > 1e-5,这个精度对于保留三位小数来说是足够的。
输出部分,直接输出 (int)(hi * 1000)。这里用 hi 而不是 lo,是因为 hi 是满足条件的上界,而 lo 是下界。实际上当精度足够小的时候,hi 和 lo 的差距已经很小了,用哪个都差不多。但为了保险起见,用 hi 更稳妥一些,因为 hi 始终是“可能存在解”的那一侧。
3.3 整数二分的替代方案
如果你担心浮点数精度问题,可以用整数二分。思路是把所有数都乘以 1000,然后二分答案。判定的时候,把每个数减去 mid(mid 是整数,表示平均值乘以 1000),然后判断是否存在子段和大于等于 0。最后输出 lo 或者 hi 即可。
bool check(long long x) { for (int i = 1; i <= n; i++) { sum[i] = sum[i-1] + a[i] * 1000 - x; } long long min_sum = 0; for (int j = L; j <= n; j++) { min_sum = min(min_sum, sum[j-L]); if (sum[j] - min_sum >= 0) return true; } return false; }整数二分的好处是完全避免了浮点数误差,缺点是 sum 数组可能会很大,需要用 long long。对于 n = 10⁵,a[i] 最大 10⁶ 的情况,sum 最大大约是 10¹¹,long long 完全够用。
4. 常见错误与调试技巧
4.1 二分边界设置错误
最常见的错误是二分上界设得太小。有些同学直接把 hi 设为 1000 或者 10000,结果如果序列中的数很大,答案就会超出这个范围,导致二分结果错误。正确的做法是设 hi 为序列中的最大值,或者设一个足够大的数比如 1e9。
另一个常见错误是二分下界设得太大。有些同学把 lo 设为序列中的最小值,这其实是不对的,因为平均值可能比最小值还小吗?不可能,平均值一定在最小值和最大值之间。但 lo 设为 0 是安全的,因为所有数都是正整数,平均值一定大于 0。
4.2 前缀和更新时机错误
在 check 函数中,min_sum 的更新时机非常关键。我见过很多同学写成这样:
for (int j = L; j <= n; j++) { if (sum[j] - min_sum >= 0) return true; min_sum = min(min_sum, sum[j-L]); }这样写是错的,因为你在判断之前还没有把 sum[j-L] 纳入 min_sum,导致可能漏掉一些解。正确的顺序是先更新 min_sum,再判断。这个顺序问题很容易被忽略,但一旦搞错,就会在某些数据上出错。
4.3 精度问题导致答案差 1
这是最让人头疼的问题。有时候你的程序在本地测试是对的,但提交上去就是差 1。这通常是因为二分精度不够,导致最后取整的时候出现了偏差。解决方法有两个:一是把精度设得更小,比如 1e-6 或者 1e-7;二是改用整数二分,彻底避免浮点数问题。
我个人推荐整数二分,因为它的正确性更容易保证。浮点数二分虽然写起来简单,但在边界情况下很容易出问题。特别是当答案恰好是某个整数的时候,浮点数误差可能会导致你输出比正确答案小 1 或者大 1。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 答案偏小 | 二分下界设得太大 | 把 lo 设为 0 |
| 答案偏大 | 二分上界设得太小 | 把 hi 设为序列最大值或更大 |
| 答案差 1 | 浮点数精度不够 | 改用整数二分或减小精度 |
| 运行超时 | check 函数写成 O(n²) | 用前缀和优化到 O(n) |
| 某些数据出错 | min_sum 更新顺序错误 | 先更新 min_sum 再判断 |
| 数组越界 | sum 数组开得太小 | 开到 n+5 |
5. 从这道题延伸出的通用解题套路
5.1 二分答案的适用场景
二分答案这个技巧,本质上是在解决一类“最大化最小值”或者“最小化最大值”的问题。它的适用条件是:答案具有单调性,也就是说,如果某个值 x 满足条件,那么所有小于 x 的值也满足条件(或者反过来)。在这道题中,如果存在平均值 ≥ x 的子段,那么对于任何 y < x,也存在平均值 ≥ y 的子段,因为平均值 ≥ x 的子段自然也 ≥ y。
判断一道题能不能用二分答案,关键看两点:第一,答案是否具有单调性;第二,给定一个答案,能不能在多项式时间内判断它是否可行。如果这两个条件都满足,那就可以考虑二分答案。
5.2 前缀和与差分的配合使用
前缀和是处理区间和问题的利器。在这道题中,我们用前缀和把“子段和”转化成了“两个前缀和之差”,从而把 O(n) 的区间求和操作降到了 O(1)。类似地,差分数组可以用来处理区间修改问题。这两者经常配合使用,比如在“区间加 + 区间求和”的问题中,可以用差分数组来维护修改,用前缀和来查询。
在这道题中,前缀和的作用不仅仅是加速求和,更重要的是它让我们能够用“维护最小值”的方式来判定条件。如果没有前缀和,我们就需要枚举所有可能的子段,时间复杂度会退化到 O(n²)。
5.3 实数域二分的注意事项
实数域二分和整数域二分有一些不同。整数域二分通常用 while (lo < hi) 或者 while (lo <= hi) 作为循环条件,每次把 mid 赋值为 (lo + hi) / 2 或者 (lo + hi + 1) / 2。实数域二分则通常用 while (hi - lo > eps) 作为循环条件,其中 eps 是精度要求。
实数域二分的精度设置很关键。如果 eps 设得太大,答案会不准确;如果设得太小,可能会因为浮点数精度限制而陷入死循环。一般来说,如果题目要求保留 k 位小数,eps 设为 10^(-k-2) 是比较安全的。比如要求保留三位小数,eps 设为 1e-5 就足够了。
另外,实数域二分在输出的时候,通常需要对结果进行四舍五入或者取整。这道题要求“乘以 1000 后取整”,所以直接输出 (int)(hi * 1000) 即可。但要注意,如果 hi * 1000 恰好是 123.9999999,直接取整会得到 123 而不是 124。为了避免这种情况,可以在取整之前加上一个很小的偏移量,比如 (int)(hi * 1000 + 1e-6)。
5.4 类似题目的变体与扩展
这道题有几个常见的变体。第一个变体是“长度恰好为 L”,那就更简单了,直接用滑动窗口求最大平均值即可,不需要二分。第二个变体是“长度不超过 L”,这个稍微复杂一点,但也可以用类似的思路解决。第三个变体是“求最大中位数”,思路和求最大平均值类似,也是二分答案加前缀和,只不过判定条件变成了“是否存在一个子段,其中大于等于 x 的数的个数大于小于 x 的数的个数”。
还有一个变体是“二维版本”,也就是在一个矩阵中找平均值最大的子矩阵。这个难度就大多了,需要用到二维前缀和,而且二分的判定过程也更复杂。不过核心思路还是一样的:二分答案,然后把矩阵中的每个数减去 x,判断是否存在一个子矩阵的和大于等于 0。
6. 实操中的经验与避坑指南
6.1 如何快速验证程序的正确性
写完代码之后,不要急着提交,先用几个小数据手动验证一下。比如 n = 3, L = 2, 序列是 [1, 2, 3],那么可能的子段有 [1,2] 平均值 1.5,[2,3] 平均值 2.5,[1,2,3] 平均值 2。最大平均值是 2.5,乘以 1000 是 2500。你可以用这个数据测试一下程序输出是不是 2500。
再比如 n = 5, L = 3, 序列是 [1, 1, 1, 1, 1],最大平均值是 1,输出应该是 1000。这种边界情况也要测试一下。
如果条件允许,可以写一个暴力程序,枚举所有长度不小于 L 的子段,计算平均值,然后和你的二分程序对拍。对拍是发现错误的最有效方法之一。你可以随机生成很多组小数据,然后用两个程序分别跑,比较输出是否一致。如果发现不一致,就手动分析那组数据,找出问题所在。
6.2 时间复杂度分析与优化
这道题的时间复杂度是 O(n log C),其中 n 是序列长度,C 是答案的范围。对于 n = 10⁵,C = 10⁶ 的情况,log C 大约是 20,所以总操作次数大约是 2 × 10⁶,完全可以在 1 秒内跑完。
如果你觉得还不够快,可以尝试一些优化。比如在 check 函数中,如果发现 sum[j] - min_sum >= 0,可以立即返回 true,不需要继续遍历。这个剪枝在大多数情况下都能显著减少运行时间。另外,如果你用的是整数二分,可以把 sum 数组的类型从 long long 改成 int,如果数据范围允许的话,这样可以减少内存访问时间。
还有一个优化技巧是预处理出序列的最大值和最小值,用它们来缩小二分范围。比如如果序列的最大值是 100,最小值是 1,那么答案一定在 [1, 100] 之间,你可以把 lo 设为 1,hi 设为 100,这样二分的次数会少一些。不过这个优化效果有限,因为 log 函数的增长很慢。
6.3 代码风格与调试技巧
在写这类算法题的时候,我建议把 check 函数单独拿出来写,不要把所有逻辑都塞在 main 函数里。这样代码结构更清晰,调试的时候也更容易定位问题。另外,变量命名要有意义,比如用 min_sum 而不是 ms,用 max_val 而不是 mv,这样别人看你的代码也能很快理解。
调试的时候,可以在 check 函数里加一些输出语句,打印出每次二分的 mid 值和 check 的返回值,看看二分的走向是否符合预期。如果发现 check 的返回值一直是 false,那可能是二分上界设得太小;如果一直是 true,那可能是二分下界设得太大。
还有一个常见的调试技巧是“缩小数据规模”。如果你在大数据上出错,可以尝试把 n 改小,比如改成 10 或者 20,然后手动模拟一下程序的行为,看看在哪一步出现了偏差。很多时候,问题在小数据上更容易暴露出来。
6.4 竞赛中的时间分配建议
这道题如果出现在竞赛中,我建议花 5 到 10 分钟理解题意和设计算法,然后花 15 到 20 分钟写代码和调试。如果你对二分答案和前缀和比较熟悉,整个过程应该能在 30 分钟内完成。如果卡住了,不要死磕,先去做其他题,回头再来检查。
另外,提交之前一定要检查几个关键点:数组有没有开够?二分边界有没有设对?精度有没有设够?输出格式对不对?这些细节问题往往是导致 WA 的主要原因。我见过太多同学因为数组开小了一位或者输出少了一个换行符而丢分,非常可惜。
6.5 从这道题学到的通用思维
这道题给我最大的启发是:遇到优化问题,先想想能不能转化成判定问题。很多优化问题直接求解很困难,但如果把它转化成“给定一个值,判断是否可行”的判定问题,往往就能用二分答案来解决。这个思路在算法竞赛中非常常见,比如“求第 k 大的数”、“求最小生成树的最大边”等等,都可以用类似的方法处理。
另一个启发是:数学转化是算法设计的核心。这道题的关键转化是把“平均值 ≥ x”变成“减去 x 后的和 ≥ 0”,这个转化看似简单,但它是整道题的灵魂。如果没有这个转化,前缀和就派不上用场,二分答案也无从谈起。所以平时做题的时候,要多注意积累这类数学转化的技巧,它们往往能让你在关键时刻豁然开朗。
最后再分享一个小技巧:如果你在比赛中遇到了类似的题目,但一时想不出正解,可以先写一个暴力程序拿部分分。比如这道题,你可以枚举所有长度不小于 L 的子段,计算平均值,取最大值。虽然时间复杂度是 O(n²),但对于 n ≤ 1000 的数据点来说,完全可以在时限内跑完。这样至少能拿到 30% 到 50% 的分数,比空着不写要好得多。等拿到部分分之后,再回头思考如何优化到 O(n log C)。