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

文本编辑工具 与 g++ 编译进阶

vim 让你在无鼠标的赛场里以光速编辑代码,g++ 的选项则决定你的程序是"跑对、跑快"还是"悄悄错"。本讲用三个真实案例,演示编译选项如何把隐藏 bug 抓出来。

3 种编辑器 4 阶段编译流程 10 个必会选项 3 个抓 bug 案例 12 道洛谷原题
本讲 · 第 2/52 讲文本编辑工具与g++编译进阶
模块 01

情景导航

从"编辑器卡顿 + 编译器沉默"的两次事故,理解本讲两个知识点的真实价值。

故事一个没保存的下午

小 C 第一次进 NOI Linux 机房,发现只有 vim。他打开 a.cpp,随手敲了几行代码,然后习惯性地按 Ctrl+S。屏幕没有保存,反而弹出一行 -- INSERT -- 变成别的东西;他又乱按 Esc、狂敲 :wq,最后 vim 报了一串错误退出,代码一行都没存下来。

隔壁小 D 只花了 30 秒:vim a.cpp → 按 i 进入插入模式 → 贴入模板 → 按 Esc → 输入 :wq 回车。之后他顺手在 ~/.vimrc 里加了三行配置,从此每次打开 vim 都自动显示行号、自动缩进。

下午的第二次事故更隐蔽:小 C 写了一个 40 行的程序,编译时只敲了 g++ a.cpp,屏幕上什么都没输出 —— 他以为"没有报错就是对的"。结果程序时对时错。小 D 用 g++ a.cpp -O2 -Wall -Wextra -std=c++17 -o a,编译器立刻打印:

a.cpp:9:23: warning: 'ans' may be used uninitialized [-Wmaybe-uninitialized]

第 9 行的变量没初始化!小 C 补上一个 = 0,问题消失。

编译器不是"报错机器",而是一位免费的代码审查员 —— 前提是你得请它开口。

💡 两个知识点,两种"效率"

编辑器决定编辑效率。vim 的全部操作都在键盘主区完成,双手不离键盘;熟悉之后,跳转到第 200 行、全局替换、批量缩进都是两三次按键的事。赛场上每次编辑省 10 秒,一场比赛就能省下十几分钟。

编译选项决定代码质量。-Wall -Wextra 让编译器把可疑写法全部报出来;-fsanitize 在运行时检查越界与未定义行为;-O2 让程序跑得快,但也会放大未定义行为的后果(这正是"本地过、交上去 WA"的常见原因之一)。

两者配合,就是"又快又对"。本讲把这两个工具讲到能实战的程度。

①生活类比
🚗

vim 三模式 = 汽车档位

普通模式是空档(用来指挥),插入模式是前进档(用来写字),命令行模式是倒车/呼叫(用来执行指令)。切错档就"开不动"。

→ Esc 换档
🏭

g++ = 四位工人流水线

预处理工、编译工、汇编工、链接工逐棒接力,把 .cpp 变成可执行的 a.out。

→ -E -S -c -o
🔍

-Wall = 体检报告

不体检也能活,但报告能把"血糖偏高"这类早期隐患指出来;-Wall 就是编译器的体检报告。

→ warning
🎯

-fsanitize = 实验室探针

运行时给内存装上探针,一旦越界立刻报警并告诉你"在哪一行、越了多少"。

→ AddressSanitizer
②核心定义

vim:Linux 上最经典的模式化文本编辑器。它的核心特点是不同模式下同一个按键含义不同,因此必须清楚"我现在在哪个模式"。

三种基本模式:普通模式(默认,用于移动光标与执行编辑命令)、插入模式(真正输入文字)、命令行模式(以 : 开头,用于保存、退出、替换、设置)。

编译(compile):把高级语言写的源代码翻译成机器能执行的程序。g++ 完成的是从 .cpp 到可执行文件的完整翻译与组装流程。

四个阶段:预处理(展开宏与头文件)→ 编译(生成汇编)→ 汇编(生成目标文件 .o)→ 链接(合并库与目标文件,生成可执行文件)。

编译期错误 vs 运行期错误:前者在翻译阶段就被拦下(语法、类型不匹配),后者要等程序跑起来才暴露(越界、未初始化、死循环)。能把运行期错误提前发现,就是本讲的核心收益。

未定义行为(UB):C++ 标准没有规定结果的行为(如越界访问、读未初始化变量、有符号整数溢出)。它最可怕的不是"一定错",而是"有时对、有时错、换个编译器就变"。

📌 本讲核心知识地图

板块核心内容对应大纲
vim 基本操作i/a/o、Esc、:w :q :wq :q!、dd yy p u【5】文本编辑工具
vim 进阶跳转 gg/G/:n、查找 /、替换 :%s、.vimrc【5】文本编辑工具
其他编辑器gedit(图形)、nano(极简)【5】文本编辑工具
g++ 流程预处理 → 编译 → 汇编 → 链接【5】g++ 与编译选项
常用选项-o -O2 -Wall -Wextra -std=c++17 -g -fsanitize【5】g++ 与编译选项
警告与宏读 warning、-D 定义宏、static_assert【5】g++ 与编译选项
③核心口诀
🔑 vim 三模式

进去先看左下角:无字=普通,INSERT=插入,冒号=命令行;
迷路了就狂按 Esc,回到普通模式再 :wq 保存退出!

🔑 编译四阶段

预处编译汇链接,一气呵成一条命令;
想看中间产物就 -E -S -c,想看警告就 -Wall!

🔑 赛场编译标配

g++ a.cpp -O2 -Wall -Wextra -std=c++17 -o a;
调试加 -g,查越界加 -fsanitize=address,undefined!

模块 02

知识探究

先掌握 vim 的操作逻辑,再深入 g++ 的四阶段编译与关键选项。

1vim 三模式与必会操作 基础

vim 的设计思想是"移动与编辑分开":普通模式负责快速移动与发号施令,插入模式专心打字,命令行模式处理文件级操作。

① 普通模式默认 / 指挥
② 插入模式i / a / o
③ 命令行模式: 开头
操作按键说明
进入编辑i / a / o光标前插入 / 光标后插入 / 下方新起一行插入
返回普通模式Esc最重要的一键,迷路就按它
保存 / 退出:w / :q / :wq / :q!:q! 强制不保存退出(放弃修改)
移动光标h j k l左 下 上 右(不用方向键,手不离主键盘)
行内跳转0 / $跳到行首 / 行尾
按词跳转w / b下一个词首 / 上一个词首
删除 / 复制 / 粘贴dd / yy / p删整行 / 复制整行 / 粘贴到光标后
撤销 / 重做u / Ctrl+r撤销上一步 / 反撤销
数字前缀5dd / 3yy删除 5 行 / 复制 3 行
Shell用 vim 写第一个比赛程序(完整按键序列)
vim a.cpp
# ── 进入后处在「普通模式」──
i                                       # 按 i 进入插入模式(左下角出现 -- INSERT --)
# 输入以下代码:
#   #include <bits/stdc++.h>
#   using namespace std;
#   int main() { return 0; }
Esc                                     # 回到普通模式(左下角 -- INSERT -- 消失)
:wq                                     # 冒号进入命令行模式,w=写入 q=退出

# ── 三个救命场景 ──
# 场景1:改乱了想放弃修改
:q!                                     # 强制退出,不保存
# 场景2:文件是只读的(例如权限不足)
:w !sudo tee %                          # 以管理员身份写入(需谨慎)
# 场景3:卡在奇怪的模式里
Esc Esc Esc                             # 连按 Esc,一定能回到普通模式
赛场最高的性价比操作:进 vim 先按 :set nu 打开行号,再按 :set paste 进入粘贴模式(否则从外部粘贴多行代码会"阶梯式缩进",越贴越乱)。
2vim 进阶:跳转、查找替换与 .vimrc 提效
需求按键 / 命令说明
跳到第 n 行:n 或 nG:42 或 42G 都跳到第 42 行
跳到文件首 / 尾gg / G调试时最常用:直接看末尾输出
向下查找/关键字回车后 n 找下一个,N 找上一个
向下查找光标所在单词*不用输入,直接搜当前词
全文替换:%s/旧/新/g% 表示所有行,g 表示每行所有匹配
替换前先确认:%s/旧/新/gcc = confirm,逐个询问,防误替换
只替换第 10~20 行:10,20s/旧/新/g限定行范围(改一段代码很实用)
整体缩进gg=G全文自动缩进(代码乱成一团时救命)
显示行号:set nu / :set nonu配合报错信息里的行号使用

.vimrc 是 vim 的配置文件,放在主目录下(~/.vimrc),每次启动 vim 都会自动加载。写几行常用配置,能省下每场几十次重复操作。

Vimscript~/.vimrc —— 竞赛选手的三行配置
" ── 基础配置(推荐人人都加)──
set nu                  " 显示行号:报错信息里的行号立刻能定位
set tabstop=4           " 一个 Tab 显示为 4 个空格
set shiftwidth=4        " 自动缩进也按 4 个空格
set expandtab           " 把 Tab 自动转成空格(避免混合缩进)
set autoindent          " 新行继承上一行缩进
set mouse=a             " 允许鼠标选择与滚动(新手友好)
set backspace=2         " 退格键可以删除到行首
syntax on               " 语法高亮

