前面每一章都是「一句话问题 → 暴力 → 正解」,学完你会多一个算法。 这一章学完你不会多任何算法 —— 你会多一个考场上每道题都要做一次的判断:
看一眼数据范围,决定「往哪个复杂度去想」,在动手写之前。
它值得单独一章,因为这个判断做错的代价是最大的:方向错了,代码写得再对也是 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¹⁵。
先按最粗的那句口诀算:一秒 10⁸ 次基本操作。
8×10¹⁵ ÷ 10⁸ = 8×10⁷ 秒 ≈ 2.5 年。
⇒ 不用写,不用跑,不用等评测机 —— 这个方向当场就死了。 这一步只花了十秒钟,而它省下的是一整场考试里最贵的东西:时间。
⚠ 但「口诀说一秒 10⁸ 次」这句话本身可信吗?第 5 步会自己去量。先把两个暴力写出来,看它们真的有多慢。
3暴力一:三重循环,O(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;}点「运行 ▶」看结果
本机实测(数据来自 ./genBig <n> 1,见下面那份生成器):
n |
耗时 | 和上一行比 |
|---|---|---|
| 500 | 0.006 秒 | |
| 1000 | 0.039 秒 | ×6.5 |
| 2000 | 0.309 秒 | ×7.9 |
| 4000 | 2.518 秒 | ×8.2 |
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] 时只要加一个数。
// 暴力二:左端点固定住,右端点往右挪一格就加一个数 —— 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;}点「运行 ▶」看结果
本机实测:
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 章各犯过一次), 所以这一章不背那个数,现场量。
// ★ 这一章的关键一步:把「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 missstatic 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 倍 |
最快那一档是最慢那一档的 80 多倍。而口诀里那个 10⁸,
正好落在最慢的那一头 —— 也就是「每次都要去内存里随便捞一个数」的那种循环。
⇒ 所以这句口诀应该这么用:
- 循环体是顺序扫数组、只做加减:往
10⁹那头估,10⁸是保守了十倍; - 循环体里有取模、除法:
10⁸ ~ 10⁹之间; - 循环体在大数组里随机跳(哈希表、指针、链式前向星、堆):老老实实按
10⁸估。
★ 这也解释了第 3 步那个差距:口诀估 O(n³) 要 2.5 年,实测外推是 3.6 天,差了两个数量级 ——
因为 brute3 的内层循环正是「顺序扫、只做加法」那一档,它比 10⁸ 快了将近百倍。
⚠ 而 10⁸ 仍然是考场上该用的那个数:估算要往坏处估。 估宽了最多多写十分钟正解,
估窄了是当场 TLE。
- 空循环量不出来:
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 步 |
- 常数不在表里。 同样是
O(n log n),sort和「每次 new 一个节点的线段树」能差五倍。 上一步那五档差 80 倍,就是常数的样子。 log很小。log₂(10⁶) ≈ 20—— 所以O(n log n)和O(n)在考场上通常同一档, 为了把 log 去掉而写一个复杂十倍的算法,多半不划算。- 它是「往哪儿想」,不是「一定能过」。 表说
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)。
// 正解:一遍扫过去,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;}点「运行 ▶」看结果
8动画一:看它一格一格扫过去
盯住「从我自己重新开始」的那一步 —— 蓝色的那一段会当场断掉重来。
★ 把上面的数组换成 -3 -1 -4 -1 再看一眼:答案是 −1,不是 0。
9四条曲线同台:换一把尺子,数次数
秒表有个毛病:换台机器就变。次数不变。所以把四份做法放在一起比的时候,数的是 「往和里加一个数」这个动作各做了多少次 —— 四份都有它,才可比。
// 换一把尺子:数「基本操作」做了多少次//// 秒表能告诉你「这台机器上要跑多久」,但它换台机器就变。**次数不变。**// 所以这一章的主表用的是次数,秒数只在需要「量级感」的地方出现。//// ⚠ 这里的次数全是**真数出来的**(在算法里放计数器),不是套公式印出来的 ——// 然后再和公式对一遍。两者对不上,说明我对自己写的代码理解错了。//// 数的是同一件事:**「往和里加一个数」这个动作做了几次**。四份做法都有它,才可比。// 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 章的账:把序列从中间劈开,
最大的那段要么整个在左半、要么整个在右半、要么跨过中线 —— 前两种递归,第三种从中线往两边各扫一趟。
10动画二:★ 这一章真正的主角
每按一步 n 就跳一档,看四条横条各自怎么长。
★ 上面那个下拉框能换「这台机器一秒能做多少次」—— 换一次,判决线就整体挪一格。
那正是第 5 步量出来的事。
本机实测的耗时表(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 冒险。
★ 标着「外推」的三个数不是跑出来的(真跑一次要三天),是拿同一列上一行的实测值
按 n³ / n² 乘出来的。外推可以,但必须说明它是外推。
动画默认按 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 秒。
// 读入优化:同样是读 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 倍 |
cin + ios::sync_with_stdio(false) |
32 | 4 倍 |
手写快读(fread 整块读) |
8.6 | 1 倍 |
ios::sync_with_stdio(false) 在这本书的 307 份代码里出现过,正文一次都没解释过。
C++ 的流和 C 的 stdio 默认是同步的:cin 读一个字符要经过 stdio 那一层,
还得保证两边的缓冲不打架。那句话就是把这层同步断开,让 cin 用自己的缓冲。
⚠ 代价:从此不许再和 scanf / printf 混用,两边各自缓冲,打印顺序会乱。
(第 26 章踩过:表格跑到结语后面去了。所以这本书里凡是拿 printf 打表的程序,一律不关同步。)
本机实测 scanf 44 毫秒,cin 关掉同步之后 32 毫秒 —— 关了同步的 cin 反而更快。
⇒ 结论要钉在理由上(第 39 章那条):慢的不是 cin,是那层同步。
⚠ 但这个先后顺序换台机器可能会反过来(两者只差一点),所以断言里只钉了
「默认的 cin 最慢、手写快读最快」这两头 —— 那两头差 24 倍,稳得多。
12评测机的五种反馈,各配一份真能跑出来的代码
考场上你看到的不是「对/错」,是五个缩写。每一个都有它最常见的原因。
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 写成了别的名字。)
本章的 wrongZero.cpp 就是一份标准的 WA:best 从 0 开始,
样例照过,只有「全是负数」的测试点会挂。
★ WA 的典型长相是「样例过得去、测试点过不去」 —— 因为样例通常是最普通的那一组。
⇒ 查 WA 的第一件事不是读代码,是自己造一组极端数据:
全负、全正、n = 1、全相等、最大值、最小值。第 13、14 步就是在系统地干这件事。
把上面 brute2.cpp(O(n²))喂给 n = 2×10⁵ 的数据:本机 9.754 秒,时限 1 秒。
⚠ TLE 不一定是算法错,也可能是常数太大或读入太慢(第 11 步)。
⇒ 先按第 6 步那张表判断:复杂度本身就超了,换算法;复杂度对得上,再去抠常数。
★ 顺序反过来是考场上最常见的浪费 —— 给一个注定超时的 O(n²) 加读入优化,一分都换不来。
| 类型 | 一个占几字节 | 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(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;}点「运行 ▶」看结果
在下面那份代码的输入框里填 at / div / deep,各跑一次:
| 填什么 | 死因 | 难查程度 |
|---|---|---|
at |
下标越界(这里用 vector::at,会抛异常) |
当场就能定位 |
div |
除以 0(整数除零是硬件异常) | 当场就能定位 |
deep |
递归太深,8 MB 的栈压爆了 | ★★ 小数据一切正常,大数据才崩 |
⚠⚠ 最后一种是这三种里唯一会在考场上真正咬人的:
deep 在 n 小的时候完全正确,一上大数据就 RE ——
第 30 章那条「图上 DFS 的深度上限是点数,不是层数」讲的就是它。
⚠ 还有一个更坏的情况:下标越界写成 v[i] 往往不会崩,
它只是安静地读到一块不属于你的内存 —— 那就从 RE 变成了一个查不出来的 WA。
// 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;}点「运行 ▶」看结果
13★ 对拍:四个写错的版本
标准答案用 brute2.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;}300 轮实测(种子 1..300,最终档 6):
| 故意写错的地方 | 被抓 | 靠什么现形 |
|---|---|---|
③ wrongCross:分治里跨中线那半少扫一格 |
276 / 300 | 不挑数据,随机就能抓 |
④ wrongTail:循环写成 i < n,漏了最后一个数 |
116 / 300 | 最优段以 a[n] 结尾 |
① wrongZero:best 从 0 开始 |
★ 77 / 300 | 整组全是负数(形状) |
② wrongInt:和用 int 存 |
★ 57 / 300 | 值域拧满到 ±10⁹(值域) |
★ 每一份的文件头都写着「它靠什么现形」—— 那不是注释,那是下一步调生成器的依据。
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 章那条),每加一处都要在最终环境里重量一次。
wrongZero 在顺手档抓到了 4 / 300:n 最小是 3,三个数碰巧全是负数的概率约 1/8,
乘上「答案确实是负数」才算数,三百轮里蒙到几次很正常。
⇒ 但你绝不能靠它:4/300 意味着跑 75 轮才碰上一次,而多数人对拍只跑 20 轮就收工了。 ★ 和第 44 章那个「顺手档靠运气抓到 7 轮」是同一件事: 靠运气撞上的边界,等数据一变大就再也撞不到了。
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 档)、
其它情况输出最大的那个单个元素当兜底。
// 骗分:一份**不会正解**的程序,在考场上能拿多少分//// ⚠⚠ 先把话说清楚:**这是考场策略,不是学习方法。**// 平时练习时写这种东西,等于骗自己 —— 你要学的正是它跳过的那一步。// 它只在一种场合有意义:考场上时间不够了,而这道题的部分分就摆在那儿。//// 它只做三件事,每一件都对应题面里的一句话:// ① 「所有 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;}点「运行 ▶」看结果
| 档 | 它输出的对不对 |
|---|---|
1(n ≤ 1000 随机) |
✓ 走的是 O(n²) 那一支 |
2(n ≤ 1000 全负) |
✓ 同上 |
3(n 大、全正) |
✓ 走的是「特殊性质」那一支 |
4(n 大、随机) |
✗ 兜底,输出了最大的单个元素 |
5(n 大、全负) |
★ ✓ 兜底那一句居然正好是对的 |
★ 第 5 行是这一节最值得记住的:全是负数时,答案本来就是「最大的那个单个元素」—— 兜底撞对了。骗分就是这样:你并不总知道自己会得几分。
⇒ 三条能带走的:
- 永远输出点什么。 空着一定是 0 分,兜底至少有机会。
- 照着数据范围分档写。
if (n <= 1000)加一个暴力,通常就是白捡的一档。 - 「特殊性质」那一档往往一行就能做。 题面里那句「保证所有数为正」不是废话,是分。
16考场上的顺序,以及这一章没讲的
- 读数据范围(比读题面还早)—— 它告诉你往哪个复杂度想;
- 想一个能过的算法,在纸上估一遍次数,别写完再说;
- 写正解;写不出来就照第 15 步分档骗分;
- 过样例,然后自己造极端数据:
n = 1、全负、全正、全相等、值域顶满; - 交之前编译一次(CE 那一档分白丢最冤);
- 还有时间就对拍(第 13 步)。
| 没讲的 | 一句话 |
|---|---|
| 均摊复杂度 | 单次可能很慢、一串操作平均下来很快(第 36 章的并查集、vector 的扩容) |
| 空间复杂度的精细估算 | 这里只给了「除一下」的粗算法;vector、map 的额外开销要另算(第 29 章量过) |
| 常数优化 | O2 开关、循环展开、register 这类 —— ⚠ 它们能救的是「差一点点」,救不了「差一个数量级」 |
| 多测(一个输入里若干组数据) | 那时候复杂度要乘上组数 T,n 的上限往往会小很多 |
★ 最后一句最要紧:复杂度估算能救的是「差一个数量级」,常数优化能救的是「差一点点」。 先看是哪一种,再决定动手改什么。(第 22、36 章那条「动手优化前先量一遍是谁慢」的又一次。)
17自测
- 洛谷 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²) 排序到底能不能过」—— 估算说不能,交一发看看估得准不准
- 看到数据范围先估一遍,再动手。 方向错了,代码写得再对也是 0 分;而估这一下只要十秒钟。
- ★★ 「1 秒 ≈ 10⁸ 次」不是一个数,是一个范围。 本机实测五档差 80 多倍 ——
顺序扫数组往
10⁹估,在大数组里随机跳就老老实实按10⁸估。 - 复杂度估算救的是「差一个数量级」,常数优化救的是「差一点点」。 先量清楚是哪一种,再决定动手改什么。