CSP-S 提高级课程中心 / 编程环境进阶 / 第3讲
CSP-S 提高级 · 编程环境进阶 · s1-03

程序运行测时 与 GDB 调试

算法对了但超时?程序崩了却不知哪一行?本讲教你用 time 量化性能、用 GDB 精准定位三类经典 bug,把"猜错因"变成"看错因"。

3 种测时手段 1s ≈ 10⁸ 经验尺 12 个 GDB 命令 3 类 bug 调试 12 道洛谷原题
本讲 · 第 3/52 讲程序运行测时与GDB调试
模块 01

情景导航

从"能过样例却 TLE""跑着跑着就崩"两个真实困扰出发,说明测时与调试为什么是提高组的核心竞争力。

故事最后十分钟的 TLE

CSP-S 赛场,小 C 的算法思路完全正确:他写了一个三重循环的解法,样例全过。可他心里有点不安——因为最大数据是 n = 2000。

他试着估算:n³ = 8×10⁹。他听说"1 秒能跑 10⁸ 次运算",心里算了一下:那要 80 秒。但小 C 想:"我本地跑样例时,程序几乎是瞬间输出结果的呀!"于是他按下了提交。

结果:TLE,0 分。因为样例的 n = 10,10³ = 1000 次运算当然"瞬间完成"——他用最小的数据验证了最大的风险。

同一场的另一道题,小 D 的程序"跑着跑着就崩了",屏幕上只有一个冰冷的 Segmentation fault (core dumped)。很多人对这种错误的反应是"随便改改试试看",改了两小时也没找到。小 D 的做法完全不同:

g++ a.cpp -g -O0 -Wall -o a
gdb ./a
(gdb) run
(gdb) bt
#0 main () at a.cpp:14

三条命令,问题定位到第 14 行:a[i + 1] 在 i 取到最大值时越界。

"猜"和"看"之间,差的不是天赋,而是一套方法与两个工具。

💡 本讲要解决的两个核心痛点

痛点一 · 不知道程序能不能在时限内跑完。解法是"先估算、再实测":用复杂度与 108 这把尺子做纸上估算,再用 time 命令做真实验证。两者对不上时,说明你的复杂度分析错了。

痛点二 · 程序出错但不知道错在哪。解法是 GDB:让程序在指定位置停下,把每个变量的值"看"出来。GDB 的价值不是"更高级的 printf",而是随时暂停、随时查看。

两者共同的思维方式:把模糊感觉变成可测量的事实。这也是信息学竞赛考察的核心素养之一。

①生活类比
⏱️

time = 秒表 + 双计时器

不仅记总耗时,还分开记"CPU 干活的时间"和"等待输入输出的时间"。

→ real / user / sys
📏

1s ≈ 10⁸ = 一把尺子

像装修前用卷尺量房,估出规模才知道方案行不行;不用量就开始干,返工代价很大。

→ 复杂度估规模
⏸️

断点 = 视频暂停键

视频关键处暂停,逐帧看发生了什么。断点让程序停在指定行,逐条查看每个变量的值。

→ break / print
🧾

bt = 出警记录

事故发生后调出"谁调用了谁"的完整链条,从现场一路回溯到肇事者。

→ backtrace
②核心定义

real(墙上时间):从程序启动到结束的真实流逝时间,包含等待输入输出、被其他进程抢占的时间。评测机的时限针对的就是它。

user(用户态 CPU 时间):程序在用户态真正消耗的 CPU 时间,主要对应你的算法计算量。

sys(内核态 CPU 时间):系统调用(读写文件、分配内存)消耗的 CPU 时间。

重定向测速:./a.out < in.txt > out.txt。把输出写进文件而不是刷到终端,能避免"输出到屏幕"这一巨大的时间开销 —— 赛场上测时几乎一定要重定向。

clock():程序内部计时,返回程序自启动以来消耗的CPU 时钟数,除以 CLOCKS_PER_SEC 就得到秒数。它测的是 CPU 时间,不包含等待 IO 的时间。

GDB(GNU Debugger):Linux 下的源码级调试器。它能让程序在指定位置暂停、逐行执行、查看与修改变量、打印调用栈。

core dump(核心转储):程序因段错误等信号异常终止时,操作系统把该进程的内存镜像写成文件,供事后用 GDB 分析"死亡现场"。

📌 本讲核心知识地图

板块核心内容对应大纲
time 命令real / user / sys 三个时间的含义与读法【5】运行程序与 time
测速方法重定向、time、clock() 三种手段【5】运行程序与 time
规模估算1s ≈ 108 次基本运算,反推可用复杂度【5】运行程序与 time
GDB 基础-g、break、run、next、step、print、watch、bt【5】GDB 的使用
Core dumpulimit -c unlimited 与事后现场分析【5】GDB 的使用
三类经典 bug越界、死循环、未初始化变量的调试套路【5】GDB 的使用
③核心口诀
🔑 测时三句

评测看 real,算法看 user,IO 看 sys;
不重定向测出来的是"屏幕刷新时间",不是程序速度!

🔑 规模估算

一秒十的八次方(10⁸),这是手上的尺子;
n 的规模对着表一查,复杂度能不能用心里就有数!

🔑 调试四步

加 -g 编译 → gdb ./a → b 行号 + r → p 变量 / bt 看栈;
先看现场,再改代码,别瞎猜!

模块 02

知识探究

先学会"量化程序有多快",再学会"让程序停下来被观察"。

1time 命令:real / user / sys 核心

在命令行最前面加上 time,程序跑完后会自动输出三个时间。

Shell标准测时写法
# ✅ 推荐:输入输出都重定向
time ./a < big.in > out.txt

# 输出示例:
# real    0m0.842s
# user    0m0.810s
# sys     0m0.028s

# ❌ 不推荐:输出直接刷到终端
# 若程序输出 100000 个数字,仅"打印到屏幕"就可能花掉几秒
./a < big.in

# 只关心时间、不关心输出内容时可以直接丢弃
time ./a < big.in > /dev/null
① real总流逝时间
② user算法消耗
③ sys系统调用
观察到的现象可能的原因该怎么处理
real ≈ user + sys程序是纯计算型,没有被 IO 拖慢正常。想加速只能优化算法
real ≫ user + sys大量时间在等待(读文件慢、被抢占、cin 未解除同步)加 ios::sync_with_stdio(0)、用更快的读入、避免频繁刷新
user 很大而 sys 很小计算量大,算法本身有瓶颈换更优复杂度的算法(如 O(n²)→O(n log n))
sys 异常大频繁系统调用(每轮都 cout << endl 刷新、频繁开闭文件)把 endl 改成 "\n",用 '\n' 代替行刷新
user 比 real 还大程序用了多线程/多进程,CPU 时间可累加竞赛为单线程,一般不会出现
赛场关键认知:评测机的时限是拿 real 来比的。所以"我的程序 user 只有 0.3 秒"并不能保证不超时 —— 如果输出到终端或读入用了 cin 的慢路径,real 可能远超 user。
2三种测时方法:time / 重定向 / clock()
① time 命令(外部测时)

优点:零改动、能分开看 real/user/sys、最接近评测机真实体验。

局限:只能给"整个程序"一个总时间,不能测某一段代码。

用法:time ./a < in > out。

② 重定向(测时的前提)

作用:把 IO 与计算分离,避免"屏幕输出"污染测时结果。

要点:对比测速时,一定保证输入输出都走文件,否则数据没有可比性。

③ clock()(内部测时)

优点:能精确测"某一段"代码耗时,例如只测排序、只测建图。

局限:测的是 CPU 时间,不包含 IO 等待;且实现精度有限。

C++用 clock() 给某段代码计时
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 需要计时的代码放在两行 clock() 之间
    clock_t begin = clock();

    volatile long long s = 0;
    for (int i = 0; i < 100000000; i++) s += i;   // 约 1e8 次加法

    clock_t end = clock();
    double sec = (double)(end - begin) / CLOCKS_PER_SEC;   // 换算成秒
    cout << "耗时:" << fixed << setprecision(3) << sec << " s" << endl;
    cout << "s = " << s << endl;
    return 0;
}

🔍 代码逐段解析 · clock() 计时程序

第 5 行 —— clock_t begin = clock():在被测代码开始前取一个"时钟读数",类型用 clock_t。

第 7~8 行 —— 被测代码:1×108 次加法。注意 s 声明为 volatile long long —— volatile 禁止优化器把"结果没被使用"的循环整个删掉,long long 防止 108 次累加溢出 int。

第 10~11 行 —— 结束时再取一次读数;差值除以 CLOCKS_PER_SEC 换算成秒,强转 double 避免整数除法丢精度。

第 12~13 行 —— 输出耗时与 s 本身(输出 s 也是"结果被真正使用"的保险)。

易错点 —— ① 去掉 volatile 后 -O2 可能把循环优化成一条公式,测出"0.000 s"的假象;② clock() 测的是 CPU 时间,含不了 IO 等待,与 time 的 real 不可直接比大小。

运行结果
$ g++ timing.cpp -O2 -o timing
$ ./timing
耗时:0.283 s
s = 4999999950000000

$ ./timing
耗时:0.281 s
s = 4999999950000000

# 注意:如果不加 volatile,-O2 可能把整个循环优化掉(因为结果没被"真正使用")
# 那样测出来的时间接近 0,这是"测量假象"
两个测量陷阱:① 被优化掉:只累加不使用的结果,-O2 会直接删掉循环;可用 volatile 或把结果输出。② 测的是 CPU 时间:如果被测代码主要在做输入输出,clock() 会明显小于 time 报的 real。
3估算:1 秒到底能跑多少次运算 必背