" ── 进阶:用 F5 一键编译运行 ──
map <F5> :w<CR>:!g++ % -O2 -Wall -std=c++17 -o %< && ./%<<CR>
终端输出
$ cat > ~/.vimrc << 'EOF'
... 上面的配置 ...
EOF
$ vim a.cpp
   1 #include <bits/stdc++.h>
   2 using namespace std;
   3 int main() {
   4     ios::sync_with_stdio(0);
   5     int n; cin >> n;
   6     cout << n * 2 << endl;
   7     return 0;
   8 }
# 行号已自动显示,缩进按 4 个空格自动对齐
⌨️ vim 实战 7 步:在右侧这个 8 行的 a.cpp 里操作
1
vim a.cpp 打开文件,处于普通模式;因为 .vimrc 里 set nu,行号 1~8 已经显示。
2
gg 跳到第 1 行;想直接去某一行就 :5 回车,光标落在第 5 行 int n; cin >> n;。
3
/cout 回车向下查找:光标停在第 6 行的 cout 上;n 跳下一个匹配。
4
0 到行首、$ 到行尾、w/b 按词移动 —— 不碰方向键,手不离主键盘区。
5
yy 复制当前整行,p 粘贴到下方;想删整行就 dd(5dd 一次删 5 行)。
6
改错了?u 撤销上一步,Ctrl+r 重做。全文缩进乱了就 gg=G。
7
:wq 保存退出。迷路时记住保命三键:Esc → 普通模式 → :q! 放弃修改。
📌 对照上方"终端输出"里的 8 行代码逐步想象光标位置,这 7 步覆盖赛场 90% 的编辑需求。
注意:不同评测机可能不保留你的 ~/.vimrc,所以不要依赖配置来正确写代码 —— 它只是让习惯更顺,核心操作(i、Esc、:wq)必须成为条件反射。
3三种编辑器对比:vim / gedit / nano
⌨️ vim —— 赛场首选

优点:任何 Linux 都有,速度极快,键盘操作零延迟,支持宏与插件。

缺点:学习曲线陡,模式容易搞混。

适用:比赛作答(尤其是需要频繁修改时)。

🖱️ gedit —— 图形化友好

优点:像记事本一样点点就写,支持中文输入法、语法高亮、撤销历史。

缺点:需要图形界面(NOI Linux 有),启动比 vim 慢。

适用:不熟悉 vim 时的过渡工具。命令:gedit a.cpp &(& 让终端不被占用)。

📝 nano —— 极简易上手

优点:屏幕下方直接列出快捷键(^O 保存、^X 退出),几乎零学习成本。

缺点:功能弱,没有强大的跳转与替换。

适用:临时改一行配置、写短文件。命令:nano a.cpp。

对比项vimgeditnano
需要图形界面否是否
保存并退出Esc 然后 :wqCtrl+S 后关窗Ctrl+O 回车,Ctrl+X
跳到任意行:n 或 nGCtrl+I不支持
全局替换:%s/旧/新/gCtrl+H不支持
赛场推荐度★★★★★★★★☆☆★★☆☆☆

💡 该学哪个?

结论很直接:必须会 vim 的"最小可用集合"(进插入、回普通、保存退出、跳行、撤销共 6 个操作),其余按需再学。不熟悉时可以先在考场上用 gedit 应急,但一旦遇到只有纯终端的评测环境,vim 是唯一选择。

4g++ 的四阶段编译流程 核心

一条 g++ a.cpp -o a 背后其实经过了四位"工人"的接力。理解这条流水线,你才能读懂报错信息、看懂中间产物。

① 预处理a.cpp → a.i (-E)
② 编译a.i → a.s (-S)
③ 汇编a.s → a.o (-c)
④ 链接a.o → a (-o)
Shell手动走完四阶段(看清每一步产物)
# ① 预处理:看头文件被展开成什么样(输出很大,通常重定向到文件)
g++ -E a.cpp -o a.i
wc -l a.i                      # 一个 #include <bits/stdc++.h> 可能展开成几万行

# ② 编译成汇编(可以看看你的代码被翻译成什么)
g++ -S a.cpp -o a.s
head -n 20 a.s

# ③ 汇编成目标文件(注意 -c 只编译不链接)
g++ -c a.cpp -o a.o
file a.o                      # → ELF 64-bit relocatable object

# ④ 链接成可执行文件(这一步才会用到库)
g++ a.o -o a
./a < in.txt > out.txt

# 日常一条命令等价于上面 4 步
g++ a.cpp -O2 -Wall -std=c++17 -o a

# 多个文件编译时,中间产物可以复用(改一个文件只重编一个)
g++ -c a.cpp -O2 -o a.o
g++ -c b.cpp -O2 -o b.o
g++ a.o b.o -o prog
错误类型出现在哪个阶段典型信息
预处理错误阶段①No such file or directory(头文件找不到)
语法/类型错误阶段②expected ';' before ...、no matching function
链接错误阶段④undefined reference to 'main'、multiple definition
运行期错误运行编译通过但 RE / WA(本讲的重点防范对象)
看懂报错的小技巧:编译器报的 error 有时会"连锁反应"——第一处错误修好后,后面那一长串自动消失。永远从第一条错误开始改。
5常用编译选项清单 必背
1
📛
-o 名字

指定输出文件名,不写则默认 a.out

2
⚡
-O2

开启二级优化,赛场标配

3
🔍
-Wall

打开绝大部分常用警告

4
🔎
-Wextra

补充更多警告(比 -Wall 更严)

5
📏
-std=c++17

指定语言标准版本

6
🐞
-g

生成调试信息,供 GDB 使用

7
🧪
-fsanitize

运行时检查越界与 UB

8
🏷️
-D名字

命令行定义宏,用于调试开关

9
📉
-O0

关闭优化,调试时行为更"老实"

选项作用什么时候用
-o 文件名指定输出的可执行文件名永远用(避免 a.out 互相覆盖)
-O0 / -O2 / -O3优化级别,越高越快,编译越慢比赛交卷用 -O2;调试用 -O0
-Wall打开常用警告每次编译都加
-Wextra额外警告(未使用参数、可疑比较等)写正解、复盘时加
-Werror把警告当错误处理强迫自己清零警告时
-std=c++17使用 C++17 标准用到结构化绑定、auto 推导等新特性时
-g生成调试符号准备用 GDB 时(-g 与 -O2 可同时用)
-fsanitize=address,undefined运行时检查内存越界与未定义行为本地查 RE 原因,不要用于最终提交(会慢 2~20 倍)
-D名字=值命令行定义宏用 -DDEBUG 打开调试输出
-lm链接数学库用到 sqrt、pow 等(C++ 一般可省)
-I 目录增加头文件搜索路径自己的头文件放在子目录时
Shell三种典型编译组合(背下来)
# ① 交卷版:要快、要稳、看得见警告
g++ a.cpp -O2 -Wall -Wextra -std=c++17 -o a

# ② 调试版:要能进 GDB,行为要"老实"
g++ a.cpp -g -O0 -Wall -o a_dbg

# ③ 查错版:让程序自己报告越界与 UB
g++ a.cpp -g -O0 -fsanitize=address,undefined -Wall -o a_san
./a_san < in.txt > out.txt
# 一旦越界,程序会直接停下并打印出错位置与栈信息
🧪 三种编译组合对比:同一段代码,谁能发现问题
1
待检代码:第 5 行 int ans; 未初始化,第 9 行数组下标可能越界(详见本讲案例 1、案例 2)。
2
裸编译 g++ a.cpp -o a:两个问题都不报,程序带着隐患直接运行 —— 最危险。
3
交卷版 -O2 -Wall -Wextra:立刻报出 'ans' may be used uninitialized(静态分析,编译期完成)。
4
调试版 -g -O0:警告同样报;产物可进 GDB 单步,变量值与源码一一对应(下一讲详解)。
5
查错版 -g -O0 -fsanitize=address,undefined:运行到越界那一行的瞬间程序停下,报 stack-buffer-overflow 与行号。
6
结论:警告抓"编译期看得出"的问题,Sanitizer 抓"运行时才暴露"的问题,两者互补、缺一不可。
📌 赛场节奏:写完 → 查错版跑样例 → 无报警 → 换交卷版提交。Sanitizer 版产物永远不要交卷(慢 2~20 倍)。
常见误区:很多人以为"没报错就是对的"。实际上编译器默认只报语法错误,几乎不报可疑写法。不加 -Wall,等于主动关掉一位免费审查员。
6读懂警告信息与宏引起的编译错误

编译警告的格式是固定的:文件名 : 行号 : 列号 : 等级 : 内容 [选项名]。读懂这个格式,定位速度能快好几倍。

Text警告信息的解剖图
a.cpp:9:23: warning: 'ans' may be used uninitialized in this function [-Wmaybe-uninitialized]
~~~~~ ~ ~~~ ~~~~~~~  ~~~~~  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~  ~~~~~~~~~~~~~~~~~~~~~~
  │   │  │    │        │                      │                                 │
  │   │  │    │        │                      └─ 具体说明(这里:ans 可能未初始化)
  │   │  │    │        └─────────────────── 等级(warning 警告 / error 错误 / note 补充)
  │   │  │    └──────────────────────────── 列号
  │   │  └───────────────────────────────── 行号  ← 直接跳过去改这一行
  │   └──────────────────────────────────── 文件名
  └──────────────────────────────────────── 警告类别开关(可用 -Wno-xxx 关闭,但强烈不建议)
