0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1115,日期见页头。两边不一致时信原站。
题目描述
给出一个长度为 n 的序列 a,选出其中连续且非空的一段使得这段和最大。
输入格式
第一行是一个整数,表示序列的长度 n。
第二行有 n 个整数,第 i 个整数表示序列的第 i 个数字 aᵢ。
输出格式
输出一行一个整数表示答案。
说明 / 提示
样例 1 解释:选取 [3, 5] 子段 {3, -1, 2},其和为 4。
数据规模与约定
- 对于
40%的数据,保证n ≤ 2 × 10³。 - 对于
100%的数据,保证1 ≤ n ≤ 2 × 10⁵,-10⁴ ≤ aᵢ ≤ 10⁴。
2026/01/21:增加一组 hack 数据。
输入输出样例
输入
7 2 -4 3 -1 2 -4 3
输出
4
1⚠ 这道题的全部难度,是题面里的两个字
把「非空」两个字删掉,这道题的标准答案就变成下面第 ② 步那个错的写法。
- 允许空段 ⇒ 答案下限是
0(什么都不选,和为 0)⇒ans = 0起手是对的; - 不允许空段 ⇒ 全是负数时,答案是最大的那个负数⇒
ans = 0起手就是 WA。
⇒ 这道题只有一条 bug 触发线,而它是题面里的两个字。 下面整页都在量这条线:它有多窄、随机数据能不能撞上、换个实现还认不认得出来。
// P1115 最大子段和 —— 能 AC 的那一版(O(n) 扫一遍)//// 递推一句话:**以第 i 个数结尾**的最大子段和,要么是「前面那一段接上 a[i]」,// 要么是「从 a[i] 重新开始」——// cur = max(a[i], cur + a[i])// 全程取最大值就是答案。//// ⚠ 起手值是这道题唯一的坑:`ans` 和 `cur` 都从 **a[0]** 开始,不是从 0 开始。// 题面写的是「选出其中连续且**非空**的一段」——// 那两个字就是这道题的全部难度(解析页第 ② 步把它量出来了)。//// ⚠ 用不用 long long?**这道题刚好不用**:n ≤ 2×10⁵、|aᵢ| ≤ 10⁴// ⇒ 和最大 2×10⁹,而 int 的上限是 2 147 483 647 —— 只差 1.47×10⁸,余量约 7%。// 这是一道算术题,不是「保险起见都开」。(同一章的 P1908 是反过来的:差 58 倍。)
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 200005;static int a[MAXN];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) cin >> a[i];
int ans = a[0], cur = a[0]; // ★ 从 a[0] 起手,不是 0 for (int i = 1; i < n; i++) { cur = max(a[i], cur + a[i]); ans = max(ans, cur); }
cout << ans << "\n"; return 0;}点「运行 ▶」看结果
顶格数据(n = 2 × 10⁵)本机实测:0.01 秒以内,时限 1 秒。
2⚠ 第 ① 版:ans = 0 起手 —— 教科书上最常见的写法
// P1115 ⚠ 第 ① 版:`ans` 从 0 起手 —— 教科书上最常见的写法,而它在这道题上是错的//// 「和变成负数就丢掉,从头再来」——这套写法本身没问题,// 问题出在 **ans 的起手值是 0**:它等于偷偷允许了「一段都不选」。//// ⇒ 只要序列里**有一个非负数**,它就是对的;// **所有数都是负数**时,它输出 0,而正确答案是「最大的那个负数」。//// ★ 触发条件是一个**开关**,不是一个概率:max(a) < 0。// ⇒ 对拍的抓获率就等于「这一档数据里全负的比例」——// 而那个比例是能**事先算出来**的(解析页第 ③ 步)。//// ★ 最小的反例只有一个数:// 输入 `1` / `-1` ⇒ 它输出 0,正确答案 -1。
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 200005;static int a[MAXN];
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) cin >> a[i];
int ans = 0, cur = 0; // ⚠ 就是这里 for (int i = 0; i < n; i++) { cur += a[i]; if (cur > ans) ans = cur; if (cur < 0) cur = 0; }
cout << ans << "\n"; return 0;}点「运行 ▶」看结果
「和变成负数就丢掉,从头再来」——这套写法本身没问题。
问题在 ans 的起手值是 0:它等于偷偷允许了「一段都不选」。
样例过(4)。而最小的反例只要一个数:
| 输入 | 正确答案 | ⚠ 它输出 |
|---|---|---|
1 / -1 |
-1 |
★ 0 |
3★★★ 抓获率不是概率 —— 它是一个能数出来的计数
// 数据生成器(P1115 对拍用):`./p1115Gen <seed> [level] [n]`//// level 0(默认)★ **n ≤ 10**,aᵢ ∈ [-10⁴, 10⁴] —— 抓获率能**先算出来**的那一档// level 1 n ≤ 200,同样的值域 —— 只把 n 放宽,抓获率就塌了// level 2 ★ **全是负数**(aᵢ ∈ [-10⁴, -1])—— 触发线那一档,300/300// level 3 n = 1(最小的反例:`1` / `-1` 就够)// level 4 顶格 n(第三个参数,默认 2×10⁵)//// ★ 这道题的 bug 触发条件是一个**开关**:max(a) < 0。// ⇒ 抓获率 = 这一档里「全负」的比例,而那个比例是算术:// level 0 里 n 均匀取 1..10、每个数为负的概率 p = 10000/20001 ≈ 0.49998// ⇒ P(全负) = (1/10) Σ_{n=1..10} pⁿ ≈ **9.99%** ⇒ 300 轮期望 **29.97** 次。// level 1 里 n 均匀取 1..200 ⇒ 掉到约 **0.5%**(期望 1.5 次)。// ⇒ 这一次是**先算后量**,不是量完再解释。
#include <bits/stdc++.h>using namespace std;static mt19937 rng;static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
int main(int argc, char** argv) { rng.seed((argc > 1) ? (unsigned)atoi(argv[1]) : 1); int level = (argc > 2) ? atoi(argv[2]) : 0;
int n; if (level == 1) n = ri(1, 200); else if (level == 3) n = 1; else if (level == 4) n = 200000; else n = ri(1, 10); if (argc > 3) n = atoi(argv[3]);
printf("%d\n", n); for (int i = 0; i < n; i++) { int v = (level == 2) ? ri(-10000, -1) : ri(-10000, 10000); printf("%d%c", v, i + 1 == n ? '\n' : ' '); } return 0;}点「运行 ▶」看结果
这个 bug 的触发条件是一个开关(max(a) < 0),不是一个「碰运气」。
所以在动手对拍之前,抓获率就已经能算出来了:
level 0 里 n 均匀取 1..10、每个数为负的概率 p = 10000/20001 ≈ 0.49998
⇒ P(全负) = (1/10) Σ pⁿ ≈ 9.99% ⇒ 300 轮的期望是 29.97 次。
四档 × 300 轮,实测:
| 档 | n |
值域 | 这 300 组里「全负」的有几组 | ⚠ p1115Zero 被抓 |
|---|---|---|---|---|
| level 0 | 1~10 | [-10⁴, 10⁴] |
38 | 38 / 300 |
| level 1 | 1~200 | [-10⁴, 10⁴] |
1 | 1 / 300 |
| level 2 ★ 全负 | 1~10 | [-10⁴, -1] |
300 | 300 / 300 |
| level 3 | n = 1 |
[-10⁴, 10⁴] |
130 | 130 / 300 |
「被抓的轮数」和「满足触发条件的轮数」四格全等,一个不多一个不少。
⇒ 这不是巧合,是这类 bug 的本性:抓获数根本不是一个统计量,它是一个计数。 把生成器造的那 300 组数据拿出来,数一数有几组满足触发条件,那就是抓获数。
★ 于是有一条马上能用的操作:
对拍抓不到的时候,别急着加轮数 —— 先去数一数你这 300 组里有几组满足触发条件。 如果是 0,那么加到 3000 轮、30000 轮,还是 0。
(这也是第 5 章 P1042「概率低 vs 结构上不可能」那条的度量方式: 「结构上不可能」就是这个计数恰好为 0。)
算术给的是期望,实测这 300 个种子恰好造出 38 组全负 —— 涨落而已(标准差约 5.2)。
⇒ 两件事都要做,但用途不同:
算术定量级(「这一档大概能抓到一成」还是「一次都抓不到」),
计数定精确值(断言里写的必须是这一个)。
⚠ 而断言里绝不能写算出来的那个期望 —— 这本书上一次栽在这上头是
第 10 章 P1104:推了个 49,实测 30,check:viz 第一次跑就红。
4★★ 同一个 bug 换一副身体:分治版
本章题单点名要拿这道题练分治的「跨越中点」。先看写对的那版:
// P1115 第 ③ 版:分治 —— 本章题单点名要练的那一版//// 「跨越中点的那部分怎么算」才是分治题的真正内容(本章正文第 ⑧ 步)://// 最大子段和 = max( 全在左边的, 全在右边的, **跨过中点的** )// ↑递归 ↑递归 ↑左半边的最大后缀 + 右半边的最大前缀//// ⚠ 而「最大后缀 / 最大前缀」这两个量**必须非空** ——// 它们各自至少要含一个元素,跨中点那一段才真的跨过了中点。// 写成「允许为空(即下限 0)」就退化成 p1115DivideBad.cpp,// 而那和第 ① 版的 `ans = 0` **是同一个 bug 的另一副身体**。//// 一次递归返回四个量:整段和、最大前缀、最大后缀、区间内最大子段和。
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 200005;static int a[MAXN];
struct Node { long long sum, pre, suf, best; };
static Node solve(int l, int r) { if (l == r) return {a[l], a[l], a[l], a[l]}; // ★ 单个元素:四个量都是 a[l] int mid = l + (r - l) / 2; Node L = solve(l, mid), R = solve(mid + 1, r); Node c; c.sum = L.sum + R.sum; c.pre = max(L.pre, L.sum + R.pre); // 前缀:要么只在左边,要么吃掉整个左边 c.suf = max(R.suf, R.sum + L.suf); c.best = max(max(L.best, R.best), L.suf + R.pre); // ★ 跨中点:左最大后缀 + 右最大前缀 return c;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) cin >> a[i];
cout << solve(0, n - 1).best << "\n"; return 0;}点「运行 ▶」看结果
一次递归返回四个量:整段和、最大前缀、最大后缀、区间内最大子段和。 跨中点那一段 = 左半边的最大后缀 + 右半边的最大前缀 —— 这就是这道题分治的全部内容。
// P1115 ⚠ 第 ③' 版:分治,但「最大前缀 / 最大后缀」允许为空//// 和 p1115Divide.cpp 的差别只有两处 `max(0LL, …)` —— 看起来像是「顺手加个保护」。//// ★ 而它和第 ① 版 `ans = 0` **是同一个 bug**:// 允许前缀 / 后缀为空 ⇒ 跨中点那一段可以是「空 + 空 = 0」⇒ 又一次偷偷允许了「不选」。// 触发线一模一样:**所有数都是负数**。//// ⇒ 这一版留在这儿只为一句话:**换了实现不等于换了 bug。**// 两处代码长得毫不相干,`p1115Zero` 是在主循环里错的、它是在合并那一步错的,// 而它们在同一档数据上一起翻车、在别的档上一起正确 —— 逐字节相同。
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 200005;static int a[MAXN];
struct Node { long long sum, pre, suf, best; };
static Node solve(int l, int r) { if (l == r) return {a[l], max(0LL, (long long)a[l]), max(0LL, (long long)a[l]), a[l]}; int mid = l + (r - l) / 2; Node L = solve(l, mid), R = solve(mid + 1, r); Node c; c.sum = L.sum + R.sum; c.pre = max(0LL, max(L.pre, L.sum + R.pre)); // ⚠ 就是这个 0 c.suf = max(0LL, max(R.suf, R.sum + L.suf)); // ⚠ 和这个 c.best = max(max(L.best, R.best), L.suf + R.pre); return c;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int n; if (!(cin >> n)) return 0; for (int i = 0; i < n; i++) cin >> a[i];
cout << solve(0, n - 1).best << "\n"; return 0;}点「运行 ▶」看结果
差别只有两处 max(0LL, …),看起来像是「顺手加个保护」。
允许前缀 / 后缀为空 ⇒ 跨中点那一段可以是「空 + 空 = 0」⇒ 又一次偷偷允许了「不选」。
p1115Zero 错在主循环的起手值上,p1115DivideBad 错在合并那一步上 ——
两段代码没有一行是像的,可它们:
p1115Zero |
p1115DivideBad |
|
|---|---|---|
level 0(n ≤ 10) |
38 / 300 | 17 / 300 |
| level 2(全负) | 300 / 300 | 266 / 300 |
level 3(n = 1) |
130 / 300 | ★ 0 / 300 |
⇒ 换了实现不等于换了 bug。
★ 而那三处差额全部来自同一件事:分治版在 n = 1 上不触发
(只有一个元素时根本没有「跨中点」这一步)。
所以它的触发线比另一版窄一个 n = 1:
level 2 那 300 组里恰好有 34 组是 n = 1,300 - 34 = 266;
level 0 那 38 组全负里有 21 组是 n = 1,38 - 21 = 17。又是精确对得上的计数。
5★ 换两把尺子:分治真的是 O(n log n) 吗
// P1115 换两把尺子:扫描 vs 分治,以及「暴力能拿多少分」这笔账//// 用法:./p1115Count [n] 人话版(默认 n = 2×10⁵,题面顶格)// ./p1115Count [n] csv 给 check:viz 用//// 三件事:// ① ★ **「分治求最大子段和是 O(n log n)」这句话,对写法有要求。**// 每次合并都现扫一遍求最大前后缀,才是 O(n log n);// 像 p1115Divide.cpp 那样**把四个量一起返回**,合并就是 O(1) ——// 一共 2n-1 次调用,总复杂度其实是 **O(n)**,和扫描版同一档。// ② 同一档复杂度,**常数不一样**:递归调用 + 栈 + 访存模式,实测差几倍。// ③ O(n²) 暴力在 40% 那一档(n ≤ 2×10³)真跑一次,再外推到顶格 ——// 「先写个暴力拿 40 分」是一道**算术题**。
#include <bits/stdc++.h>#include <chrono>using namespace std;using namespace std::chrono;
static const int MAXN = 200005;static int a[MAXN];static long long calls = 0, maxDepth = 0;
struct Node { long long sum, pre, suf, best; };static Node solve(int l, int r, long long depth) { calls++; maxDepth = max(maxDepth, depth); if (l == r) return {a[l], a[l], a[l], a[l]}; int mid = l + (r - l) / 2; Node L = solve(l, mid, depth + 1), R = solve(mid + 1, r, depth + 1); Node c; c.sum = L.sum + R.sum; c.pre = max(L.pre, L.sum + R.pre); c.suf = max(R.suf, R.sum + L.suf); c.best = max(max(L.best, R.best), L.suf + R.pre); return c;}
int main(int argc, char** argv) { int n = (argc > 1) ? atoi(argv[1]) : 200000; bool csv = (argc > 2 && string(argv[2]) == "csv");
mt19937 rng(20260827u); for (int i = 0; i < n; i++) a[i] = (int)(rng() % 20001u) - 10000;
auto t0 = steady_clock::now(); long long ansScan = a[0], cur = a[0]; for (int i = 1; i < n; i++) { cur = max((long long)a[i], cur + a[i]); ansScan = max(ansScan, cur); } double msScan = duration<double, milli>(steady_clock::now() - t0).count();
t0 = steady_clock::now(); long long ansDiv = solve(0, n - 1, 1).best; double msDiv = duration<double, milli>(steady_clock::now() - t0).count();
/* ③ O(n²) 在 40% 那一档上真跑 */ const int NSMALL = 2000; static long long pre[MAXN]; for (int i = 0; i < NSMALL; i++) pre[i + 1] = pre[i] + a[i]; t0 = steady_clock::now(); long long ansSq = LLONG_MIN; for (int i = 0; i < NSMALL; i++) for (int j = i; j < NSMALL; j++) ansSq = max(ansSq, pre[j + 1] - pre[i]); double msSq = duration<double, milli>(steady_clock::now() - t0).count();
long long ansSmall = a[0], curS = a[0]; // 同一段前缀,扫描版的答案(拿来对一下) for (int i = 1; i < NSMALL; i++) { curS = max((long long)a[i], curS + a[i]); ansSmall = max(ansSmall, curS); }
double sqOpsSmall = (double)NSMALL * (NSMALL + 1) / 2; double sqOpsFull = (double)n * (n + 1) / 2; double sqSecFull = msSq / sqOpsSmall * sqOpsFull / 1000.0;
/* int 够不够:这道题的和最大 2×10⁵ × 10⁴ */ long long worstSum = (long long)n * 10000; double headroom = (2147483647.0 - (double)worstSum) / 2147483647.0 * 100.0;
if (csv) { printf("n,%d\nsame,%d\n", n, (ansScan == ansDiv) ? 1 : 0); printf("calls,%lld\nexpectCalls,%d\ndepth,%lld\n", calls, 2 * n - 1, maxDepth); printf("divSlower,%d\n", (msDiv > msScan) ? 1 : 0); printf("sqOk,%d\nsqFitsInSubtask,%d\n", (ansSq == ansSmall) ? 1 : 0, (msSq < 100.0) ? 1 : 0); printf("sqFullSecGE,%d\n", (int)sqSecFull); printf("worstSum,%lld\nintEnough,%d\nheadroomPct,%d\n", worstSum, (worstSum <= 2147483647LL) ? 1 : 0, (int)headroom); return 0; }
printf("n = %d(题面顶格),随机数据:\n\n", n); printf(" 扫描 O(n) %7.2f 毫秒 n = %d 次更新\n", msScan, n); printf(" 分治 %7.2f 毫秒 %lld 次递归调用(= 2n-1,%s),最深 %lld 层\n", msDiv, calls, (calls == 2LL * n - 1) ? "对上了" : "对不上", maxDepth); printf(" 两边答案 %lld %s\n\n", ansScan, (ansScan == ansDiv) ? "一致" : "居然不一样!"); printf(" ⇒ ★ 合并写成 O(1) 之后,分治其实也是 **O(n)** ——\n"); printf(" 「分治是 O(n log n)」说的是每次合并现扫一遍的那种写法。\n"); printf(" 同一档复杂度,分治慢 %.1f 倍:递归调用 + 栈 + 访存都要钱。\n\n", msDiv / max(0.001, msScan)); printf(" O(n²) 暴力:n = %d(40%% 那一档)%.2f 毫秒(答案 %lld,%s)⇒ 稳过\n", NSMALL, msSq, ansSq, (ansSq == ansSmall) ? "和扫描版在同一段前缀上一致" : "居然不一样!"); printf(" n = %d(顶格)按同一速率外推 **约 %.0f 秒** ⇒ 时限 1 秒,没戏\n\n", n, sqSecFull); printf(" ⇒ 「先写个暴力拿 40 分」不是安慰奖:部分分那一栏给的 n,\n"); printf(" 就是出题人替你算好的「暴力能跑到哪儿」。\n\n"); printf(" 顺带:和最大 %d × 10⁴ = %lld,int 上限 2147483647 ⇒ %s,余量只有 %.1f%%。\n", n, worstSum, (worstSum <= 2147483647LL) ? "刚好够" : "不够", headroom); return 0;}点「运行 ▶」看结果
那句话说的是每次合并都现扫一遍求最大前后缀的写法(每层 O(n),共 log n 层)。
而上面那份把四个量一起返回了,合并就是 O(1) ——
n = 2×10⁵ 时一共 399 999 次递归调用(正好 2n-1),最深 19 层。
⇒ 它其实是 O(n),和扫描版同一档。
同一档复杂度,实测分治仍然慢 5 倍上下(0.19 毫秒 vs 0.88 毫秒): 递归调用、栈、访存模式都要钱。 ⇒ 第 7 章 P1873 那条的邻居:那道题是「复杂度更优的反而慢」, 这道题是「复杂度一样,常数差五倍」。
① 暴力能拿多少分,是算得出来的。
O(n²) 在 n = 2×10³(题面写的 40% 那一档)实测 1.0 毫秒,稳过;
按同一速率外推到顶格 n = 2×10⁵ 是约 10 秒,时限 1 秒。
⇒ 部分分那一栏给的 n,就是出题人替你算好的「暴力能跑到哪儿」。
② 这道题要不要开 long long?不用 —— 而这是算出来的,不是感觉出来的。
和最大 2×10⁵ × 10⁴ = 2 000 000 000,int 上限 2 147 483 647
⇒ 只差 1.47 × 10⁸,余量约 7%。
⚠ 和同一章的 P1908 正好两个方向:那道题差 58 倍,必须开。
⇒ 「保险起见都开 long long」不算理由;算一下只要三十秒。
6一张总表
| 版本 | 做法 | 样例 | n=1, a=-1 |
全负 300 轮 | 顶格 | 结果 |
|---|---|---|---|---|---|---|
① p1115Zero |
扫描,ans = 0 起手 |
✓ 4 | ✗ 0 | ✗ 300/300 被抓 | 0.01 秒 | ✗ WA |
② p1115Sq |
前缀和枚举两端 O(n²) |
✓ 4 | ✓ | ✓ | 外推 10 秒 | ✗ TLE(40 分) |
③ p1115DivideBad |
分治,前后缀允许空 | ✓ 4 | ★ ✓ -1 | ✗ 266/300 被抓 | 0.01 秒 | ✗ WA |
④ p1115Divide |
分治,前后缀非空 | ✓ 4 | ✓ | ✓ | 0.01 秒 | ★ AC |
⑤ p1115 |
扫描,ans = a[0] 起手 |
✓ 4 | ✓ | ✓ | 0.01 秒 | ★ AC |
- ★★★ 对拍的抓获数不是概率,是一个计数。 四档 × 两个 bug 共八个格子,「被抓轮数」和「满足触发条件的轮数」一个不差。 ⇒ 抓不到的时候别加轮数,去数一数你那 300 组里有几组满足触发条件 —— 是 0 就永远是 0。 ⚠ 而算出来的期望不能写进断言:我先算的是 29.97,实测是 38。
- ★★★ 这道题唯一的一条 bug 线,是题面里的两个字(「连续且非空」)。
删掉那两个字,错的写法就是对的。最小的反例只要一个数:
n = 1, a = [-1]。 - ★★ 换了实现不等于换了 bug。
扫描版的
ans = 0和分治版的「前后缀允许为空」是同一件事的两副身体 —— 两段代码没有一行是像的,触发线只差一个n = 1。