阶段 9 · 补课 · 第 45 章普及组 J

复杂度估算与考场策略

★ 关键一步是把「1 秒 ≈ 10⁸ 次基本操作」这句口诀自己量出来 —— 实测五档差了 80 多倍,所以它不是一个数,是一个范围,落在哪一头由「你在循环里干了什么」决定。⚠ 这一章教的是判断,不是算法。

需要先学:第 12 章 分治进阶第 21 章 DP 入门:从记忆化到递推例题:最大子段和(n ≤ 2×10⁵),四种复杂度同台建议用时:120 分钟
这一章和前面 44 章都不一样:它教的是判断,不是算法

前面每一章都是「一句话问题 → 暴力 → 正解」,学完你会多一个算法。 这一章学完你不会多任何算法 —— 你会多一个考场上每道题都要做一次的判断

看一眼数据范围,决定「往哪个复杂度去想」,在动手写之前

它值得单独一章,因为这个判断做错的代价是最大的:方向错了,代码写得再对也是 0 分。

★ 这一章的做法是拿一道题贯穿到底:最大子段和。 它有四种写法,复杂度分别是 O(n³)O(n²)O(n log n)O(n), 四份都短到能一眼读完,答案还能逐字节对拍 —— 四条曲线同台,就是这一章要讲的全部东西。 (顺带还第 12 章分治那一笔账:那一章把这道题留在了练习里。)

1一句话问题

给一个长度为 n 的整数序列 a[1..n]−10⁹ ≤ a[i] ≤ 10⁹n ≤ 2×10⁵), 在所有非空的连续子段里,求和最大的那个和。

输入第一行 n,第二行 n 个整数;输出一个数。⚠ 时限 1 秒。

输入

6
-2 11 -4 13 -5 -2

输出

20

最大的那一段是 11 −4 13,和是 20。

⚠ 注意它不是「把所有正数加起来」(那样得 24):子段必须连续, 要拿到 11 和 13,中间那个 −4 就得一起吃下。

输入

4
-3 -1 -4 -1

输出

-1

★ 第二组样例是专门放在这儿的:全是负数

子段非空,所以答案是最大的那个负数 −1,不是 0。 这一行差别后面会反复出现 —— 它是这道题最经典的那个错。

⚠ 这一章故意只问一个数,代价要说清楚

按第 35 章那条「题面多问一句,对拍就多一条腿」,本来还该让它输出子段的左右端点。 这一章没有那么做,理由是四份做法必须摆在一起比,多一问会让分治那份多出十几行和主题无关的代码。

⇒ 代价是对拍少了一条腿(只比一个数,位置错了看不见)。这笔账写在这儿,不粉饰。

2动手之前,先估一遍

这是这一章唯一要你养成的习惯:看到范围先算一遍,别急着写。

最直白的做法是把所有子段都试一遍。子段由左右端点决定,一共 n(n+1)/2 个; 每个再从头加一遍,平均长度 n/3 左右 —— 合起来是 O(n³) 量级。

n = 2×10⁵ 时,n³ = 8×10¹⁵

★ 8×10¹⁵ 是什么概念

先按最粗的那句口诀算:一秒 10⁸ 次基本操作。

8×10¹⁵ ÷ 10⁸ = 8×10⁷ 秒 ≈ 2.5 年

⇒ 不用写,不用跑,不用等评测机 —— 这个方向当场就死了。 这一步只花了十秒钟,而它省下的是一整场考试里最贵的东西:时间。

⚠ 但「口诀说一秒 10⁸ 次」这句话本身可信吗?第 5 步会自己去量。先把两个暴力写出来,看它们真的有多慢。

3暴力一:三重循环,O(n³)

brute3.cppO(n³):枚举左右端点,再把这一段加一遍
// 暴力一:枚举左右端点,再老老实实把这一段加一遍 —— O(n³)
//
// 它是「一句话问题」最直白的翻译:题目说「所有连续子段里最大的那个和」,
// 那就把所有连续子段列出来,每个都算一遍和。三重循环,一个字都不多。
//
// ★ 这一章要拿它当量尺:它是四份里唯一一个「n 只能到几百」的做法。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
long long best = LLONG_MIN;
for (int l = 1; l <= n; l++)
for (int r = l; r <= n; r++) {
long long s = 0;
for (int k = l; k <= r; k++) s += a[k]; // ← 每次都从头加一遍
best = max(best, s);
}
cout << best << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测(数据来自 ./genBig <n> 1,见下面那份生成器):

n 耗时 和上一行比
500 0.006 秒
1000 0.039 秒 ×6.5
2000 0.309 秒 ×7.9
4000 2.518 秒 ×8.2
★ 「n 翻倍,时间乘八」—— 这就是立方在秒表上的样子

2³ = 8。这张表最有用的不是那几个秒数,是最右边那一列: 它一直是 8 左右,说明这份代码的确是 O(n³),而不是我以为它是。

⇒ 顺着往下推:n = 4000 要 2.5 秒,n = 2×10⁵ 是它的 50 倍, 时间是 50³ = 125000 倍 = 3.6 天。 (比第 2 步用口诀估的 2.5 年少了两个数量级 —— 为什么会差这么多,正是第 5 步要讲的。)

4暴力二:省掉一重循环,O(n²)

上一份里那句「从头加一遍」是纯粹的重复劳动:算完 a[l..r] 的和,再算 a[l..r+1] 时只要加一个数。

brute2.cppO(n²):左端点固定,右端点边挪边加
// 暴力二:左端点固定住,右端点往右挪一格就加一个数 —— O(n²)
//
// 和 brute3.cpp 的差别只有一句:那份每次都把 a[l..r] 从头加一遍,
// 这份把上一次的和留着,`s += a[r]` 一句就接上了。
// ★ 三重循环变两重,代码却更短 —— **省掉的那一重,是重复劳动,不是必要的工作。**
//
// 这份在本章里还有第二个身份:第 10 步演示 TLE 用的就是它(n = 10⁵ 跑不完)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
long long best = LLONG_MIN;
for (int l = 1; l <= n; l++) {
long long s = 0;
for (int r = l; r <= n; r++) {
s += a[r]; // ← 上一次的和留着用
best = max(best, s);
}
}
cout << best << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测