🔎 逐字段读警告:a.cpp:9:23 这一条在说什么
1
完整信息:a.cpp:9:23: warning: 'ans' may be used uninitialized in this function [-Wmaybe-uninitialized]。
2
a.cpp —— 哪个文件。多文件工程里第一眼先确认是不是自己刚改的那个。
3
:9: —— 第 9 行。vim 里 :9 回车直接跳过去,不用肉眼数。
4
:23: —— 第 23 列,精确到那一行的第几个字符(例如正是 ans 出现的位置)。
5
warning —— 等级。warning 可带病运行,error 则编译直接失败;note 是补充说明。
6
'ans' may be used uninitialized —— 内容:变量 ans 可能没赋初值就被读,结合第 9 行代码核对。
7
[-Wmaybe-uninitialized] —— 触发它的警告开关。想搜同类问题可以 grep -n "maybe-uninitialized" 日志。
📌 口诀:文件 → 行号 → 跳转 → 读懂内容 → 决定修法。90% 的警告用这五步就能处理完。
典型警告含义该怎么改
may be used uninitialized变量可能没赋初值就被读声明时直接给初值,如 int ans = 0;
comparison of integer expressions of different signedness有符号与无符号混比较统一类型,循环变量用 int,与 size() 比较时强转
control reaches end of non-void function非 void 函数可能没 return补上 return(这是 UB,必须改)
unused variable / set but not used变量定义了却没用删掉,或检查是不是漏用了
array subscript is above array bounds数组下标疑似越界检查数组大小与循环上界
suggest parentheses around assignment used as truth valueif (x = 0) 把赋值当条件写 ==;确实要赋值就加括号 if ((x = 0))

宏(macro)由预处理器在阶段①做纯文本替换,因此容易产生意外。宏问题有两类:一类是括号漏写导致优先级错误,一类是条件编译开关用错导致代码根本没被编进去。

C++宏的两个经典陷阱
// 陷阱1:宏没有给参数加括号
#define MUL(a, b) a * b

cout << MUL(2 + 3, 4) << endl;   // 展开成 2 + 3 * 4 = 14,而不是期望的 20!
// 正确写法:每个参数和整体都加括号
#define MUL(a, b) ((a) * (b))

// 陷阱2:以为定义了 DEBUG 就会输出,其实名字写错了
#ifdef DEBUG
    cerr << "n = " << n << endl;
#endif
// 编译时必须写 -DDEBUG(区分大小写);写成 -Ddebug 则这段代码被整段丢弃

// 陷阱3:用 static_assert 在编译期就拦住错误
static_assert(sizeof(long long) == 8, "long long 必须是 8 字节");
Shell用 -E 与 -D 看清宏的真实效果
# 用 -E 查看宏展开后的真实代码(配合 grep 精确定位)
g++ -E a.cpp | grep -n "2 + 3"
# 1234:cout << 2 + 3 * 4 << endl;   ← 一眼看出优先级错了

# 用 -D 打开调试开关(注意大小写与等号写法)
g++ a.cpp -DDEBUG -o a            # DEBUG 被定义 → 调试输出生效
g++ a.cpp -DDEBUG=1 -o a          # 带值定义

# 用 -dM 列出所有预定义宏(排查"为什么某段代码没被编进去")
g++ -dM -E - < /dev/null | grep -i "cplusplus\|debug"
# #define __cplusplus 201703L        ← 说明当前按 C++17 编译
结论:宏是"文本替换",不理解这一点就会写出"看起来对、展开后错"的代码。提高组建议:能用 const、constexpr、inline 函数就别用宏,只在条件编译(#ifdef DEBUG)时保留宏。
案例案例1:-Wall 抓出"未初始化变量" 必看

这是一个真实的高频事故:变量忘了给初值,程序"有时对有时错"。看编译器如何揭穿它。

C++有 bug 的 uninit.cpp
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    int ans;                       // ❌ 没有初始化!
    for (int i = 1; i <= n; i++) {
        int x; cin >> x;
        if (x > 0) ans += x;       // 读的是一个"垃圾值"
    }
    cout << ans << endl;
    return 0;
}
🔍 代码逐段解析 · uninit.cpp(有 bug 版)
1
第 1~3 行:头文件与 main 的骨架,本身没问题。
2
第 4 行:int n; cin >> n; 读入元素个数,n 有确定值。
3
第 5 行(病灶):int ans; 声明后没有赋初值,它现在的值是栈上残留的"垃圾值",每次运行都可能不同。
4
第 6~9 行:只有 x > 0 时才执行 ans += x —— 若所有 x 都是负数,ans 从头到尾没被赋过值,输出纯垃圾。
5
第 10 行:输出这个不可靠的 ans,于是"有时对、有时错"。
6
易错点:①"后面循环里总会赋值"是错觉 —— 条件分支可能一次都不走;② 另一个隐患:int 求和会溢出,修复版同时改成 long long。
📌 修法只有一句话:声明即初始化 —— long long ans = 0;,成本为零,杜绝一类 UB。

🔍 三种编译方式的对比实验

① 裸编译g++ uninit.cpp -o u —— 编译器一声不吭。程序跑起来:栈上的 ans 恰好是上次函数留下的残留值,输入 3 / 1 2 3 却输出 32772 之类的怪数。

② 加 -Wallg++ uninit.cpp -Wall -o u —— 立刻报 warning: 'ans' may be used uninitialized,并指出行号。这就是"请编译器开口"的价值。

③ 加 -O2 的陷阱g++ uninit.cpp -O2 -o u —— 优化器认为"读未初始化变量是 UB,我可以假设它不会发生",于是把 ans 直接当成常数 0 或整段消掉,结果时对时错、换个编译器就变。这就是 UB 最危险的地方。

本质:未初始化变量的值是"未定义行为",不属于"随机",而属于"编译器想怎样就怎样"。唯一正确做法是声明即初始化。

编译与运行结果
$ g++ uninit.cpp -o u        # 无警告,太危险了
$ ./u <<< "3
1 2 3"
32772                        ← 垃圾值,每次可能不同

$ g++ uninit.cpp -Wall -o u  # 加 -Wall
uninit.cpp: In function 'int main()':
uninit.cpp:5:9: warning: 'ans' may be used uninitialized in this function [-Wmaybe-uninitialized]
    5 |     int ans;
      |         ^~~

$ g++ uninit.cpp -O2 -Wall -o u
$ ./u <<< "3
1 2 3"
6                            ← -O2 下"碰巧"对了,但这不是正确,只是运气
C++修复:声明即初始化
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    long long ans = 0;              // ✅ 初始化为 0,同时用 long long
    for (int i = 1; i <= n; i++) {
        long long x; cin >> x;
        if (x > 0) ans += x;
    }
    cout << ans << endl;
    return 0;
}

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

第 4~5 行 —— 关同步加速读入;读入 n。

第 6 行 —— long long ans = 0;:两处修复叠加 —— ① 初始化为 0 消灭 UB;② 升级 64 位,105 个 109 级别的正数累加也不溢出。

第 7~10 行 —— 循环体逻辑未动,但 x 也改成 long long,保证中间量全程 64 位。

第 11 行 —— 输出稳定正确:输入 3 / 1 2 3 恒得 6。

易错点 —— 只改 ans 不改 x:小数据侥幸正确,数据变大后 ans += x 的中间累加仍可能出错;修 bug 要"顺手把同类隐患一起清"。

修复后结果
$ g++ uninit.cpp -O2 -Wall -Wextra -std=c++17 -o u
(无任何警告)
$ ./u <<< "3
1 2 3"
6                            ← 稳定正确
🔑 未初始化三连

① 声明就赋值:int ans = 0;;② 求和累加一律 long long;③ 编译必加 -Wall,别让编译器闭嘴。

案例案例2:-fsanitize 抓出数组越界 进阶

数组越界是最难查的错误之一:多数时候程序不崩溃,只是"结果莫名偏小"。看 Sanitizer 如何把它变成"必定报错"。

C++有 bug 的 overflow.cpp
#include <bits/stdc++.h>
using namespace std;
const int N = 5;
int a[N];                              // 只有 5 个元素
int main() {
    int n = 8; cin >> n;               // 若输入 8,就会越界
    for (int i = 0; i < n; i++) a[i] = i;   // ❌ i 到 7,越界
        long long s = 0;for (int i = 0; i < n; i++) s += a[i];
    cout << s << endl;
    return 0;
}

🔍 代码逐段解析 · overflow.cpp(有 bug 版)

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

第 6 行 —— int n = 8; cin >> n;:赋的 8 会被输入覆盖,写法冗余但能说明问题 —— 输入 8 就触发越界。

第 7 行(病灶) —— 循环上界是 n 而不是 N:i 走到 5、6、7 时 a[i] = i 写到了数组外面的内存上。

