☰
带长度限制的最大子数组和:前缀和+单调队列全解析
2026/10/10 20:53:48 网站建设 项目流程

Maximum Subarray Sum II(CSES P1644)这道题,我第一次看到时直接把它当成了Kadane算法的换皮题:最大连续区间和嘛,经典到不能再经典。可真正动笔推演,才发现题目多出来的那个长度区间约束,直接把问题从一维线性DP升级成了“前缀和+单调队列”的经典组合。如果你正在刷CSES的题库,或者准备算法面试前集中补滑动窗口和单调队列,这道题值得细读。我在这篇文章里把思路变形、队列维护的边界、两版实现和几次WA的调试过程完整走一遍,帮你一次吃透。

1. 先看清题:最大连续区间和为何加了“长度上下限”就变难

1.1 从Kadane到约束问题的第一反应

原版最大连续子数组和是一个经典问题。给定一个长度为n的整数数组,要求找连续的一段,使和最大。Kadane算法从上世纪八十年代沿用到今天,代码只有几行,核心思想是贪心:以i结尾的最大子数组和,要么单独从当前元素开始,要么接在前一个结尾的最优后缀后面。之所以可以直接取这两种情况的较大值,是因为一个负数前缀对后续来说完全没有价值,丢掉它之后不会错过任何更优解。

CSES P1644把题目改成了Maximum Subarray Sum II,多了两个输入参数a和b,要求子数组长度必须在[a, b]之间。第一次看到这个改动时我内心是有点轻敌的,因为“滑动窗口+双指针”看起来很熟。但真正一推导就发现,长度下界的加入直接打破了Kadane的贪心结构:即使某一段前缀和为负,只要它被强制包含在长度内,我们就不能丢弃它。更麻烦的是长度还有上界,意味着我们不能贪心地一直延伸最优窗口,必须在一个有限范围内做选择。连续两个约束,等于把“全局最优”压缩成“约束最优”,问题的复杂度立刻不一样了。

这里有个很直观的反例:数组是[10, -100, 99],a=2,b=3。Kadane会选出99,但长度只有1,不满足最小长度2。再看合法区间:长度为2的有-90和-1,长度为3的只有整个数组9,所以正确答案是9。为了让长度达标,我们必须被迫包含-100这个很负的段,Kadane那种“看到负累计就丢弃”的策略完全失效。

1.2 暴力枚举与数据规模的不可调和

最朴素的枚举思路是:枚举所有l和r,检查长度r-l是否落在[a, b]内,再计算和。如果先用前缀和预处理,单次区间和查询是O(1),但枚举对数仍然是O(n^2)。CSES输入规模n最大为2×10^5,O(n^2)意味着大约4×10^10次操作,即使每秒跑10^9次也远远不够。

还要注意一个隐蔽的坑:题目元素绝对值可到10^9,这意味着前缀和跨度能到2×10^14。这个量级用32位整数存储一定会溢出,代码里如果不加小心,很容易在不知不觉间得到一个错误答案。后面调试部分还会专门说这个问题。

顺着这条思路,我们必须把复杂度压到O(n log n)或O(n)。面对“区间长度限制+极值查询”这类问题,常规武器库里有线段树、树状数组、堆和单调队列。为什么最终选择单调队列,后面会有详细对比。

1.3 固定右端点,把问题改成候选区间的查询

既然无法同时优化两端,那就固定一端。我们枚举右端点r,每次只考虑以r结尾的子数组。对于固定的r,合法的左端点l(前缀和下标)需要同时满足r - l >= a和r - l <= b,整理一下就是l ∈ [r-b, r-a]。

区间和表示为pre[r] - pre[l],当r固定时pre[r]是常量,想让区间和最大,等价于在窗口[r-b, r-a]里找到l使得pre[l]最小。到这里,问题已经变成一个标准的“滑动窗口最小值”查询:随着r从a逐步增加到n,窗口右边界r-a不断增加,左边界r-b也不断向右推,每次窗口右端新进一个下标,左侧可能挤掉一个下标。

这个转化是整个题目的灵魂。很多人看到这题第一反应是维护“滑动窗口和”,那是被固定长度窗口题带偏了。正确思路是维护“滑动窗口内候选前缀和的最小值”,前缀和数组一旦计算好,后续操作完全不碰原数组元素,两个下标之差就是完整区间和,既干净又不容易出错。

2. 前缀和:把“区间求和”降维成“两点求差”

2.1 前缀和的定义与边界处理