竞赛中最有用的经验尺子是:现代评测机 1 秒大约能完成 108 次简单运算(保守估计;实测可达 3×108 ~ 109,但估规模时要保守)。

n 的规模可用复杂度典型算法运算量级
n ≤ 10O(n!) / O(2ⁿ)全排列枚举、爆搜10⁶ 以内
n ≤ 20O(2ⁿ) / O(n·2ⁿ)状压 DP、子集枚举10⁶ ~ 10⁷
n ≤ 100O(n⁴) / O(n³)Floyd、区间 DP10⁸ 上下(n³ 悬)
n ≤ 2×10³O(n²)二维 DP、朴素最短路4×10⁶ ✅ 安全
n ≤ 10⁵O(n log n) / O(n√n)排序、树状数组、分块1.7×10⁶ ✅ 安全
n ≤ 10⁶O(n log n) 偏紧 / O(n)线性筛、单调队列2×10⁷ ✅ 尚可
n ≤ 10⁷O(n)简单扫描、前缀和10⁷ ✅ 但常数要小

📐 估算三步法

第 1 步 · 数循环层数。三重循环(每层到 n)大约是 n³ 次;n = 2000 时 n³ = 8×109,是 1 秒上限的 80 倍,必 TLE。

第 2 步 · 别忘了常数与内部开销。循环体里有取模、乘除、函数调用时,单次耗时会明显高于一次加法。经验上把"加法"作为基准单位,除法/取模按 3~10 倍算。

第 3 步 · 用最小与最大数据都测一次。纸上估算完成后,一定要用 time 在最大规模数据上实测。样例的 n = 10 完全没有参考价值。

一个反直觉的事实:时间复杂度相同、常数不同的两个实现,实测可能差 3~5 倍。cin 与 scanf 的差距、endl 与 "\n" 的差距,都足以决定 TLE 与否。

🧮 估算演示:本讲开头小 C 的 O(n³) 为什么必挂
1
题目数据范围 n ≤ 2000,小 C 的算法是三重循环 O(n³)。
2
代入计算:2000³ = 8×109 次基本运算。
3
与尺子比较:1 s ≈ 108,超出 80 倍 → 纸上判定必 TLE。
4
可小 C 只用样例 n = 10 测过:10³ = 1000 次,当然"瞬间完成" —— 最小的数据验证不了最大的风险。
5
反推可行方案:O(n²) 时 2000² = 4×106 ✅ 安全,说明该题应换二维 DP / 朴素最短路级别的做法。
6
最后一步:造 n = 2000 的最大数据,time ./a < big.in > /dev/null 实测确认(见下方代码块)。
📌 30 秒的纸上估算,能替你省下提交后 0 分的代价。估算可行也别忘了实测兜底。
Shell把估算落到实处:生成大规模数据并实测
# 生成 n = 2000 的随机数据(用于测 n^3 的解法)
python3 -c "import random; n=2000; print(n); print(' '.join(str(random.randint(1,10**9)) for _ in range(n)))" > big.in

# 用最大数据实测(这一步必做)
g++ a.cpp -O2 -Wall -o a
time ./a < big.in > /dev/null
# real    0m9.517s        ← 远超 1s,必须换算法

# 对照:用 n = 100 的小数据测,几乎瞬间完成(但毫无参考价值)
python3 -c "import random; n=100; print(n); print(' '.join(str(random.randint(1,10**9)) for _ in range(n)))" > small.in
time ./a < small.in > /dev/null
# real    0m0.001s        ← 完全测不出问题
4GDB 基本命令与调试流程 核心

使用 GDB 的第一步是用 -g 编译:没有调试信息,GDB 只能看到机器地址,看不到变量名与行号。调试时通常配 -O0(关闭优化),这样"源码行"与"执行顺序"一一对应。

Shell编译与启动
# 编译:加 -g 生成调试信息,用 -O0 保证行为与源码一致
g++ a.cpp -g -O0 -Wall -o a

# 启动 GDB(进入交互式命令行)
gdb ./a

# 也可以一步到位:直接带参数运行
gdb -q ./a
# -q 表示不打印版本信息(屏幕上少刷几行)
🐞 GDB 第一次上手:七步定位 bug1.cpp 的越界
1
g++ bug1.cpp -g -O0 -Wall -o bug1:没有 -g,后面所有 p、bt 都看不到变量与行号。
2
gdb -q ./bug1 进入调试器,提示符变成 (gdb)。
3
b 7 在可疑的第 7 行设断点;也可以 b 7 if i == 5 只在出事的轮次停下。
4
r < in.txt 运行并喂入数据;程序在断点或崩溃点停下。
5
p i、p n、p i + 1:得到 i = 5、n = 5、下标 6 越界 —— 病因水落石出。
6
info locals 一次看全部局部变量,bt 确认调用栈;n/s 单步、c 继续。
7
q 退出。回车键 = 重复上一条命令,连续 n 时不用一直敲字母。
📌 记忆钩子:g 编译 → b 站岗 → r 开跑 → p 看牌 → bt 回头看 → q 收工。下面这张 GDB 会话实录就是这七步的真实样子。
1
▶️
run (r)

开始运行程序,可带参数与重定向

2
⏸️
break (b)

在指定行/函数设断点

3
🔍
print (p)

打印变量的值

4
➡️
next (n)

单步执行,不进入函数

5
⬇️
step (s)

单步执行,进入函数内部

6
🧾
backtrace (bt)

打印函数调用栈

7
👁️
watch

监视变量,值变化时自动停下

8
➡️
continue (c)

继续运行到下一个断点

9
🚪
quit (q)

退出 GDB

命令简写作用使用要点
runr运行程序可写成 r < in.txt 传入输入重定向
break 行号b 14在第 14 行设断点也支持 b main(函数名)
break ... if 条件b 14 if i==5条件断点循环里只在 i=5 时停下,效率极高
nextn执行下一行(不进入函数)看流程用;回车可重复上一条命令
steps执行下一行(进入函数)怀疑问题在被调函数里时用
print 表达式p i打印变量或表达式的值p/x i 十六进制;p arr[3] 元素
display 变量—每次停下自动显示该变量调试循环时特别省事
watch 变量—变量被改写时立刻停下查"谁改坏了我的变量"最强的一招
backtracebt打印调用栈崩溃后第一件事就是 bt
info locals—显示当前栈帧所有局部变量一次性看清现场,比逐个 p 快
listl显示当前行附近的源码不用切到编辑器就能看代码
set var i = 0—修改变量的值想跳过/重现某个分支时很方便
continuec继续运行到下一个断点或程序结束
Text一次完整的 GDB 会话(模板,背下来)
$ g++ a.cpp -g -O0 -Wall -o a
$ gdb -q ./a
Reading symbols from ./a...done.
(gdb) b 14                        # 在第 14 行设断点
Breakpoint 1 at 0x11a9: file a.cpp, line 14.
(gdb) r < in.txt                  # 运行并传入输入文件
Breakpoint 1, main () at a.cpp:14
14          s += a[i + 1];         # 程序停在这一行
(gdb) p i                         # 看 i 是多少
$1 = 10
(gdb) p n
$2 = 10
(gdb) p i + 1                     # 看下标会不会越界
$3 = 11                            ← n=10,下标 11 已经越界!
(gdb) bt                          # 看调用栈
#0  main () at a.cpp:14
(gdb) q                           # 退出
提高效率的三个细节:① 在 GDB 里直接按回车会重复执行上一条命令(连续 n 时不用一直敲);② -tui 模式(gdb -tui ./a)会把源码显示在上半屏;③ 崩溃后先 bt,再 frame 切换栈帧、info locals 看变量,不要一上来就逐行 n。
5段错误与 core dump:事后分析死亡现场

Segmentation fault 表示程序访问了不属于它的内存(典型原因:数组越界、指针未初始化、递归爆栈)。如果系统开启了 core dump,崩溃瞬间的内存镜像会被保存下来,事后可用 GDB 分析。

Shellcore dump 的开启与使用
# 1. 查看当前限制(0 表示不产生 core 文件)
ulimit -c
# 0

# 2. 打开 core 文件生成(只对当前 shell 有效)
ulimit -c unlimited
# 若仍不生成,可能是 core_pattern 指向了系统统一目录,用下面命令查看
cat /proc/sys/kernel/core_pattern

# 3. 运行崩溃的程序(会生成 core 或 core.PID)
./a < in.txt
# Segmentation fault (core dumped)