第 8 行 —— 求和循环同样以 n 为上界,把刚越界写进去的值又读回来,于是"碰巧"算出 0+1+…+7 = 28 —— 这份运气不可复现。

易错点 —— ① 越界写最阴险:未必立刻崩溃,而是悄悄改坏相邻变量;② 循环上界应绑定为 N(数组容量)或对 n 做 min(n, N) 约束,绝不能两个变量各说各话。

🔍 两种编译方式的对比实验

① 裸编译g++ overflow.cpp -O2 -o o —— 程序照常运行,甚至"算出结果"。越界写入的数据落在了别的变量上,悄悄改坏了别人的值。这就是最难查的一类 bug。

② 加 Sanitizerg++ overflow.cpp -g -fsanitize=address,undefined -o o_san —— 运行到越界那一行的瞬间程序停下,打印 stack-buffer-overflow,并给出"在哪一行写、越界多少字节、变量 a 有多大"。

多出了一个 -g 的原因:没有调试符号时,Sanitizer 只能报地址;有 -g 就能报文件名与行号。

为什么不能一直用它交卷?因为它会给每次内存访问加检查,程序通常慢 2~20 倍、内存占用也变大,评测时可能 TLE / MLE。它的定位是本地查错工具,查完就换回普通编译。

运行结果对比
$ g++ overflow.cpp -O2 -o o && ./o <<< 8
28                            ← 没崩溃,甚至"看起来对了",纯属运气

$ g++ overflow.cpp -g -fsanitize=address,undefined -o o_san
$ ./o_san <<< 8
==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd...
WRITE of size 4 at 0x7ffd... thread T0
    #0 0x4012ab in main overflow.cpp:7        ← 直接指出第 7 行(a[i] = i 的写入处)
Address 0x7ffd... is located in stack of thread T0 at offset 20 in frame
    #0 0x4011f6 in main overflow.cpp:5
  This frame has 1 object(s):
    [32, 52) 'a' (line 4)                     ← 数组 a 只有 20 字节
SUMMARY: AddressSanitizer: stack-buffer-overflow overflow.cpp:7 in main
C++修复:数组开够 + 边界判断
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;            // ✅ 按数据范围上界开,再多开 5 个
int a[MAXN];
int main() {
    ios::sync_with_stdio(0);
    int n; cin >> n;
    for (int i = 0; i < n; i++) cin >> a[i];
    long long s = 0;
    for (int i = 0; i < n; i++) s += a[i];
    cout << s << endl;
    return 0;
}

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

第 3~4 行 —— const int MAXN = 100005:按数据范围上界开数组并留余量,此后合法下标远大于 n 的可能取值。

第 5~7 行 —— 读入 n 与 n 个元素;只要题目保证 n ≤ 10^5,第 7 行的写入就全部落在数组内。

第 8~9 行 —— 求和用 long long:n 达 105、元素 109 级时总和可达 1014,int 装不下。

第 10 行 —— 输出。输入 8 0 1 2 3 4 5 6 恒得 28,Sanitizer 与 -O2 两种编译结果一致。

易错点 —— ① 数组开够后仍要确认输入的 n 真的受题目范围约束,别在代码里再"多读";② 若循环从 1 开始(i <= n),数组要开到 MAXN + 1。

修复后结果
$ g++ overflow.cpp -g -fsanitize=address,undefined -Wall -o o_san
$ ./o_san <<< "8 0 1 2 3 4 5 6"
28                            ← 不再有 Sanitizer 报错

$ g++ overflow.cpp -O2 -Wall -o o && ./o <<< "8 0 1 2 3 4 5 6"
28                            ← 正常编译下同样正确
赛场实践建议:写完代码先用 -fsanitize=address,undefined 跑几组样例,确认没有越界与 UB,再换 -O2 编译提交。这一步通常只要 10 秒,却能拦住大量 RE。
案例案例3:宏展开与 -D 调试开关 进阶

这个案例演示两件事:宏因漏写括号导致结果错误,以及如何用 -D 让调试输出"随编译开关自动消失"。

C++宏陷阱 + 条件编译 macro.cpp
#include <bits/stdc++.h>
using namespace std;

#define MUL_BAD(a, b) a * b            // ❌ 漏了括号
#define MUL_OK(a, b)  ((a) * (b))     // ✅ 参数与整体都加括号
#define MAXN 100005

int main() {
    cout << MUL_BAD(2 + 3, 4) << endl;   // 期望 20
    cout << MUL_OK(2 + 3, 4) << endl;    // 期望 20

#ifdef DEBUG
    cerr << "[调试] MAXN = " << MAXN << endl;
    static_assert(MAXN >= 100000, "MAXN 太小,会 RE");
#endif
    return 0;
}

🔍 代码逐段解析 · macro.cpp(宏陷阱 + 条件编译)

第 4~5 行 —— 两个功能相同、写法不同的宏:MUL_BAD(a,b) a * b 是纯文本替换,MUL_OK(a,b) ((a) * (b)) 给参数与整体都加了括号。

第 9~10 行 —— 同样的调用 MUL(2+3, 4):坏宏展开成 2 + 3 * 4 = 14,好宏展开成 ((2 + 3) * (4)) = 20,一行代码对比出括号的价值。