设pre[0] = 0,pre[i] = sum(arr[1..i]),其中i从1到n。为什么要留一个pre[0]?因为子数组[l+1, r]的和正好等于pre[r] - pre[l],当l=0时代表从第一个元素开始取,这时pre[0]=0是必不可少的哨兵。数组下标从1开始读入会避免很多边界混乱,我习惯声明长度为n+1的vector,pre[0]固定为0。

在读入数据时可以直接构造前缀和,不需要额外存原数组:

for (int i = 1; i <= n; ++i) { long long x; cin >> x; pre[i] = pre[i - 1] + x; }

这一步之后,原数组就不需要保留了,所有操作都在pre上做。

2.2 为什么前缀和方案比直接维护窗口和更稳

如果直接在滑动窗口里存元素,由于窗口长度可变(从a到b),删除左侧时你得记录窗口内数值总和,新加右侧也得加值。这样做虽然也能算对,但逻辑上容易混淆:窗口长度变化的边界、删除的是哪个元素、是否删除合法元素。

前缀和方案完全绕开了这些细节:窗口维护的不是“窗口内的和”,而是“窗口内候选l对应的pre[l]值”。窗口移动被抽象成下标区间的滑动,数值上是单调队列在维护pre值的大小关系,操作非常规整。这也解释了为什么这类题的标准解几乎都是“前缀和+单调队列”,而不是那种“动态窗口和”的做法。

2.3 从例子里看前缀和的威力

沿用上面的例子arr = [1, -3, 1, 5, -2, 3],pre数组为[0, 1, -2, -1, 4, 2, 5]。想找r=4、长度2到4的子数组,只需看窗口[0, 2]内的pre值。最小是pre[2]=-2,于是得到最优区间和pre[4]-pre[2]=6。手算验证:arr[3]+arr[4]=1+5=6,长度是2,合法。整个过程只用比较几个pre,没有做任何元素累加,这就是前缀和的优势:把区间求和从“重复累加”变成“一次减法”。

3. 单调队列:滑动窗口最小值问题的标准解法

3.1 窗口内最小值的三种候选方案

窗口左边界、右边界都随r线性移动,我们每秒要回答“当前窗口内pre最小值是谁”。最暴力的做法是每次扫描窗口,复杂度O(n×窗口大小)。用优先队列(堆)也可以,每轮压入新下标,按pre排序,但堆不支持快速删除任意过期元素,只能懒删除——每次取堆顶时检查下标是否过期,过期就丢弃。这个方案复杂度O(n log n),能过题但代码要处理惰性删除,写起来没有单调队列清爽且常数偏大。用线段树则是把pre数组建树,区间查询最小值,一套模板下来最少五六十行,对这道题有点杀鸡用牛刀。

单调队列的优势在于:窗口是单调滑动的,每个下标只会进出队列一次,整体摊还O(n);队列内维护一个“由小到大”的候选序列,取最小值直接看队头O(1)。它正是为“滑动窗口最值”量身定做。

3.2 入队与出队的两个原则

队列里存的是pre数组的下标,而不是pre的值。为什么存下标?因为判断过期需要看下标是否滑出区间,同时比较值大小还得通过下标访问pre。我总结成两个原则:

  • 原则一:维持队列内pre值单调递增。新下标入队前,把所有pre值大于等于pre[新下标]的队尾弹出。因为队列内值相同的,新下标寿命更长,旧下标留着没有任何优势。
  • 原则二:每次取队头前,先把所有下标小于窗口左边界的队头弹出。漏掉第一点,队内不是单调的,取的未必是最小;漏掉第二点,队头可能已过期,答案会被历史值污染。

两个原则缺一不可。排序上,我习惯顺序是“入队维护单调性” -> “弹出过期队头” -> “计算答案”。先入队再弹出是安全的,因为新入队的下标是当前窗口内最靠右的候选,必然不会在弹出过期元素时被误删。

3.3 手工模拟一次完整过程

接着用arr = [1, -3, 1, 5, -2, 3],a=2,b=4来走流程。pre为[0, 1, -2, -1, 4, 2, 5]。

  • r=2:窗口[2-4, 2-2]=[-2, 0],实际下标只取0。入队下标0,pre[0]=0,队列变为[0]。队头0合法,ans=pre[2]-pre[0]=-2。
  • r=3:窗口[-1, 1],实际下标0到1。入队下标1,pre[1]=1,队尾pre[0]=0小于1,不弹出,队列为[0, 1]。队头0合法,ans=max(-2, pre[3]-pre[0])=-1。
  • r=4:窗口[0, 2]。入队下标2,pre[2]=-2,它比队尾pre[1]=1小,且比pre[0]=0也小,于是连续弹出0和1,队列变为[2]。队头2合法,ans=max(-1, 4-(-2))=6。
  • r=5:窗口[1, 3]。入队下标3,pre[3]=-1,队尾pre[2]=-2更小,不弹出,队列为[2, 3]。队头2合法,因为2 >= 5-4=1,ans=max(6, 2-(-2))=4。
  • r=6:窗口[2, 4]。入队下标4,pre[4]=4,队尾pre[3]=-1更小,不弹出,队列为[2, 3, 4]。队头2合法,ans=max(4, 5-(-2))=7。