# 4. 用 GDB 分析"死亡现场":不需要重新运行程序
gdb ./a core
# (gdb) bt
# #0  main () at a.cpp:14      ← 直接告诉你崩在哪一行
# (gdb) info locals           ← 崩溃时刻所有局部变量的值
# (gdb) p i
# $1 = 100000
🧾 core dump 事后分析四步(不用重新运行程序)
1
开闸:ulimit -c 显示 0 说明不生成 core;ulimit -c unlimited 放开(只对当前终端有效)。
2
复现崩溃:./a < in.txt,看到 Segmentation fault (core dumped),当前目录多出 core 文件 —— 这是崩溃瞬间的完整内存快照。
3
验尸:gdb ./a core 同时载入程序与快照;bt 直接给出崩溃行号(如 a.cpp:14)。
4
看现场:info locals 与 p i 显示崩溃那一刻的变量值 —— 比如发现 i = 100000 而数组只有 105 格,病因明确。
📌 两个前提:可执行文件必须与 core 来自同一次编译,且编译时带 -g,否则只能看到地址。分析完用 q 退出,core 文件可留作复现证据。
症状最可能的原因首选排查手段
Segmentation fault数组越界写、空指针、递归爆栈bt 定位行 → p 看下标;或 -fsanitize=address
程序不结束、CPU 占满死循环(条件写错、忘记自增)Ctrl+C 中断后在 GDB 里 bt + 条件断点
结果时对时错未初始化变量、越界读display 持续观察变量;-Wall 抓未初始化
结果稳定但错逻辑错误(不是内存问题)断点 + p 逐步核对中间结果
Floating point exception整数除以 0 或取模 0bt 定位,检查分母是否可能为 0
死循环调试技巧:程序卡住时在终端按 Ctrl+C 强制中断。如果是在 GDB 里运行的,中断后直接敲 bt 就能看到"卡在哪一行的循环里"。另一个强力手段是条件断点:b 12 if i > 5,让程序在可疑状态下停下来。
动交互演示:一次完整的测时与调试流程
▶ 从"怀疑"到"确认"六步
1
看数据范围:读题时先圈出 n 的上界。看到 n ≤ 2000 就警惕 O(n³);看到 n ≤ 105 就必须 O(n log n) 以内。
2
纸上估算:算出总运算量与 108 比较,判断是否可能超时;这一步只需要 30 秒。
3
生成最大数据实测:造一组 n 取上界的数据,time ./a < big.in > /dev/null,看 real 是否超过时限。
4
程序崩溃时改用 GDB 编译:g++ a.cpp -g -O0 -Wall -o a_dbg,然后 gdb ./a_dbg。
5
定位:r < in.txt 运行;崩溃后敲 bt 得到出错行号,再 info locals 看清所有局部变量。
6
验证修复:改完后重新用 Sanitizer 版跑一遍确认不再报警,再用 -O2 版实测时间是否达标。
📌 关键在于顺序:先估算 → 再实测 → 出问题才上 GDB。跳过估算直接调试,是最浪费时间的做法。
案例案例1:用 GDB 抓出数组越界 经典

症状:程序在最大数据上直接崩溃,报 Segmentation fault (core dumped)。

C++有 bug 的 bug1.cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 5;
int a[N];
int main() {
    int n; cin >> n;                 // 输入 5
    for (int i = 0; i <= n; i++) cin >> a[i];   // ❌ i 可以等于 n,越界
    long long s = 0;
    for (int i = 0; i < n; i++) s += a[i];
    cout << s << endl;
    return 0;
}

🔍 代码逐段解析 · bug1.cpp(越界版)

第 3~4 行 —— const int N = 5,全局数组 a[5] 的合法下标只有 0~4。

第 6~7 行(病灶) —— 读入循环写成 i <= n:i 会取到 5,cin >> a[5] 写到数组外面 —— 与"把 n 个数读进 a[0..n-1]"的意图不符。

第 8~9 行 —— 求和循环正确地用 i < n,两处上界不一致正是"读入越界、求和正常"这种迷惑现象的来源。

第 10 行 —— 输出 s。

易错点 —— ① <= 与 < 一字之差;② 修复思路有两种:改循环上界,或(当确实需要 a[1..n] 下标习惯时)把数组开到 N+1 —— 二选一,不能都不做。

🔍 调试过程(真实 GDB 会话)

第 1 步 · 带调试信息编译。g++ bug1.cpp -g -O0 -Wall -o bug1。注意 -O0:优化会让行号与执行顺序错位,调试阶段一律关优化。

第 2 步 · 进入 GDB 并运行。gdb -q ./bug1,然后 r 并输入数据。

第 3 步 · 崩溃后第一件事:看调用栈。敲 bt,GDB 给出 #0 main () at bug1.cpp:7 —— 崩在第 7 行。

第 4 步 · 看现场变量的值。info locals 显示 i = 5、n = 5,而数组 a 只有 5 个元素(下标 0~4)。i <= n 让 i 取到 5,a[5] 越界。

第 5 步 · 验证结论。p i 得 $1 = 5,p &a[5] 可以看到它的地址已经在数组之外。

第 6 步 · 交叉验证。用 -fsanitize=address 编译运行,Sanitizer 会直接报 stack-buffer-overflow,与 GDB 的结论一致。

GDB 会话实录
$ g++ bug1.cpp -g -O0 -Wall -o bug1
$ gdb -q ./bug1
Reading symbols from ./bug1...done.
(gdb) r
Starting program: /home/noi/bug1
5
1 2 3 4 5

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555234 in main () at bug1.cpp:7
7           for (int i = 0; i <= n; i++) cin >> a[i];
(gdb) bt
#0  main () at bug1.cpp:7
(gdb) info locals
n = 5
i = 5                       ← i 已经等于 n
(gdb) p i
$1 = 5
(gdb) p i <= n
$2 = true                   ← 循环条件允许 i 取到 n
(gdb) q

# 交叉验证:用 Sanitizer 再看一次
$ g++ bug1.cpp -g -fsanitize=address,undefined -o bug1_san
$ ./bug1_san
==2145==ERROR: AddressSanitizer: global-buffer-overflow on address 0x5555...
WRITE of size 4 at 0x5555... thread T0
    #0 0x4012ab in main bug1.cpp:7
C++修复:数组多开一格 + 循环上界收紧
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;              // ✅ 按数据上界开,并留出余量
int a[MAXN + 5];                        // 再多开 5 个,应对 i+1 这类访问
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    for (int i = 0; i < n; i++) cin >> a[i];   // ✅ i < n
    long long s = 0;
    for (int i = 0; i < n; i++) s += a[i];
    cout << s << endl;
    return 0;
}

🔍 代码逐段解析 · bug1.cpp(修复版)

第 3~4 行 —— 数组开到 MAXN + 5:按数据上界留余量,同时为 i+1 这类偏移访问留出安全区。

第 7 行 —— 循环上界改为 i < n:读入恰好 n 个,下标 0..n-1 全部合法。

第 6~9 行 —— 求和逻辑不变,输入 5 / 1 2 3 4 5 输出 15。

易错点 —— 修完一处后要全局检查同类写法:本文件还有别的 i <= n 吗?别的数组也开够了吗?bug 往往成群出现。

修复后结果
$ g++ bug1.cpp -g -fsanitize=address,undefined -o bug1_san && ./bug1_san <<< "5
1 2 3 4 5"
15                          ← 无 Sanitizer 报警,结果正确
$ g++ bug1.cpp -O2 -Wall -o bug1 && ./bug1 <<< "5
1 2 3 4 5"
15
🔑 越界三查

① 数组开得够不够(按上界加余量);② 循环是 < 还是 <=;③ 有没有 i+1、i-1 这类偏移访问。

案例案例2:用 GDB 终结"死循环" 进阶

症状:程序永不结束,CPU 占用跑满,屏幕没有任何输出。

C++有 bug 的 bug2.cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;                 // 输入 5
    int i = 1, s = 0;
    while (i <= n) {                 // ❌ i 永远出不去
        s += i;
        i++;                         // 每轮自增……
        if (i == 3) i = 1;           // ……但走到 3 又被"重置"回 1,死循环
    }
    cout << s << endl;
    return 0;
}

🔍 代码逐段解析 · bug2.cpp(死循环版)

第 4~5 行 —— 读入 n;i = 1, s = 0 作为循环变量与累加器。

第 6~8 行 —— while 循环体内 s += i; i++; 看似正常推进。

第 9 行(病灶) —— if (i == 3) i = 1;:每当 i 走到 3 就被拽回 1,于是 i 永远在 1→2→3→1 打转,i <= n 恒成立,程序永不结束。

第 11 行 —— 输出语句永远执行不到。

易错点 —— ① "重置"逻辑必须配合明确的终止条件,否则就是死循环;② 判断死循环的不变量:"循环变量每轮是否一定变化、变化方向是否朝着终止条件"。

🔍 调试过程

第 1 步 · 先中断。程序在终端里"卡住"时,按 Ctrl+C 会强制中断,GDB 会停在"当前正在执行的那一行"。

第 2 步 · 看栈。敲 bt,得到 #0 main () at bug2.cpp:6 —— 说明程序一直困在第 6 行的 while 里。

第 3 步 · 看变量。p i 得 1,p n 得 5。循环条件 i <= n 始终成立;i 在 1~3 之间来回打转,永远无法超过 n。

第 4 步 · 定位"罪魁祸首"。用条件断点观察 i 何时被改:b 9 if i == 3,程序停在 if (i == 3) i = 1; 前;执行后 p i 变回 1 —— 正是这一行把 i 重置了。

第 5 步 · 用 watchpoint 更快。更聪明的做法是设置监视点:watch i,让 GDB 在 i 每次被修改时自动停下,直接打印调用位置与新旧值 —— 一步就能抓到"谁改了它"。

GDB 会话实录
$ g++ bug2.cpp -g -O0 -Wall -o bug2
$ gdb -q ./bug2
(gdb) r
Starting program: /home/noi/bug2
5
^C                                ← 手动 Ctrl+C 中断
Program received signal SIGINT, Interrupt.
0x0000555555555230 in main () at bug2.cpp:6
6           while (i <= n) {
(gdb) bt
#0  main () at bug2.cpp:6
(gdb) p i
$1 = 1
(gdb) p n
$2 = 5
(gdb) p i <= n
$3 = true                          ← 条件永远为真,出不去

# ── 用 watchpoint 一步抓到"修改者" ──
(gdb) watch i
Hardware watchpoint 2: i
(gdb) c
Old value = 3
New value = 1                      ← i 在第 9 行被改回 1
main () at bug2.cpp:9
9               if (i == 3) i = 1;
(gdb) q
C++修复:让循环变量正常前进
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    long long s = 0;
    for (int i = 1; i <= n; i++) {   // ✅ 用 for 保证每轮自增,不易漏写
        s += i;
        // 需要"重置"时,必须先无条件自增或改用显式推进
    }
    cout << s << endl;
    return 0;
}

