1. 题目背景与核心问题分析
洛谷P3503 [POI 2010] KLO-Blocks是一道来自波兰信息学竞赛(Polish Olympiad in Informatics)的经典题目。这道题考察的是对序列操作和单调栈技巧的综合运用能力,属于中等偏难的数据结构题目。
题目给出一个长度为n的正整数序列a₁,a₂,...,aₙ,要求找到最长的连续子序列,使得该子序列中所有数的平均值不小于给定的整数k。换句话说,我们需要找到最大的区间[l,r],使得(aₗ + aₗ₊₁ + ... + aᵣ)/(r-l+1) ≥ k。
2. 解题思路与算法选择
2.1 问题转化技巧
这道题的关键在于将原问题转化为更易处理的形式。我们可以对原序列进行预处理:
- 构造新序列b,其中bᵢ = aᵢ - k
- 问题转化为寻找最长的子序列,使得b序列对应子段的和非负
这种转化技巧在解决平均值相关问题时非常常见,它能够将除法运算转化为简单的加减法运算,大大简化问题的复杂度。
2.2 前缀和与单调栈
预处理后,我们可以使用前缀和技术进一步简化问题:
- 计算前缀和数组S,其中S₀=0,Sᵢ = Sᵢ₋₁ + bᵢ (i≥1)
- 问题转化为寻找i和j,使得Sⱼ ≥ Sᵢ且j-i最大
此时,我们可以使用单调栈来高效解决这个问题。单调栈是一种特殊的栈结构,它能够帮助我们维护序列中的单调性,从而快速找到满足特定条件的元素。
3. 详细算法实现
3.1 预处理阶段
cpp复制vector<int> a(n); // 原始序列
vector<int> b(n); // 预处理后的序列
for(int i=0; i<n; i++) {
b[i] = a[i] - k;
}
3.2 前缀和计算
cpp复制vector<long long> prefix(n+1, 0);
for(int i=1; i<=n; i++) {
prefix[i] = prefix[i-1] + b[i-1];
}
3.3 单调栈构建
我们需要构建一个单调栈,其中存储的是前缀和数组的下标,且这些下标对应的前缀和值是单调递减的:
cpp复制stack<int> stk;
stk.push(0); // 初始压入S₀
for(int i=1; i<=n; i++) {
if(prefix[i] < prefix[stk.top()]) {
stk.push(i);
}
}
3.4 求解最大区间
从后往前遍历前缀和数组,与单调栈中的元素比较:
cpp复制int max_len = 0;
for(int j=n; j>=1; j--) {
while(!stk.empty() && prefix[j] >= prefix[stk.top()]) {
max_len = max(max_len, j - stk.top());
stk.pop();
}
}
4. 复杂度分析与优化
4.1 时间复杂度
整个算法的时间复杂度为O(n),其中:
- 预处理阶段:O(n)
- 前缀和计算:O(n)
- 单调栈构建:O(n)
- 求解最大区间:O(n)
每个阶段都是线性复杂度,因此总复杂度保持为O(n),这在n≤1,000,000的数据规模下是完全可行的。
4.2 空间复杂度
算法需要额外的空间存储:
- 预处理序列b:O(n)
- 前缀和数组:O(n)
- 单调栈:最坏情况下O(n)
因此总空间复杂度也是O(n)。
5. 常见错误与调试技巧
5.1 边界条件处理
- 空序列或单元素序列的特殊情况
- 所有元素都小于k的情况
- 前缀和可能溢出int范围,需要使用long long
5.2 调试建议
- 打印预处理后的b序列,验证转化是否正确
- 打印前缀和数组,检查计算是否正确
- 可视化单调栈的变化过程,理解算法工作原理
5.3 典型错误案例
cpp复制// 错误示例:没有考虑前缀和可能相等的情况
while(!stk.empty() && prefix[j] > prefix[stk.top()]) {
// 应该使用>=而不是>
max_len = max(max_len, j - stk.top());
stk.pop();
}
6. 算法扩展与变种
6.1 二维版本扩展
这道题可以扩展到二维情况,即在一个矩阵中寻找最大的子矩阵,使其平均值不小于k。这种情况下可以使用类似的思想,结合二维前缀和和单调栈技巧,时间复杂度可以做到O(n²)。
6.2 带权平均值问题
如果题目要求考虑带权平均值,即每个元素有不同的权重,我们可以调整预处理阶段:
cpp复制vector<int> b(n);
for(int i=0; i<n; i++) {
b[i] = w[i] * (a[i] - k); // w[i]是第i个元素的权重
}
6.3 动态版本问题
如果序列可以动态修改,我们需要使用更高级的数据结构如线段树或平衡树来维护前缀和信息,这样可以在O(log n)时间内处理每次查询和修改。
7. 实际应用场景
这种类型的算法在实际中有广泛的应用,例如:
- 金融数据分析:寻找股票价格连续上涨的最长时段
- 信号处理:检测信号强度持续高于阈值的最长区间
- 生物信息学:分析DNA序列中特定模式的连续出现
8. 代码实现完整示例
cpp复制#include <iostream>
#include <vector>
#include <stack>
using namespace std;
int solve(vector<int>& a, int k) {
int n = a.size();
vector<long long> prefix(n+1, 0);
for(int i=1; i<=n; i++) {
prefix[i] = prefix[i-1] + (a[i-1] - k);
}
stack<int> stk;
stk.push(0);
for(int i=1; i<=n; i++) {
if(prefix[i] < prefix[stk.top()]) {
stk.push(i);
}
}
int max_len = 0;
for(int j=n; j>=1; j--) {
while(!stk.empty() && prefix[j] >= prefix[stk.top()]) {
max_len = max(max_len, j - stk.top());
stk.pop();
}
}
return max_len;
}
int main() {
int n, k;
cin >> n >> k;
vector<int> a(n);
for(int i=0; i<n; i++) {
cin >> a[i];
}
cout << solve(a, k) << endl;
return 0;
}
9. 性能优化技巧
- 使用数组代替vector可以略微提升速度
- 在单调栈构建阶段可以同时进行最大长度计算
- 对于特别大的n,可以考虑分块处理或并行计算
10. 题目变式与练习建议
为了巩固这类问题的解法,建议尝试以下类似题目:
- 寻找平均值最大的子序列
- 寻找和大于等于k的最短子序列
- 寻找满足特定条件的二维子矩阵
理解单调栈和前
