1. 题目背景与核心问题解析
洛谷P3503 [POI 2010] KLO-Blocks是一道来自波兰信息学竞赛(Polish Olympiad in Informatics)的经典题目。这道题在算法竞赛圈内以考察选手对单调栈和前缀和技巧的综合运用能力而闻名。题目给出一个长度为n的整数序列,要求找到一个最长的连续子序列,使得该子序列的平均值不小于给定的k值。
在实际解题过程中,我发现很多选手容易陷入暴力枚举的误区。这道题的精妙之处在于通过数学变形将平均值问题转化为前缀和问题,再结合单调栈的特性将时间复杂度从O(n²)优化到O(n)。这种思想在解决其他区间统计类问题时也具有很强的借鉴意义。
2. 问题转化与数学建模
2.1 关键数学变形
原始问题要求找到满足条件的连续子序列a[l..r],即:
(a[l] + a[l+1] + ... + a[r])/(r-l+1) ≥ k
通过简单的代数变形可以得到:
(a[l]-k) + (a[l+1]-k) + ... + (a[r]-k) ≥ 0
这个变形是解题的关键突破口。我们定义新的序列b[i] = a[i] - k,那么问题就转化为:在b序列中寻找最长的连续子序列,使得该子序列的和非负。
2.2 前缀和数组构建
为了高效计算子序列和,我们需要构建前缀和数组s:
s[0] = 0
s[i] = s[i-1] + b[i] = s[i-1] + (a[i] - k)
此时,子序列b[l..r]的和可以表示为s[r] - s[l-1]。问题进一步转化为:找到满足s[r] ≥ s[l-1]的最大(r - l + 1)。
3. 单调栈优化解法
3.1 基本思路分析
最直观的解法是枚举所有可能的l和r,检查s[r] ≥ s[l-1]的条件,但这样的时间复杂度是O(n²),对于n=1e5的数据规模显然不可行。
通过观察可以发现,对于某个位置j,如果存在i < j且s[i] > s[j],那么i永远不会成为最优解的左端点。因为对于任何k > j,如果s[k] ≥ s[j],那么必然有s[k] ≥ s[i](因为s[j] < s[i]),而j到k的距离更长。
3.2 单调栈的构建与维护
基于上述观察,我们可以维护一个单调递减的栈,栈中存储可能成为最优解左端点的位置。具体步骤如下:
- 初始化一个空栈,将位置0(对应s[0])入栈
- 从左到右遍历前缀和数组s:
- 如果s[i] < s[栈顶元素],将i入栈
- 否则,在栈中二分查找最大的j满足s[j] ≤ s[i]
- 最大长度即为找到的i - j
这种解法的时间复杂度是O(n),因为每个元素最多入栈出栈一次,且二分查找在单调栈中的时间复杂度是O(logn)。
4. 完整代码实现与注释
cpp复制#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1e6 + 5;
ll a[MAXN], s[MAXN];
int stk[MAXN], top;
int main() {
int n, m;
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i) {
scanf("%lld", &a[i]);
}
while (m--) {
ll k;
scanf("%lld", &k);
// 计算b数组和前缀和s
s[0] = 0;
for (int i = 1; i <= n; ++i) {
s[i] = s[i-1] + a[i] - k;
}
// 初始化单调栈
top = 0;
stk[++top] = 0;
int ans = 0;
for (int i = 1; i <= n; ++i) {
// 维护单调递减栈
if (s[i] < s[stk[top]]) {
stk[++top] = i;
continue;
}
// 二分查找满足s[j] <= s[i]的最小j
int l = 1, r = top, pos = 0;
while (l <= r) {
int mid = (l + r) >> 1;
if (s[stk[mid]] <= s[i]) {
pos = mid;
r = mid - 1;
} else {
l = mid + 1;
}
}
if (pos) {
ans = max(ans, i - stk[pos]);
}
}
printf("%d ", ans);
}
return 0;
}
5. 算法优化与边界处理
5.1 输入输出优化
对于大规模数据(n=1e6),普通的cin/cout可能会超时。建议使用:
- C语言风格的scanf/printf
- 关闭同步的ios::sync_with_stdio(false)配合cin/cout
- 快速读入函数(适用于整数)
5.2 边界条件处理
需要特别注意以下几种边界情况:
- 整个序列都满足条件时,输出n
- 所有元素都小于k时,输出0
- 序列中存在多个相同最小值时的处理
- 前缀和溢出问题(使用long long存储)
5.3 空间优化
如果内存紧张,可以观察到:
- 原始数组a可以重复使用
- 前缀和数组s可以边计算边处理,不需要全部存储
- 单调栈只需要存储下标,不需要存储具体值
6. 同类问题扩展与变式
6.1 最大子段和问题
这是KLO-Blocks问题的简化版,可以使用Kadane算法在O(n)时间内解决。两者的思想有相通之处。
6.2 平均值不大于k的最长子序列
只需将条件改为s[r] ≤ s[l-1],其他处理方式类似。
6.3 二维版本扩展
在二维矩阵中寻找满足条件的最大子矩阵,可以将问题转化为多次调用一维解法,时间复杂度为O(n³)。
7. 实际应用与经验分享
7.1 调试技巧
在实现这类算法时,建议:
- 先编写暴力解法作为正确性验证
- 打印中间结果(前缀和数组、单调栈状态)
- 对小规模数据进行手动模拟
7.2 常见错误
- 忘记初始化s[0] = 0
- 单调栈维护方向错误(应为递减而非递增)
- 二分查找边界条件处理不当
- 数据类型溢出(未使用long long)
7.3 性能对比
在我的测试中,对于n=1e6的数据:
- 暴力解法:无法在合理时间内完成
- 单调栈优化:约200ms(使用快速IO)
8. 进阶思考与挑战
对于学有余力的同学,可以尝试以下扩展:
- 输出所有满足条件的最长子序列的起止位置
- 处理动态更新的序列(增加/删除元素)
- 将问题扩展到树结构上(子树平均值问题)
这道题的价值不仅在于解决特定问题,更在于培养将复杂问题转化为经典模型的能力。在实际编程竞赛中,这种数学变形+数据结构优化的组合拳非常常见,掌握这类思维模式对提升解题能力大有裨益。