🔍 代码逐段解析 · bug2.cpp(修复版)

第 6 行 —— 把 while 改成 for (int i = 1; i <= n; i++):自增写进 for 的第三段,结构上保证每轮必推进,不会漏写。

第 7 行 —— s += i 求和;s 用 long long(n 大时 1+2+…+n 可达 5×109,int 装不下)。

第 8 行 —— 原来那句"重置"已删除:若业务上确实需要重置,必须保证循环整体仍朝终止条件前进。

易错点 —— 修复后用 time ./bug2 <<< 5 实测:real 0.002s 正常结束 —— 死循环类 bug 修没修好,实测一眼可见。

修复后结果
$ g++ bug2.cpp -g -O0 -Wall -o bug2 && ./bug2 <<< 5
15                          ← 1+2+3+4+5,正常结束
$ g++ bug2.cpp -O2 -Wall -o bug2 && time ./bug2 <<< 5
15
real    0m0.002s            ← 已不再死循环
排查死循环的通用套路:① Ctrl+C 中断 → ② bt 找到卡住的循环 → ③ 打印循环变量 → ④ 确认"每轮是否一定会变、变化方向是否朝着终止条件"。watch 监视点专门用来回答"这个变量到底被谁改了"。
案例案例3:用 GDB 抓出未初始化变量 进阶

症状:程序不崩溃,但结果时对时错,换个编译选项就变了。这是最难查的一类 bug。

C++有 bug 的 bug3.cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    int mx, mn;                     // ❌ 两个变量都没初始化
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        if (i == 0) { mx = mn = x; }
        else { mx = max(mx, x); mn = min(mn, x); }
    }
    cout << mx << " " << mn << endl;
    return 0;
}

🔍 代码逐段解析 · bug3.cpp(未初始化版)

第 5 行(病灶) —— int mx, mn; 声明即使用,没有初值。若 n ≥ 1,第 8 行 i == 0 分支会给它们赋值,"看起来没事";但 n = 0 时循环一次都不执行,第 11 行直接输出栈上垃圾。

第 8~9 行 —— "首个元素特判 + 后续取 max/min"的写法把正确性押在了"循环一定执行"上,属于脆弱设计。

第 11 行 —— 输出两个可能未定义的值:时对时错、换编译选项就变。

易错点 —— ① 测试只用了 n ≥ 1 的数据,恰好掩护了 n = 0 这条路径;② -Wall -Wextra 会直接警告 'mx' may be used uninitialized,这类问题应该先让编译器说话。

🔍 调试过程

第 1 步 · 先看编译器怎么说。g++ bug3.cpp -O2 -Wall -Wextra -o bug3 立刻给出 'mx' may be used uninitialized。很多问题是编译选项就能解决的,GDB 用来处理"编译器不肯说"的那部分。

第 2 步 · 用 GDB 观察"值到底是什么"。在循环后设断点 b 11,运行后 p mx 输出一个奇怪的大数,如 32767 —— 那不是输入中的任何数,而是栈上的残留值。

第 3 步 · 找到"未被赋值的那条路径"。关键问题:如果 n == 0,循环根本不执行,mx/mn 从未被赋值就被输出。这是逻辑不足,而不是"偶尔出错"。

第 4 步 · 用 display 持续观察。display mx 让 GDB 每次停下都打印 mx,可以清楚看到它在第几次迭代被改成了什么。

第 5 步 · 交叉验证。用 Sanitizer 编译(-fsanitize=undefined)也会报告未初始化读写;两种工具互相印证。

GDB 会话实录
$ g++ bug3.cpp -g -O0 -Wall -Wextra -o bug3
bug3.cpp: In function 'int main()':
bug3.cpp:5:9: warning: 'mx' may be used uninitialized [-Wmaybe-uninitialized]
    5 |     int mx, mn;
      |         ^~
bug3.cpp:5:13: warning: 'mn' may be used uninitialized [-Wmaybe-uninitialized]

$ gdb -q ./bug3
(gdb) b 11
Breakpoint 1 at 0x11f0: file bug3.cpp, line 11.
(gdb) r
Starting program: /home/noi/bug3
3
1 5 3
Breakpoint 1, main () at bug3.cpp:11
11          cout << mx << " " << mn << endl;
(gdb) p mx
$1 = 5                     ← 这次"运气好",因为循环执行了
(gdb) display mx
1: mx = 5

# ── 换成 n = 0,问题暴露 ──
(gdb) r
Starting program: /home/noi/bug3
0
Breakpoint 1, main () at bug3.cpp:12
1: mx = 32767              ← 栈上的垃圾值!循环一次都没执行
(gdb) q
C++修复:声明即初始化 + 处理边界情况
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    // ✅ 用"哨兵值"初始化,保证即使 n=0 也有确定结果
    const long long INF = LLONG_MAX;
    long long mx = -INF, mn = INF;
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        mx = max(mx, x);
        mn = min(mn, x);
    }
    if (n == 0) cout << "empty" << endl;   // 显式处理边界
    else        cout << mx << " " << mn << endl;
    return 0;
}

🔍 代码逐段解析 · bug3.cpp(修复版)

第 6~7 行 —— 哨兵值初始化:mx = -INF(求最大从负无穷起步)、mn = INF(求最小从正无穷起步),首个元素自然取代哨兵,不再需要 i == 0 特判。

第 8~10 行 —— 循环体只剩两行对称的 max/min,逻辑路径唯一。

第 11~12 行 —— 显式处理 n == 0:要么输出约定结果,要么按题目要求特判,绝不让未初始化的值流到输出。

易错点 —— 哨兵值要按数据范围选:a[i] 可达 109(负值同理),用 int 的 INF(约 2.1×109)可以,但累加求和场景必须用 long long 的 LLONG_MAX/LLONG_MIN,否则本身会溢出。

修复后结果
$ g++ bug3.cpp -O2 -Wall -Wextra -std=c++17 -o bug3
(无警告)
$ ./bug3 <<< "3
1 5 3"
5 1
$ ./bug3 <<< 0
empty                       ← 边界情况被显式处理,不再输出垃圾值
$ ./bug3 <<< "4
-2 1000000000 -7 3"
1000000000 -7
🔑 未初始化防身术

① 声明就初始化(求最大用 -INF,求最小用 INF,求和用 0);
② 用哨兵值替代 if (i == 0) 这类首次判断;
③ 编译必带 -Wall -Wextra,让编译器先替你看一眼。

模块 03

实践应用

三道题:估规模、做对比实验、写完整调试过程。

题1估规模判复杂度
基础分析题

给定三道题的数据范围,判断下列算法能否在 1 秒内通过

输入格式:三个任务:
① n ≤ 5000,算法为双重循环 O(n²);
② n ≤ 2000,算法为三重循环 O(n³);
③ n ≤ 105,算法为 O(n log n) 排序 + O(n) 扫描。

输出格式:对每项输出"可行 / 危险 / 不可行",并给出估算的运算量。

输入样例:如上三项。输出样例:

期望输出
① 2.5e7 次 → 可行(远小于 1e8)
② 8e9 次 → 不可行(约为上限的 80 倍)
③ 约 1.7e6 + 1e5 次 → 可行(余量充足)

数据范围:时限 1 s,内存 256 MB;估算基准为 1 s ≈ 108 次简单运算。

🔍 题目分析(两种判断风格)

方法一 · 直接算总量把 n 代入复杂度表达式,得到总运算量再与 108 比较。适合快速判断。

方法二 · 反推可接受的最大 n由 f(n) ≤ 108 解出 n 的上界:O(n²) 允许 n ≤ 104;O(n³) 允许 n ≤ 460;O(n log n) 允许 n ≤ 5×106。适合"拿到题先挑算法"。

第 ② 项的细节:2000³ = 8×109,是上限的 80 倍,即使常数极小也绝无可能通过 —— 这类题必须换到 O(n²) 甚至更低。

第 ③ 项的细节:n log n = 105 × 17 ≈ 1.7×106,余量极大。但要注意 log 的底数与常数:手写归并排序的常数比 std::sort 大,实测可能差 2 倍。

结论:估值是"筛选"工具,不是"判决"工具。估算说可行,仍要用最大数据实测确认。

Shell用实测验证估算(必做的一步)
# 生成三项任务对应的最大规模数据并实测
python3 -c "import random;n=5000;print(n);print(' '.join(str(random.randint(1,10**9)) for _ in range(n)))" > t1.in
python3 -c "import random;n=2000;print(n);print(' '.join(str(random.randint(1,10**9)) for _ in range(n)))" > t2.in
python3 -c "import random;n=100000;print(n);print(' '.join(str(random.randint(1,10**9)) for _ in range(n)))" > t3.in

g++ sol.cpp -O2 -Wall -o sol

time ./sol < t1.in > /dev/null
# real    0m0.041s    → 与估算的 2.5e7 相符 ✅
time ./sol < t2.in > /dev/null
# real    0m8.912s    → 与估算的 8e9 相符,确认不可行 ❌
time ./sol < t3.in > /dev/null
# real    0m0.019s    → 轻松通过 ✅
运行结果
$ time ./sol < t1.in > /dev/null
real    0m0.041s
$ time ./sol < t2.in > /dev/null
real    0m8.912s
$ time ./sol < t3.in > /dev/null
real    0m0.019s

# 估算与实测的一致性说明估值方法可靠
# 唯一"失手"的可能:常数特别大(如循环体内做除法/取模)

💡 一个经验修正

如果循环体内有取模、除法、map 查找这类"重操作",把估算结果再乘 3~10 倍。保守估算 + 实测确认,是避免 TLE 最可靠的方法。

题2两种测时方法的对比实验
提高实验题