第 12~15 行(#ifdef DEBUG 块) —— 不定义 DEBUG 时这段在预处理阶段就被删除,产物里根本不存在;定义 -DDEBUG 时才参与编译,调试信息走 cerr 不污染 stdout。

第 14 行 —— static_assert(MAXN >= 100000, ...) 编译期检查:数组常量太小会在编译阶段就被拦下,而不是运行时 RE。

易错点 —— ① 宏参数被代入两次以上时有"副作用"风险(如 MUL_OK(i++, j) 会 i++ 两次),函数无此问题;② -DDEBUG 与 -Ddebug 是两个不同的宏名,大小写必须与 #ifdef 一致。

🔍 逐步分析

第 1 步 · 看清宏的展开结果。用 g++ -E macro.cpp | grep -n "cout" 查看预处理后的真实代码:cout << 2 + 3 * 4 << endl;。按优先级计算 3*4=12,再加 2 得 14,而不是期望的 20。

第 2 步 · 加括号后验证。MUL_OK(2+3, 4) 展开成 ((2 + 3) * (4)),得到正确的 20。

第 3 步 · 用 -D 控制调试点。不加 -DDEBUG 时,#ifdef DEBUG 里的代码在预处理阶段就被整段删除,连编译都不会发生 —— 这正是"调试语句零成本"的实现方式。

第 4 步 · 用 static_assert 把错误提前。static_assert 在编译期求值,条件不满足就直接编译失败并打印你写的提示。它比运行期才发现越界要早得多。

第 5 步 · 注意大小写。-DDEBUG 与 -Ddebug 是不同的宏名。#ifdef DEBUG 只认前者。

编译与运行结果
$ g++ macro.cpp -O2 -o m && ./m
14                            ← MUL_BAD 展开错误
20                            ← MUL_OK 正确

$ g++ macro.cpp -DDEBUG -o m && ./m
14
20
[调试] MAXN = 100005          ← 调试输出被激活(走 cerr,不影响标准输出)

$ g++ macro.cpp -DDEBUG -o m 2> /dev/null; ./m
14
20                            ← 用 2>/dev/null 丢弃调试信息,只剩正常输出

$ g++ -E macro.cpp | grep -n "cout"
1236:    cout << 2 + 3 * 4 << endl;      ← 宏的真实展开,一眼看出优先级问题
1237:    cout << ((2 + 3) * (4)) << endl;

$ g++ macro.cpp -Ddebug -o m 2>&1 | grep 调试
(无输出:宏名大小写不匹配,调试代码被整段丢弃)
C++修复:改用安全替代方案
#include <bits/stdc++.h>
using namespace std;

// ✅ 用 inline 函数替代函数式宏:有类型检查,无优先级陷阱
inline long long mul(long long a, long long b) { return a * b; }
// ✅ 用 constexpr 常量替代常量宏:编译器会检查类型
constexpr int MAXN = 100005;

int main() {
    cout << mul(2 + 3, 4) << endl;        // 20,参数按值传递,天然正确
#ifdef DEBUG
    cerr << "[调试] MAXN = " << MAXN << endl;
#endif
    return 0;
}

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

第 5 行 —— 用 inline long long mul(long long, long long) 替代函数式宏:参数按值传递,2+3 先求值再传入,无优先级、无重复求值问题,还有类型检查。

第 7 行 —— 用 constexpr int MAXN = 100005 替代常量宏:有类型、有作用域,同样能被 static_assert 使用。

第 10 行 —— 调用处不变,mul(2 + 3, 4) 直接得到 20 —— 这就是"改宏为函数,调用方零成本"。

第 11~13 行 —— #ifdef DEBUG 条件编译保留:宏在这种场景下是合理用途。

易错点 —— inline 函数放在头文件里需防重复定义(OJ 单文件场景无碍);返回类型选 long long 是为了与累加场景匹配,避免隐式截断。

结论:宏只适合两种场景 —— 条件编译(#ifdef DEBUG)与头文件保护(#pragma once)。数值常量用 constexpr,短小计算用 inline 函数。
模块 03

实践应用

三道任务题:选编译选项、写双模式编译脚本、读日志定位错误。

题1为一段代码列出正确的编译选项
基础选择题 + 说明

给定代码 abc.cpp(使用 auto 范围 for、结构化绑定,且曾在本地出现过越界崩溃),请写出"查错用"与"交卷用"两条编译命令并说明每个选项的理由。

输入格式:一个源文件 abc.cpp,其中使用了 C++17 的结构化绑定 auto [x, y] = p;。

输出格式:两条 g++ 命令,并逐项说明选项作用。输入样例即"任意需要 C++17 特性的源文件",输出样例即下文的两条命令。数据范围:源代码 ≤ 200 行,编译时间 ≤ 3 秒(常规约定 1s / 256MB 适用于程序运行)。

🔍 题目分析

查错用g++ abc.cpp -g -O0 -fsanitize=address,undefined -Wall -Wextra -std=c++17 -o abc_san

理由:-g 让报错带行号;-O0 让调试行为与源码一一对应;-fsanitize=address,undefined 抓越界与 UB;-Wall -Wextra 打开警告;-std=c++17 支持结构化绑定。

交卷用g++ abc.cpp -O2 -Wall -Wextra -std=c++17 -o abc

理由:去掉 Sanitizer 与 -g(它们拖慢运行),保留优化与警告;警告必须清零。

为什么要两条命令?因为"查错"与"跑得快要"是两个目标:Sanitizer 保证正确性验证充分,-O2 保证运行效率。混在一起用会得到一个"又慢又难读"的产物。

Shell完整命令与验证
# ① 查错版(本地自测)
g++ abc.cpp -g -O0 -fsanitize=address,undefined -Wall -Wextra -std=c++17 -o abc_san
./abc_san < in.txt > out_san.txt

# ② 交卷版(正式产物)
g++ abc.cpp -O2 -Wall -Wextra -std=c++17 -o abc
./abc < in.txt > out.txt

# ③ 两个版本结果应当一致(不一致说明存在 UB!)
diff out.txt out_san.txt
# 无输出 = 一致 = 没有依赖未定义行为
运行结果
$ g++ abc.cpp -g -O0 -fsanitize=address,undefined -Wall -Wextra -std=c++17 -o abc_san
$ g++ abc.cpp -O2 -Wall -Wextra -std=c++17 -o abc
$ ./abc_san < in.txt > out_san.txt
$ ./abc < in.txt > out.txt
$ diff out.txt out_san.txt
(无输出 —— 两种编译结果完全一致)
$ ls -lh abc abc_san
-rwxr-xr-x 1 noi noi  17K abc
-rwxr-xr-x 1 noi noi 1.1M abc_san       ← Sanitizer 版体积大很多

💡 两条命令的取舍

diff 这一步极有价值:如果查错版与优化版结果不同,说明代码里存在依赖未定义行为的逻辑(例如越界读了一个"恰好"的值)。这时候的正确反应不是"优化版过了就算过",而是回头把 UB 修掉。

题2编写双模式编译脚本
提高脚本编程

写脚本 build.sh:支持 debug 与 release 两种模式,自动套用对应的编译选项并输出产物名。

输入格式:命令行为 bash build.sh <模式> <源文件>,模式取值为 debug 或 release(缺省为 release)。

输出格式:先打印本次使用的完整编译命令,再打印编译结果(成功 / 失败);产物名为 源文件名_debug 或 源文件名_release。

输入样例:bash build.sh debug a.cpp 输出样例:

期望输出
模式:debug
命令:g++ a.cpp -g -O0 -Wall -Wextra -std=c++17 -fsanitize=address,undefined -o a_debug
编译成功 → ./a_debug

数据范围:源文件行数 ≤ 2000;编译耗时 ≤ 10 秒;脚本本身不允许修改源文件。

🔍 题目分析:两种实现风格

方法一 · if 分支用 if [ "$MODE" = "debug" ] 分别给 FLAGS 变量赋值。直观、易读。

方法二 · case 分支用 case "$MODE" in 匹配,天然支持"非法参数直接报错退出",扩展性更好(以后想加 asan、profile 等模式只需加一个分支)。

关键技巧:① 用 ${1:-release} 给默认值;② 用 set -u 防止用了未定义变量(写脚本的常见事故);③ 用 "$?" 或 || 判断编译结果;④ 打印完整命令,方便复制重现。

Shellbuild.sh(case 版本,推荐)
#!/bin/bash
# 用法:bash build.sh [debug|release] 源文件
set -u

MODE="${1:-release}"
SRC="${2:-main.cpp}"
NAME="${SRC%.cpp}"              # 去掉 .cpp 后缀

case "$MODE" in
  debug)
    FLAGS="-g -O0 -Wall -Wextra -std=c++17 -fsanitize=address,undefined"
    OUT="${NAME}_debug"
    ;;
  release)
    FLAGS="-O2 -Wall -Wextra -std=c++17"
    OUT="${NAME}_release"
    ;;
  *)
    echo "用法:bash build.sh [debug|release] 源文件"
    exit 2
    ;;
esac

echo "模式:$MODE"
echo "命令:g++ $SRC $FLAGS -o $OUT"

g++ "$SRC" $FLAGS -o "$OUT"
if [ $? -eq 0 ]; then
    echo "编译成功 → ./$OUT"
else
    echo "编译失败,请按上面的错误信息修改源码"
    exit 1
fi

🔍 代码逐段解析 · build.sh(双模式编译脚本)

第 2~3 行 —— shebang 与用法注释;set -u 让"使用了未定义变量"直接报错退出,防拼写事故。

第 5~7 行 —— ${1:-release} 模式缺省 release、源文件缺省 main.cpp;${SRC%.cpp} 用 % 去掉 .cpp 后缀得到产物基名。

第 9~21 行(case 分支) —— debug 配 -g -O0 -fsanitize(查错优先),release 配 -O2(速度优先);* 兜底分支对非法模式打印用法并 exit 2。

第 23~24 行 —— 先打印模式与完整命令,便于复制重现、写进对拍流程。

第 26~31 行 —— 编译后用 [ $? -eq 0 ] 判退出码:0 打印成功,否则提示并 exit 1。

易错点 —— ① 第 26 行 g++ "$SRC" $FLAGS 里 FLAGS 故意不加引号(要让多个选项按空格拆开),与 "$SRC" 必须加引号形成对比,别一刀切;② $? 必须紧跟 g++ 之后读取,中间插一条 echo 就读不到编译结果了。

运行结果
$ bash build.sh debug a.cpp
模式:debug
命令:g++ a.cpp -g -O0 -Wall -Wextra -std=c++17 -fsanitize=address,undefined -o a_debug
编译成功 → ./a_debug

$ bash build.sh release a.cpp
模式:release
命令:g++ a.cpp -O2 -Wall -Wextra -std=c++17 -o a_release
编译成功 → ./a_release

$ bash build.sh
模式:release
命令:g++ main.cpp -O2 -Wall -Wextra -std=c++17 -o main_release
编译成功 → ./main_release

$ bash build.sh fast a.cpp
用法:bash build.sh [debug|release] 源文件

💡 为什么参数要加双引号?

因为文件名可能含空格。不加引号时 $SRC 会被 shell 按空格拆成两个参数,g++ 会以为你给了两个文件。写 shell 脚本的黄金法则:变量展开一律加双引号。

题3读编译日志定位错误
提高诊断题

下面是编译某程序的真实输出,请指出:① 有几处必须修的错误 ② 每处的行号与原因 ③ 修改方案

输入(编译日志片段):

build.log
main.cpp: In function 'int main()':
main.cpp:7:18: error: expected ';' before 'cout'
    7 |     int ans = 0
      |                  ^
main.cpp:12:23: warning: comparison of integer expressions of different signedness: 'int' and 'std::vector<int>::size_type' [-Wsign-compare]
main.cpp:15:9: warning: 'ans' is used uninitialized [-Wuninitialized]
main.cpp:20:1: error: 's' was not declared in this scope
   20 |     cout << s << endl;
      |          ^

输出要求:逐条给出"行号 → 类型(error/warning)→ 原因 → 修改方案"。

数据范围:日志不超过 100 行;需在 2 分钟内完成定位(模拟考场节奏)。

🔍 逐条分析

① 第 7 行 error:expected ';' before 'cout' —— int ans = 0 后面少了分号。编译器在读到 cout 时才发现上一句没结束。修法:补 ;。

② 第 12 行 warning:signedness 比较 —— 拿 int i 与 v.size()(无符号)比较。size() 返回无符号类型,一旦 i 为负就会被当成极大的正数。修法:比较时显式强转 (int)v.size(),或把循环变量 i 声明为 size_t。

③ 第 15 行 warning:ans 未初始化 —— 声明时没给初值。修法:long long ans = 0;。

④ 第 20 行 error:'s' was not declared —— 用了未声明的变量 s,或它的声明在 { } 作用域里已经结束。修法:在函数开头声明 long long s = 0;,或检查变量名是否拼错。

关键顺序:先修第 7 行(语法错误会连带影响后续解析),再看 20 行的 undeclared —— 有时代码只是"上一行少个分号"造成连锁报错。

Shell用命令把日志"切片"处理
# 把编译输出全部存起来(警告与错误都走 stderr)
g++ main.cpp -O2 -Wall -Wextra -std=c++17 -o main 2> build.log

