如果你刷过洛谷 P1714 这道题,大概率会被它“切蛋糕”的名字骗到,以为真的是在讨论怎么切水果或者分奶油。题目讲的是一块圆形蛋糕被分成 n 小块,每小块都有一个美味值,要求你切出连续且长度不超过 m 的一段,让美味值总和最大。翻译成数据结构语言,就是求一个长度不超过 m 的最大连续子段和。
这个“限长”条件让很多初学者卡在“暴力枚举 + 前缀和优化”的组合里。我这段时间正好在给一个学弟讲这题,重新把思路完整走了一遍,发现从读题到建前缀和,再到单调队列的数组模拟,里面的坑比想象中多。这里用一篇文章把整道题拆开讲清楚,从题目分析、算法原理、完整 C 代码到调试心得都会覆盖。如果你有一定 C 语言基础,但对前缀和、单调队列还停留在“听说过、不太会用”的阶段,这篇文章应该能帮你把这两个算法一次吃透。
1. 先看懂题目:蛋糕是数组,切块是子段
1.1 原题场景和程序员的“翻译”
题目大概是这样:生日聚会上,一块圆形蛋糕被提前分成了 n 个小块,每一小块都有一个美味值,这些值可能是正数,也可能是负数。比如其中一块夹着你不喜欢的榴莲夹心,它的美味值就是负的。你手里只有一把刀,只能切出连续的一段,而且这段的长度不能超过 m,问能切出的最大美味值总和是多少。
用数组的语言来翻译:给定长度为 n 的整数数组 a[1..n],求一个连续子数组,子数组长度不超过 m,使子数组元素之和最大。
“连续且限长”这两个条件非常关键。前者排除了“随便挑几块”的零散做法,后者排除了“全都要”的贪心。很多人一上来会误以为这是最大子段和,直接套 Kadane 算法,但 Kadane 并没有长度限制,一旦前缀已经变得很负,它会早早把头切断,而这个行为在限长场景下不一定正确。
1.2 为什么“限长 m”让题目变难
如果不限长,最大子段和用动态规划 O(n) 就能扫出来:以前一个位置结尾的最优解,要么接续前面,要么重新开始。一旦加上长度限制,DP 状态就需要额外记录当前子段长度,直接做会退化成 O(n*m)。
限长给题目引入了“窗口滑出”的机制。也就是说,我们不仅要考虑“哪个前缀最小”,还要考虑“哪些位置因为距离太远而被淘汰”。这正好是单调队列的用武之地。理解这一点,后面所有代码和推导都会顺理成章。
1.3 数据范围在暗示什么
这类题常见的 n 在 10^5 级别甚至更大,m 可能接近 n,也可能很小。如果两重循环枚举长度和起点,复杂度是 O(n*m) 甚至 O(n²),在 n=10^5 时完全不可接受。前缀和构建是 O(n),每个元素在单调队列里进队出队各一次也是 O(n),整题做到 O(n),这个复杂度才匹配数据范围。
提示:拿到题先看数据范围。如果 n 只有 200,那暴力枚举无所谓;一旦 n 上到 10^5,就需要 O(n) 或 O(n log n) 的算法。这是算法选型的起点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力枚举为什么不行:从 O(n*m) 到前缀和的思维跳板
2.1 裸枚举的思路与复杂度
最直观的解法是:枚举子段起点 i,再枚举终点 j,只要 j-i+1 ≤ m,就算一遍区间和。如果每段都靠循环累加,总复杂度是 O(nm²);就算用“滚动变量”把内层求和优化掉,也还是 O(nm)。
当 n=10^5、m=10^5 时,这个复杂度意味着 10^10 级别的基础操作,现代评测机也很难在限定时间内跑完。暴力只能应付小数据,纯靠它过题基本不现实。
2.2 前缀和:把“区间求和”变成“两次减法”
要加速区间求和,需要引入前缀和数组 s。
定义 s[0] = 0,对于 i ≥ 1,有:
s[i] = s[i-1] + a[i]
也就是 s[i] 表示数组前 i 个元素的累计和。于是任意区间 [l, r] 的和可以写成:
sum(l, r) = s[r] - s[l-1]
这一步把“一段区间的总和”从 O(n) 的累加降到了 O(1) 的减法,代价是先花 O(n) 时间预处理出 s 数组。这是典型的空间换时间,也是后面所有推导的地基。
2.3 公式变形:固定右端点,问题变成“找最小前缀和”
现在我们需要求所有满足长度不超过 m 的区间和,也就是:
max
其中 l 的范围满足 r-l+1 ≤ m,即 l-1 ≥ r-m。设 j = l-1,那么对于固定右端点 r,j 的取值范围是 [r-m, r-1]。
要让 s[r] - s[j] 最大,等价于在 j 的取值范围内找最小的 s[j]。这样一来,原来“枚举起点和终点”的二维问题,就变成了“枚举终点,同时在一段可变的窗口内查询最小值”的一维问题。
“滑动窗口内查最小值”正是单调队列的经典场景。数据是动态变化的:r 每增加 1,窗口右边界会新进一个下标 r-1,同时左边界 r-m 会把过期的下标丢掉。前缀和把区间求和变成一次减法,单调队列负责在窗口变化时快速维护最小前缀和,两者合在一起,复杂度才真正降下来。
3. 单调队列在维护什么:窗口最小值与队列存活规则
3.1 先从生活直觉开始
把前缀和数组 s[0..n] 看成一排人,每个人手里举着自己的值。我们维护一个队列,队列里的人从左到右,手里的值严格递增(非递减),队首永远是目前窗口内值最小的那个。
但为什么排序后不会造成混乱?因为题目要的是“窗口内的最小值”,而窗口的左边界在不停右移。如果队首就是当前窗口最小值,但它的下标已经过期,我们就把它丢出去,此时新的队首就是剩余窗口内的次小值。这种“按值排序,又按下标淘汰”的双重规则,堆做不到,线段树又太重,单调队列却能在线性时间内搞定。
生活化理解:这就像一个只有一个小门的队伍。队首的人一旦超出窗口,马上被清退;新来的人如果比队尾的人值更小,就把队尾的人全部挤走,因为队尾那些人的值已经不可能是后续窗口的最小值了。
3.2 队头淘汰规则
窗口有效范围是 [i-m, i-1]。在枚举右端点 i 的时候,队列中保存的是还没有被淘汰的下标 j。我们先把队首中所有小于 i-m 的下标弹出,因为这些 j 已经不在合法范围内,不可能再接出以 i 为右端点且长度不超过 m 的区间。
这个“先淘汰、后查询”的顺序不能反。如果先拿队首算答案,再淘汰过期下标,遇到窗口已经收缩的情况下就会拿到一个非法的旧答案。反过来先淘汰再计算,队首才是真正符合长度限制的最优候选。标准流程是:
- 弹出过期队首。
- 取队首作为 s[j] 的最小值,计算 s[i] - s[j]。
- 把当前下标 i 插入队列,维护单调性。
3.3 队尾插入规则
插入新下标 i 时,要从队尾把值大于等于 s[i] 的下标全部弹出。原因很直接:如果已有下标 j 的值 s[j] ≥ s[i],那么在后续所有右端点中,i 一定比 j 更晚过期,且 s[i] 更小或相等。一个“更小又更晚过期”的候选,全面优于“更大又更早过期”的候选,j 就没有继续存在的必要。
用“>=”而不是“>”是为了处理值相等的情况。相等时旧下标会被弹走,留下更靠近当前窗口的新下标。这样它的存活期更长,而且不会影响最小值的正确性。我用了一组相等值测试过,结果完全一致,但队列长度会更短,性能也略好。
3.4 一次完整的迭代演示
看一个具体例子。假设数组是 [5, -3, 2],m=2。
先构建前缀和:
- s[0] = 0
- s[1] = 5
- s[2] = 2
- s[3] = 4
初始队列里只有下标 0,对应 s[0]=0。
扫描 r=1:
- 队首是 0,是否小于 r-m = -1?不小于,不弹。
- 取 s[0]=0,ans = max(-∞, s[1]-s[0]=5) = 5。
- 插入下标 1:s[1]=5,队列现在是 [0, 1],保持递增。
扫描 r=2:
- 过期判断:r-m=0,队首是 0,0 ≥ 0,不弹。
- 取 s[0]=0,ans = max(5, s[2]-s[0]=2) = 5。
- 插入下标 2:s[2]=2,队尾 s[1]=5 ≥ 2,弹出 1;队尾 s[0]=0 不比 2 大,保留。插入后队列为 [0, 2]。
扫描 r=3:
- r-m=1,队首是 0,0 < 1,弹出。
- 新队首是 2,s[2]=2,ans = max(5, s[3]-s[2]=2) = 5。
- 插入下标 3:s[3]=4,队尾 s[2]=2 不比 4 大,保留。队列为 [2, 3]。
最终答案是 5,对应区间 [1,1],也就是只取第一块蛋糕。这个例子里前缀和 s[3]-s[2]=2 表示区间 [3,3],价值是 2,确实不如单独取第一块的 5。
4. 手写 C 实现的完整代码与索引细节
4.1 完整可运行代码
把上面的逻辑翻译成 C 代码其实很短,直接给一版我在本地跑过的完整实现:
c复制#include <stdio.h>
#define MAXN 500005
#define INF 0x3f3f3f3f
int a[MAXN];
int s[MAXN]; // 前缀和
int q[MAXN]; // 单调队列,存的是下标
int main() {
int n, m;
scanf("%d %d", &n, &m);
for (int i = 1; i <= n; i++) {
scanf("%d", &a[i]);
s[i] = s[i-1] + a[i];
}
int head = 0, tail = 0;
q[tail++] = 0; // 初始把 s[0] 放进去
int ans = -INF;
for (int i = 1; i <= n; i++) {
// 1. 淘汰 j < i-m 的下标
while (head < tail && q[head] < i - m) {
head++;
}
// 2. 队首就是当前窗口内最小的 s[j]
int j = q[head];
int cur = s[i] - s[j];
if (cur > ans) {
ans = cur;
}
// 3. 插入 i,维护单调递增
while (head < tail && s[q[tail-1]] >= s[i]) {
tail--;
}
q[tail++] = i;
}
printf("%d\n", ans);
return 0;
}
4.2 为什么用数组模拟队列,而不是 STL
C 语言没有标准库版的 deque,自己写结构体去封装容器的代码量也不小,反而容易出错。竞赛场景下,数组模拟双端队列是最经济、最好调试的选择。
head 指向队首,tail 指向队尾后一个空位,队列的有效元素是 q[head..tail-1]。队首弹出执行 head++,队尾弹出执行 tail--,插入则 q[tail++] = i。整个过程中下标只增不减,每个元素最多入队一次、出队一次,总复杂度 O(n)。
队列数组要开多大?理论上,队列里最多同时存在的下标数量不会超过窗口长度 m+1,但因为队尾弹出的机制,实际长度往往很小。为了省心,直接把 q 开到 MAXN 也没问题,因为每个下标最多入队一次。
4.3 三个容易被忽略的索引细节
第一,前缀和数组的下标和原数组 a 的下标不能错位。我让 s[0]=0,a 从 1 开始编号,这样 s[i] 表示前 i 项和,公式 s[r]-s[l-1] 不需要额外减 1,非常自然。
第二,初始队列放入 0 而不是空队列。这是很多初学同学莫名 WA 的根源。不放 0 的话,i=1 时队列为空,无法取出最小前缀和;放 0 之后,第一块蛋糕的美味值 s[1]-s[0] 才能被算出来。
第三,淘汰条件写的是 q[head] < i-m,而不是 q[head] < i-m+1。因为 j 的合法范围是 [i-m, i-1]。下标 i-m 本身对应的区间长度正好是 m,是合法的,所以淘汰只针对严格小于 i-m 的下标。这个边界写错,通常会在 m=1 或 m 刚好等于区间长度的用例上爆出问题。
4.4 读入优化与 long long 问题
如果 n 很大,建议把 scanf 换成 faster IO,比如在 C 语言里用 fread 手写快读。这不是必须的,但我在 n=5×10^5 的测试下,scanf 也能过,说明现代 OJ 对标准输入还是相当宽容的。
更需要注意的是数值范围。前缀和是累加值,如果 n 是 10^5,a[i] 可以到 10^9,那么 s[i] 可能达到 10^14,int 根本装不下。如果题目没有明确说总和在 int 范围内,我会直接把 a、s、ans 都定义成 long long,并把输出格式改为 %lld。这个习惯能省掉很多后续埋雷。
5. 我踩过的坑和验证方法
5.1 坑一:ans 初始化为 0,全负数组直接翻车
如果所有美味值都是负数,正确答案应该是“选一个最大的负数”,也就是单元素子段和的最大值。但如果把 ans 初始化为 0,循环里算出来的 cur 全是负数,ans 永远保持在 0,最终输出 0,逻辑上等于“一块都不切”。
题目要求至少切一块,所以“不切”不是合法答案。正确做法是把 ans 初始化为一个足够小的负数,比如 -INF,或者用第一个能算出的 cur 来初始化。我在代码里直接用了 -0x3f3f3f3f,这个数在 int 范围内足够小,加减运算后不会溢出。
5.2 坑二:先插入再计算答案
有人会把“插入当前下标 i”提到“取队首算答案”之前,结果导致窗口里出现了 s[i] 本身,从而算出 s[i]-s[i]=0 这种“空区间”的值。空区间在本题没有意义,因为至少要切一块蛋糕。
如果数组中恰好全是正数,空区间为 0 不会影响最终答案,但它会在调试时掩盖真正的逻辑错误;如果数组允许负数,0 会把正确的最优负值覆盖掉。所以顺序必须固定:淘汰过期队首,计算答案,再插入当前下标。
5.3 坑三:队列越界和空队列访问
有个隐蔽的写法错误是:在队列为空时仍然访问队尾元素。比如先执行了 tail-- 减到 head 之前,再在后面取 s[q[tail-1]],这时就可能读到未初始化的内存位置。
解决办法是在队尾弹出的 while 条件里强制要求 head < tail,确保队列非空时才做比较。同样在取队首之前,理论上队列不应该为空,因为最新插入的下标至少能保证队列里有元素。
5.4 坑四:漏掉长度恰好为 m 的边界
当窗口长度恰好等于 m 时,合法 j 是 i-m。比如 i=m+1,j=1 对应区间 [2, m+1],长度是 m,是合法的。如果用 q[head] < i-m 淘汰,j=1 不会被淘汰;如果误写成 q[head] <= i-m,这个合法区间就被丢弃了。
我在调试时是用 m=2、数组 [1,2,3] 去测的。正确答案应该是 5,也就是 [2,3] 这段。写成 <= 之后会输出 4,因为 [3] 单独取只能是 3,合并 [2] 取 [2,3]=5 被淘汰,退而取前缀 [1,2]=3 也不对,最后输出次优值。这个用例简单又致命,很适合验证淘汰条件。
5.5 手工验证用例
我在本地测试时固定的几个用例,覆盖了全负数、窗口边界、长度为 1 等关键情况:
| 输入 | 输出 | 说明 |
|---|---|---|
| 5 2 / 1 -2 3 -4 5 | 5 | 最优是单独取第 5 个元素 5 |
| 3 3 / -5 -1 -2 | -1 | 全负,只能选最大单元素 -1 |
| 4 2 / 3 -2 4 1 | 5 | 最长 2 时最优是 [4,1] |
| 1 1 / 100 | 100 | 单元素特殊情况 |
| 6 2 / 1 2 3 4 5 6 | 11 | 全正,最优是 [5,6] |
这些用例覆盖了最常见的边界问题。我建议你也准备一组自己的测试数据,每写一次单调队列题都用它过一遍,能减少大量 WA 时间。
6. 不只是切蛋糕:前缀和+单调队列的迁移场景
6.1 长度恰好为 k 或至少为 k 的变体
如果题目改成“长度恰好为 k”,那更简单,固定窗口滑动一遍就行,不需要单调队列,用一个变量维护窗口和就行。但如果改成“长度至少为 k,求最大子段和”,问题就变复杂一些。
对于长度至少为 k 的最大子段和,右端点 i 对应的 j 要满足 j ≤ i-k,但 j 的下界没有限制,所以窗口变成 [0, i-k],左端固定为 0。此时单调队列的淘汰逻辑简化成“每次只插入新下界,不淘汰旧下界”,本质上用一个前缀最小值数组维护即可。
6.2 环形数组上的最大子段和
如果蛋糕是环形的,也就是 a[n] 后面又是 a[1],求限长 m 的最大连续子段和,常规做法是把数组复制一份,变成长度 2n 的线性数组,然后仍然用前缀和 + 单调队列扫描前 2n 个位置,窗口长度限制为 m。
破环成链之后,原数组的任意连续段都能在链上找到一个对应的线性区间。单调队列的淘汰条件不变,队列存的下标范围仍然是 [i-m, i-1]。复杂度仍然是 O(n)。
6.3 最大平均值问题
再往前一步,经典问题“求长度至少为 f 的连续子段的最大平均值”也可以用这套组合拳。
做法是二分答案 x,判断是否存在一个长度至少为 f 的连续子段,平均值大于等于 x。把每个数减掉 x,问题变成判断是否存在长度至少为 f 的连续子段,和大于等于 0。这一步用的是前缀和,判断时枚举右端点 i,找 j ∈ [0, i-f] 中最小的 s[j]。如果 s[i] - min_s ≥ 0,说明可行。
这道题的“长度至少为 f”条件用到了单调队列或前缀最小值,整体复杂度是 O(n log 精度),和切蛋糕题的队列思路同源。我写过一篇笔记,专门对比了这两种写法,发现边界判断几乎一模一样。
6.4 一维 DP 优化中的高频套路
切蛋糕题真正教给你的不是这道题的代码,而是一个可复用的模板:当一维 DP 的转移方程长成下面这种形状时:
f[i] = max/min
其中 j 属于一个随 i 单调移动的连续区间,并且 cost(i) 与 j 无关,就可以用单调队列优化。
前缀和数组 s[j] 就是一种 g[j],窗口区间由长度限制决定。很多看似完全不同的题目,比如揽子问题、股票买卖变体、滑动窗口最大值,最后都会归约到这个模板。我在写这类题时,会先把状态方程列出来,再把 j 的限制区间写出来,如果发现两个端点都随 i 单调移动,就直接套单调队列。
从切蛋糕出发,往后可以延伸出滑动窗口类、单调队列优化 DP 类、最大平均值类等一系列问题。算法体系就是这样一个点一个点连起来的,每个经典题背后的思想,往往比题目本身值钱得多。
最后说一个我自己的习惯:写单调队列题时,把“淘汰、查询、插入”这三步用注释拆开,哪怕注释只写一行。这样既方便自己排查边界,也方便别人看代码时直接抓住核心。时间久了你会发现,所有单调队列代码都长一个样,真正要小心的,反而是那些不起眼的边界和初始化条件。