同一份程序,分别用 time 与 clock() 测时;再对比"输出到终端"与"输出到文件"的差异,解释原因。

输入格式:一个程序,读入 n 与 n 个整数,输出它们的和;分别在 n = 106 且"输出一行"与"每个数输出一行"两种模式下测试。

输出格式:四组数据:(终端 + 逐行输出)、(文件 + 逐行输出)、(文件 + 单行输出)、程序内部 clock() 测得的计算耗时。

输入样例:5
1 2 3 4 5
;输出样例:

期望输出(示意)
终端逐行输出:real 2.310s  user 0.180s  sys 0.420s
文件逐行输出:real 0.230s  user 0.180s  sys 0.020s
文件单行输出:real 0.185s  user 0.179s  sys 0.004s
clock() 内部:0.178s
结论:real ≫ user 说明瓶颈在 IO,不在算法

数据范围:输入规模 n ≤ 106;时限 1 s;内存 256 MB。

🔍 题目分析

第 1 步 · 为什么"终端逐行输出"最慢?每次 cout << endl 都会刷新缓冲区并触发一次系统调用写终端;终端还要做字符渲染。106 次写操作把时间全部花在 IO 上 —— 这体现在 real 与 sys 都很大,而 user 很小。

第 2 步 · 换成文件输出为什么快?写文件虽然也是系统调用,但没有终端渲染开销;再加上缓冲区合并,写次数大幅减少。

第 3 步 · 为什么 endl 要换成 "\n"?endl = 换行 + 强制刷新;"\n" 只换行不刷新,交给缓冲区批量写出。这是提高组最常被忽视的常数优化。

第 4 步 · clock() 为什么最小?它只计 CPU 时间,把"等终端渲染"和"等磁盘"的时间排除在外。因此 clock() 适合比较算法快慢,time 的 real 才决定是否 TLE。

结论:测时要固定 IO 方式(都重定向到文件),否则测出来的是 IO 速度而不是算法速度。

C++被测试程序 timing2.cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);        // 解除与 C 标准库的同步,显著加速 cin
    cin.tie(0);                     // 解绑 cin/cout,避免每次读入都刷新 cout
    int n; cin >> n;
    long long s = 0;
    clock_t begin = clock();
    for (int i = 0; i < n; i++) { int x; cin >> x; s += x; }
    clock_t mid = clock();
    cout << s << "\n";              // ✅ 用 \n 而不是 endl
    clock_t end = clock();
    cerr << "读入+求和耗时:" << (double)(mid - begin) / CLOCKS_PER_SEC << " s\n";
    cerr << "输出耗时:" << (double)(end - mid) / CLOCKS_PER_SEC << " s\n";
    return 0;
}

🔍 代码逐段解析 · timing2.cpp(被测程序)

第 4~5 行 —— IO 加速双件套:sync_with_stdio(0) 解除与 C 标准库同步,cin.tie(0) 解绑 cin/cout(读入前不再强制刷新输出)。

第 6~8 行 —— 读入 n;begin 时钟起点的位置有意放在"读 n 之后",让计时段覆盖"读入 n 个数 + 求和"。

第 9~10 行 —— 被测主体:循环读入并累加;mid 记录计算结束时刻,于是 mid - begin 就是"读入+求和"的 CPU 耗时。

第 11 行 —— 输出用 "\n" 不用 endl:不强制刷新缓冲区。

第 13~14 行 —— 两段耗时报告走 cerr:stderr 与 stdout 分流,测计报告不会混进程序输出,也不影响 diff 比对。

易错点 —— ① clock() 的两个读数必须夹住完整的被测段,漏一处计时不完整;② cerr 输出也会花时间,正式测速时把 stderr 重定向掉(2>/dev/null)。

Shell四组对比实验
g++ timing2.cpp -O2 -Wall -o timing2

# ①②③ 的数据来自"逐行输出模式"变体:在程序末尾再加一段
# for (int i = 0; i < n; i++) cout << i << "\n";
# 用来放大 IO 差异(n = 10^6 行输出)。单行输出的 timing2.cpp 本身差距没这么大。

# ① 输出到终端(最慢)
time ./timing2 < big.in
# real 2.310s  user 0.180s  sys 0.420s

# ② 输出到文件(推荐)
time ./timing2 < big.in > out.txt
# real 0.230s  user 0.180s  sys 0.020s

# ③ 丢弃输出(只测计算)
time ./timing2 < big.in > /dev/null
# real 0.185s  user 0.179s  sys 0.004s

# ④ 程序内部 clock() 报告(走 stderr,不污染输出)
./timing2 < big.in > /dev/null
# 读入+求和耗时:0.178 s
# 输出耗时:0.001 s
运行结果(整理对比)
终端输出:   real 2.310s   user 0.180s   sys 0.420s   ← real 远超 user+sys
文件输出:   real 0.230s   user 0.180s   sys 0.020s
丢弃输出:   real 0.185s   user 0.179s   sys 0.004s
clock()内测:0.178s(仅 CPU 时间)

结论:
1. 输出到终端让 real 变成原来的 12 倍,这是 IO 造成的,不是算法慢
2. user 几乎不变(0.18s),说明计算量没有变化
3. 用 clock() 测得 0.178s,与丢弃输出时的 user 基本一致
4. 所以:测算法速度看 clock()/user,判断是否 TLE 看 real
可直接带走的结论:① 测时一律重定向;② 输出用 "\n" 不用 endl;③ 读入加 ios::sync_with_stdio(0); cin.tie(0);;④ 调试信息走 cerr,不污染标准输出。
题3写出一段完整的 GDB 调试会话
提高操作题

程序在最大数据上 RE,请写出从编译到定位的完整 GDB 会话,并指出错误原因与修法

输入格式:源文件 wa.cpp 内容如下(求 n 个数的最大子段和,数组按 n 开但循环访问了 a[i+1]):

C++wa.cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    int a[n + 1];
    for (int i = 1; i <= n; i++) cin >> a[i];
    long long cur = 0, best = 0;
    for (int i = 1; i <= n; i++) {
        cur = max(cur + a[i], (long long)a[i + 1]);   // ❌ 用到 a[i+1]
        best = max(best, cur);
    }
    cout << best << endl;
    return 0;
}

🔍 代码逐段解析 · wa.cpp(RE 版)

第 5 行 —— 变长数组 int a[n+1](VLA):合法下标 0..n+1-1,即 0..n。VLA 不是标准 C++ 的必备特性,正式写法应改用全局数组或 vector。

第 6 行 —— 读入 a[1..n],1 起始的下标习惯。

第 9 行(病灶) —— (long long)a[i + 1]:当 i = n 时访问 a[n+1],比合法上界多走一格 —— 越界读,往往不崩、只读到垃圾值,所以样例能"碰巧"过。

第 10 行 —— best 初值为 0:全负数组会错答 0(修复版改为 LLONG_MIN)。

易错点 —— ① 转移方程里出现 i+1/i-1 时,必须回查数组容量是否覆盖偏移;② 用 -fsanitize=address 跑一遍,这一行会立刻被点名。

输出格式:一段可直接复现的 GDB 会话(含命令与关键输出),并给出错误原因与修改后的关键行。

输入样例:5 / -2 7 -1 4 -3;输出样例:见下方会话实录(bt 定位到第 9 行,p i 得 i = n)。

数据范围:1 ≤ n ≤ 105,|a[i]| ≤ 109;时限 1 s,内存 256 MB。

🔍 题目分析

第 1 步 · 先想"为什么不总是崩"。变长数组按 n+1 开,下标 1..n 合法;但 a[i+1] 在 i = n 时访问 a[n+1] 越界。越界一格往往"不崩",只是读到垃圾值 —— 这就是它偶尔能过样例的原因。

第 2 步 · 记忆点:变长数组 int a[n+1](VLA)在标准 C++ 中不是必备特性,开大数组应使用全局数组或 vector。此外大数组放在全局区可避免爆栈。

第 3 步 · 调试策略:崩溃 → bt 看行号 → p i、p n 确认下标越界 → 修成 a[i] 或把数组开到 MAXN+5。

GDB 会话实录
$ g++ wa.cpp -g -O0 -Wall -Wextra -o wa
$ gdb -q ./wa
(gdb) r
Starting program: /home/noi/wa
5
-2 7 -1 4 -3

Program received signal SIGSEGV, Segmentation fault.
0x00005555555552b1 in main () at wa.cpp:9
9               cur = max(cur + a[i], (long long)a[i + 1]);
(gdb) bt
#0  main () at wa.cpp:9
(gdb) p i
$1 = 5
(gdb) p n
$2 = 5                       ← i == n,而代码访问了 a[i+1] = a[6]
(gdb) p i + 1
$3 = 6                       ← 越界下标
(gdb) info locals
n = 5
cur = 17
best = 17
(gdb) q