# 只看 error,并带行号
grep -n "error" build.log
# 7:...error: expected ';' before 'cout'
# 20:...error: 's' was not declared in this scope

# 统计各类问题的数量(判断"是集中改一处还是到处都要改")
grep -c "error" build.log
grep -c "warning" build.log

# 只挑高危警告
grep -n -E "uninitialized|sign-compare|array-bounds" build.log

# 统计"哪个文件问题最多"(多文件工程时很有用)
grep -o "^[A-Za-z0-9_/.]*\.cpp" build.log | sort | uniq -c | sort -rn
运行结果
$ grep -n "error" build.log
7:main.cpp:7:18: error: expected ';' before 'cout'
20:main.cpp:20:1: error: 's' was not declared in this scope

$ grep -c "warning" build.log
2

$ grep -n -E "uninitialized|sign-compare" build.log
12:main.cpp:12:23: warning: comparison of integer expressions of different signedness ...
15:main.cpp:15:9: warning: 'ans' is used uninitialized [-Wuninitialized]

$ grep -o "^[A-Za-z0-9_/.]*\.cpp" build.log | sort | uniq -c | sort -rn
      5 main.cpp
修复顺序建议:① 先修所有 error(语法/名字/缺失分号)→ ② 重新编译,很多 warning 会随之改变或消失 → ③ 再逐条清掉剩余的 warning。

💡 一个心态提醒

看到 20 行报错不要慌。绝大多数情况下,真正的错误只有 2~3 处,其余都是连锁反应。先修第一条,重新编译,你会发现报错数量急剧减少 —— 这就是"从第一条开始改"的价值。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心赛场用途
vim 三模式普通(指挥)/ 插入(写)/ 命令行(:)任何 Linux 都能改代码
vim 保命操作i 进插入、Esc 回普通、:wq 保存退出、:q! 放弃不丢代码
vim 提效:n/nG 跳行、/ 查找、:%s/a/b/g 替换、u 撤销快速定位与批量修改
四阶段编译预处理 → 编译 → 汇编 → 链接看懂报错来自哪一步
-O2二级优化,交卷标配;调试时用 -O0避免 TLE
-Wall -Wextra打开警告,免费审查员抓未初始化、类型混比较
-fsanitize运行时抓越界与 UB(本地专用)快速定位 RE 原因
-D 与 #ifdef命令行定义宏,控制调试输出调试语句零成本开关

万能模板:编译与验证四步走(每次改完代码都按这个顺序跑一遍)

Shell标准工作流 compile.sh
#!/bin/bash
# 用法:bash compile.sh a.cpp
SRC="${1:-main.cpp}"; NAME="${SRC%.cpp}"

# ① 查错编译:Sanitizer + 全警告 + 调试符号
g++ "$SRC" -g -O0 -Wall -Wextra -std=c++17 \
      -fsanitize=address,undefined -o "${NAME}_san"
# ② 本地验证:跑样例,看有没有 Sanitizer 报警
./"${NAME}_san" < "${NAME}.in" > "${NAME}_san.out"

# ③ 正式编译:优化版
g++ "$SRC" -O2 -Wall -Wextra -std=c++17 -o "$NAME"
./"$NAME" < "${NAME}.in" > "${NAME}.out"

# ④ 双版本互校 + 与标准答案比对
diff "${NAME}.out" "${NAME}_san.out" && echo "两版本一致(无 UB 依赖)"
diff -b "${NAME}.out" "${NAME}.ans" && echo "样例通过"

🔍 代码逐段解析 · compile.sh(编译与验证四步走)

第 3 行 —— 取源文件名并去掉 .cpp 得到 NAME,后续所有产物名都由它派生,改一次源文件名脚本照样能用。

第 5~6 行(步骤①) —— 查错编译:-g -O0 保证行为与源码对应,-fsanitize=address,undefined 装上运行时探针,产物名加 _san 后缀。

第 7~8 行(步骤②) —— 用重定向喂 NAME.in、收 NAME_san.out;若代码有越界/UB,这一步会直接报警。

第 10~11 行(步骤③) —— 正式编译 -O2 -Wall -Wextra,并同样跑一遍样例得到 NAME.out。

第 13~14 行(步骤④) —— 双版本 diff 互校(不一致 = 依赖 UB,必须回头修)+ 与标准答案 diff -b 比对;&& 只在比对成功时打印结论。

易错点 —— ① 脚本默认存在 a.in 与 a.ans,文件缺失时重定向会报错,使用前先 ls 确认;② 第 13 行互校不加 -b 是故意的:UB 导致的差异通常是数值不同而非行尾空白,严格比对更安全。

运行结果
$ bash compile.sh a.cpp
两版本一致(无 UB 依赖)
样例通过
②拓展案例

拓展 1:用 vim 宏录制完成机械式批量修改(两种做法)

情境:你有 40 行形如 a[i] 的语句,需要全部改成 a[i + 1]。

方案一 · 替换命令:%s/a\[\(\w*\)\]/a[\1 + 1]/g 一行命令解决,但正则要写对。

方案二 · 宏录制按 qa 开始录制到寄存器 a,做完一次修改后按 q 结束;之后按 @a 重放一次、10@a 重放十次。适用于"规律不明显、需要多次按键才能完成"的修改。

选择建议:改动规律清晰 → 用替换命令(快且可复查);改动步骤多且不规则 → 用宏录制。

Vimscript宏录制示例
" 目标:把 a[i] 改成 a[i + 1](模拟一次手工修改)
qa                        " 开始录制到寄存器 a
/a[                         " 查找 a[
f]                          " 光标跳到 ] 上
i + 1<Esc>                " 在 ] 前插入 " + 1"
n                           " 跳到下一个匹配
q                           " 结束录制

@a                          " 重放一次
10@a                        " 重放十次(一次改十处)

" 更省事的等价方案:用替换命令一次性完成
:%s/a\[\(\w*\)\]/a[\1 + 1]/g

拓展 2:用 -O2 与 -O0 的差异来验证算法复杂度

思路:同一份代码分别用 -O0 与 -O2 编译,用 time 测运行时间。如果 -O2 下仍然超时,说明算法复杂度真的不够,而不是"常数太大"。

判据通常 -O2 能带来 2~5 倍加速。如果加速后仍远超时限,就必须换算法;如果只差一点点,可能只需把 cin 换成 scanf 或加 ios::sync_with_stdio(0)。

Shell对比实验
# 生成一组规模较大的数据
python3 -c "import random;print(100000);print(' '.join(str(random.randint(1,10**9)) for _ in range(100000)))" > big.in

g++ a.cpp -O0 -Wall -o a0
g++ a.cpp -O2 -Wall -o a2

time ./a0 < big.in > /dev/null
# real    0m2.184s
time ./a2 < big.in > /dev/null
# real    0m0.412s    ← 快了 5 倍,说明 -O2 生效

# 若 -O2 下仍超过 1s,就要考虑换算法或优化读入

拓展 3:-Werror 与 -Wno-xxx 的取舍

思路:-Werror 把警告升级为错误,强迫自己"零警告";-Wno-xxx 则关闭某一类警告(例如 -Wno-unused-variable)。

警示关警告很危险 —— 你今天关掉的可能正是明天 WA 的原因。正确做法:先看警告,判断"是真问题"还是"误报",再决定修还是关。竞技编程里几乎全部警告都值得修。

Shell进阶警告控制
# 强迫零警告(本地开发时用,交卷前建议去掉以免被第三方头文件的警告卡住)
g++ a.cpp -O2 -Wall -Wextra -Werror -o a

# 只关心"会造成错误"的警告类别
g++ a.cpp -Wall -Wextra -Wno-unused-parameter -o a

# 看看编译器还支持哪些警告类别
g++ -Q --help=warnings | head -n 30

# 把警告与错误分开保存(-Werror 之后错误里也含警告信息)
g++ a.cpp -Wall -Wextra -Werror -o a 2> err.log
grep -c "error" err.log
③思考与启发

💡 思考 1:为什么"没报错"不等于"程序正确"?

请从"编译器能检查什么""编译器检查不了什么"两个角度,解释为什么越界、未初始化、UB 这类问题不会被默认报出来。

查看参考思路

编译器能检查的:语法是否合法、类型是否匹配、名字是否存在、模板是否能实例化。这些都是"翻译层面"的问题。

编译器检查不了的:运行时的下标是否在范围内、变量在当前执行路径上是否被赋过值、两个指针是否指向同一块内存。因为这些取决于运行时的具体输入,而编译时输入还不知道。

所以工具分两层:-Wall 用静态分析做"猜测式提醒"(可能误报、可能漏报);-fsanitize 在运行时插入检查(准确,但只在真正执行到那一行时才报)。两者互补。

结论:"编译通过"只代表"语法与类型没问题",正确性必须靠测试用例、对拍、Sanitizer 三者共同保证。

💡 思考 2:-O2 会"改变"程序行为吗?

有同学发现同一份代码在 -O0 下正确、在 -O2 下错误。这是编译器 bug 吗?

查看参考思路

不是编译器 bug。如果代码里存在未定义行为(读未初始化变量、越界访问、有符号溢出、除零等),C++ 标准允许编译器假设它不会发生,从而做出任意优化。

