难度:普及− | 知识点:一维前缀和、维护最小值 | 所属专栏:【洛谷题解】
前置知识:《一维前缀和详解》
目录
-
- 一、题目描述:
- 二、题目分析:
-
- [1. 暴力做法](#1. 暴力做法)
- [2. 用前缀和表示区间和](#2. 用前缀和表示区间和)
- [3. 固定右端点,寻找最好的左端点](#3. 固定右端点,寻找最好的左端点)
- [4. 维护最小前缀和](#4. 维护最小前缀和)
- [5. 用样例模拟一遍](#5. 用样例模拟一遍)
- 三、代码实现:
-
- [1. 算法思路:](#1. 算法思路:)
- [2. C++代码实现:](#2. C++代码实现:)
- 四、细节补充:
-
- [1. 答案不能初始化为 0](#1. 答案不能初始化为 0)
- [2. 必须先更新答案,再维护最小值](#2. 必须先更新答案,再维护最小值)
- [3. 为什么 minPrefix 初始化为 0?](#3. 为什么 minPrefix 初始化为 0?)
- [4. 不能使用整个前缀和数组的最小值](#4. 不能使用整个前缀和数组的最小值)
- [5. 需要开 long long 吗?](#5. 需要开 long long 吗?)
- [6. 复杂度分析](#6. 复杂度分析)
- 五、总结:
一、题目描述:
题目链接:P1115 最大子段和
【题目描述】:
给出一个长度为 n n n 的序列 a a a,选出其中连续且非空的一段使得这段和最大。
【输入格式】:
第一行是一个整数,表示序列的长度 n n n。
第二行有 n n n 个整数,第 i i i 个整数表示序列的第 i i i 个数字 a i a_i ai。
【输出格式】:
输出一行一个整数表示答案。
【输入样例】:
7
2 -4 3 -1 2 -4 3
【输出样例】:
4
【说明/提示】:
样例 1 解释
选取 3 , 5 3,5 3,5 子段 { 3 , − 1 , 2 } \{3,-1,2\} {3,−1,2},其和为 4 4 4。
数据规模与约定
- 对于 40 % 40\% 40% 的数据,保证 n ≤ 2 × 10 3 n \leq 2\times10^3 n≤2×103。
- 对于 100 % 100\% 100% 的数据,保证 1 ≤ n ≤ 2 × 10 5 1 \leq n \leq 2\times10^5 1≤n≤2×105, − 10 4 ≤ a i ≤ 10 4 -10^4 \leq a_i \leq 10^4 −104≤ai≤104。
2026/01/21:增加一组 hack 数据。
二、题目分析:
上一道《水壶》中,我们求的是固定长度区间的最大和。
而这道题没有限制区间长度:可以只选一个数,也可以选两个、三个,甚至选整个数组。我们需要在所有合法的区间中,找出和最大的那一个。
在开始分析之前,先注意题目中的两个关键词:
- 连续:选出的数必须在原数组中紧挨着,不能跳过中间的元素。
- 非空:至少要选一个数,不能什么都不选。
例如,样例中的 3 , − 1 , 2 3,-1,2 3,−1,2 是连续的一段,可以选择;但不能只取其中的 3 3 3 和 2 2 2,把中间的 − 1 -1 −1 跳过去。
理解了这些要求之后,我们先从最朴素的做法开始想。
1. 暴力做法
既然区间的长度不固定,那么最直接的想法就是:枚举所有可能的区间,分别计算它们的和,取最大值。
我们可以先确定左端点 l l l,再让右端点 r r r 从 l l l 开始,不断向右移动。
右端点每向右移动一格,区间中就多出一个数,只要把这个数累加进来即可:
cpp
long long ans = LLONG_MIN;
for (int l = 1; l <= n; l++) {
long long sum = 0;
for (int r = l; r <= n; r++) {
sum += a[r]; // 当前区间 [l, r] 的和
ans = max(ans, sum);
}
}
这里使用 sum 逐步累加,避免了每次都重新计算整个区间,但仍然需要枚举所有的左右端点组合。
当左端点为 1 1 1 时,有 n n n 个区间;当左端点为 2 2 2 时,有 n − 1 n-1 n−1 个区间......总共需要枚举:
n + ( n − 1 ) + ⋯ + 1 = n ( n + 1 ) 2 n+(n-1)+\cdots+1=\frac{n(n+1)}{2} n+(n−1)+⋯+1=2n(n+1)
所以时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
- 对于 40 % 40\% 40% 的数据, n ≤ 2000 n \leq 2000 n≤2000,区间数量约为 2 × 10 6 2\times10^6 2×106,这样的做法可以应对。
- 对于全部数据, n n n 最大达到 2 × 10 5 2\times10^5 2×105,区间数量约为 2 × 10 10 2\times10^{10} 2×1010,难以在时限内完成。
因此,我们需要进一步优化,避免把所有区间都枚举一遍。
2. 用前缀和表示区间和
看到"区间和",我们先尝试用前缀和把它表示出来。
定义 s i s_i si 表示数组前 i i i 个数的和,并规定 s 0 = 0 s_0=0 s0=0:
s i = a 1 + a 2 + ⋯ + a i s_i=a_1+a_2+\cdots+a_i si=a1+a2+⋯+ai
那么,区间 l , r l,r l,r 的和就是:
s r − s l − 1 s_r-s_{l-1} sr−sl−1
于是,题目就变成了:
在所有满足 1 ≤ l ≤ r ≤ n 1\leq l\leq r\leq n 1≤l≤r≤n 的左右端点中,找到最大的 s r − s l − 1 s_r-s_{l-1} sr−sl−1。
不过,仅仅把区间和换成前缀和相减,还没有完成优化。
如果仍然枚举所有的 l l l 和 r r r,时间复杂度依然是 O ( n 2 ) O(n^2) O(n2)。
我们需要继续思考:能不能少枚举一个端点?
3. 固定右端点,寻找最好的左端点
我们先把右端点 r r r 固定下来。
此时, s r s_r sr 已经是一个确定的数,区间和为:
s r − s l − 1 s_r-s_{l-1} sr−sl−1
要让这个结果尽可能大,应该怎样选择 s l − 1 s_{l-1} sl−1 呢?
我们先看一个简单的减法。假设 s r = 8 s_r=8 sr=8,可以减去的数分别是 0 , 3 , − 2 0,3,-2 0,3,−2:
8 − 0 = 8 8-0=8 8−0=8
8 − 3 = 5 8-3=5 8−3=5
8 − ( − 2 ) = 10 8-(-2)=10 8−(−2)=10
可以发现,减去的数越小,得到的结果就越大。
因此,当右端点 r r r 固定时,只需要找到所有合法前缀和中最小的一个,就能得到以 r r r 为右端点的最大子段和。
接下来,我们就要确定:这个最小值应该在哪些前缀和中寻找?
因为区间必须非空,所以:
1 ≤ l ≤ r 1\leq l\leq r 1≤l≤r
两边减去 1 1 1,得到:
0 ≤ l − 1 ≤ r − 1 0\leq l-1\leq r-1 0≤l−1≤r−1
也就是说,允许被减去的前缀和只有:
s 0 , s 1 , ... , s r − 1 s_0,s_1,\ldots,s_{r-1} s0,s1,...,sr−1
不能包含 s r s_r sr 本身,更不能包含它后面的前缀和。
所以,以 r r r 为右端点的最大子段和为:
s r − min ( s 0 , s 1 , ... , s r − 1 ) s_r-\min(s_0,s_1,\ldots,s_{r-1}) sr−min(s0,s1,...,sr−1)
到这里,我们就把"枚举左端点"的问题,转化成了"寻找前面最小前缀和"的问题。
4. 维护最小前缀和
公式虽然推导出来了,但如果每次处理右端点 r r r,都重新扫描 s 0 s_0 s0 到 s r − 1 s_{r-1} sr−1 寻找最小值,时间复杂度仍然是 O ( n 2 ) O(n^2) O(n2)。
我们来观察一下,右端点向右移动时,最小值的候选范围有什么变化:
- 处理右端点 r r r 时,候选范围为 s 0 ∼ s r − 1 s_0\sim s_{r-1} s0∼sr−1;
- 处理右端点 r + 1 r+1 r+1 时,候选范围为 s 0 ∼ s r s_0\sim s_r s0∼sr。
新的范围只比原来多了一个 s r s_r sr。
所以,我们不需要重新扫描,只要用一个变量 minPrefix,记录已经找到的最小前缀和即可。
在处理右端点 r r r 之前,让它保存:
m i n P r e f i x = min ( s 0 , s 1 , ... , s r − 1 ) minPrefix=\min(s_0,s_1,\ldots,s_{r-1}) minPrefix=min(s0,s1,...,sr−1)
那么,当前右端点对应的最大子段和就是:
cpp
s[r] - minPrefix
先用它更新答案:
cpp
ans = max(ans, s[r] - minPrefix);
然后,把当前前缀和 s r s_r sr 加入最小值维护,供后面的右端点使用:
cpp
minPrefix = min(minPrefix, s[r]);
这样,每个右端点只需要进行常数次计算,总的时间复杂度就降到了 O ( n ) O(n) O(n)。
这里还有一个重要细节:两行代码的顺序不能交换。
如果先把 s r s_r sr 加入最小值维护,再计算答案,就可能出现:
s r − s r = 0 s_r-s_r=0 sr−sr=0
这相当于选择了一个没有任何元素的空区间,不符合题目"非空"的要求。
因此,大家可以记住这个顺序:
先用前面的最小前缀和计算答案,再把当前前缀和加入维护。
5. 用样例模拟一遍
理解了维护方法之后,我们用题目样例完整模拟一次。
数组为:
a = 2 , − 4 , 3 , − 1 , 2 , − 4 , 3 a=2,-4,3,-1,2,-4,3 a=2,−4,3,−1,2,−4,3
先求出前缀和:
| 下标 i i i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| a i a_i ai | --- | 2 | -4 | 3 | -1 | 2 | -4 | 3 |
| s i s_i si | 0 | 2 | -2 | 1 | 0 | 2 | -2 | 1 |
开始枚举之前:
minPrefix = 0,对应 s 0 s_0 s0;ans初始化为一个足够小的值。
接下来依次枚举右端点:
| 右端点 r r r | s r s_r sr | 本轮计算前的 minPrefix |
以 r r r 结尾的最大和 | 更新后的 ans |
|---|---|---|---|---|
| 1 | 2 | 0 | 2 − 0 = 2 2-0=2 2−0=2 | 2 |
| 2 | -2 | 0 | − 2 − 0 = − 2 -2-0=-2 −2−0=−2 | 2 |
| 3 | 1 | -2 | 1 − ( − 2 ) = 3 1-(-2)=3 1−(−2)=3 | 3 |
| 4 | 0 | -2 | 0 − ( − 2 ) = 2 0-(-2)=2 0−(−2)=2 | 3 |
| 5 | 2 | -2 | 2 − ( − 2 ) = 4 2-(-2)=4 2−(−2)=4 | 4 |
| 6 | -2 | -2 | − 2 − ( − 2 ) = 0 -2-(-2)=0 −2−(−2)=0 | 4 |
| 7 | 1 | -2 | 1 − ( − 2 ) = 3 1-(-2)=3 1−(−2)=3 | 4 |
我们重点看右端点 r = 5 r=5 r=5 的这一轮。
此时,前面最小的前缀和是:
s 2 = − 2 s_2=-2 s2=−2
因此,当前最优区间的左端点为 2 + 1 = 3 2+1=3 2+1=3,区间是 3 , 5 3,5 3,5,区间和为:
s 5 − s 2 = 2 − ( − 2 ) = 4 s_5-s_2=2-(-2)=4 s5−s2=2−(−2)=4
对应的三个数正是:
3 + ( − 1 ) + 2 = 4 3+(-1)+2=4 3+(−1)+2=4
最终得到答案 4 4 4,与样例输出一致。
另外,表格中 r = 6 r=6 r=6 时也出现了结果 0 0 0,但这里是合法的:我们减去的是之前出现的 s 2 s_2 s2,对应非空区间 3 , 6 3,6 3,6。
所以,问题不在于"区间和能不能为 0 0 0",而在于有没有错误地用当前的 s r s_r sr 减去它自己。
三、代码实现:
1. 算法思路:
根据上面的分析,代码可以分为三个部分:
- 读入数组,同时求出前缀和;
- 初始化答案和最小前缀和;
- 枚举右端点,先更新答案,再维护最小前缀和。
我们根据思路,一步一步来实现:
① 首先读入数据,同时计算前缀和。
由于后续只需要前缀和,不需要再次访问原数组,所以可以用一个变量 x 读入当前元素:
cpp
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
int x;
scanf("%d", &x);
s[i] = s[i - 1] + x;
}
这里的 s 定义为全局数组,所以 s[0] 自动初始化为 0 0 0。
② 接下来初始化答案和最小前缀和:
cpp
long long ans = LLONG_MIN;
long long minPrefix = 0;
LLONG_MIN 表示 long long 类型能够表示的最小值。由于本题答案可能是负数,不能直接把 ans 初始化为 0 0 0。
而 minPrefix = 0,表示最开始只有 s 0 = 0 s_0=0 s0=0 可以使用。
③ 最后枚举每一个右端点,先更新答案,再维护最小前缀和:
cpp
for (int r = 1; r <= n; r++) {
ans = max(ans, s[r] - minPrefix);
minPrefix = min(minPrefix, s[r]);
}
循环开始时,minPrefix 保存的是 s 0 ∼ s r − 1 s_0\sim s_{r-1} s0∼sr−1 的最小值;循环结束时,它就变成了 s 0 ∼ s r s_0\sim s_r s0∼sr 的最小值,恰好可以供下一轮使用。
2. C++代码实现:
最后我们将代码整合一下,完整代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 200010;
int n;
long long s[N];
int main() {
// ① 读入数据,同时求前缀和
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
int x;
scanf("%d", &x);
s[i] = s[i - 1] + x;
}
// ② 初始化答案和最小前缀和
long long ans = LLONG_MIN;
long long minPrefix = 0;
// ③ 枚举区间右端点
for (int r = 1; r <= n; r++) {
// 先用 s[0] 到 s[r-1] 中的最小值更新答案
ans = max(ans, s[r] - minPrefix);
// 再加入当前前缀和,供后面使用
minPrefix = min(minPrefix, s[r]);
}
printf("%lld\n", ans);
return 0;
}
四、细节补充:
1. 答案不能初始化为 0
题目要求选出的子段非空,所以当所有元素都是负数时,答案也必须是负数。
例如:
a = − 5 , − 2 , − 7 a=-5,-2,-7 a=−5,−2,−7
最好的选择是只取中间的 − 2 -2 −2,答案为 − 2 -2 −2。
如果把答案初始化为:
cpp
long long ans = 0; // 错误:会漏掉负数答案
后面所有合法区间的和都无法更新它,程序就会错误地输出 0 0 0。
因此,本代码使用:
cpp
long long ans = LLONG_MIN;
保证第一个合法区间和就能正常更新答案。
2. 必须先更新答案,再维护最小值
还是用一个全负数的例子:
a = − 5 a=-5 a=−5
此时 s 0 = 0 s_0=0 s0=0, s 1 = − 5 s_1=-5 s1=−5。
如果先维护最小值:
cpp
minPrefix = min(minPrefix, s[1]); // minPrefix 变为 -5
ans = max(ans, s[1] - minPrefix); // 错误地得到 0
就相当于选了空区间。
正确顺序应该是:
cpp
ans = max(ans, s[1] - minPrefix); // -5 - 0 = -5
minPrefix = min(minPrefix, s[1]);
这样得到的答案才是 − 5 -5 −5。
3. 为什么 minPrefix 初始化为 0?
这个 0 0 0 对应的是 s 0 s_0 s0,并不是随意设置的。
当最优区间从第一个数开始时,我们需要计算:
s r − s 0 s_r-s_0 sr−s0
如果遗漏了 s 0 s_0 s0,就可能漏掉从位置 1 1 1 开始的最优区间。
例如数组 2 , 3 2,3 2,3,答案是整个数组的和:
s 2 − s 0 = 5 − 0 = 5 s_2-s_0=5-0=5 s2−s0=5−0=5
4. 不能使用整个前缀和数组的最小值
我们维护的是当前右端点之前的最小前缀和,而不是整个数组的最小前缀和。
例如:
a = 5 , − 10 a=5,-10 a=5,−10
对应:
s 0 = 0 , s 1 = 5 , s 2 = − 5 s_0=0,\qquad s_1=5,\qquad s_2=-5 s0=0,s1=5,s2=−5
如果提前找到全局最小值 s 2 = − 5 s_2=-5 s2=−5,然后用它计算右端点 r = 1 r=1 r=1,就会得到:
s 1 − s 2 = 5 − ( − 5 ) = 10 s_1-s_2=5-(-5)=10 s1−s2=5−(−5)=10
但数组中根本不存在和为 10 10 10 的合法子段。
所以一定要从左到右枚举,一边计算答案,一边维护最小值,不能把未来的位置提前加入。
5. 需要开 long long 吗?
根据题目数据范围,任意前缀和或合法区间和的绝对值都不会超过:
2 × 10 5 × 10 4 = 2 × 10 9 2\times10^5\times10^4=2\times10^9 2×105×104=2×109
在常见的 32 位 int 环境下,这个范围没有超过 int 的表示范围,因此本题使用 int 也能容纳这些和。
本文仍使用 long long 保存前缀和与答案,方便将相同写法应用到数据范围更大的题目中。实际做题时,仍然要根据数据范围判断类型是否足够。
6. 复杂度分析
- 时间复杂度:计算前缀和 O ( n ) O(n) O(n),枚举右端点 O ( n ) O(n) O(n),总计 O ( n ) O(n) O(n)。
- 空间复杂度: O ( n ) O(n) O(n),用于保存前缀和数组。
进一步优化时,可以一边读入,一边维护当前前缀和、最小前缀和与答案,将额外空间降到 O ( 1 ) O(1) O(1)。
初学时先理解数组写法即可,空间优化可以作为一个小练习尝试完成。
五、总结:
这道题和《水壶》的区别在于:区间长度不固定,不能只枚举固定长度的窗口。
我们的优化过程可以概括为:
- 用前缀和表示区间和 : s r − s l − 1 s_r-s_{l-1} sr−sl−1;
- 固定右端点 :此时 s r s_r sr 是定值,要让结果最大,就要减去最小的合法前缀和;
- 维护前面的最小值 :用一个变量保存 s 0 ∼ s r − 1 s_0\sim s_{r-1} s0∼sr−1 的最小值;
- 注意非空限制:先更新答案,再把当前前缀和加入维护;
- 完成复杂度优化 :从 O ( n 2 ) O(n^2) O(n2) 降到 O ( n ) O(n) O(n)。
这道题值得记住的,不只是最大子段和的做法,还有这样一种思考方法:
当一个问题需要枚举两个端点时,可以尝试固定其中一个,再看看另一个能否通过维护最值来快速确定。
掌握了这道题之后,可以继续挑战 P3131 USACO16JAN Subsequences Summing to Sevens S。
它同样使用前缀和,但关注的不再是前缀和的大小,而是除以 7 7 7 后的余数。
相关文章 :《一维前缀和详解》|上一篇题解:P6568 水壶|下一篇题解:P3131 Subsequences Summing to Sevens
如果这篇题解对你有帮助,欢迎点赞、收藏、关注,持续更新信奥知识点和洛谷题解。