$ # 想直接看数组越界时刻的值,也可用条件断点
$ gdb -q ./wa
(gdb) b 9 if i >= n
Breakpoint 1 at 0x12b0: file wa.cpp, line 9.
(gdb) r
5
-2 7 -1 4 -3
Breakpoint 1, main () at wa.cpp:9
(gdb) p i
$1 = 5                       ← 正是在最后一轮出事
C++修复:改后缀表达式 + 大数组放全局
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long a[MAXN + 5];               // ✅ 全局大数组,按上界+余量开
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    long long cur = 0, best = LLONG_MIN;
    for (int i = 1; i <= n; i++) {
        cur = max(a[i], cur + a[i]);      // ✅ 只用 a[i],不再访问 a[i+1]
        best = max(best, cur);
    }
    cout << best << endl;
    return 0;
}
🔍 代码逐段解析 · wa.cpp(修复版 · 最大子段和)
1
第 3~4 行:MAXN = 100005、全局数组 a[MAXN+5] —— 按数据上界加余量开,并避免大数组放栈上导致爆栈。
2
第 7 行:best = LLONG_MIN —— 关键修复二。全负数组(如 -5 -2 -8)的正确答案是 -2 而不是 0,初值必须"比任何可能的答案都小"。
3
第 9 行(状态转移):cur = max(a[i], cur + a[i]) —— 要么把 a[i] 接到前面的子段上,要么从 a[i] 另起新段;只用 a[i],彻底移除了越界的 a[i+1]。
4
第 10 行:best = max(best, cur) 随时记录历史最优 —— 最优子段未必以最后一个元素结尾。
5
样例手验:a = -2 7 -1 4 -3。cur 依次为 -2→7→6→10→7,best 依次为 -2→7→7→10→10,输出 10 = 7+(-1)+4 ✅。
6
易错点:① best 初值用 0 会在全负数组上错答 0;② 复杂度 O(n),n ≤ 105 时约 105 次运算,实测远低于 1s 时限。
📌 这就是 Kadane 算法:一维 DP,"以 i 结尾的最大子段和"层层递推,空间 O(1)。
修复后结果
$ g++ wa.cpp -g -fsanitize=address,undefined -Wall -o wa_san
$ ./wa_san <<< "5
-2 7 -1 4 -3"
10                          ← 7 + (-1) + 4 = 10,无 Sanitizer 报警
$ g++ wa.cpp -O2 -Wall -o wa && ./wa <<< "5
-2 7 -1 4 -3"
10
$ # 全负数测试(考验初始值设置)
$ ./wa <<< "3
-5 -2 -8"
-2
两个附带结论:① 求最大子段和时 best 应初始化为负无穷(LLONG_MIN),否则全负数数组会错答成 0;② 大数组(105 级别)应放全局,栈上放不下会导致栈溢出型 RE。

💡 从这道题学到的方法论

RE 的排查顺序永远是:先 bt 定位行 → 再 p 看下标与边界 → 最后想"为什么样例不崩"。记住一句话:越界一格通常不崩,只读到垃圾值,所以它能骗过样例,骗不过大数据。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心赛场用途
time 三时间real 总耗时 / user 算法 / sys 系统调用判断是算法慢还是 IO 慢
测时前提一定重定向:time ./a < in > out避免终端输出污染测时
clock()测 CPU 时间,适合比较算法定位性能瓶颈在哪一段
规模尺子1 s ≈ 108 次简单运算拿到题先判断复杂度可行性
GDB 前提必须 -g 编译,调试时用 -O0看到变量名与行号
GDB 核心b 断点 / r 运行 / n 单步 / p 打印 / bt 看栈定位崩溃与逻辑错误
core dumpulimit -c unlimited + gdb ./a core事后分析崩溃现场
三类 bug 套路越界看下标、死循环看变量不变量、未初始化看初值快速缩小范围

万能模板:性能与正确性双检脚本

Shellcheck.sh —— 测时 + 查错一条龙
#!/bin/bash
# 用法:bash check.sh a.cpp big.in
SRC="${1:-a.cpp}"; DATA="${2:-big.in}"; N="${SRC%.cpp}"

g++ "$SRC" -O2 -Wall -Wextra -std=c++17 -o "$N" || exit 1
g++ "$SRC" -g -O0 -fsanitize=address,undefined -o "${N}_san"

echo "=== 1. Sanitizer 检查(抓越界与 UB)==="
./"${N}_san" < "$DATA" > /dev/null
[ $? -ne 0 ] && echo "⚠️ 运行异常,请看上方 Sanitizer 报告"

echo "=== 2. 性能实测(看 real 是否超时)==="
time ./"$N" < "$DATA" > /dev/null

echo "=== 3. 崩溃则用 GDB 事后分析 ==="
ulimit -c unlimited
./"$N" < "$DATA" > /dev/null 2>&1 || \
  echo "提示:若生成了 core,可执行 gdb ./$N core 后输入 bt"

🔍 代码逐段解析 · check.sh(测时 + 查错一条龙)

第 3 行 —— 取源文件、数据文件两个参数(都有缺省值),${SRC%.cpp} 派生产物基名。

第 5~6 行 —— 编译两个版本:-O2 -Wall -Wextra 交卷版(编译失败 || exit 1 立即中止)+ -g -O0 -fsanitize 查错版。

第 9~11 行(第 1 检) —— 先跑查错版:输出丢到 /dev/null(我们只关心有没有报警);[ $? -ne 0 ] 捕获 Sanitizer 检出问题后的非零退出码并提示。

第 13~14 行(第 2 检) —— 交卷版 + time 实测,直接看 real 与时限的关系。

第 16~19 行(第 3 检) —— ulimit -c unlimited 开启 core dump 后再跑一次,若仍崩溃则提示用 gdb ./a core 事后分析;2>&1 把 stderr 合并进来便于观察。

易错点 —— ① $? 必须紧跟在目标命令之后读取;② 脚本把"程序正常退出"当作通过,若程序 RE 但 Sanitizer 未开启会漏检 —— 所以第 1 检必须用 Sanitizer 版。

运行结果
$ bash check.sh a.cpp big.in
=== 1. Sanitizer 检查(抓越界与 UB)===
=== 2. 性能实测(看 real 是否超时)===

real    0m0.412s
user    0m0.401s
sys     0m0.008s

=== 3. 崩溃则用 GDB 事后分析 ===
# real ≈ user + sys,说明是纯计算型,没有被 IO 拖慢
# 0.412s < 1s,可以交卷
②拓展案例

拓展 1:用 GDB 分析"程序跑一半就卡住"(两种手段)

情境:程序不是"一直跑",而是"跑到某个测试点就再也不出结果",无法判断卡在哪。

手段一 · 直接进 GDB 运行 + Ctrl+C在 GDB 里 r,等它卡住后按 Ctrl+C,然后 bt 看栈、p 看循环变量。

手段二 · attach 到运行中的进程先正常运行程序,从另一个终端用 gdb -p PID 附加(PID 用 ps aux | grep a.out 查),然后 bt。适合"进程已经在跑很久"的场景。

Shell两种手段的实操
# 手段一:GDB 内中断
gdb -q ./a
(gdb) r < big.in
# 卡住后按 Ctrl+C,然后:
(gdb) bt                    # 看卡在哪个函数
(gdb) frame 0; p i          # 看当前帧的循环变量
(gdb) p i <= n              # 检查循环条件是否恒真

# 手段二:attach 到已运行的进程
./a < big.in &
# [1] 31415
gdb -p 31415 -q
(gdb) bt
# #0  main () at a.cpp:22    ← 卡在第 22 行
(gdb) p i
# $1 = 0                    ← 循环变量根本没变
(gdb) detach
(gdb) q
kill %1

拓展 2:用条件断点 + display 高效调试长循环

情境:一个 40 行的循环,只在前 5 次迭代正确,第 6 次开始出错。逐行 n 五次太慢。

解法:用条件断点 b 15 if i == 6 让程序直接停在出事的那一轮;再用 display 持续显示关键变量,之后每次 n 都会自动打印它们的值。

效率对比逐行单步需要按几十次回车;条件断点 + display 通常 3 条命令就到位。

Text条件断点会话
(gdb) b 15 if i == 6
Breakpoint 1 at 0x1234: file a.cpp, line 15.
(gdb) r < in.txt
Breakpoint 1, main () at a.cpp:15
15              f[i] = f[i-1] + f[i-2];
(gdb) display i
1: i = 6
(gdb) display f[i-1]
2: f[i-1] = 32767          ← 一眼看出上一项是垃圾值
(gdb) n
16          }
2: f[i-1] = 32767
1: i = 6
(gdb) p f[0]@5             ← 一次打印数组前 5 个元素(GDB 的区间语法)
$1 = {0, 1, 1, 2, 3}       ← 只有 f[0..4] 被初始化,循环从 6 开始越过了 f[5]
(gdb) q

拓展 3:把测时结论转化为常数优化

情境:实测 real = 1.4 s,而算法复杂度已经是最优。怎么办?

思路:只差 0.4 秒,属于"常数问题",可以做三件事:① 把 cin 换成 scanf / 加 ios::sync_with_stdio(0);② 把 endl 全部换成 "\n";③ 把 vector 访问改成数组、把函数调用改为 inline。实测通常能回收 30%~60% 的时间。

Shell优化前后实测对比
# 优化前:cin + endl + 未解绑
g++ slow.cpp -O2 -o slow && time ./slow < big.in > /dev/null
# real    0m1.412s

# 优化后:加 sync_with_stdio(0) + cin.tie(0) + \n
g++ fast.cpp -O2 -o fast && time ./fast < big.in > /dev/null
# real    0m0.518s

# 结论:算法没变,仅 IO 优化就快了 2.7 倍,已能通过
③思考与启发

💡 思考 1:为什么说"样例通过"几乎不能说明任何问题?

结合本讲的测时方法与三类 bug,说明样例在"性能"与"正确性"两个维度上的局限。

查看参考思路

性能维度:样例的数据规模通常极小(如 n = 10),10³ 次运算在任何复杂度下都是"瞬间完成"。用样例测时间等于没测 —— 必须用最大规模数据实测。

正确性维度:三类经典 bug 都能骗过样例:越界一格读到垃圾值(样例数据小,数组恰好在边界外仍是"安全的栈空间");未初始化变量恰好被赋过值;死循环只在特定输入下才发生。

正确的验证策略是三层:① 样例(保证基本正确);② 对拍(保证随机数据正确);③ Sanitizer + 最大数据测时(保证无 UB 且不超时)。

结论:样例只是"下限验证",把样例当作全部验证是提高组最常见的认知误区之一。