最终输出7,对应子数组arr[3..6]即1+5-2+3,长度4在[2,4]内。这个例子中窗口右边界到了4之后,没有出现队头过期的情况,所以看起来“没有弹旧”。要验证弹旧,把b改小一些,比如b=3,r=6时窗口变成[3,3],下标2就过期了,答案会变成pre[6]-pre[3]=6。这个退化用例我建议你亲手推一遍,对理解边界很有帮助。

4. 两版工程实现与关键代码剖析

4.1 C++实现与逐行注释

下面是一份我认为最简的C++实现。代码不长,但每一行都有讲究。

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, a, b; cin >> n >> a >> b; vector<long long> pre(n + 1, 0); for (int i = 1; i <= n; ++i) { long long x; cin >> x; pre[i] = pre[i - 1] + x; } deque<int> dq; long long ans = LLONG_MIN; for (int r = a; r <= n; ++r) { int cand = r - a; while (!dq.empty() && pre[dq.back()] >= pre[cand]) { dq.pop_back(); } dq.push_back(cand); while (!dq.empty() && dq.front() < r - b) { dq.pop_front(); } ans = max(ans, pre[r] - pre[dq.front()]); } cout << ans << '\n'; return 0; }

逐行说明:

  • pre用long long。n最大2×10^5且每个元素绝对值可达10^9,前缀和跨度能到2×10^14,int一定爆。
  • 循环从r=a开始。r小于a时不存在任何合法子数组,直接跳过。
  • 每轮新加入的候选左端点是cand = r-a,它对应长度恰好为a的子数组。窗口的右边界就是r-a,所以这个下标刚好是窗口内最靠右的候选。
  • 弹出队尾时用>=而不是>,保证pre相同时留下更靠右的下标。靠右的下标寿命更长,对后续r更有利。
  • 弹出队头时判断front() < r-b。注意等号不能写:front()等于r-b时,子数组长度正好是b,依然合法。
  • 最后ans = max(ans, pre[r] - pre[dq.front()])。

4.2 Python实现与性能提醒

如果比赛环境允许Python,可以直接翻译。Python的collections.deque用起来甚至更简洁:

import sys from collections import deque def solve(): input = sys.stdin.readline n, a, b = map(int, input().split()) arr = list(map(int, input().split())) pre = [0] * (n + 1) for i in range(1, n + 1): pre[i] = pre[i - 1] + arr[i - 1] q = deque() ans = -10**30 for r in range(a, n + 1): cand = r - a while q and pre[q[-1]] >= pre[cand]: q.pop() q.append(cand) while q and q[0] < r - b: q.popleft() ans = max(ans, pre[r] - pre[q[0]]) print(ans) solve()

跑CSES的2×10^5数据用PyPy通常没问题,注意用sys.stdin.readline加速输入。如果遇到时间限制很紧,可以尝试把pre的构建改成生成器表达式,但实测readline这种写法已经足够稳定。

4.3 复杂度与方案取舍

这里把三种可行方案放在一张表里,方便你根据场景选择:

方案时间复杂度代码量备注
暴力扫描O(n×窗口长度)最短只能处理小数据
线段树/树状数组O(n log n)长思路通用,但实现重
堆+懒删除O(n log n)中要处理过期堆顶,容易漏
单调队列O(n)很短本题最优解

这道题在CSES的位置很妙,它前面已经有普通版Maximum Subarray Sum,后面接着就需要你掌握前缀后缀的各种变体。如果只会线段树,虽然也能过,但会错失单调队列这个重要的窗口工具。我个人的建议是:第一次做可以试着用线段树过一遍,搞清楚建模过程;然后必须用单调队列重写一遍,体会O(n)的优雅。

5. 实战中的五个高频错误与调试方法

5.1 错误一:把ans初始化为0

这是我第一次提交WA后发现的低级错误。题目要求子数组长度至少a,a>=1,不允许选空区间。假设数组是[-5,-2,-3],a=2,b=3,合法的子数组和全是负数:[-5,-2]=-7,[-2,-3]=-5,整个数组=-10,正确答案是-5。如果ans初始化为0,max函数会永远保留0,输出错误答案。正确做法是初始化成LLONG_MIN,或Python里的-10**30。

