在处理"连续区间和"问题时,前缀和是我们最常用的武器。但如果题目加上了"区间长度必须在 [l,r] 之间"的死命令,传统的 O(N^2) 暴力枚举就会瞬间超时。今天,我们用单调队列这把尖刀,从"正向"和"逆向"两个极端的视角,把这道极其经典的滑动窗口问题彻底扒光,实现 O(N) 的降维打击。
一、 题目描述
【题目大意】 给你 n 个数 a1,a2,...,an 和两个数 l,r。你需要在这 n 个数中选出一段连续区间的数,满足区间长度 ∈[l,r]。 求出满足条件的最大连续区间和。
【样例输入】
8 1 2
-1 3 -2 5 3 -5 2 2
【样例输出】
8
【数据范围】 1≤l≤r≤n≤200000 −10^9≤a[i]≤10^9
二、 题目分析与解题思路
求区间和的第一反应必须是前缀和 。设S[i]为前i个元素的和。 任意一段以L为起点、R为终点的区间和,可以转化为:S[R]−S[L−1]。
既然要让差值最大,我们有两种截然不同的战术思路:
-
战术一(固定起点,向未来找最高售价): 假设买入成本S[L−1]已固定,我们在合法的未来窗口内,找一个最大的卖出价S[R]。
-
战术二(固定终点,向历史找最低成本): 假设卖出价S[R]已固定,我们在合法的历史窗口内,找一个最小的买入成本S[L−1]。
这两种战术,对应了两种双指针滑动的写法。
三、 算法设计与思考过程
做法一:固定起点,正向找最大终点
-
外层循环: 枚举起点i(也就是公式里的 L),范围是从1到n−l+1。
-
侦察兵x: 代表终点R。它从最短合法距离x=L开始,一路向右扫描。
-
单调队列维护: 我们要找最大的S[x],所以维护一个单调递减队列。遇到前缀和比自己大、且寿命更长的新人,队尾的老将直接被物理抹杀。
-
过期清理: 合法终点至少要达到i+l−1。如果队首的老将距离起点太近,直接踢出队列。
做法二:固定终点,逆向找最小起点
-
外层循环: 枚举终点i(公式里的R),采用逆向倒序,从n一路退到L。
-
侦察兵 x: 代表被减数的位置(起点的前一位 L−1)。它从最靠右的合法位置x=n−l开始,一路向左退。
-
单调队列维护: 我们要找最小的成本S[x],所以维护一个单调递增队列。遇到成本更低的新人,队尾老将出队。
-
逆向过期逻辑(全场最核心): 因为我们是从右往左扫描,先入队的老将,其坐标x大于 后入队的新人。当终点 i 不断向左退时,右边的老将就会变成"距离终点太近"的违规品。因此,过期条件是
q[front]>i-l。
四、 易错点总结
-
极小值初始化: 最大和
ma不能初始化为0。如果数组全是负数,答案也会是负数。必须初始化为极小值(如-1e18)。 -
循环的精确边界: * 正向写法的起点边界:最大只能走到
n-l+1,多一步都走不了。- 逆向写法的终点边界:最小只能退到
l,退到l−1就凑不够长度l了。
- 逆向写法的终点边界:最小只能退到
-
前缀和的
-1障眼法: 单调队列里存的到底是谁?在做法二中,侦察兵x本身代表的就是"起点的前一位",所以结算时直接用s[i]-s[q[front]]即可,绝对不能 再去写s[q[front]-1],否则会发生下标漂移。
五、 时空复杂度分析
-
时间复杂度:O(N) 。无论是正向还是逆向,外层指针和内层侦察兵 x 都是单向行驶、绝不回头的。每个下标最多入队一次、出队一次,均摊复杂度严格为O(N)。
-
空间复杂度:O(N)。需要开辟长度为N的前缀和数组S以及单调队列数组q。
六、 完整代码
解法一:固定起点(从左往右扫描,维护递减队列)
cpp
//最大连续区间和 先求前缀和
//先求前缀和然后两种做法 1固定起点 找满足区间长度要求值最大终点
//2固定终点,找满足区间长度要求值最小起点
//这里先写第一种做法
#include <iostream>
#include <algorithm>//对应min max函数
using namespace std;
int n,l,r;
long long s[200010];//前缀和数组
int q[200010];//队列
int front=1,rear=0;//队首 队尾
long long ma=-1e18;//初始化最大连续区间和为极小值
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>l>>r;
//求前缀和数组
for(int i=1;i<=n;i++){
int x;
cin>>x;
s[i]=s[i-1]+x;
}
//x为终点,起点从1开始,终点最小为L才能满足区间长度不小于L
int x=l;
//i代表起点,从1开始,最大为n-l+1
for(int i=1;i<=n-l+1;i++){
//i+r-1是满足区间长度要求终点最大值,不能超过这个范围
//n是终点最大值
while(x<=i+r-1&&x<=n){
//如果当前队列非空
//且当前终点的值(x)大于等于队尾的值,队尾元素出队
//因为队尾元素既不大于x又更老,未来不可能用到
while(front<=rear&&s[x]>=s[q[rear]]){
rear--;
}
//直到队列为空或x小于队尾元素
//把x入队
q[++rear]=x;
x++;
}
//接下来判断队首是否已经过期(不满足区间长度要求)
//如果已经过期就队首出队
//i+l-1是终点至少要大于等于这个值,才满足区间长度要求
//即没过期
while(q[front]<i+l-1) front++;
//队首就是区间内最大值
ma=max(ma,s[q[front]]-s[i-1]);
}
cout<<ma;
return 0;
}
解法二:固定终点(从右往左逆向扫描,维护递增队列)
cpp
//最大连续区间和 先求前缀和
//先求前缀和然后两种做法 1固定起点 找满足区间长度要求值最大终点
//2固定终点,找满足区间长度要求值最小起点
//这里写第二种做法
#include <iostream>
using namespace std;
int n,l,r;
long long s[200010];//前缀和数组
int q[200010];//队列 存最小起点
int front=1,rear=0;//队首 队尾
long long ma=-1e18;
int main(){
cin>>n>>l>>r;
//求前缀和
for(int i=1;i<=n;i++){
int x;
cin>>x;
s[i]=s[i-1]+x;
}
//起点从n-L+1开始(当终点为n时,起点至多是n-L+1才能满足区间长度要求)
//但是求终点和起点之间的和,要用终点-(起点前面一位)
//起点前面一位是n-L
int x=n-l;
//i代表终点,从右往左,终点至少要大于等于L才能满足区间长度最小要求
for(int i=n;i>=l;i--){
//当起点前一位不小于能取到的最小值且起点满足区间长度要求时
while(x>=0&&x>=i-r){
//如果x比队尾更小,就把队尾出队
//因为老队尾值又大,进栈时间又早,再也不会被后面使用到
while(front<=rear&&s[x]<=s[q[rear]]){
rear--;
}
//当队列为空或x大于队尾元素时
q[++rear]=x;
x--;
}
//把过期队首出队
while(q[front]>i-l) front++;
ma=max(ma,s[i]-s[q[front]]);
}
cout<<ma;
return 0;
}