💡 思考 2:GDB 与 printf 调试,各自适合什么场景?

有同学说"我在代码里插 printf 也能看变量,何必学 GDB"。请比较两者的优劣。

查看参考思路

printf 的优势:零学习成本;输出的就是程序真实行为;在无法使用 GDB 的环境(如某些在线评测)里也能用;一次输出可包括大量信息。

GDB 的优势:① 不用改代码,不会引入"调试语句忘删"的风险;② 可以任意时刻查看任意变量,包括没打印出来的;③ 崩溃后能事后分析(core dump);④ watch 能回答"谁改了我的变量";⑤ bt 能看清调用链。

实践建议:大规模输出中间状态用 printf/cerr(信息量大、可重定向到文件分析);崩溃与死循环排查用 GDB(不改代码、能看现场)。两者互补,不是替代关系。

关键纪律:用 printf 调试时,输出必须走 stderr(cerr),这样不会污染标准输出;交卷前用 grep -n "cerr\|printf" 源文件 检查残留。

💡 思考 3:如果只给你 1 分钟排查一个 RE,你会怎么做?

请设计一个"1 分钟应急排查流程",要求每一步都能给出明确信息。

查看参考思路

0~15 秒 · 编译查错版。g++ a.cpp -g -O0 -fsanitize=address,undefined -Wall -o a_san,运行并读 Sanitizer 报告 —— 它能直接告出"越界 / 除零 / 空指针 / 未初始化"。

15~30 秒 · GDB 看栈。如果没有 Sanitizer 报告但程序仍崩,进 GDB r 后 bt,得到出错行号;再 info locals 看清所有局部变量。

30~45 秒 · 三项固定怀疑。① 数组是否开够(含 i+1 偏移);② 递归深度是否爆栈(改大数组/改迭代);③ 是否有除零或取模零。

45~60 秒 · 生成最小复现。把出错的输入缩减到最小规模(例如只留 3 个数),确认"在什么条件下必崩",然后针对性修改。

核心思想:顺序固定、每步都有产出。有流程的人 1 分钟能定位问题,没有流程的人 1 小时还在试。

模块 05

学习检测

先找错,再背口诀 —— 把易错点消灭在考试之前。

易错1

不重定向就测时,把 IO 当成了算法慢

❌ 错误
time ./a < big.in(输出刷到终端),得到 real 2.3s 就断定"算法太慢",回头把正确的算法改坏了。
✅ 正确
time ./a < big.in > /dev/null。若 user 只有 0.18s 而 real 是 2.3s,说明瓶颈在 IO,不是算法。
口诀:测时不重定向,测出来的是屏幕刷新速度;real 大、user 小 → 问题在 IO!
易错2

用样例估算时间,以为"瞬间完成"就没问题

❌ 错误
样例 n = 10 时 O(n³) 只用 1000 次运算,于是认为 n = 2000 的 O(n³) 也没问题(实际 8×109 次)。
✅ 正确
先按最大范围算运算量:2000³ = 8×109 ≫ 108 → 直接判定不可行;再用最大数据 time 实测确认。
口诀:用最大数据估规模,别用样例骗自己;一秒十的八次方,超过就得换算法!
易错3

调试时忘记加 -g,GDB 看不到变量名

❌ 错误
g++ a.cpp -O2 -o a 后进 GDB,敲 p i 却报 No symbol "i" in current context,或 bt 只显示地址不显示行号。
✅ 正确
调试版必须加 -g,并建议加 -O0:g++ a.cpp -g -O0 -Wall -o a。-O2 会让变量被优化掉、行号与执行顺序错位。
口诀:调试要加 -g -O0,看得到变量看得到行;-O2 调试是自找麻烦!
易错4

死循环里用逐行单步,永远等不到头

❌ 错误
进入死循环后一直敲 n,程序永远停在同一个循环里,浪费时间。
✅ 正确
先用 Ctrl+C(或从 GDB 中断)停下,然后 bt 看位置、p 循环变量 看值;更高效的是设监视点 watch i,让 GDB 在变量改变时自动报告。
口诀:死循环别单步,先中断再看栈;想看谁改了变量,watch 一下就现形!
易错5

用 cout << endl 输出百万行,被 IO 拖到 TLE

❌ 错误
循环里写 cout << a[i] << endl;。endl 每次都强制刷新缓冲区,百万次刷新造成大量系统调用。
✅ 正确
改用 cout << a[i] << "\n";;并在 main 开头加 ios::sync_with_stdio(0); cin.tie(0);,实测常能快 2~5 倍。
口诀:endl 是"换行+刷新","\n" 只换行;大批量输出只用 "\n",再解开 cin 的同步!
📋速查表:测时与调试
类别命令 / 操作要点
测时time ./a < in > out必须重定向;real 决定是否 TLE
丢弃输出> /dev/null只测计算、不看结果时用
内部计时clock() / CLOCKS_PER_SEC测 CPU 时间;小心被 -O2 优化掉
规模尺子1 s ≈ 108 次简单运算O(n²) 允许 n ≤ 10⁴;O(n³) 允许 n ≤ 460
调试编译-g -O0 -Wall不加 -g 则看不到变量与行号
启动 GDBgdb -q ./a-tui 可分屏显示源码
断点b 行号 / b 函数名 / b 行号 if 条件条件断点是排查循环的利器
运行与控制r / n / s / c / Ctrl+C回车重复上一条命令
查看数据p 变量 / display 变量 / info localsp arr[0]@5 可打印数组区间
监视变量watch 变量变量改变时自动停下,抓"谁改的"
崩溃分析bt / frame n崩溃后第一件事就是 bt
core dumpulimit -c unlimited + gdb ./a core事后分析,不必重现
退出q程序运行中需先回答 y 确认
IO 优化ios::sync_with_stdio(0); cin.tie(0); + "\n"常能提速 2~5 倍
📌知识点笔记卡
📖 必记概念
  • real 总耗时、user 算法、sys 系统调用
  • 评测机时限针对 real
  • 1 s ≈ 108 次简单运算
  • GDB 需要 -g 才有调试信息
  • core dump 是"死亡现场"内存快照
  • endl = 换行 + 强制刷新
🧰 必背命令
  • 测时:time ./a < big.in > /dev/null
  • 调试编译:g++ a.cpp -g -O0 -Wall -o a
  • 进调试:gdb -q ./a → b 行号 → r
  • 看现场:bt、info locals、p 变量
  • 监视:watch 变量
  • 事后:ulimit -c unlimited 后 gdb ./a core
🚨 考场提醒
  • 估规模 → 生成最大数据 → 实测三步走
  • 测时一律重定向,别让终端拖慢
  • 调试结束记得换回 -O2 交卷
  • 删除调试残留(grep -n "cerr\|printf")
  • 除零与越界优先怀疑
  • 递归深时注意爆栈
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

解释 time 输出的 real / user / sys 三个时间的含义,并说明评测机的时限对应哪一个。

查看参考答案

real:从程序启动到结束真实流逝的"墙上时间",包含 CPU 计算、IO 等待与进程被调度抢占的时间。

user:程序在用户态消耗的 CPU 时间,主要反映算法计算量。

sys:程序在内核态消耗的 CPU 时间,主要来自读写文件、申请内存等系统调用。

时限对应 real。所以"user 很小"不代表安全 —— 如果 IO 很慢,real 一样会超时。

易错:以为评测机只看 CPU 时间(user+sys),从而忽视 IO 优化。

A2估算题

某题 n ≤ 5000,时限 2 s。O(n²) 与 O(n³) 两种解法分别能否通过?

查看参考答案

O(n²):5000² = 2.5×107,远小于 108,2 秒时限下非常安全。

O(n³):5000³ = 1.25×1011,是 1 秒上限的 1000 倍以上,绝不可能通过,必须换算法。

考点:用 1 s ≈ 108 这把尺子做快速筛选;时限 2 s 就把上限看成 2×108。

易错:忘了循环体内可能有除法/取模等重操作(常数放大 3~10 倍);或忽略内存限制 —— O(n³) 若还要开三维数组会直接 MLE。

A3GDB 操作

写出完成下列操作的 GDB 命令:① 在第 25 行设断点 ② 运行并传入 in.txt 作为输入 ③ 打印变量 ans ④ 查看调用栈 ⑤ 退出

查看参考答案

前置:g++ a.cpp -g -O0 -Wall -o a,然后 gdb -q ./a。

① b 25;② r < in.txt;③ p ans;④ bt;⑤ q。

考点:断点、运行(带输入重定向)、打印变量、查看调用栈、退出五件套。

易错:编译时忘记 -g(导致 p ans 报找不到符号);或写 run in.txt(GDB 不认这种输入方式,必须写 r < in.txt)。

A4情境判断

程序跑大测试点时 real = 3.2s、user = 0.15s、sys = 0.02s。请判断问题出在哪,并给出两条优化建议。

查看参考答案

判断:real ≫ user + sys,说明程序大部分时间在"等待",而 CPU 计算只用了 0.15 秒 —— 瓶颈是 IO,不是算法。

建议一:检查输出方式。把 cout << ... << endl 改为 "\n",避免每次刷新缓冲区。

建议二:检查读入方式。在 main 开头加 ios::sync_with_stdio(0); cin.tie(0);,或改用 scanf / 更快的手写读入。

易错:看到 real 大就以为"算法不行",于是去改正确的算法 —— 这会白白浪费大量时间,甚至把对的改错。

🟡 B组 · 提高(4题)

B1调试实操

下面代码输入 5
1 2 3 4 5
时输出正确,输入 3
-1 -2 -3
时输出 0(应为 -1)。写出用 GDB 定位问题的完整过程。