典型例子:读未初始化的 ans,-O0 时恰好读到栈上的残留值(也许正好是 0),-O2 时优化器可能把它当成常量 0,也可能整段消掉循环 —— 两种行为都"符合标准"。

如何判断?用 Sanitizer 编译一版跑一下:如果直接报错,就证明是 UB,而不是优化器的错。

启示:优化级别放大的是"你代码里本来就有的裂缝",不是新造裂缝。把 UB 修掉,两个版本就必然一致。

💡 思考 3:编辑器与编译器,哪个更值得先学?

如果你只能先熟练掌握一个,应该选哪个?请给出你的判断标准。

查看参考思路

先学编译器选项。因为编辑器只影响"效率",而编译选项直接影响"正确性"。会 vim 但不用 -Wall,仍然会交出错误的代码;不熟 vim 但用全套选项,至少能保证程序是对的。

但 vim 的最小集合必须会。因为考场没有图形界面时,不会 vim 就完全无法作答 —— 这是"能不能做题"的问题,不是"快不快"的问题。

推荐路径:① 先背下 vim 的 6 个操作(i Esc :wq :q! :n u);② 再背三条编译命令(交卷版 / 调试版 / 查错版);③ 其余按需扩展。

模块 05

学习检测

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

易错1

vim 里乱按 Ctrl+S,卡住不动

❌ 错误
在终端 vim 中按 Ctrl+S(Windows 的保存习惯),终端"冻结",按键无反应。
✅ 正确
按 Ctrl+Q 解除冻结(这是终端流控),然后在普通模式下 :w 保存。vim 的保存是 :w,不是 Ctrl+S。
口诀:终端里没有 Ctrl+S,保存只有冒号 w;被冻住别慌,Ctrl+Q 解冻!
易错2

编译不加 -Wall,漏掉未初始化警告

❌ 错误
g++ a.cpp -o a,编译器对 int ans; 一言不发,程序输出垃圾值。
✅ 正确
g++ a.cpp -O2 -Wall -Wextra -std=c++17 -o a,立刻得到 'ans' may be used uninitialized。
口诀:不加 -Wall,编译器装哑巴;加了 -Wall,免费审查员上岗!
易错3

用 Sanitizer 版程序去交卷,TLE

❌ 错误
把 g++ a.cpp -fsanitize=address,undefined -o a 编出的程序当正式产物提交,评测超时。
✅ 正确
Sanitizer 只用于本地查错;交卷必须换成 g++ a.cpp -O2 -Wall -std=c++17 -o a。
口诀:Sanitizer 是显微镜,查完就收;交卷只用 -O2!
易错4

宏漏写括号,优先级出错

❌ 错误
#define MUL(a,b) a*b,则 MUL(2+3,4) 展开成 2+3*4=14,与期望的 20 不符。
✅ 正确
写成 #define MUL(a,b) ((a)*(b));更好的做法是用 inline long long mul(long long a, long long b) 替代宏。
口诀:宏是文本替换,参数常量和整体都要加括号;能写函数就别写宏!
易错5

加了调试语句却用 -DDEBUG 之外的方式"打开"

❌ 错误
写了 #ifdef DEBUG ... #endif,编译时敲 -Ddebug(小写),调试代码被整段丢弃,却以为"调试没生效是因为逻辑问题"。
✅ 正确
宏名区分大小写,必须与 #ifdef 里写的一致:g++ a.cpp -DDEBUG -o a。
口诀:宏名区分大小写,-DDEBUG 必须与 #ifdef DEBUG 一字不差!
📋速查表:vim 与 g++ 高频操作
类别操作命令 / 按键
vim 模式进插入 / 回普通 / 进命令行i / Esc / :
vim 保存保存 / 退出 / 保存退出 / 放弃:w / :q / :wq / :q!
vim 移动行首尾 / 首尾行 / 第 n 行0 $ / gg G / :n nG
vim 编辑删行 / 复制行 / 粘贴 / 撤销dd / yy / p / u
vim 查找替换查找 / 下一个 / 全文替换/词 / n / :%s/旧/新/g
vim 设置行号 / 粘贴模式 / 缩进:set nu / :set paste / gg=G
g++ 输出指定产物名-o 名字
g++ 阶段只看预处理 / 汇编 / 目标文件-E / -S / -c
g++ 优化不优化 / 二级优化-O0 / -O2
g++ 警告常用警告 / 更多 / 当错误-Wall / -Wextra / -Werror
g++ 标准指定 C++ 版本-std=c++17
g++ 调试调试符号 / Sanitizer-g / -fsanitize=address,undefined
g++ 宏命令行定义宏-D名字 / -D名字=值
📌知识点笔记卡
📖 必记概念
  • vim 三模式:普通 / 插入 / 命令行
  • 报错格式:文件:行:列: 等级: 内容 [选项]
  • 编译四阶段:预处理→编译→汇编→链接
  • error 在编译期,RE/WA 在运行期
  • UB = 标准未规定结果,随编译器变化
  • 宏 = 预处理阶段的纯文本替换
🧰 必背命令
  • 交卷:g++ a.cpp -O2 -Wall -Wextra -std=c++17 -o a
  • 调试:g++ a.cpp -g -O0 -Wall -o a_dbg
  • 查错:g++ a.cpp -g -O0 -fsanitize=address,undefined -o a_san
  • 看宏:g++ -E a.cpp | grep -n 关键词
  • 开调试开关:g++ a.cpp -DDEBUG -o a
  • vim 保命:i → Esc → :wq
🚨 考场提醒
  • 每次编译都带 -Wall,警告清零
  • 写 for 循环变量别漏初始化累加器
  • Sanitizer 版本不能交卷
  • 交卷前 grep -n "freopen\|printf" 查残留
  • 老实用 :w 保存,别按 Ctrl+S
  • 改代码前 cp 一份备份
课后练习

分层练习作业

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

🟢 A组 · 基础(4题)

A1vim 操作

写出完成下列操作的按键序列:① 打开 a.cpp 并在第 2 行下方新起一行输入 int b; ② 保存并退出 ③ 放弃所有修改直接退出

查看参考答案

思路:先想"我需要进入哪种模式",再想"用哪个键进入"。

① vim a.cpp → :2 回车跳到第 2 行 → o(在下方新开一行并进入插入模式)→ 输入 int b; → Esc。

② :wq 回车。③ :q! 回车。

考点:普通模式跳行(:n)、插入命令 o、命令行保存退出。

易错:在第 2 行按 o 会新开在下方(第 3 行位置);若想开在上方应使用 O(大写)。忘记按 Esc 就输入 :wq,会把冒号内容当成正文打字。

A2编译命令

写出命令:① 把 a.cpp 编译成可执行文件 a 并打开常用警告 ② 只做预处理生成 a.i ③ 生成目标文件 a.o

查看参考答案

① g++ a.cpp -O2 -Wall -std=c++17 -o a;② g++ -E a.cpp -o a.i;③ g++ -c a.cpp -o a.o。

考点:-o 指定产物名、-E 只做预处理、-c 只编译成目标文件不链接。

易错:把 -E 与 -e 混淆(后者不是这个含义);忘了 -c 时 g++ 会尝试链接,从而因缺少 main 而报 undefined reference。

A3读警告

解释这条警告的含义,并给出两种正确的修改方式:
a.cpp:12:23: warning: comparison of integer expressions of different signedness: 'int' and 'std::vector<int>::size_type'

查看参考答案

含义:第 12 行把有符号的 int 与无符号的 size_t(vector::size() 的返回类型)做了比较。C++ 会把 int 隐式转成无符号,若它为负就会变成极大的正数,导致比较结果错误。

改法一:把循环变量声明为 size_t:for (size_t i = 0; i < v.size(); i++)。

改法二:显式强转:for (int i = 0; i < (int)v.size(); i++)。竞技编程中更常用这种写法,因为与数组下标习惯一致。

易错:直接忽略这条警告 —— 在 i 可能为负(例如反向遍历)时会直接引发越界崩溃。

A4宏辨析

已知 #define SQ(x) x*x,求 SQ(1+2) 的展开结果与期望结果的差异,并修正。

查看参考答案

展开:SQ(1+2) → 1+2*1+2 → 按优先级计算 1 + 2 + 2 = 5;期望是 (1+2)² = 9。

修正:#define SQ(x) ((x)*(x)),展开成 ((1+2)*(1+2)) = 9。

更优做法:用 inline int sq(int x) { return x * x; } 或 constexpr 函数 —— 有类型检查,不会产生优先级与多次求值的陷阱。

易错:只给参数加括号而忘了给整体加括号((x)*(x)),在 2*SQ(3) 这类表达式中仍会出错。

🟡 B组 · 提高(4题)

B1脚本编写

写脚本 run.sh:接收源文件名与一组测试数据编号,对每个编号分别用 Sanitizer 版与优化版运行,并报告两版本是否一致。

查看参考答案

思路:先编两个版本,再循环跑每个测试点,最后比对输出。