n 耗时 和上一行比
10 000 0.025 秒
50 000 0.602 秒 ×24(n ×5,5² = 25
200 000 9.754 秒 ×16(n ×4,4² = 16

⇒ 题面给的上限 n = 2×10⁵、时限 1 秒 —— 它慢了将近十倍,是 TLE。

★ 但这一份不是白写的:它值分

O(n²) 能过 n ≤ 10⁴ 那几档数据(0.025 秒,绰绰有余)。 真实的题目几乎总是分档给分的,而 O(n²) 通常就是「一半分」的那一档。 第 13 步会把这件事算成具体的分数。

5★ 关键的一步:把「1 秒 ≈ 10⁸ 次」自己量出来

到这儿为止,所有估算都建立在一句口诀上:一秒钟大约能做 10⁸ 次基本操作。 这本书的立场一向是「口诀要拿实测复核」(第 29、32、33、34、41 章各犯过一次), 所以这一章不背那个数,现场量

ops.cpp★ 五个内核,各量一遍「一秒能做多少次」
五个循环体只差一次操作:顺序加法 / 乘法 / 对常数取模 / 对变量取模 / 随机访问一个 64 MB 的数组。跑一次要一秒多。
// ★ 这一章的关键一步:把「1 秒 ≈ 10⁸ 次基本操作」这句口诀自己量出来
//
// 这句口诀几乎每本竞赛书都写,可它到底准不准、准到什么程度,没人量给你看。
// 本书的立场一向是「口诀要拿实测复核」(第 29、32、33、34、41 章各犯过一次)——
// 所以这一章不背那个数,现场量。
//
// 五个内核,每个都是「循环里做一次基本操作」,只有那一次操作不同:
// add x += a[i] 顺序读一个 int,加一次
// mul x += a[i] * a[i] 多一次乘法
// mod x += a[i] % 7 对**常数**取模 —— ⚠ 编译器会把它换成乘法+移位,并不是真除法
// modv x += a[i] % d 对**变量**取模 —— 这才是真的整数除法指令
// rnd x += big[j],j 每次跳到一个伪随机位置(64 MB 的数组)—— 几乎每次都 cache miss
//
// ⇒ 五个数会差**一个数量级以上**。那正是这一章最该讲的:
// **「10⁸」不是一个数,是一个范围,落在哪一头由「你在循环里干了什么」决定。**
//
// ⚠ 两处防编译器的写法,缺一不可(否则量到的是 0 秒):
// ① 每一轮的结果累加进 volatile sink —— 不然整个循环会被当成死代码删掉;
// ② modv 的除数 d 从 argv 里来(编译期不知道),不然它和 mod 一样会被换成乘法。
//
// 用法:./ops [每个内核的目标毫秒数,默认 250] [取模的除数,默认 7] [csv]
// 带 csv 就只打 `键,每秒次数`,给 check:viz 用。
#include <bits/stdc++.h>
#include <chrono>
using namespace std;
using namespace std::chrono;
static const int N = 1 << 16; // 256 KB:稳稳落在 L2 里,量的是纯计算
static const int BIG = 1 << 24; // 64 MB:远大于任何一级缓存,量的是 cache miss
static vector<int> a, big;
static volatile long long sink = 0;
// 按显示宽度补空格:ASCII 算 1 格,汉字算 2 格(printf 的 %-Ns 数的是字节,中文列会歪)
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
/** 同上,但补在左边(右对齐用) */
static string padLeftDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return string(max(0, width - disp), ' ') + s;
}
/** 跑 kernel,直到累计时间超过 targetMs,返回「每秒多少次基本操作」 */
template <class F>
double bench(F kernel, double targetMs) {
double ms = 0;
long long ops = 0;
auto t0 = steady_clock::now();
while (ms < targetMs) {
sink += kernel();
ops += N;
ms = duration<double, milli>(steady_clock::now() - t0).count();
}
return ops / (ms / 1000.0);
}
int main(int argc, char** argv) {
double targetMs = (argc > 1) ? atof(argv[1]) : 250.0;
int d = (argc > 2) ? atoi(argv[2]) : 7; // ← 编译期不知道它是 7
bool csv = (argc > 3 && string(argv[3]) == "csv");
if (targetMs < 1) targetMs = 1;
if (d < 2) d = 2;
a.resize(N);
for (int i = 0; i < N; i++) a[i] = (i * 37) % 1000 + 1;
big.assign(BIG, 1);
for (int i = 0; i < BIG; i++) big[i] = (i * 7) % 1000 + 1;
double vAdd = bench([&] {
long long x = 0;
for (int i = 0; i < N; i++) x += a[i];
return x;
}, targetMs);
double vMul = bench([&] {
long long x = 0;
for (int i = 0; i < N; i++) x += (long long)a[i] * a[i];
return x;
}, targetMs);
double vMod = bench([&] {
long long x = 0;
for (int i = 0; i < N; i++) x += a[i] % 7;
return x;
}, targetMs);
double vModV = bench([&] {
long long x = 0;
for (int i = 0; i < N; i++) x += a[i] % d;
return x;
}, targetMs);
unsigned j = 1;
double vRnd = bench([&] {
long long x = 0;
for (int i = 0; i < N; i++) {
j = j * 1103515245u + 12345u;
x += big[(j >> 4) & (BIG - 1)];
}
return x;
}, targetMs);
const char* name[5] = { "add x += a[i]", "mul x += a[i]*a[i]",
"mod x += a[i] % 7", "modv x += a[i] % d",
"rnd x += big[j] (j 乱跳)" };
double v[5] = { vAdd, vMul, vMod, vModV, vRnd };
if (csv) {
const char* key[5] = { "add", "mul", "mod", "modv", "rnd" };
for (int k = 0; k < 5; k++) printf("%s,%.4e\n", key[k], v[k]);
return 0;
}
printf("一秒钟能做多少次?(本机实测,每个内核跑约 %.0f 毫秒)\n\n", targetMs);
printf("%s %s %s %s\n", padDisp("内核", 30).c_str(), padLeftDisp("次/秒", 14).c_str(),
padLeftDisp("相当于", 11).c_str(), padLeftDisp("比 rnd 快", 12).c_str());
for (int k = 0; k < 5; k++) {
printf("%s %14.2e %8.1f 亿 %9.1f 倍\n", padDisp(name[k], 30).c_str(),
v[k], v[k] / 1e8, v[k] / vRnd);
}
printf("\n★ 最快的那行是最慢的 %.0f 倍 —— 「1 秒 ≈ 10^8 次」这句口诀,", vAdd / vRnd);
printf("说的是这个区间里的某一处,不是一个准数。\n");
printf("⚠ 秒数换台机器就变;**倍数**才是这张表要你记住的东西。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-08-25,独占):

循环里干的事 一秒能做 相当于 比最慢那档快
x += a[i](顺序读,能向量化) 8.2×10⁹ 82 亿次 82 倍
x += a[i] * a[i] 3.2×10⁹ 32 亿次 32 倍
x += a[i] % 7(对常数取模) 1.5×10⁹ 15 亿次 15 倍
x += a[i] % d(对变量取模) 7.0×10⁸ 7 亿次 7 倍
x += big[乱跳的下标](cache miss) 9.7×10⁷ 1.0 亿次 1 倍
★★ 「10⁸」不是一个数,是一个范围

最快那一档是最慢那一档的 80 多倍。而口诀里那个 10⁸, 正好落在最慢的那一头 —— 也就是「每次都要去内存里随便捞一个数」的那种循环。

⇒ 所以这句口诀应该这么用:

  • 循环体是顺序扫数组、只做加减:往 10⁹ 那头估,10⁸ 是保守了十倍;
  • 循环体里有取模、除法10⁸ ~ 10⁹ 之间;
  • 循环体在大数组里随机跳(哈希表、指针、链式前向星、堆):老老实实按 10⁸ 估。

★ 这也解释了第 3 步那个差距:口诀估 O(n³) 要 2.5 年,实测外推是 3.6 天,差了两个数量级 —— 因为 brute3 的内层循环正是「顺序扫、只做加法」那一档,它比 10⁸ 快了将近百倍。

⚠ 而 10⁸ 仍然是考场上该用的那个数:估算要往坏处估。 估宽了最多多写十分钟正解, 估窄了是当场 TLE。

⚠ 两个坑,都写在 ops.cpp 的文件头里
  • 空循环量不出来for (i…) x += 1; 会被 -O2 直接算成闭式,一步都不跑。 所以每个内核的结果都要用掉(累加进一个 volatile 变量),否则量到的是 0 秒。
  • 同一个 %,对常数取模比对变量取模快一倍:编译器把「除以 7」换成了乘法加移位, 而除数是变量时只能用真正的除法指令。 ⇒ 同一句代码的快慢,取决于编译器知道多少。 这一条在取模特别多的题里(比如第 43 章)值不少时间。

6那张对照表:n 多大,该往哪个复杂度想

有了上一步那把尺子,考场上真正要用的东西就出来了。 按 1 秒时限、10⁸ 次/秒(保守的那一头)估:

n 的范围 能接受的复杂度 典型算法
n ≤ 10 O(n!)O(2ⁿ × n) 全排列、暴力搜索(第 3、4 章)
n ≤ 20 O(2ⁿ) 子集枚举、状压 DP(第 28 章)
n ≤ 100 O(n³) Floyd(第 33 章)、区间 DP(第 26 章)
n ≤ 1000 O(n²) 朴素 DP、O(n²) 的 LIS(第 22 章)
n ≤ 10⁵ O(n log n) 排序、二分、堆、并查集、Dijkstra
n ≤ 10⁶ O(n)O(n log log n) 双指针、递推、线性筛(第 41 章)
n ≥ 10⁷ O(n),而且要小心读入 ★ 这时候读入本身就是瓶颈,见第 9 步
⚠ 这张表只能当估算,三条理由都要知道
  1. 常数不在表里。 同样是 O(n log n)sort 和「每次 new 一个节点的线段树」能差五倍。 上一步那五档差 80 倍,就是常数的样子。
  2. log 很小。 log₂(10⁶) ≈ 20 —— 所以 O(n log n)O(n) 在考场上通常同一档, 为了把 log 去掉而写一个复杂十倍的算法,多半不划算。
  3. 它是「往哪儿想」,不是「一定能过」。 表说 n ≤ 10⁵ 该往 O(n log n) 想, 但真写出来还得看常数、看内存、看读入。 ⇒ ★ 表给方向,实测给结论。
★ 反过来用这张表,才是它最值钱的用法

考场上更常见的动作是从范围倒推算法

n = 2×10⁵ ⇒ 允许 O(n log n) ⇒ 这道题多半要「排序 / 二分 / 堆 / 某种数据结构」; n = 20 ⇒ 允许 O(2ⁿ) ⇒ 出题人是在明示「就是要你暴力枚举子集」。

数据范围是出题人留给你的提示,而且是免费的。不看白不看。

7正解:一遍扫过去,O(n)

回到题目。要把 O(n²) 降到 O(n),得换一个问法 —— 第 21 章那个动作:

不去问「哪一段最大」,而是问:a[i] 结尾的子段里,最大的和是多少?(记作 cur

a[i] 结尾的段只有两种:接着「以 a[i-1] 结尾的最好那段」往后长,或者从 a[i] 自己重新开始。

★ 关键的一行
cur = max(a[i], cur + a[i])
        |        |
        |        +-- 接在前面那一段后面
        +-- 前面那段拖后腿,扔掉,从我自己重新开始

⚠ 两个初值都是 a[1],不是 0 —— 子段非空。这一处就是第 11 步 wrongZero.cpp 的全部内容, 而第一个样例挡不住它(两种写法都输出 20)。

fast.cpp正解:O(n),一遍扫完
// 正解:一遍扫过去,O(n)
//
// 这一章的主角不是这个算法本身(第 21 章那条「以 i 结尾」的递推你已经见过),
// 而是它和另外三份做法**摆在一起**的样子:四份的答案逐字节相同,
// 差别全在「n 大到什么程度还跑得完」。
//
// 递推那一句只有一个决定:
// 以 a[i] 结尾的最大子段和 cur = max(a[i], cur + a[i])
// ↑ 从我自己重新开始 ↑ 接在前面那一段后面
// ⚠ 两个初值都必须是 a[1],不是 0 —— 子段**非空**,全是负数时答案就是最大的那个负数。
// 这一处正是本章 wrongZero.cpp 的全部内容,而第一个样例挡不住它。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<long long> a(n + 1); // 1 基下标:a[1..n]
for (int i = 1; i <= n; i++) cin >> a[i];
long long best = a[1], cur = a[1]; // ⚠ 不是 0
for (int i = 2; i <= n; i++) {
cur = max(a[i], cur + a[i]);
best = max(best, cur);
}
cout << best << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

8动画一:看它一格一格扫过去

盯住「从我自己重新开始」的那一步 —— 蓝色的那一段会当场断掉重来。 ★ 把上面的数组换成 -3 -1 -4 -1 再看一眼:答案是 −1,不是 0。

一遍扫过去:每一步只有两个候选
第 1 / 7 步
下标 i
1
2
3
4
5
6
a[i]
-2
11
-4
13
-5
-2
cur(以 a[1] 结尾的最大和)= -2
best(目前的答案)= -2 a[1..1]
■ 当前这一格 ■ 目前最好的那一段 ■ cur 正挂着的那一段
起手:cur = best = a[1] = -2。⚠ 两个初值都不是 0 —— 子段非空,全是负数时答案就是最大的那个负数。

9四条曲线同台:换一把尺子,数次数

秒表有个毛病:换台机器就变。次数不变。所以把四份做法放在一起比的时候,数的是 「往和里加一个数」这个动作各做了多少次 —— 四份都有它,才可比。

count.cpp在算法里放计数器,真数一遍
四份做法各跑一遍,数「往和里加一个数」做了几次;再和公式对一遍。对不上就说明我对自己的代码理解错了。
// 换一把尺子:数「基本操作」做了多少次
//
// 秒表能告诉你「这台机器上要跑多久」,但它换台机器就变。**次数不变。**
// 所以这一章的主表用的是次数,秒数只在需要「量级感」的地方出现。
//
// ⚠ 这里的次数全是**真数出来的**(在算法里放计数器),不是套公式印出来的 ——
// 然后再和公式对一遍。两者对不上,说明我对自己写的代码理解错了。
//
// 数的是同一件事:**「往和里加一个数」这个动作做了几次**。四份做法都有它,才可比。
// O(n³) s += a[k] n(n+1)(n+2)/6 次
// O(n²) s += a[r] n(n+1)/2 次
// O(n log n) 跨中线那两个 for n 是 2 的幂时正好 n·log₂n 次
// O(n) cur + a[i] n−1 次
//
// 用法:./count 打一张人看的表(n = 8 / 50 / 200 / 1000)
// ./count <n> 只打这一个 n
// ./count <n> csv 只打 `键,值`,给 check:viz 用
// ⚠ n > 3000 时 O(n³) 那一列不真跑(真跑要几十秒),打 -1 —— 这一章正文里那一列
// 到 n = 1000 为止,再往上是**按公式外推**的,而正文会把这句话写出来。
#include <bits/stdc++.h>
using namespace std;
static long long c3, c2, cdc, cf;
/** 一份固定的数据 —— 这四个计数和数据长什么样无关(循环边界都不看数值),所以随便造 */
static vector<long long> mk(int n) {
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) a[i] = (i % 7) - 3;
return a;
}
void run3(const vector<long long>& a, int n) {
long long best = LLONG_MIN;
for (int l = 1; l <= n; l++)
for (int r = l; r <= n; r++) {
long long s = 0;
for (int k = l; k <= r; k++) { s += a[k]; c3++; }
best = max(best, s);
}
(void)best;
}
void run2(const vector<long long>& a, int n) {
long long best = LLONG_MIN;
for (int l = 1; l <= n; l++) {
long long s = 0;
for (int r = l; r <= n; r++) { s += a[r]; c2++; best = max(best, s); }
}
(void)best;
}
long long runDc(const vector<long long>& a, int l, int r) {
if (l == r) return a[l];
int mid = (l + r) / 2;
long long best = max(runDc(a, l, mid), runDc(a, mid + 1, r));
long long s = 0, lb = LLONG_MIN;
for (int i = mid; i >= l; i--) { s += a[i]; cdc++; lb = max(lb, s); }
s = 0;
long long rb = LLONG_MIN;
for (int i = mid + 1; i <= r; i++) { s += a[i]; cdc++; rb = max(rb, s); }
return max(best, lb + rb);
}
void runFast(const vector<long long>& a, int n) {
long long best = a[1], cur = a[1];
for (int i = 2; i <= n; i++) { cur = max(a[i], cur + a[i]); cf++; best = max(best, cur); }
(void)best;
}
/** 这一行的四个数;c3 在 n 太大时不真跑 */
static void measure(int n) {
c3 = c2 = cdc = cf = 0;
vector<long long> a = mk(n);
if (n <= 3000) run3(a, n); else c3 = -1;
run2(a, n);
runDc(a, 1, n);
runFast(a, n);
}
int main(int argc, char** argv) {
bool csv = (argc > 2 && string(argv[2]) == "csv");
if (argc > 1) {
int n = atoi(argv[1]);
if (n < 1) n = 1;
measure(n);
long long f3 = (long long)n * (n + 1) * (n + 2) / 6;
long long f2 = (long long)n * (n + 1) / 2;
long long fpow = -1;
if ((n & (n - 1)) == 0) { int k = 0; while ((1 << k) < n) k++; fpow = (long long)n * k; }
if (csv) {
printf("b3,%lld\nb2,%lld\ndc,%lld\nfast,%lld\n", c3, c2, cdc, cf);
printf("b3f,%lld\nb2f,%lld\ndcpow,%lld\nfastf,%d\n", f3, f2, fpow, n - 1);
} else {
printf("n = %d\n", n);
printf(" O(n^3) %15lld 公式 n(n+1)(n+2)/6 = %lld\n", c3, f3);
printf(" O(n^2) %15lld 公式 n(n+1)/2 = %lld\n", c2, f2);
printf(" O(n log n) %15lld n 是 2 的幂时 = n*log2(n) = %lld\n", cdc, fpow);
printf(" O(n) %15lld 公式 n-1 = %d\n", cf, n - 1);
}
return 0;
}
printf("「往和里加一个数」这个动作,各做了多少次\n\n");
printf("%8s %16s %14s %12s %8s\n", "n", "O(n^3)", "O(n^2)", "O(n log n)", "O(n)");
for (int n : {8, 50, 200, 1000}) {
measure(n);
printf("%8d %16lld %14lld %12lld %8lld\n", n, c3, c2, cdc, cf);
}
printf("\n每一列都和公式逐个对过:n(n+1)(n+2)/6 · n(n+1)/2 · (n=2^k 时 n*log2 n) · n-1\n");
printf("★ 注意最右边两列:n 翻 5 倍,O(n) 那列就翻 5 倍,O(n^3) 那列翻 125 倍。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
n O(n³) O(n²) O(n log n) O(n)
8 120 36 24 7
50 22 100 1 275 286 49
200 1 353 400 20 100 1 544 199
1000 167 167 000 500 500 9 976 999
★★ 「数出来的」和「算出来的」是同一个数
做法 公式 n = 1000
O(n³) n(n+1)(n+2)/6 167 167 000
O(n²) n(n+1)/2 500 500
O(n log n) n 是 2 的幂时正好 n·log₂n n = 1024 时 10 240 = 1024 × 10
O(n) n − 1 999

这四条公式在 check-viz.mjs 里逐个对过 —— 不是印上去的,是跑出来对上的。 (第 37 章那句话的又一次现场:证出来的常数和实测出来的常数是同一个数, 这种时刻要专门摆出来看。)

分治那一份O(n log n))顺带还第 12 章的账:把序列从中间劈开, 最大的那段要么整个在左半、要么整个在右半、要么跨过中线 —— 前两种递归,第三种从中线往两边各扫一趟。

divide.cppO(n log n):分治(第 12 章那套)

10动画二:★ 这一章真正的主角

每按一步 n 就跳一档,看四条横条各自怎么长。 ★ 上面那个下拉框能换「这台机器一秒能做多少次」—— 换一次,判决线就整体挪一格。 那正是第 5 步量出来的事。

同一道题,四种做法:n 一大,差的就不是一点半点
第 1 / 9 步
n = 8 (题面给的上限是 200,000)
O(n³) 三重循环
120
< 1 毫秒
O(n²) 两重循环
36
< 1 毫秒
O(n log n) 分治
24
< 1 毫秒
O(n) 递推
7
< 1 毫秒
条形是对数刻度 —— 不然 O(n) 那一条早就看不见了。 右边两列:按上面选的速度估出来的耗时,以及「1 秒的时限够不够」。
n = 8:四种做法的差别还看不出来 —— 这么小的数据,怎么写都是一瞬间。

本机实测的耗时表(A 机:WSL2 6.18 / 8 核 / 8 GB,2026-08-25,独占;数据来自 ./genBig <n> 1):

n O(n³) O(n²) O(n log n) O(n)
4 000 2.518 秒 0.005 秒
50 000 ≈ 1.4 小时(外推) 0.602 秒
200 000(题面上限) ≈ 3.6 天(外推) 9.754 秒 0.012 秒 0.009 秒
10 000 000 ≈ 6.7 小时(外推) 0.555 秒 0.425 秒
★ 这张表要读的是最后两列,不是第一列

n = 2×10⁵ 时,O(n²) 要 9.754 秒,O(n) 要 0.009 秒 —— 差 1000 倍, 而两份代码只差一重循环。

⚠ 而 O(n log n)O(n) 在同一个 n 上只差 0.003 秒(0.012 对 0.009)—— 正是第 6 步那条「log 很小,这两档考场上通常算同一档」。 ⇒ 会写 O(n log n) 就先写它,别为了去掉一个 log 冒险。

★ 标着「外推」的三个数不是跑出来的(真跑一次要三天),是拿同一列上一行的实测值 / 乘出来的。外推可以,但必须说明它是外推。

⚠⚠ 动画和这张表会打架 —— 而那正是第 5 步那件事的现场

动画默认按 10⁸ 次/秒估,于是它说 n = 2×10⁵O(n²)3.3 分钟O(n³)154 天; 上面这张表实测是 9.754 秒、外推是 3.6 天差了二十倍到四十倍。

⇒ 哪个错了?都没错,是那把尺子的分辨率就这么粗。 这两份代码的内层循环正是「顺序扫数组、只做加法」那一档 —— 第 5 步量出来它一秒能跑 82 亿次, 比 10⁸ 快了将近百倍。把动画上面的下拉框换成 8×10⁹,它给的就是 2.5 秒和 1.9 天, 和实测同一个量级了。

★ 所以这一章反复说的那句话,在这儿变成了一个可操作的动作: 估算只保证量级,而按 10⁸ 估是往坏处估。 估宽了最多多写十分钟正解,估窄了是当场 TLE。

11⚠ n 大到 10⁷ 之后:读入本身就是瓶颈

上面那张表最后一行藏着一件事:O(n) 的正解在 n = 10⁷ 上跑了 0.425 秒 —— 其中 0.325 秒花在读入上。 算法本身只用了 0.1 秒。

read.cpp同样读 10⁶ 个整数,四种写法
它自己造数据、自己写临时文件、再读四遍,不用喂输入。⚠ 四趟必须按这个顺序跑,理由写在文件头。
// 读入优化:同样是读 10⁶ 个整数,四种写法差多少
//
// ★ 这一节顺带还一笔账:`ios::sync_with_stdio(false)` 在全书 283 份代码里出现过,
// 而正文**一次都没解释过它是干什么的**。这里解释清楚,并且量给你看。
//
// cin(默认) —— C++ 的流和 C 的 stdio 默认是**同步**的:每读一个字符都要经过
// stdio 那一层,还要保证两边的缓冲不打架。慢就慢在这个「同步」上。
// cin + 关同步 —— 一句 ios::sync_with_stdio(false) 断开那层同步,cin 用自己的缓冲。
// ⚠ 代价:从此不许再和 scanf/printf 混用(第 26 章踩过:输出顺序会乱)。
// scanf —— 直接走 stdio,没有 C++ 流那层包装。
// 快读 —— 自己拿 fread 把整块字节读进来,手动拼数字。没有任何格式解析的开销。
//
// ⚠ 这四趟必须按这个顺序跑,不能换:关掉同步之后 cin 会自己预读一大块,
// 那之后再 freopen 换文件,cin 缓冲里剩的就是上一份文件的残渣。
// ⇒ 所以「同步开着」那一趟必须排在最前面。这条坑写在这儿,免得后来的人重排一次。
//
// 用法:./read [n] [csv] 默认 n = 1000000;带 csv 就只打 `键,毫秒`,给 check:viz 用
// 它自己造数据、自己写进一个临时文件、再 freopen 回 stdin 读四遍 —— 不需要喂输入。
#include <bits/stdc++.h>
#include <chrono>
using namespace std;
using namespace std::chrono;
static char path[64];
static void makeData(int n) {
snprintf(path, sizeof(path), "/tmp/read-bench-%d.txt", (int)getpid());
FILE* f = fopen(path, "w");
if (!f) { fprintf(stderr, "写不出临时文件 %s\n", path); exit(1); }
mt19937 rng(20260825u);
fprintf(f, "%d\n", n);
for (int i = 0; i < n; i++) fprintf(f, "%d%c", (int)(rng() % 1000000000u), i + 1 == n ? '\n' : ' ');
fclose(f);
}
/** 每一趟都从头 freopen 一次,读到的和是防作弊用的:四趟必须一模一样 */
static void reopen() {
if (!freopen(path, "r", stdin)) { fprintf(stderr, "读不回临时文件\n"); exit(1); }
}
static int readIntFast() { // 手写快读:只认非负整数和负号
static char buf[1 << 16];
static size_t len = 0, pos = 0;
auto gc = [&]() -> int {
if (pos == len) { len = fread(buf, 1, sizeof(buf), stdin); pos = 0; if (!len) return -1; }
return buf[pos++];
};
int c = gc();
while (c != -1 && (c < '0' || c > '9') && c != '-') c = gc();
int sgn = 1;
if (c == '-') { sgn = -1; c = gc(); }
long long x = 0;
while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = gc(); }
return (int)(sgn * x);
}
int main(int argc, char** argv) {
int n = (argc > 1) ? atoi(argv[1]) : 1000000;
bool csv = (argc > 2 && string(argv[2]) == "csv");
if (n < 1) n = 1;
makeData(n);
double ms[4];
long long sum[4];
/* ① cin,同步开着(默认)—— ⚠ 必须第一个跑,见文件头 */
{ reopen();
auto t0 = steady_clock::now();
int m, x; long long s = 0;
cin >> m;
for (int i = 0; i < m; i++) { cin >> x; s += x; }
ms[0] = duration<double, milli>(steady_clock::now() - t0).count(); sum[0] = s; }
/* ② cin + ios::sync_with_stdio(false) */
{ reopen();
auto t0 = steady_clock::now();
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m, x; long long s = 0;
cin >> m;
for (int i = 0; i < m; i++) { cin >> x; s += x; }
ms[1] = duration<double, milli>(steady_clock::now() - t0).count(); sum[1] = s; }
/* ③ scanf */
{ reopen();
auto t0 = steady_clock::now();
int m, x; long long s = 0;
if (scanf("%d", &m) != 1) m = 0;
for (int i = 0; i < m; i++) { if (scanf("%d", &x) != 1) break; s += x; }
ms[2] = duration<double, milli>(steady_clock::now() - t0).count(); sum[2] = s; }
/* ④ 手写快读(fread 整块读进来) */
{ reopen();
auto t0 = steady_clock::now();
int m = readIntFast(); long long s = 0;
for (int i = 0; i < m; i++) s += readIntFast();
ms[3] = duration<double, milli>(steady_clock::now() - t0).count(); sum[3] = s; }
remove(path);
const char* name[4] = { "cin(默认,同步开着)", "cin + sync_with_stdio(false)", "scanf", "手写快读(fread)" };
bool same = (sum[0] == sum[1] && sum[1] == sum[2] && sum[2] == sum[3]);
auto disp = [](const string& t) {
int d = 0;
for (unsigned char c : t) { if ((c & 0xC0) == 0x80) continue; d += (c < 0x80) ? 1 : 2; }
return d;
};
auto padR = [&](const string& t, int w) { return t + string(max(0, w - disp(t)), ' '); };
auto padL = [&](const string& t, int w) { return string(max(0, w - disp(t)), ' ') + t; };
if (csv) {
const char* key[4] = { "cin", "nosync", "scanf", "fast" };
for (int k = 0; k < 4; k++) printf("%s,%.3f\n", key[k], ms[k]);
printf("same,%d\nsum,%lld\n", same ? 1 : 0, sum[0]);
return 0;
}
printf("读 %d 个整数,四种写法(本机实测)\n\n", n);
printf(" %s %s %s\n", padR("写法", 30).c_str(), padL("毫秒", 8).c_str(),
padL("比最快的慢", 15).c_str());
double best = *min_element(ms, ms + 4);
for (int k = 0; k < 4; k++)
printf(" %s %8.1f %13.1f 倍\n", padR(name[k], 30).c_str(), ms[k], ms[k] / best);
printf("\n四趟读到的和%s(%lld)—— 这一行是防作弊的:读法能换,读到的东西不能变。\n",
same ? "完全一致" : "居然不一样,有 bug", sum[0]);
printf("⚠ 秒数换台机器就变,**倍数**才是要记住的东西。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测,读 10⁶ 个整数

写法 毫秒 比最快的慢
cin(默认,同步开着) 209 24 倍
scanf 44 5 倍
cinios::sync_with_stdio(false) 32 4 倍
手写快读(fread 整块读) 8.6 1 倍
★ 这一节还的是一笔旧账:sync_with_stdio 到底是什么

ios::sync_with_stdio(false) 在这本书的 307 份代码里出现过,正文一次都没解释过

C++ 的流和 C 的 stdio 默认是同步的:cin 读一个字符要经过 stdio 那一层, 还得保证两边的缓冲不打架。那句话就是把这层同步断开,让 cin 用自己的缓冲。

⚠ 代价:从此不许再和 scanf / printf 混用,两边各自缓冲,打印顺序会乱。 (第 26 章踩过:表格跑到结语后面去了。所以这本书里凡是拿 printf 打表的程序,一律不关同步。)

⚠ 顺带一次口诀复核:「scanf 一定比 cin 快」不成立

本机实测 scanf 44 毫秒,cin 关掉同步之后 32 毫秒 —— 关了同步的 cin 反而更快。

⇒ 结论要钉在理由上(第 39 章那条):慢的不是 cin,是那层同步。 ⚠ 但这个先后顺序换台机器可能会反过来(两者只差一点),所以断言里只钉了 「默认的 cin 最慢、手写快读最快」这两头 —— 那两头差 24 倍,稳得多。

12评测机的五种反馈,各配一份真能跑出来的代码

考场上你看到的不是「对/错」,是五个缩写。每一个都有它最常见的原因。

① CE(Compile Error,编译错误)—— 唯一一种「本机一跑就知道」的
main.cpp:6:13: error: expected ';' before 'vector'
    6 |     cin >> n
      |             ^
      |             ;
main.cpp:8:41: error: 'a' was not declared in this scope
main.cpp:9:13: error: 'a' was not declared in this scope

三条报错,真正的错只有第一条 —— 少了一个分号,后面两条全是它的余震。 ⇒ 永远从第一条 error 看起,改完再编译一次,后面的多半自己就没了。

⚠ CE 是唯一一种在自己电脑上一定能提前发现的错。交题前编译一次,这一档分就丢不了。 (另外两个常见原因:用了评测机不支持的语法标准;main 写成了别的名字。)

ce.cpp① CE:少一个分号
② WA(Wrong Answer,答案错误)—— 最常见,也最难查

本章的 wrongZero.cpp 就是一份标准的 WA:best 从 0 开始, 样例照过,只有「全是负数」的测试点会挂。

WA 的典型长相是「样例过得去、测试点过不去」 —— 因为样例通常是最普通的那一组。 ⇒ 查 WA 的第一件事不是读代码,是自己造一组极端数据: 全负、全正、n = 1、全相等、最大值、最小值。第 13、14 步就是在系统地干这件事。

wrongZero.cpp② WA:样例过,全负数挂
③ TLE(Time Limit Exceeded,超时)—— 这一章整章都在讲它

把上面 brute2.cppO(n²))喂给 n = 2×10⁵ 的数据:本机 9.754 秒,时限 1 秒。

TLE 不一定是算法错,也可能是常数太大或读入太慢(第 11 步)。 ⇒ 先按第 6 步那张表判断:复杂度本身就超了,换算法;复杂度对得上,再去抠常数。 ★ 顺序反过来是考场上最常见的浪费 —— 给一个注定超时的 O(n²) 加读入优化,一分都换不来。

同题对比:⚠ O(n²) vs ✓ O(n)
先跑 20000(两边都很快),再改成 200000 —— 左边那份会当场超时,右边纹丝不动。
⚠ O(n²)
✓ O(n)
④ MLE(Memory Limit Exceeded,内存超限)—— 一道除法就能提前算出来
类型 一个占几字节 256 MiB 能放几个
int 4 6700 万
long long / double 8 3350 万
bool 1(不是 1 位) 2.6 亿

⚠ 最常见的 MLE 不是「数组开大了一点」,是二维数组int f[10000][10000] 就是 400 MB。 ⇒ 看到二维 DP 先乘一遍,再决定要不要滚动数组(第 23 章)。

mle.cpp④ MLE:把峰值内存量出来
本机实测:声明一个 381 MiB 的数组时峰值只有 4 MiB,每一页碰过一遍之后是 385 MiB。★ 声明不花内存,碰它才花 —— 但评测机量的是峰值。
// MLE(Memory Limit Exceeded):数组开多大才算大
//
// ⚠ 这一份**在本机是跑得完的**(开发机内存足够),它演示的不是「崩给你看」,
// 而是**把峰值内存量出来**,再和题目给的限制比一比 —— 考场上你要做的正是这件事。
//
// 常见题目的内存限制是 256 MB。换算:
// int 4 字节 → 256 MB 能放 6700 万个
// long long 8 字节 → 3350 万个
// ⚠ bool 是 1 字节,不是 1 位;vector<bool> 才是 1 位(那是个特例,第 35 章提过)
//
// 下面这个数组是 10⁸ 个 int = 4×10⁸ 字节 = **381 MiB**,超了。
// ⚠ 顺带说清一个总被搞混的换算:题目写的「256 MB」是 256 MiB = 268 435 456 字节。而它在本机能跑完这件事本身就是个陷阱:
// **本机跑得过 ≠ 评测机跑得过。**
//
// ★ 估内存和估时间是同一件事:先算一遍,再动手,别等评测机告诉你。
#include <bits/stdc++.h>
using namespace std;
static int a[100000000]; // 10^8 个 int = 400 MB
/** 从 /proc/self/status 里读峰值内存(VmHWM,单位 KB)—— Linux 上最直接的量法 */
static long long peakKb() {
FILE* f = fopen("/proc/self/status", "r");
if (!f) return -1;
char line[256];
long long kb = -1;
while (fgets(line, sizeof(line), f))
if (strncmp(line, "VmHWM:", 6) == 0) { sscanf(line + 6, "%lld", &kb); break; }
fclose(f);
return kb;
}
int main() {
printf("数组声明:int a[100000000] -> %.0f MiB(%zu 字节)\n", sizeof(a) / 1048576.0, sizeof(a));
printf("声明完但还没碰它,此刻峰值内存 %.1f MiB —— ★ 注意它还很小\n", peakKb() / 1024.0);
// ⚠ 写完之后必须再读回来用掉,否则 -O2 会把这个循环整个删掉(写了没人看 = 死代码),
// 峰值内存一点都不涨 —— 第一版就栽在这儿,量出来还是 4 MB。
long long sum = 0;
for (int i = 0; i < 100000000; i += 1024) a[i] = i; // 每一页碰一下,逼系统真给内存
for (int i = 0; i < 100000000; i += 1024) sum += a[i];
printf("每一页都碰过一遍之后,峰值内存 %.1f MiB(校验和 %lld,防止这段被优化掉)\n",
peakKb() / 1024.0, sum);
printf("\n★ 两个数字差这么多,是因为系统「用到了才给」:\n");
printf(" 声明一个大数组不花内存,**碰它**才花。⚠ 但评测机量的是峰值,你迟早会碰到它。\n");
printf("⇒ 256 MiB 的限制下,这个数组是 MLE;改成 int a[60000000](229 MiB)才放得下。\n");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
⑤ RE(Runtime Error,运行时错误)—— 三种死法,难查程度完全不同

在下面那份代码的输入框里填 at / div / deep,各跑一次:

填什么 死因 难查程度
at 下标越界(这里用 vector::at,会抛异常) 当场就能定位
div 除以 0(整数除零是硬件异常) 当场就能定位
deep 递归太深,8 MB 的栈压爆了 ★★ 小数据一切正常,大数据才崩

⚠⚠ 最后一种是这三种里唯一会在考场上真正咬人的: deepn 小的时候完全正确,一上大数据就 RE —— 第 30 章那条「图上 DFS 的深度上限是点数,不是层数」讲的就是它。

⚠ 还有一个更坏的情况:下标越界写成 v[i] 往往不会崩, 它只是安静地读到一块不属于你的内存 —— 那就从 RE 变成了一个查不出来的 WA。

re.cpp⑤ RE:填 at / div / deep 各跑一次
⚠ 注意每一种的最后都没有打出「如果你看到这一行」—— 它是真的没跑完,不是装的。
// RE(Runtime Error):三种最常见的死法,每一种都真的会死
//
// 在输入框里填 div / at / deep 之一(默认 at),按运行看它到底怎么崩的。
//
// div 除以 0 —— 整数除零在 x86 上是硬件异常,进程收到 SIGFPE 当场没
// at 下标越界 —— 这里用 vector::at,它会抛异常;⚠ 写成 v[i] 的话**多半不会崩**,
// 而是安静地读到一块不属于你的内存 —— 那就变成一个查不出来的 WA
// deep 递归太深 —— 每一层递归都要占栈,栈只有 8 MB,压爆了就是 SIGSEGV
//
// ★ 这三种在评测机上都显示 RE,可查起来的难度完全不同:
// 前两种当场就能定位,第三种(爆栈)在小数据上一切正常,只有大数据才崩 ——
// 第 30 章那条「图上 DFS 的深度上限是点数,不是层数」讲的就是它。
#include <bits/stdc++.h>
using namespace std;
static int lim = 100000000; // 递归的层数上限,从输入里来 —— 编译期不知道它多大
// ⚠ 这个函数的写法有两处是**故意**的,不然编译器会把它优化成一个循环,压根不爆栈:
// ① 出口条件 k >= lim 里的 lim 是运行时才知道的(写死成常数的话 -Wall 直接报「无限递归」);
// ② 递归**返回之后**还要用本帧的 pad —— 这样它就不是尾调用,每一层都必须留一个真的栈帧。
// ★ 这本身就是一课:你以为写下的递归,编译器可能根本没那么执行。
int deep(int k) {
volatile int pad[256]; // 让每一层占掉约 1 KB 栈,快点压爆
for (int i = 0; i < 4; i++) pad[i] = k + i;
int r = (k >= lim) ? 0 : deep(k + 1);
return r + pad[0] + pad[3];
}
int main() {
string mode;
if (!(cin >> mode)) mode = "at";
if (mode == "div") {
int a = 10, b = 0;
cin >> b; // 从输入里读 b(读不到就还是 0),免得编译器直接算出来
printf("准备算 %d / %d …\n", a, b);
fflush(stdout);
printf("%d\n", a / b); // ⚠ 就是这里
} else if (mode == "deep") {
printf("开始往下递归,栈只有 8 MB(每层约 1 KB,也就是八千层左右)…\n");
fflush(stdout);
printf("%d\n", deep(1)); // ⚠ 爆栈
} else {
vector<int> v(10, 7);
int idx = 1000000;
cin >> idx; // 下标从输入里来(读不到就用 1000000)
printf("v 的长度是 %d,现在去读 v.at(%d) …\n", (int)v.size(), idx);
fflush(stdout);
printf("%d\n", v.at(idx)); // ⚠ 越界,抛 std::out_of_range
}
printf("(如果你看到这一行,说明它没崩 —— 那才是坏消息)\n");
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

13★ 对拍:四个写错的版本

标准答案用 brute2.cppO(n²),思路和正解完全不同:它枚举端点,正解是递推)。

对拍器
★ 把右边换成你自己默写的那份,再点开始。生成器不给档位时跑的就是最终档(第 14 步那张表里的档位 6):会造出「全是负数」「值域拧满到 ±10⁹」「最优段顶到末尾」这三类数据。
// 正解:一遍扫过去,O(n)
//
// 这一章的主角不是这个算法本身(第 21 章那条「以 i 结尾」的递推你已经见过),
// 而是它和另外三份做法**摆在一起**的样子:四份的答案逐字节相同,
// 差别全在「n 大到什么程度还跑得完」。
//
// 递推那一句只有一个决定:
// 以 a[i] 结尾的最大子段和 cur = max(a[i], cur + a[i])
// ↑ 从我自己重新开始 ↑ 接在前面那一段后面
// ⚠ 两个初值都必须是 a[1],不是 0 —— 子段**非空**,全是负数时答案就是最大的那个负数。
// 这一处正是本章 wrongZero.cpp 的全部内容,而第一个样例挡不住它。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<long long> a(n + 1); // 1 基下标:a[1..n]
for (int i = 1; i <= n; i++) cin >> a[i];
long long best = a[1], cur = a[1]; // ⚠ 不是 0
for (int i = 2; i <= n; i++) {
cur = max(a[i], cur + a[i]);
best = max(best, cur);
}
cout << best << '\n';
return 0;
}
点一下即可编辑

300 轮实测(种子 1..300,最终档 6):

故意写错的地方 被抓 靠什么现形
wrongCross:分治里跨中线那半少扫一格 276 / 300 不挑数据,随机就能抓
wrongTail:循环写成 i < n,漏了最后一个数 116 / 300 最优段以 a[n] 结尾
wrongZerobest 从 0 开始 77 / 300 整组全是负数(形状)
wrongInt:和用 int ★ 57 / 300 值域拧满到 ±10⁹(值域)
四个错误版本(点开看)
wrongZero.cpp① best 从 0 开始
wrongInt.cpp② 和用 int 存
wrongCross.cpp③ 分治少扫一格
wrongTail.cpp④ 循环少走一步

★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。

⚠ 第 ② 行那个数字为什么没有钉死

wrongInt 是靠有符号整数溢出出错的,而那是未定义行为(第 10 章)—— 它「错成什么样」原理上不可复现,换编译器、换优化等级都可能变。

⇒ 照第 43 章立下的规矩:这一列不钉数字,只钉形状。 该是 0 的精确钉 0(那是「生成器造不造得出大数」决定的,和机器无关), 非 0 的只要求「三百轮里抓得住一大截」。

14★★ 生成器:两个 bug 随机数据抓不到,而它们缺的东西不一样

档位 相对上一档拧了什么 ① Zero ② Int ③ Cross ④ Tail
0(顺手写法) n ∈ [3,10]a[i] ∈ [−9,9] ★ 4 0 260 98
1 ★ 三成概率整组全负 80 0 273 94
2 ★ 值域第一版:三成概率拧到 ±10⁹ 8 6 263 108
3 ★ 值域第二版:五成 + 这时强制 n ≥ 6 5 21 270 105
4 ★ 值域第三版:再加「七成为正」 3 61 277 122
5 ★ 三成概率末尾塞个大正数 3 0 259 126
6 ★ 最终档 = 1 + 4 + 5 77 57 276 116
7 对照 = 6 − 「全负」 3 61 278 146
8 对照 = 6 − 「值域」 80 0 273 110
★★★ 两笔干净的账,而它们缺的东西不是同一类

① 「全负」这个旋钮,单独决定 wrongZero 的死活。

档位 7(最终档去掉它):3 / 300;加回去(档位 6):77 / 300。 它缺的是形状 —— 值域怎么调都没用,必须整组都是负数。

② 「值域拧满」这个旋钮,单独决定 wrongInt 的死活。

档位 8(最终档去掉它):0 / 300,而且是精确的 0;加回去:57 / 300。 它缺的是值域 —— 形状怎么调都没用,±9 加十次也到不了 int 的上限。

★★ 两个都是「随机数据抓不到」,可要造的东西完全不同 —— 这就是为什么第 3 步那张「每个 bug 靠什么现形」的清单必须动笔前先写: 不写下来,你只会一股脑「把 n 调大」,而这两个 bug 一个都抓不到。

⚠ 顺带两笔老实账: · 值域那处改动调了三版(6 → 21 → 61),三次都单独实测过 —— 一次只改一处(第 26 章那条规矩)。 · 最终档里 wrongInt 是 57,比单独开值域时的 61 还低一点:全负旋钮吃掉了一部分机会。 调优不可加(第 32、34、35 章那条),每加一处都要在最终环境里重量一次。

⚠ 顺手档抓到 4 轮,不是 0 —— 这个细节值得看一眼

wrongZero 在顺手档抓到了 4 / 300n 最小是 3,三个数碰巧全是负数的概率约 1/8, 乘上「答案确实是负数」才算数,三百轮里蒙到几次很正常。

⇒ 但你绝不能靠它:4/300 意味着跑 75 轮才碰上一次,而多数人对拍只跑 20 轮就收工了。 ★ 和第 44 章那个「顺手档靠运气抓到 7 轮」是同一件事: 靠运气撞上的边界,等数据一变大就再也撞不到了。

gen.cpp(九个档位)三个旋钮,每一个都写清了它是为哪个 bug 拧的

15骗分:考场策略,不是学习方法

⚠⚠ 先把话说清楚

这一节讲的是考场上时间不够时怎么把分捡回来

平时练习写这种东西,等于骗自己 —— 你要学的正是它跳过的那一步。 ⇒ 这两件事必须分开:练习追求正解,考场追求分数。

真实的题目几乎总是分档给分的,而档与档之间的界线,就是第 6 步那张表的界线。 假设这道题有五档数据,各 20 分:

数据 会正解能拿 只会 O(n²) 能拿
1 n ≤ 1000,随机 20 20
2 n ≤ 1000,全负 20 20
3 n = 2×10⁵保证全是正数 20 0(超时)
4 n = 2×10⁵,随机 20 0(超时)
5 n = 2×10⁵,全负 20 0(超时)

一份完全不会正解的程序能拿多少?下面这份只做三件事: 全正就直接输出总和(第 3 档的「特殊性质」)、n ≤ 1000 就跑 O(n²)(第 1、2 档)、 其它情况输出最大的那个单个元素当兜底。

partial.cpp⚠ 骗分版:不会正解,但要拿分
// 骗分:一份**不会正解**的程序,在考场上能拿多少分
//
// ⚠⚠ 先把话说清楚:**这是考场策略,不是学习方法。**
// 平时练习时写这种东西,等于骗自己 —— 你要学的正是它跳过的那一步。
// 它只在一种场合有意义:考场上时间不够了,而这道题的部分分就摆在那儿。
//
// 它只做三件事,每一件都对应题面里的一句话:
// ① 「所有 a[i] > 0」这一档(特殊性质分) → 答案就是整段的和,一行搞定
// ② 「n ≤ 1000」这几档(小数据分) → O(n²) 的暴力足够快
// ③ 其它 → ★ 兜底:输出最大的那个单个元素
// 它不保证对,但**一定是某个合法子段的和**,
// 比空着不输出强(空着一定是 0 分)
//
// ★ 第 11 步会拿它去跑五档数据,看看到底得几分 —— 结果比多数人想的高。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
bool allPos = true;
long long total = 0, mx = a[1];
for (int i = 1; i <= n; i++) {
if (a[i] <= 0) allPos = false;
total += a[i];
mx = max(mx, a[i]);
}
if (allPos) { cout << total << '\n'; return 0; } // ① 特殊性质
if (n <= 1000) { // ② 小数据
long long best = LLONG_MIN;
for (int l = 1; l <= n; l++) {
long long s = 0;
for (int r = l; r <= n; r++) { s += a[r]; best = max(best, s); }
}
cout << best << '\n';
return 0;
}
cout << mx << '\n'; // ③ 兜底
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 实测:五档过了四档,80 分
它输出的对不对
1(n ≤ 1000 随机) ✓ 走的是 O(n²) 那一支
2(n ≤ 1000 全负) ✓ 同上
3(n 大、全正) ✓ 走的是「特殊性质」那一支
4(n 大、随机) ✗ 兜底,输出了最大的单个元素
5(n 大、全负) ★ ✓ 兜底那一句居然正好是对的

★ 第 5 行是这一节最值得记住的:全是负数时,答案本来就是「最大的那个单个元素」—— 兜底撞对了。骗分就是这样:你并不总知道自己会得几分。

⇒ 三条能带走的:

  1. 永远输出点什么。 空着一定是 0 分,兜底至少有机会。
  2. 照着数据范围分档写。 if (n <= 1000) 加一个暴力,通常就是白捡的一档。
  3. 「特殊性质」那一档往往一行就能做。 题面里那句「保证所有数为正」不是废话,是分。

16考场上的顺序,以及这一章没讲的

★ 拿到一道题,按这个顺序走
  1. 读数据范围(比读题面还早)—— 它告诉你往哪个复杂度想;
  2. 想一个能过的算法,在纸上估一遍次数,别写完再说;
  3. 写正解;写不出来就照第 15 步分档骗分;
  4. 过样例,然后自己造极端数据n = 1、全负、全正、全相等、值域顶满;
  5. 交之前编译一次(CE 那一档分白丢最冤);
  6. 还有时间就对拍(第 13 步)。
⚠ 这一章没讲的
没讲的 一句话
均摊复杂度 单次可能很慢、一串操作平均下来很快(第 36 章的并查集、vector 的扩容)
空间复杂度的精细估算 这里只给了「除一下」的粗算法;vectormap 的额外开销要另算(第 29 章量过)
常数优化 O2 开关、循环展开、register 这类 —— ⚠ 它们能救的是「差一点点」,救不了「差一个数量级」
多测(一个输入里若干组数据) 那时候复杂度要乘上组数 Tn 的上限往往会小很多

★ 最后一句最要紧:复杂度估算能救的是「差一个数量级」,常数优化能救的是「差一点点」。 先看是哪一种,再决定动手改什么。(第 22、36 章那条「动手优化前先量一遍是谁慢」的又一次。)

17自测

自测清单0 / 11
配套练习
  • 洛谷 P1115 最大子段和 —— ★ 就是这一章那道题的原题。交之前先想清楚:全是负数那组数据,你的初值挡不挡得住
  • 洛谷 P1719 最大加权矩形 —— ★★ 二维版:枚举上下边界,把每一列压成一个数,就退化成这一章这道题。n ≤ 120 —— 先按第 6 步那张表估一估 O(n³) 行不行
  • 洛谷 P1786 帮贡排序 —— 数据小得离谱(n ≤ 100),是专门用来练「先看范围再决定写多复杂」的:这道题排序怎么写都能过
  • 洛谷 P1746 离开中山路 —— ⚠ 1000×1000 的网格 = 10⁶ 个点。按这一章的表估一下就知道:必须 O(n²) 的 BFS,DFS 找最短路会挂(第 14 章)
  • 洛谷 P1996 约瑟夫问题 —— 第 8 章那道题。这里换个角度看它:n ≤ 100 时怎么写都行,如果 n 是 10⁶ 呢?先估,再决定用不用链表
  • 洛谷 P1177 【模板】排序 —— ★ n ≤ 10⁵ 而且卡了常数:拿它试一次「O(n²) 排序到底能不能过」—— 估算说不能,交一发看看估得准不准
这一章我卡在哪(过一个月回来看,这几行比整章正文都值钱)
这一章记住三句话
  1. 看到数据范围先估一遍,再动手。 方向错了,代码写得再对也是 0 分;而估这一下只要十秒钟。
  2. ★★ 「1 秒 ≈ 10⁸ 次」不是一个数,是一个范围。 本机实测五档差 80 多倍 —— 顺序扫数组往 10⁹ 估,在大数组里随机跳就老老实实按 10⁸ 估。
  3. 复杂度估算救的是「差一个数量级」,常数优化救的是「差一点点」。 先量清楚是哪一种,再决定动手改什么。