5.2 错误二:入队时机错一位

有的同学写的时候循环里先push_back(pre[r])再计算,这样计算以r为右端点时会错误地把l=r当作左端点,相当于允许长度为0的区间。这种写法在有些允许空区间的变体题里没问题,但P1644不允许。正确的时机是加入cand = r-a这个下标,保证每轮右端r对应的最小合法左端点刚进窗口,一步不多。

5.3 错误三:队头过期漏清或清过头

如果弹出条件写成dq.front() <= r-b,会把长度正好等于b的合法方案丢掉;如果写成dq.front() < r-b-1之类,又把本该保留的下标弹走了。建议在更新ans之前先清过期队头,顺序固定成“入队维护单调性 -> 弹出过期队头 -> 计算答案”,这样最不容易错。

5.4 错误四:int溢出

我见过有人在CSES上把pre数组声明成int,本地样例过了,大数据直接WA。CSES的反馈是WA不是RE,因为溢出后只是数值错,程序不会崩溃,排查起来更隐蔽。养成习惯:凡是前缀和、区间和的计算,一律先开long long。尤其在C++里,short和int混用很容易在不知不觉中发生隐式转换丢精度。

5.5 测试用例构造与退化验证

刷题最怕“样例过了就交”。这道题我建议自己构造三类用例:

  • 全正数:比如[1,2,3,4],a=1,b=4。这时最优是全部加起来等于10,既能验证窗口上界处理正确,也能验证单调队列里的弹旧逻辑不会误伤。
  • 全负数:比如[-5,-2,-3],a=2,b=3。验证ans不是初始化为0。
  • 负正交替:比如[5,-100,200,-50,10],a=2,b=3。这个例子手算答案不唯一,但可以用来对比程序输出。

我实际调试时最常用的是退化测试:把a和b设成相等,比如a=b=2,问题退化成固定长度2的滑动窗口。此时单调队列应该输出与“固定窗口双指针”一致的结果。用这个退化用例,几乎能立刻看出入队时机和过期条件是否写反。

再补充一个速查表:

症状可能原因排查方式
样例过,大数据WApre用int溢出全部改成long long
全负数组输出0ans初始化为0改用LLONG_MIN
输出偏大队头过期没弹出检查弹出条件是否为front() < r-b
输出偏小入队时机错位检查入队下标是否为r-a
a=b=2时结果不对窗口边界理解错手推固定窗口双指针对照

6. 从这道题延伸出去的几个想法

6.1 如果题目改成求最小连续区间和

思路完全对称:窗口内要找最大前缀和,队列维护单调递减(队头最大),其余不变。初次尝试时我把while的>=错写成<=,然后取min,结果样例都过不了。因为这个看起来“对称”的操作实际没那么容易直接套,建议对称做法也要重新推一遍边界再写。

6.2 如果长度下限是0

那空子数组也合法,有趣的是Kadane可以回归,或者单调队列里也可以提前把pre[0]=0放在窗口里。但绝大多数竞赛题里a是正整数,记住这个边界差别即可。这个问题也是面试中常见的追问:面试官给你这道题,第二个问题往往就是“如果允许空区间,你的代码要怎么改”。

6.3 解题套路的识别信号

“给定长度范围,求区间最值/和最大”这一类问题,只要数据范围到2×10^5,基本都能套前缀和+单调队列。识别信号很明确:

  • 题目同时出现区间长度限制[a, b]和极值需求。
  • 问题可以转化成“每个右端点找一个左端点”。
  • 左端点范围随右端点单调滑动。

看到这三个信号,直接考虑前缀和+单调队列。做题多了你会发现,单调队列很少单独出现,它经常和前缀和、DP状态优化绑定在一起。比如有些滑动窗口优化DP的题,本质上就是在转移方程里维护一个窗口内最值,和本题的处理手法如出一辙。

我个人在实际刷题中最大的体会是:代码本身并不难,难的是第一次想通“为什么r-a这个下标会在r循环中恰好作为新的入队候选”。我反复推导几次后总结了一个记忆方法:每轮循环只往队列里塞一个新的左端点候选,就是当前右端点r对应最小合法长度a的那个左端点,然后查询所有可能左端点中pre值最小的那个。队列里存的是pre值递增的下标,队头有效且最小。最后再分享一个小技巧:在本地提交前,先跑一遍a=b的退化用例和全负数组用例,这两个用例能过滤掉绝大多数边界bug。踩过几次坑之后,这类“窗口长度约束+前缀最值”的题会变得非常稳定,属于看一遍就知道解法模板的题型。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询