int main() {
    int n; cin >> n;
    int mx = 0;                  // ❌ 初值不该是 0
    for (int i = 0; i < n; i++) { int x; cin >> x; mx = max(mx, x); }
    cout << mx << endl;
}
查看参考答案

思路:先用 bt/断点确认执行路径,再用 display 观察 mx 是否被更新。

会话:g++ a.cpp -g -O0 -Wall -o a → gdb -q ./a → r,输入 3 与三个负数 → 在循环后(如第 6 行)设断点 b 6 → p mx 得 0 → display x 与 display mx,n 逐步执行发现每轮 max(0, x) 都等于 0。

结论:mx 初值为 0,导致所有负数都比不过它,最终输出 0。这是初值设置错误,不是内存错误。

修法:初始化成极小值:int mx = INT_MIN;(或 long long mx = LLONG_MIN;),或先把第一个数读进来作为初值。

易错:把"输出 0"误当成"变量没赋值",实际上它是被赋了 0;以及只测正数样例(正好掩盖了这个 bug)。

B2性能优化

程序 real = 1.35s(时限 1s),但复杂度已是最优。请给出至少三条常数优化方案并说明预期效果。

查看参考答案

方案一 · 解除 IO 同步:在 main 开头加 ios::sync_with_stdio(0); cin.tie(0);。取消 cin/cout 与 C 标准库的同步、并解绑输入输出流,读入速度常提升 2~4 倍。

方案二 · 去掉 endl:把 endl 全部换成 "\n"。避免每行刷新缓冲区,输出量越大收益越明显。

方案三 · 减少高开销运算:把循环内的 %、除法、函数调用提到循环外;用局部变量缓存 a[i];把 vector 访问换成数组(去掉边界检查与间接寻址)。

验证方式:每改一处就 time ./a < big.in > /dev/null 实测一次,确认确实有提升,避免"凭感觉优化"。

易错:忘记在优化后重新用最大数据实测;或把 -O0 编译的产物拿去测(那样测出来的时间毫无意义)。

B3Core dump

如何开启 core dump 并在"不重新运行程序"的情况下分析崩溃原因?写出完整步骤。

查看参考答案

第 1 步:ulimit -c unlimited 放开 core 文件大小限制(默认常为 0)。可用 ulimit -c 确认。

第 2 步:运行程序使其崩溃,得到 Segmentation fault (core dumped),当前目录生成 core 或 core.PID。

第 3 步:gdb ./a core 载入可执行文件与核心转储。注意必须用同一次编译的可执行文件,否则符号对不上。

第 4 步:在 GDB 里执行 bt 查看崩溃时的调用栈,frame 0 切到最内层,info locals 与 p 变量 查看当时的变量值。

注意:某些系统的 /proc/sys/kernel/core_pattern 会把 core 写到统一目录,可用 cat 查看路径。可执行文件需带 -g 编译才能看到行号与变量名。

易错:崩溃后重新编译了程序再去看旧的 core(符号不匹配);或没有 -g 导致只能看到地址。

B4方法辨析

下面两种测时方式,哪种更能反映"算法快慢"?为什么?
① time ./a < in > /dev/null ② 程序内 clock()

查看参考答案

比较算法快慢用 ② clock() 更合适。它测的是CPU 时间,排除了"等待磁盘 / 等待终端 / 被其他进程抢占"的干扰,同一份算法在不同机器上相对关系更稳定。

但判断"是否 TLE"必须用 ① 的 real。因为评测机是拿墙上时间来卡的;如果 IO 很慢,即使用户态只花 0.1 秒,real 也可能超过 1 秒。

实践组合:用 clock() 定位"哪一段最耗时"(例如发现 80% 时间花在排序上),用 time 的 real 判断最终能否通过。两者配合才能既找准瓶颈又保证不超时。

易错:只用 clock() 就断定"不会 TLE";或在不重定向的情况下用 time 测,把终端渲染时间算进了算法耗时。

🔴 C组 · 挑战(4题)

C1原理分析

为什么在 GDB 中"逐行单步调试一段被 -O2 优化过的代码"会得到令人困惑的结果?

查看参考答案

原因一 · 语句与指令不再一一对应。优化器会合并、重排、删除语句:循环可能被向量化,变量可能被放进寄存器,甚至整段代码被常量折叠掉。于是"单步一次"可能跨过好几行源码,或反复停留在同一行。

原因二 · 变量可能已被消除。优化器发现某个变量只在局部使用,就会把它优化成寄存器甚至常量,导致 p 变量 报 <optimized out>。

原因三 · 内联(inline)打乱调用栈。被内联的函数不会出现在 bt 里,s 也进不去。

标准做法:调试一律用 -g -O0;-O2 只在"性能实测"时使用。把"查错的版本"和"测速的版本"分开编译,是最省心的工程习惯。

C2综合设计

设计一套完整方案:对一道题自动完成"生成最大数据 → 测时 → 越界检查 → 对拍 → 超时预警"。

查看参考答案
#!/bin/bash
# 用法:bash auto.sh sol.cpp brute.cpp gen.cpp 1.0
SOL="$1"; BRU="$2"; GEN="$3"; LIMIT="${4:-1.0}"

g++ "$SOL" -O2 -Wall -o sol || exit 1
g++ "$BRU" -O2 -Wall -o brute || exit 1
g++ "$GEN" -O2 -Wall -o gen   || exit 1
g++ "$SOL" -g -O0 -fsanitize=address,undefined -o sol_san

echo "① Sanitizer 越界检查"
./gen > data.in
./sol_san < data.in > /dev/null || { echo "有 UB!停止"; exit 1; }

echo "② 最大数据测时(上限 ${LIMIT}s)"
ST=$( { /usr/bin/time -f "%e" ./sol < data.in > /dev/null; } 2>&1 )
echo "实测 real = ${ST}s"
awk -v s="$ST" -v l="$LIMIT" 'BEGIN{ if (s+0 > l+0) print "⚠️ 超时风险!"; else print "✅ 时间安全" }'

echo "③ 与暴力对拍 200 轮"
for i in $(seq 1 200); do
    ./gen > data.in
    ./sol   < data.in > a.out
    ./brute < data.in > b.out
    diff -b -q a.out b.out > /dev/null || { echo "第 $i 轮不一致,数据已保存"; exit 1; }
done
echo "✅ 200 轮全部一致,可以交卷"

要点:先做正确性(Sanitizer)再做性能(测时)最后做随机对拍;任一步失败立即 exit,避免"带着问题继续跑"。

易错:用 time 的输出直接做数值比较很麻烦,需要 2>&1 捕获或用 /usr/bin/time -f "%e";浮点比较用 awk 而不是 shell 的整数比较 [ ]。

C3深度思考

如果一道题的解法复杂度已最优、常数也优化到极限,实测仍然 TLE,还有哪些"非常规"手段?

查看参考答案

思路一 · 换更快的 IO。把 cin 换成手写 getchar 快速读入(业内称"fread 读入"或"快速读入"),在大输入量下可再快 2~3 倍。

思路二 · 用空间换时间。增加预处理(如前缀和、稀疏表)把重复计算变成查表;用更大的数组换取更少的判断分支。

思路三 · 降低常数级别。把 vector 换成静态数组、把递归改成迭代、把 map 换成数组或哈希、把 % 换成位运算(模 2 的幂时)。

思路四 · 分部得分策略。若确实无法全过,就针对部分分写成"小数据用暴力、大数据用近似"的分支,争取 60~80 分,而不是 0 分。

核心提醒:先确认"实测方法是否正确"(是否正确重定向、是否用了 -O2 版本、是否用到了最大数据)。很多所谓的"优化到极限还超时",其实是测得不对。

C4方法总结

请把本讲内容整理成一份"RE / WA / TLE 三类故障的排查决策树"。

查看参考答案

TLE(超时)分支:① 用最大数据实测 time → 看 real;② 若 real ≫ user+sys → 优化 IO(sync_with_stdio、\n 替代 endl);③ 若 user 本身就很大 → 换算法(重新估复杂度);④ 若两者都不大但 real 大 → 检查是否被系统抢占或使用了过慢的读入。

RE(运行错误)分支:① 编译 -fsanitize=address,undefined 版并运行 → 直接看报告;② 无报告则进 GDB bt 定位行;③ 三项固定怀疑:数组越界(含 i+1 偏移)、除零/取模零、递归爆栈(大数组放全局)。

WA(答案错误)分支:① 先看是否 -Wall 有未初始化警告;② 检查数据类型是否溢出(累加用 long long);③ 检查边界情况(n = 0、全负数、等于边界的取值);④ 用 GDB 断点核对中间结果;⑤ 写暴力程序对拍找出反例。

决策树的灵魂:每一条分支的第一步都是"获取事实"(实测时间、Sanitizer 报告、GDB 现场),而不是直接改代码。先测量、再修改,是工程师与"乱试者"的分界线。

🏆 洛谷原题精选(12道)

以下 12 道题建议在提交前完成三件事:① 用最大数据 time 实测并记录;② 用 -fsanitize=address,undefined 跑一遍;③ 用 GDB 至少调试一次(哪怕没有 bug,也练一次断点与 p)。按难度递增排列。

1
P1001
A+B Problem入门
2
P1424
小鱼的航程入门
3
P1047
校门外的树入门
4
P1059
明明的随机数普及-
5
P1177
【模板】快速排序普及-
6
P1044
栈普及-
7
P1908
逆序对普及/提高-
8
P1090
合并果子普及/提高-
9
P1226
【模板】快速幂普及/提高-
10
P3374
【模板】树状数组 1提高
11
P1886
滑动窗口【模板】单调队列提高
12
P3375
【模板】KMP字符串匹配提高