#!/bin/bash
SRC="${1:-main.cpp}"; NAME="${SRC%.cpp}"; shift
g++ "$SRC" -g -O0 -fsanitize=address,undefined -Wall -o "${NAME}_san" || exit 1
g++ "$SRC" -O2 -Wall -o "$NAME" || exit 1
for t in "$@"; do
    ./"${NAME}_san" < "$t.in" > "$t.san.out" 2> "$t.san.err"
    ./"$NAME"     < "$t.in" > "$t.out"
    if [ -s "$t.san.err" ]; then
        echo "$t:Sanitizer 报警!见 $t.san.err"
    elif diff -b -q "$t.out" "$t.san.out" > /dev/null; then
        echo "$t:一致,无 UB 依赖"
    else
        echo "$t:不一致!可能存在未定义行为"
    fi
done

考点:2> 单独捕获 stderr、[ -s 文件 ] 判断文件非空、diff -q 静默比对。

易错:把 Sanitizer 的输出与程序输出混在一个文件里(必须用 2> 分离);忘记先判断编译是否成功就开始跑测试。

B2诊断题

下面代码编译通过但运行结果错误,指出问题所在并说明用哪个编译选项可以最快发现它。
int a[105]; int n; cin >> n; for (int i = 1; i <= n; i++) cin >> a[i]; // n 可达 100000

查看参考答案

问题:数组 a 只开了 105 个元素,而 n 可达 105,当 n > 105 时发生数组越界写(未定义行为)。

最快发现的方式:g++ a.cpp -g -O0 -fsanitize=address,undefined -Wall -o a,运行到越界那一行会立刻打印 stack-buffer-overflow 并给出文件名与行号。

修法:按数据范围上界开数组并留出余量,例如 const int MAXN = 100005; int a[MAXN];;循环从 1 开始时数组至少开到 MAXN+1。

易错:只把数组改大而不检查循环上界(例如写成 i <= n 却又从 0 开始);或误以为"本地小数据过了就没问题"。

B3宏与条件编译

写一段代码:当用 -DDEBUG 编译时,每次循环都输出当前的 i 与累加和;不用该选项时,这些输出必须完全不产生(连代码都不编进去)。

查看参考答案

思路:用 #ifdef DEBUG 包住调试输出,输出走 cerr 以免污染标准输出;用 -DDEBUG 打开开关。

#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++) {
        long long x; cin >> x;
        s += x;
#ifdef DEBUG
        cerr << "[调试] i=" << i << " s=" << s << endl;
#endif
    }
    cout << s << endl;
    return 0;
}

验证:g++ a.cpp -DDEBUG -o a && ./a 会看到调试行;g++ a.cpp -o a && ./a 完全看不到,且用 g++ -E a.cpp | grep "调试" 也搜不到 —— 证明代码在预处理阶段就被删掉了。

易错:用 #if DEBUG 而不是 #ifdef DEBUG(当宏无值时 #if 会报错);或调试信息用 cout 输出,污染了标准输出导致 WA。

B4原理分析

为什么 g++ a.cpp b.cpp -o prog 与"先分别 -c 再链接"在结果上等价?后者的优势是什么?

查看参考答案

等价原因:一条命令只是让 g++ 自动完成"编译两个源文件 → 生成各自的目标文件 → 链接"三步,最终产物完全一样。

后者优势:目标文件可以复用。改动了 a.cpp 时,只需重编 a.o 再链接,b.o 不用重新编译 —— 大工程能节省大量编译时间。

操作方式:g++ -c a.cpp -O2 -o a.o;g++ -c b.cpp -O2 -o b.o;g++ a.o b.o -o prog。这正是 Makefile 的核心思想。

易错:把 -c 编译出的 .o 当成可执行文件去 ./a.o 运行(会报格式错误);或修改头文件后忘记重编依赖它的 .o,导致"改了代码没生效"。

🔴 C组 · 挑战(4题)

C1综合辨析

写出并解释三条 vim 命令:① 把 10~20 行中所有 int 替换为 long long 并逐个确认 ② 删除第 30 到第 45 行 ③ 把整个文件按 4 空格重新缩进

查看参考答案

① :10,20s/int/long long/gc —— 10,20 限定行范围,s///g 全文替换,c 逐个确认。注意 int 也会匹配到 printf 里的 int,所以 c(确认模式)非常必要。

② :30,45d —— 删除指定行区间。等价做法:跳过去按 16dd(从第 30 行起删 16 行),但计数容易错,用行号区间更安全。

③ gg=G —— gg 到文件首,= 是"按缩进规则重排",G 到文件尾,合起来表示全文自动缩进。

考点:vim 命令行模式下的行范围语法 起,止 命令,以及 g/c 标志位的语义。

C2深度分析

为什么"有符号整数溢出"是未定义行为,而无符号整数溢出是明确定义的?这对竞赛代码有什么实际影响?

查看参考答案

无符号:标准规定其运算按 2^n 取模(模运算语义),因此 unsigned int 溢出后回绕是定义明确的,可以依赖它写代码(例如哈希、位运算)。

有符号:历史上不同机器有不同的整数表示(补码、反码、原码),为兼容它们,标准把有符号溢出定为未定义行为,从而允许编译器假设"a + b 一定不会溢出",并据此做优化(如把 i <= n 的循环变量当作永不回绕)。

实际影响:① 用 int 累加大数据会溢出,结果不可预测 —— 求和必须用 long long;② 有些"哨兵值 1e18"技巧若超过 int 范围也会坏掉;③ 反向遍历 for (int i = n-1; i >= 0; i--) 是安全的(不溢出),但 for (unsigned i = n-1; i >= 0; i--) 会死循环。

启示:提高组选手要养成"看到累加、乘积、计数就用 long long"的条件反射。

C3工具链设计

为多文件小工程写一个 Makefile,要求:支持 make 编译出 prog、make debug 编译调试版、make clean 清理产物。

查看参考答案

思路:Makefile 的核心是"目标 : 依赖 → 命令",并利用时间戳决定是否需要重新编译。

CXX      = g++
CXXFLAGS = -O2 -Wall -Wextra -std=c++17
DBGFLAGS = -g -O0 -Wall -fsanitize=address,undefined
OBJS     = a.o b.o

prog: $(OBJS)
	$(CXX) $(OBJS) -o prog

%.o: %.cpp
	$(CXX) $(CXXFLAGS) -c $< -o $@

debug:
	$(CXX) $(DBGFLAGS) a.cpp b.cpp -o prog_dbg

clean:
	rm -f *.o prog prog_dbg

.PHONY: debug clean

要点:%.o: %.cpp 是模式规则(自动把每个 cpp 编成 o);$< 是第一个依赖、$@ 是目标名;.PHONY 声明 debug 与 clean 不是真实文件。

易错:Makefile 的命令行必须用 Tab 开头(不是空格),这是新手最常踩的坑;clean 里的 rm -f 一定要带 -f,否则文件不存在时会中断。

C4综合设计

设计一套从"写完代码"到"确认可以交卷"的完整检查流程,要求覆盖:编译零警告、无 Sanitizer 报警、两版本一致、样例通过、无调试残留。

查看参考答案

思路:把每项检查变成可判定的一步,任一步失败立即终止并给出明确提示。

#!/bin/bash
set -e
SRC="${1:-main.cpp}"; N="${SRC%.cpp}"

echo "[1/5] 交卷版编译(要求零警告)"
g++ "$SRC" -O2 -Wall -Wextra -std=c++17 -Werror -o "$N"

echo "[2/5] 查错版编译 + Sanitizer 检查"
g++ "$SRC" -g -O0 -fsanitize=address,undefined -o "${N}_san"
./"${N}_san" < "$N.in" > "${N}_san.out" 2> san.err
[ -s san.err ] && { echo "Sanitizer 报警,请查看 san.err"; exit 1; }

echo "[3/5] 两版本结果一致性"
./"$N" < "$N.in" > "$N.out"
diff "$N.out" "${N}_san.out" > /dev/null

echo "[4/5] 样例对比"
diff -b "$N.out" "$N.ans" > /dev/null && echo "样例通过"

echo "[5/5] 调试残留检查"
grep -n "freopen\|cerr\|printf\|TODO" "$SRC" && { echo "发现残留,请处理"; exit 1; }
echo "✅ 全部检查通过,可以交卷"

考点:set -e 让脚本"遇错即停";-Werror 把警告升级为失败;2> 分离 Sanitizer 诊断;grep && exit 1 把"找到了残留"视为失败。

易错:不加 set -e 时中间步骤失败脚本仍会继续跑,最后一行的"通过"会给人假的安全感;或把 grep 当成"有输出才算成功"(这里语义相反)。

🏆 洛谷原题精选(12道)

以下 12 道题请在 NOI Linux + vim 环境中完成,并要求:① 每次编译都带 -Wall -Wextra 且警告清零;② 至少用 -fsanitize=address,undefined 检查过一遍;③ 全部用重定向测样例。按难度从入门到普及+排列。

1
P1001
A+B Problem入门
2
P1421
小玉买文具入门
3
P1424
小鱼的航程入门
4
P1888
三角函数入门
5
P1047
校门外的树入门
6
P1307
数字反转入门
7
P1909
买铅笔入门
8
P2141
珠心算测验入门
9
P1059
明明的随机数普及-
10
P1308
统计单词数普及-
11
P1044
栈普及-
12
P1177
【模板】快速排序普及-