【洛谷题解】P1115 最大子段和(前缀和 + 维护最小前缀)

难度:普及− | 知识点:一维前缀和、维护最小值 | 所属专栏:【洛谷题解】

前置知识:《一维前缀和详解》

目录

    • 一、题目描述:
    • 二、题目分析:
      • [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. 算法思路:

根据上面的分析,代码可以分为三个部分:

  1. 读入数组,同时求出前缀和;
  2. 初始化答案和最小前缀和;
  3. 枚举右端点,先更新答案,再维护最小前缀和。

我们根据思路,一步一步来实现:

① 首先读入数据,同时计算前缀和。

由于后续只需要前缀和,不需要再次访问原数组,所以可以用一个变量 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)。

初学时先理解数组写法即可,空间优化可以作为一个小练习尝试完成。


五、总结:

这道题和《水壶》的区别在于:区间长度不固定,不能只枚举固定长度的窗口。

我们的优化过程可以概括为:

  1. 用前缀和表示区间和 : s r − s l − 1 s_r-s_{l-1} sr−sl−1;
  2. 固定右端点 :此时 s r s_r sr 是定值,要让结果最大,就要减去最小的合法前缀和;
  3. 维护前面的最小值 :用一个变量保存 s 0 ∼ s r − 1 s_0\sim s_{r-1} s0∼sr−1 的最小值;
  4. 注意非空限制:先更新答案,再把当前前缀和加入维护;
  5. 完成复杂度优化 :从 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

如果这篇题解对你有帮助,欢迎点赞、收藏、关注,持续更新信奥知识点和洛谷题解。

相关推荐
renhongxia11 小时前
RAG检索增强生成:打通大模型与外部知识的通道
大数据·人工智能·算法
2501_933923252 小时前
双指针算法:移动零+复写零(Java)
数据结构·算法·双指针
和芯星通unicore2 小时前
卫星信号消失的地方,定位如何继续?——解密和芯星通DR 航位推算算法
人工智能·算法·gnss·智能驾驶gnss模块
邵奈一2 小时前
让WorkBuddy用上OpenCode免费模型:Magpie小白教程
算法
cu1433 小时前
细谈IT65631的具体功能与其应用
c++·单片机·嵌入式硬件
幻智星科技3 小时前
Karatsuba与CSD优化FPGA大数乘法
c++·fpga开发·密码学
人工智能培训3 小时前
边缘AI与大模型轻量化:让智能跑进每一个终端
大数据·人工智能·算法·重构·生活
flower9803233 小时前
C++消息总线软件开发框架AI重构版
c++·设计模式·架构