美味鸡汤:
这是三篇系列文章的第一篇。三篇写完之后,你应该能够独立参加 CSP-J(信息学奥赛入门级)的机试,也就是说,能读懂题目、能想出暴力做法、能把它稳稳当当地写成代码、能自己调出错误。
这一篇解决的是"从完全不会到能写出一个完整程序"的问题:环境怎么装、代码怎么跑、变量是什么、输入输出怎么写、判断和循环怎么用、数组怎么存数据、函数怎么拆。看起来都是最基础的东西,但 CSP-J 里真正让人丢分的,往往不是算法没想到,而是这些基础写错了------数组开小了、
= =写成=、int溢出了、cin太慢超时了。所以这一篇请务必认真过一遍,边看边敲。看懂和敲过是两件完全不同的事。
第 0 章 先搞清楚我们在干什么
0.1 编程到底在做什么
一句话:你把解决问题的步骤,用一种机器能精确执行的语言写下来。
举个生活里的例子。你要告诉一个从没做过饭的人"怎么煮泡面",你不能说"煮一下就好了",你得说:
- 找一个锅;
- 接 500 毫升水;
- 放到灶上,开大火;
- 等水烧开(冒大泡);
- 把面饼放进去;
- 等 3 分钟;
- 关火,放调料,搅拌。
注意这里面有几个特征:
- 顺序:步骤有先后,不能先放面再烧水;
- 条件:如果水没开就继续等,如果开了就放面;
- 重复:如果面还没软,就继续等;
- 数据:水量、时间、调料的数量,都是具体的值。
计算机程序就由这四样东西构成:顺序、分支、循环、数据。这一篇后面的内容,本质上就是把这几件事讲透。
0.2 为什么入门选 C++
市面上入门语言很多,Python 更简单,Java 更"工程"。但如果你要打信息学竞赛(CSP-J/S、NOIP),C++ 是唯一的主流选择,原因有三个:
- 快。C++ 编译成机器码直接运行,同样的算法,C++ 往往比 Python 快几十倍。竞赛里一道题限时 1 秒,Python 写同样的暴力很可能超时,C++ 就能过。
- 可控。内存怎么用、数组开多大、传参是拷贝还是引用,你都能精确控制。这在需要卡常数、卡内存的竞赛题里是刚需。
- 生态。竞赛题库、题解、模板代码,绝大多数是 C++ 的。你写 Python 会遇到"看得懂题解但不知道对应写法"的问题。
C++ 也确实更难一些------指针、内存、编译错误,都会劝退人。但只要你按顺序走,不跳步,这些难点是可以一个个啃下来的。这篇只讲最必要的部分,不会一上来就跟你聊虚函数和模板元编程。
0.3 CSP-J 是什么,我们需要达到什么水平
CSP-J 是中国计算机学会(CCF)主办的"非专业级软件能力认证"的入门级(J = Junior),每年 9 月左右举行,分两轮:
- 第一轮(初赛):笔试,全是选择题,考计算机基础知识、C++ 语法细节、阅读程序(给你一段代码问输出)、完善程序(给你缺了几行的代码,选正确的填空)。
- 第二轮(复赛):机试,通常 4 道编程题,每道题有若干测试点,按通过测试点给分,满分 100 分。
对我们来说,目标很明确:
- 语法层面:能看懂任何一段入门级 C++ 代码,能准确说出它的输出(初赛阅读程序题全靠这个);
- 算法层面:掌握模拟、枚举、排序、递推、递归、前缀和、二分、贪心、搜索、简单动态规划、简单图论;
- 工程层面:能在 3.5 小时内写完 4 道题的暴力做法,并且不犯低级错误。
第三篇会专门讲赛制和应试。现在先把地基打好。
0.4 三篇的分工
| 篇目 | 解决什么 | 学完能做什么 |
|---|---|---|
| 第一篇(本篇) | 语法地基:变量、输入输出、分支、循环、数组、字符串、函数 | 写出完整的、能通过基础题的程序 |
| 第二篇 | 进阶工具:指针与引用、结构体、STL 容器、排序、复杂度 | 用现成工具高效处理数据,判断算法会不会超时 |
| 第三篇 | 竞赛算法与应试:模拟、高精度、二分、贪心、搜索、DP、图论、数论、比赛策略 | 独立完成 CSP-J 复赛 |
0.5 学习方式(重要)
给你三条硬建议:
- 每段代码都亲手敲一遍。不要复制。复制来的代码你不会有肌肉记忆,考试时手会抖。
- 每敲完一段,故意改坏它 。把
i < n改成i <= n,看看会怎样;把int改成char,看看会怎样。错误见得多,你才不会怕错误。 - 每章末尾的练习必须做。看懂了和写出来之间有一条很宽的河,练习就是那座桥。
好,开始。
第 1 章 环境:把第一个程序跑起来
1.1 编译这件事
C++ 不是"解释执行"的语言。你写的 .cpp 文件是给人看的文本,机器看不懂。中间需要一个翻译过程,叫编译:
你的代码 source.cpp
│ 预处理(处理 #include 之类)
▼
展开后的代码
│ 编译(语法检查 + 转成汇编)
▼
汇编代码 .s
│ 汇编
▼
机器码 .o(目标文件)
│ 链接(把用到的库拼进来)
▼
可执行文件 a.out / a.exe
这个流程解释了两件事:
- 为什么语法错误在运行前就被发现:编译器在"编译"这一步就会检查语法,报错并停下。所以你写错一个分号,程序根本跑不起来。
- 为什么
#include是必须的 :cin、cout这些东西不是凭空存在的,它们的实现在标准库里。#include <iostream>相当于告诉编译器"我要用这个库的声明,链接的时候记得把它拼进来"。
竞赛里,你写好代码,评测机在后台做的就是这个流程:编译 → 运行 → 用测试数据喂进去 → 比对输出。所以编译不过 = 0 分,这一点要刻在脑子里。
1.2 选什么工具
三种选择,按你的情况挑:
(1)Dev-C++(最省事) 老牌竞赛 IDE,界面土,但开箱即用:新建源文件、按 F11 编译运行,一步到位。很多学校的机房装的就是它。缺点是经常有奇怪的兼容问题,而且不带调试器的话不好查错。
(2)VS Code + MinGW(推荐) 现代编辑器,装 C/C++ 插件和 MinGW(Windows)或 g++(Linux/Mac)就能用。好处是智能提示强,写错立刻有波浪线;装 Code Runner 插件后按 Ctrl+Alt+N 直接跑。缺点是要配环境,第一次装可能折腾半小时。
(3)在线环境(最保险) 洛谷(luogu.com.cn)、Codeforces、或者各种在线编译器,浏览器里写、浏览器里跑。好处是零配置;坏处是考试时你不可能用在线环境,还是得熟悉本地编译。
不管用哪个,请务必确保你能做到:新建一个文件 → 写代码 → 编译 → 看到输出。这一步卡住的人,后面全都会卡。
Windows 上如果命令行编译,常用命令是:
g++ -o hello hello.cpp -std=c++14 -O2 -Wall
逐个解释:
-o hello:输出文件叫hello(Windows 下是hello.exe);hello.cpp:源文件;-std=c++14:用 C++14 标准(竞赛常用 c++14,现在也有 c++17);-O2:开二级优化,让程序跑得快(竞赛评测机会自动开,本地测试建议也开,但调试时关掉更好);-Wall:显示所有警告。这个参数非常有用,很多隐患(比如变量没初始化、比较有无符号混用)它会提醒你。
然后运行:
./hello # Linux / Mac
hello.exe # Windows
1.3 常见的第一天报错
| 报错信息 | 真实原因 |
|---|---|
'xxx' was not declared in this scope |
变量名拼错了,或者用了没定义的变量,或者忘了 #include |
expected ';' before '}' token |
上一行漏了分号 |
expected declaration before '}' token |
大括号不配对,多了或少了 } |
invalid conversion from 'const char*' to 'char' |
用双引号写了单个字符,应该用单引号 |
redefinition of 'x' |
同一个作用域里定义了两次同名变量 |
看到报错不要慌,先看第一个错误。C++ 编译器一旦错了,后面会连锁报出一堆假错误,从第一个开始改,改完重新编译,往往后面一堆错误自己就消失了。
第 2 章 第一个程序:逐行拆解
2.1 完整代码
#include <iostream>
using namespace std;
int main() {
cout << "Hello, World!" << endl;
return 0;
}
输出:
Hello, World!
看起来很简单,但这五行里每一行都有讲究,值得一行一行拆。
2.2 #include <iostream>
iostream = input/output stream,输入输出流。它里面声明了 cin、cout、endl 这些东西。
为什么必须写 :编译器是"从上往下"读你的代码的。读到 cout 的时候,如果之前没见过 cout 的声明,它就不认识,直接报错。#include做的事,简单理解就是把那个头文件的内容原封不动地抄到这一行的位置 。抄完之后,编译器就知道了"哦,有个叫 cout 的东西,它是这么用的"。
所以你如果要用 printf,就得写 #include <cstdio>;要用 sort,就得写 #include <algorithm>;要用 string,就得写 #include <string>。用什么就包含什么,这是最基本的规矩。
2.3 using namespace std;
C++ 标准库的所有东西都放在一个叫 std 的"命名空间"里。完整写法其实是:
std::cout << "Hello" << std::endl;
std:: 前缀就是告诉编译器"去 std 这个命名空间里找 cout"。每次都写 std:: 太烦,所以用 using namespace std; 声明"下面的代码里,如果看到一个名字在本作用域找不到,就去 std 里找找"。
注意:这句话是个"偷懒"的做法,在正式工程里不推荐(因为可能和自己的变量重名冲突)。但竞赛里所有人都这么写,因为快。竞赛代码里,这一行是标配。
如果不想用 using namespace std,也可以只引入你要用的:
using std::cin;
using std::cout;
using std::endl;
2.4 int main()
main 是主函数 ,是整个程序的入口。操作系统运行你的程序时,第一件事就是找到 main 然后从它开始执行。名字必须是 main,不能改。
int表示这个函数执行完会返回一个整数;()里是参数列表,这里空着表示不接收命令行参数;{ }里是函数体,也就是"具体要执行的代码"。
如果没有 main 函数会怎样? 链接阶段会报 undefined reference to 'main',因为链接器找不到入口。
2.5 cout << "Hello, World!" << endl;
拆开看:
cout= character output,标准输出流,默认指屏幕;<<是"插入运算符",你可以理解成"把右边的东西塞进左边的流里";"Hello, World!"是字符串字面量,必须用双引号;endl= end line,输出一个换行符,并且刷新缓冲区。
<< 可以连着写,因为 cout << a 这个表达式本身还会返回 cout,所以可以接着 << b:
cout << "x = " << x << ", y = " << y << endl;
2.6 return 0;
把 0 返回给操作系统。约定俗成的含义是"程序正常结束"。返回非 0 表示出了某种错误。
竞赛里这个 return 0 忘写了通常不影响(C++ 规定 main 不写 return 默认返回 0),但建议写上,而且第二篇会讲一个例外情况------如果你在 main 中间 return 0 提前退出,那是有实际意义的(提前结束程序)。
2.7 关于 endl 的一个坑
endl 除了换行,还会强制刷新缓冲区 。缓冲区是什么?简单说就是 cout 不会每输出一个字符就真的往屏幕上写,它先攒着,攒一批再一起写,这样效率高。endl 会强制"现在就写出去"。
刷新的代价不小。如果你在循环里用 endl 输出 10 万行,程序可能因此慢一大截,竞赛里足以超时。
更好的做法 :用 '\n' 代替 endl。
cout << x << '\n'; // 快
cout << x << endl; // 慢,而且没必要
这一条在第三篇讲卡常数时还会提,但现在就养成习惯。
2.8 输出格式的精确性
竞赛是机器判题,逐字符比对。你多一个空格、少一个换行,就是"格式错误"(Presentation Error)或者直接判错。
例如题目要求"输出两个数,用一个空格隔开",你输出成 1 2(两个空格),就可能错。要求"每行一个",你全写在一行,就错。
所以:题目说输出什么格式,就严格照做。这是新手最常见的冤枉分。
第 3 章 变量与数据类型
3.1 变量是什么
变量是一块有名字的内存空间。你给它起个名字,之后就能通过名字读它、写它。
int age = 18;
这句话做了两件事:
- 向系统申请一块能装
int的空间,给它起名age; - 把 18 这个值放进去。
之后你可以:
cout << age; // 读出来,输出 18
age = 20; // 写进去,改成 20
cout << age; // 现在输出 20
注意第二次那个等号不是"相等"的意思,是"赋值"的意思:把右边的值存到左边那块空间里。这是新手最容易混淆的地方,后面讲分支时会重点讲。
3.2 基本数据类型一览
这是你必须背下来的表:
| 类型 | 通常大小 | 范围 | 用途 |
|---|---|---|---|
char |
1 字节 | -128 ~ 127 | 单个字符,或小整数 |
bool |
1 字节 | true / false |
逻辑判断 |
short |
2 字节 | -32768 ~ 32767 | 小整数(很少用) |
int |
4 字节 | 约 -2.1e9 ~ 2.1e9 | 最常用的整数 |
long long |
8 字节 | 约 -9.2e18 ~ 9.2e18 | 大整数 |
unsigned int |
4 字节 | 0 ~ 约 4.29e9 | 非负且可能超 21 亿 |
float |
4 字节 | 约 6~7 位有效数字 | 单精度浮点(竞赛少用) |
double |
8 字节 | 约 15~16 位有效数字 | 浮点首选 |
关键数字要记住:
int最大约 21 亿(2,147,483,647,即 2^31 - 1);long long最大约 9.2e18(9,223,372,036,854,775,807,即 2^63 - 1);unsigned int最大约 42.9 亿(4,294,967,295,即 2^32 - 1);- 2^31 约等于 2.1e9;2^63 约等于 9.2e18。
3.3 溢出:竞赛里的头号杀手
int 只能装到 21 亿左右。如果你算 100000 * 100000,结果是 100 亿,装不下,会发生溢出:
int a = 100000;
int b = 100000;
cout << a * b << '\n'; // 输出 -1474836480 之类的奇怪数字
为什么会变成负数?因为 int 用补码表示,超出范围后会"绕回去"。这不会报错,不会崩溃,它就默默地给你一个错误答案。这是最可怕的错误类型------你根本不知道它错了。
怎么防:
long long a = 100000;
long long b = 100000;
cout << a * b << '\n'; // 正确输出 10000000000
或者至少把其中一个转成 long long:
int a = 100000, b = 100000;
cout << 1LL * a * b << '\n'; // 1LL 会把表达式提升为 long long
1LL 这个技巧要记住:1LL 是一个 long long 类型的常量 1,1LL * a * b 从左边开始算,第一步 1LL * a就把结果提升成 long long 了,后面就不会溢出。
经验法则 :题目里如果出现"模 10^9+7"、"求和"、"乘积"、"方案数",几乎必然要用 long long。看到数据范围接近 10^9 还要做加减乘,也要 long long。宁可全用 long long,慢一点点无所谓。
3.4 读题时先看数据范围
这是一个刚入门就该养成的习惯。每道竞赛题都会写"数据范围",比如:
对于 30% 的数据,1 ≤ n ≤ 100; 对于 100% 的数据,1 ≤ n ≤ 10^5,1 ≤ a_i ≤ 10^9。
看到 a_i ≤ 10^9,你就知道:
- 用
int存单个a_i没问题(10^9 < 2.1e9); - 但如果要把它们加起来,10^5 个 10^9 相加等于 10^14,
int必炸,必须long long。
看到 n ≤ 10^5,你就知道:
- 开数组要开 100005 以上;
- 双重循环
O(n^2)是 10^10 次运算,必然超时; - 单重循环
O(n)是 10^5,毫无压力。
数据范围决定算法,也决定类型。这一步做对了,后面能省很多返工。
3.5 变量命名
原则:名字要有意义,短一点但看得懂。
竞赛里常见的命名习惯:
int n, m; // 题目给的规模,习惯用 n m
int a[100005]; // 数组习惯用 a b c
long long sum; // 总和
int cnt; // 计数
int ans; // 答案
int maxv, minv; // 最大最小值
bool flag; // 标记
int i, j, k; // 循环变量
不要 用 a1 a2 a3 a4 这种,改起来会疯。
另外注意:变量名不能和关键字冲突(int、for、while、class、return、double......这些都是关键字)。也不能以数字开头(1a 是错的,a1 是对的)。
3.6 声明的位置与初始化
int x; // 声明了,但值是不确定的(垃圾值)
int y = 0; // 声明并初始化为 0
全局变量和局部变量的默认值不一样:
- 全局变量 (定义在
main外面的)默认初始化为 0; - 局部变量(定义在函数内部的)默认值不确定,是内存里残留的垃圾数据。
所以:
int main() {
int sum; // 危险!
for (int i = 1; i <= 10; i++) sum += i; // sum 初始值可能是 8 亿多的某个数
cout << sum << '\n'; // 输出一个荒谬的结果
}
正确写法:
int main() {
int sum = 0; // 一定初始化
for (int i = 1; i <= 10; i++) sum += i;
cout << sum << '\n'; // 55
}
结论:所有变量声明时就初始化。 这一条能替你省掉无数个"为什么输出这么奇怪"的调试夜晚。
3.7 常量 const
const int N = 100005;
int a[N];
const 表示"这个值不允许改"。好处有两个:
- 防止你手滑改掉它;
- 数组大小必须是编译期常量,用
const int定义最方便(const在编译时就把值替换进去了)。
竞赛里非常常见的写法:
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
const int MOD = 1e9 + 7;
int a[N];
#include <bits/stdc++.h> 是一个"万能头文件",它把所有标准库头文件都包含进来了。竞赛里非常常用,因为它省事。缺点是编译稍慢,但竞赛里无所谓。(注意:某些评测环境不支持这个头文件,第三篇会讲怎么判断。)
第 4 章 输入输出
4.1 cin 和 cout
int a, b;
cin >> a >> b; // 从键盘读两个数
cout << a + b << '\n'; // 输出和
>> 是"提取运算符",可以理解成"从右边这个流里取出一个值塞进左边的变量"。cin >> a >> b 等价于先 cin >> a 再 cin >> b。
cin 的智能之处:它会自动跳过空白 (空格、换行、Tab)。所以你不用管输入是不是"一行一个数"还是"一行多个数",cin >> a >> b 都能正确读到。
这是 cin 最大的优点,也是竞赛里大家都爱用它的原因之一。
4.2 scanf / printf
C 语言风格的输入输出,速度比 cin/cout 快(在没做优化的情况下)。
int a, b;
scanf("%d %d", &a, &b);
printf("%d\n", a + b);
要点:
scanf的变量前必须加&(取地址符)。忘了就是程序崩溃或读到乱码;printf不加&;- 格式符必须和类型匹配。
常用格式符:
| 类型 | scanf |
printf |
|---|---|---|
int |
%d |
%d |
long long |
%lld |
%lld |
unsigned int |
%u |
%u |
float |
%f |
%f |
double |
%lf |
%f(注意:printf 用 %f,scanf 用 %lf) |
char |
%c |
%c |
| 字符串(char 数组) | %s |
%s |
double 那个不一致是历史遗留的坑 :scanf 必须用 %lf,printf用 %f 就行(%lf 在 printf 里也合法但不规范)。记下来,考试会考。
关于 printf 的精度控制:
printf("%.2f\n", 3.14159); // 3.14,保留两位小数
printf("%.6f\n", 1.0/3); // 0.333333
printf("%5d\n", 42); // " 42",占 5 位右对齐
printf("%05d\n", 42); // "00042",占 5 位补零
题目说"保留两位小数",就必须写 %.2f,忘了就会错。
4.3 该用哪个
建议:一开始全用 cin/cout,简单、不容易错。等遇到大数据量超时了,再加优化或改用 scanf/printf。
如果坚持用 cin/cout 又要快,加这三行:
ios::sync_with_stdio(false); // 关掉 cin/cout 与 scanf/printf 的同步
cin.tie(0); // 解绑 cin 和 cout
cout.tie(0);
放在 main 的第一行。加上之后 cin/cout 的速度基本能跟上 scanf/printf。
重要警告 :加了 ios::sync_with_stdio(false) 之后,不能再混用 cin 和 scanf,否则读到的数据会错乱。要么全用一套,要么别加这句。
4.4 读字符串的两种方式
char s[105];
cin >> s; // 遇到空格就停,只能读一个"单词"
string t;
getline(cin, t); // 读一整行,包含空格
用 cin >> s 读 hello world,只会读到 hello。如果要读整行(含空格),用 getline。
但 getline 有个经典坑:如果你前面用 cin >> 读过东西,输入缓冲区里还留着一个换行符,getline 会立刻读到这个空行然后返回。
int n;
cin >> n; // 读完数字,缓冲区里还有一个 '\n'
string s;
getline(cin, s); // 立刻读到一个空串!s = ""
解决办法:在读之前先吃掉那个换行:
int n;
cin >> n;
cin.ignore(); // 或者 getchar(),吃掉一个字符
string s;
getline(cin, s); // 现在正常了
这个坑非常经典,第二篇讲字符串时会再强化一次。
4.5 cin 读失败的处理
cin 在读不到东西时会"失败",可以这样判断:
int x;
while (cin >> x) {
// 一直读到输入结束
}
这种写法常用于"不知道要读多少个数"的题。第二篇和第三篇会用到。
第 5 章 运算符
5.1 算术运算符
| 运算符 | 含义 | 例子 |
|---|---|---|
+ |
加 | 3 + 2 = 5 |
- |
减 | 3 - 2 = 1 |
* |
乘 | 3 * 2 = 6 |
/ |
除 | 7 / 2 = 3(整数除法,向下取整式截断) |
% |
取模(求余数) | 7 % 2 = 1 |
整数除法要特别注意:
cout << 7 / 2 << '\n'; // 3,不是 3.5
cout << 7.0 / 2 << '\n'; // 3.5,因为 7.0 是 double
cout << (double)7 / 2 << '\n'; // 3.5,强制转换
两个整数相除,结果还是整数,小数部分直接扔掉。如果你的答案需要小数,至少要让其中一个变成浮点数。
取模的符号:
cout << 7 % 3 << '\n'; // 1
cout << -7 % 3 << '\n'; // -1(C++ 中余数符号跟被除数一致)
cout << 7 % -3 << '\n'; // 1
这个在需要"负数取模得到正数"的场景要当心,通常这样处理:
int mod(int x, int m) {
return (x % m + m) % m; // 保证结果是 [0, m)
}
5.2 自增自减
int i = 5;
i++; // i 变成 6
i--; // i 变成 5
++i; // i 变成 6
--i; // i 变成 5
单独使用时,i++ 和 ++i 没区别。但放进表达式里就有区别:
int i = 5;
cout << i++ << '\n'; // 输出 5,然后 i 变成 6
cout << ++i << '\n'; // i 先变成 7,然后输出 7
i++:先用原值,再加 1(后置);++i:先加 1,再用新值(前置)。
竞赛里建议尽量单独写 i++,不要塞进复杂表达式 。a[i++] = b[i++] 这种写法在不同编译器下可能结果不同(未定义行为),别玩。
5.3 关系运算符
| 运算符 | 含义 |
|---|---|
== |
等于 |
!= |
不等于 |
< |
小于 |
> |
大于 |
<= |
小于等于 |
>= |
大于等于 |
结果都是 bool(true 或 false)。
== 和 = 的区别,这是新手第一大坑:
int x = 5;
if (x = 3) { // 注意!这里是赋值,不是比较
cout << "yes";
}
x = 3 这个表达式的结果是 3,非 0 值在条件里被视为"真",所以 if 成立,而且 x 被改成了 3。程序看起来"运行了",但逻辑全错。
防呆写法:把常量写在左边。
if (3 == x) { ... } // 如果手滑写成 3 = x,编译器会直接报错
因为常量不能被赋值,3 = x 是编译错误,你立刻能发现。这个习惯值得刻意养成。
5.4 逻辑运算符
| 运算符 | 含义 | 说明 |
|---|---|---|
&& |
与 | 两边都真才真 |
| ` | ` | |
! |
非 | 取反 |
短路求值(很重要):
A && B:如果 A 是假,B 根本不会被计算,直接返回假;A || B:如果 A 是真,B 根本不会被计算,直接返回真。
这个特性可以用来做保护:
if (i < n && a[i] > 0) { ... }
如果 i >= n,a[i] 是越界访问(可能崩溃),但因为 i < n 已经是假,后面那句不会被执行,所以安全。
反过来写就危险:
if (a[i] > 0 && i < n) { ... } // 先访问 a[i] 再检查下标,可能越界
结论:把"边界检查"放在 && 的左边。
5.5 位运算(先认识,第三篇再深挖)
| 运算符 | 含义 |
|---|---|
& |
按位与 |
| ` | ` |
^ |
按位异或 |
~ |
按位取反 |
<< |
左移(相当于乘以 2 的若干次方) |
>> |
右移(相当于除以 2 的若干次方) |
cout << (5 & 3) << '\n'; // 1 (101 & 011 = 001)
cout << (5 | 3) << '\n'; // 7 (101 | 011 = 111)
cout << (5 ^ 3) << '\n'; // 6 (101 ^ 011 = 110)
cout << (1 << 4) << '\n'; // 16 (2^4)
cout << (16 >> 2) << '\n'; // 4
位运算有两个立刻能用的场景:
(1)判断奇偶:
if (x & 1) { /* 奇数 */ }
else { /* 偶数 */ }
(2)乘除 2 的幂:
x << 1 // 2x
x << k // x * 2^k
x >> 1 // x / 2(对非负整数)
5.6 运算符优先级
完整表很长,你只需要记住这几条规则:
()最高,不确定就加括号;!、~、++、--、强制转换 高于算术;*/%高于+-;- 移位
<<>>低于+-(这条容易错); - 关系
<><=>=低于算术; ==!=低于关系;&&低于||;- 赋值
=最低。
别背,加括号。这是最省心的做法:
if ((a + b) % 2 == 0) { ... } // 明确
if (a + b % 2 == 0) { ... } // 真实含义是 a + (b%2) == 0,错!
5.7 类型转换
自动转换(隐式):小的往大的转。
int a = 3;
double b = a; // 自动转成 3.0
cout << a / 2 << '\n'; // 1
cout << a / 2.0 << '\n'; // 1.5
强制转换(显式):
double x = 3.99;
int y = (int)x; // 3,C 风格
int z = static_cast<int>(x); // 3,C++ 风格(更规范)
注意 (int) 是截断不是四舍五入。3.99 变成 3。要四舍五入得自己写:
int round_to_int(double x) {
return (int)(x + 0.5); // 只对非负数成立
}
负数的话要 x - 0.5,或者用 <cmath> 里的 round()。
浮点比较的坑:
double a = 0.1 + 0.2;
if (a == 0.3) cout << "equal"; // 不会输出!因为 a 实际是 0.30000000000000004
浮点数有精度误差,永远不要用 ==比较两个浮点数。正确做法:
const double EPS = 1e-8;
if (fabs(a - 0.3) < EPS) cout << "equal";
第 6 章 分支结构
6.1 if 的基本形式
if (条件) {
语句;
}
条件为真(非 0)就执行大括号里的内容,否则跳过。
int score;
cin >> score;
if (score >= 60) {
cout << "及格\n";
}
6.2 if - else
if (score >= 90) {
cout << "优秀\n";
} else if (score >= 75) {
cout << "良好\n";
} else if (score >= 60) {
cout << "及格\n";
} else {
cout << "不及格\n";
}
执行逻辑是从上往下,一旦某个条件成立,执行完就跳出整个结构 ,后面的 else if 不再判断。这很重要:
if (x > 0) { ... }
else if (x > 10) { ... } // 永远不会执行!因为 x > 10 时 x > 0 早就成立了
如果条件有包含关系,范围小的要放在前面,或者干脆用独立的条件判断。
上面那个例子应该改成:
if (x > 10) { ... }
else if (x > 0) { ... }
6.3 大括号的问题
if (x > 0)
cout << "正数\n";
cout << "继续\n"; // 这一句不受 if 控制,无论如何都会执行
if 后面如果不加大括号,只控制紧跟着的一条语句。缩进不影响语义,只影响你的眼睛。
if (x > 0) {
cout << "正数\n";
cout << "继续\n"; // 现在两句都在 if 里
}
建议:任何情况下都加括号,哪怕只有一条语句。这是防止 bug 的最便宜的手段。
6.4 switch
int op;
cin >> op;
switch (op) {
case 1:
cout << "one\n";
break;
case 2:
cout << "two\n";
break;
default:
cout << "other\n";
break;
}
要点:
case后面必须是常量(不能是变量);- 每个
case结束要break,否则会"贯穿"到下一个 case 继续执行; default处理所有没匹配的情况。
故意不写 break 有时是技巧,比如:
switch (grade) {
case 'A':
case 'B':
cout << "还行\n"; // A 和 B 都走这里
break;
}
不过竞赛里,switch 用得不多,if更灵活。知道它怎么用就行。
6.5 三目运算符
int maxv = (a > b) ? a : b;
格式:条件 ? 值1 : 值2。条件真取值1,假取值2。
写起来短,但别嵌套:
int x = (a > b) ? ((a > c) ? a : c) : ((b > c) ? b : c); // 能看懂,但别这么写
嵌套三目可读性极差,换成 if 或者 max({a,b,c}) 更好。
6.6 分支的实战例子
例 1:判断闰年
规则:能被 4 整除但不能被 100 整除,或者能被 400 整除。
int y;
cin >> y;
if ((y % 4 == 0 && y % 100 != 0) || y % 400 == 0) {
cout << "yes\n";
} else {
cout << "no\n";
}
例 2:判断三角形
给定三边,判断能否构成三角形,以及是什么三角形。
long long a, b, c;
cin >> a >> b >> c;
// 先排序,让 c 成为最长边
if (a > b) swap(a, b);
if (b > c) swap(b, c);
if (a > b) swap(a, b);
if (a + b <= c) {
cout << "不是三角形\n";
} else if (a * a + b * b == c * c) {
cout << "直角三角形\n";
} else if (a * a + b * b < c * c) {
cout << "钝角三角形\n";
} else {
cout << "锐角三角形\n";
}
注意这里用了 long long,因为 a*a + b*b 可能超过 21 亿(比如 a = b = 100000)。这就是前面说的"数据范围决定类型"。
例 3:成绩等级
int s;
cin >> s;
if (s >= 90) cout << "A\n";
else if (s >= 80) cout << "B\n";
else if (s >= 70) cout << "C\n";
else if (s >= 60) cout << "D\n";
else cout << "E\n";
这种"从高到低"的写法,每个 else if 天然带上了"上界",不用再写 s >= 80 && s < 90,很干净。
第 7 章 循环结构
循环是编程的灵魂。没有循环,你只能处理 10 个数据;有了循环,你能处理 10 万个。
7.1 for 循环
for (int i = 0; i < n; i++) {
// 循环体
}
三个部分,用分号隔开:
int i = 0:初始化,只执行一次;i < n:条件,每轮开始时检查,为真才继续;i++:更新,每轮结束后执行。
执行顺序:
初始化 → 检查条件 → (真) 执行循环体 → 更新 → 检查条件 → ...
↓ (假)
退出循环
常见遍历写法:
// 从 0 到 n-1(最常用,配合数组下标)
for (int i = 0; i < n; i++) { ... }
// 从 1 到 n(符合很多题目的"第 i 个"表述)
for (int i = 1; i <= n; i++) { ... }
// 倒序
for (int i = n - 1; i >= 0; i--) { ... }
// 步长为 2
for (int i = 0; i < n; i += 2) { ... }
7.2 while 循环
int i = 0;
while (i < n) {
// 循环体
i++;
}
while 只有条件,初始化和更新都要自己写。适合"不知道要循环多少次"的场景。
典型例子------不断读入直到没有输入:
int x;
while (cin >> x) {
// 处理 x
}
典型例子------求位数:
int x = 12345, cnt = 0;
while (x > 0) {
cnt++;
x /= 10;
}
cout << cnt << '\n'; // 5
7.3 do-while
do {
// 循环体
} while (条件);
和 while 的唯一区别:先执行一次,再判断条件。所以循环体至少执行一次。
适合"至少要做一次"的场景,但用得不多。注意结尾那个分号不能漏。
7.4 break 和 continue
-
break:立刻跳出整个循环; -
continue:跳过本轮剩下的语句,直接进入下一轮。for (int i = 1; i <= 100; i++) {
if (i == 50) break; // 到 50 就整个结束
if (i % 2 == 0) continue; // 偶数跳过
cout << i << ' ';
}
注意:break 只跳出它所在的那一层循环。嵌套循环里,它不会跳出外层:
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (j == 1) break; // 只跳出内层,外层继续
}
}
要跳出多层,用 goto 或加标志位,或者把循环写进函数里用 return。第三篇讲搜索时会用到 return 的写法。
7.5 嵌套循环
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cout << a[i][j] << ' ';
}
cout << '\n';
}
嵌套的两个循环,外层跑一圈,内层跑完整的一轮。总执行次数是 n * m。
这是复杂度概念的开端 :n 层嵌套循环,每层跑 n 次,就是 n^2 次运算。第三篇会详细讲。
7.6 循环的经典套路
套路 1:累加
long long sum = 0;
for (int i = 1; i <= n; i++) {
sum += a[i];
}
套路 2:求最值
int maxv = a[1]; // 用第一个元素初始化,别用 0!
for (int i = 2; i <= n; i++) {
if (a[i] > maxv) maxv = a[i];
}
为什么不能用 0 初始化 :如果所有数都是负数,maxv 会停在 0,而正确答案是最大的那个负数。要么用第一个元素,要么用 -1e9 之类足够小的值。
套路 3:计数
int cnt = 0;
for (int i = 1; i <= n; i++) {
if (a[i] > 0) cnt++;
}
套路 4:查找
int pos = -1; // -1 表示没找到
for (int i = 1; i <= n; i++) {
if (a[i] == target) {
pos = i;
break; // 找到就停
}
}
套路 5:交换
int t = a[i];
a[i] = a[j];
a[j] = t;
或者用 swap(a[i], a[j])(需要 #include <algorithm> 或万能头)。
7.7 循环的完整例子
例 1:1 到 n 求和
int n;
long long sum = 0;
cin >> n;
for (int i = 1; i <= n; i++) {
sum += i;
}
cout << sum << '\n';
如果 n 可能到 10^9,这个循环会超时。数学公式 n*(n+1)/2 是 O(1) 的:
cout << (long long)n * (n + 1) / 2 << '\n';
注意 (long long)n 的位置------先把 n 转成 long long,那么 * (n+1) 就不会溢出。
例 2:九九乘法表
for (int i = 1; i <= 9; i++) {
for (int j = 1; j <= i; j++) {
cout << j << "*" << i << "=" << i * j << '\t';
}
cout << '\n';
}
\t 是制表符,用来对齐。
例 3:判断素数(试除法)
bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i * i <= x; i++) {
if (x % i == 0) return false;
}
return true;
}
为什么是 i * i <= x 而不是 i <= x:如果 x 有一个大于 sqrt(x) 的因子,那它必然对应一个小于 sqrt(x) 的因子。所以只需要试到 sqrt(x)。这一改,复杂度从 O(n) 降到 O(sqrt(n))。
注意 i * i 在 i 接近 46341 时会溢出 int(46341^2 略大于 2^31)。安全写法:
for (int i = 2; (long long)i * i <= x; i++)
例 4:打印图形
打印 n 行的直角三角形:
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= i; j++) cout << '*';
cout << '\n';
}
打印等腰三角形(每行前面要补空格):
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n - i; j++) cout << ' ';
for (int j = 1; j <= 2 * i - 1; j++) cout << '*';
cout << '\n';
}
这类"图形题"要找规律:每一行有几个空格、几个星号,用 i 表示出来。找规律的过程就是列式子。
例 5:水仙花数
输出 100~999 之间所有各位数字的立方和等于本身的数。
for (int i = 100; i <= 999; i++) {
int a = i / 100; // 百位
int b = i / 10 % 10; // 十位
int c = i % 10; // 个位
if (a*a*a + b*b*b + c*c*c == i) cout << i << '\n';
}
取各位数字的通用方法:
- 个位:
x % 10 - 十位:
x / 10 % 10 - 百位:
x / 100 % 10 - 第 k 位(从右往左数,从 0 开始):
x / (10^k) % 10
例 6:反转数字
int x, rev = 0;
cin >> x;
while (x > 0) {
rev = rev * 10 + x % 10;
x /= 10;
}
cout << rev << '\n';
这个"逐位处理"的模板要记住:rev = rev * 10 + 当前位,配合 x /= 10 循环。
第 8 章 数组
8.1 为什么需要数组
假设你要读入 100 个数,然后倒着输出。用变量是做不到的------你不可能声明 a1 到 a100 一百个变量再逐个处理(虽然能写,但那是自虐)。
数组就是"一排连续的、同类型的存储单元",用一个名字加下标就能访问任意一个。
int a[100]; // 声明一个能装 100 个 int 的数组
a[0] = 5; // 第 0 个元素
a[99] = 7; // 第 99 个元素(最后一个)
8.2 下标从 0 开始
这是 C++ 最重要的约定:长度为 n 的数组,合法下标是 0 到 n-1。
int a[10]; // 合法下标 0..9
a[10] = 1; // 越界!行为未定义
越界访问不会报错(大多数时候),它会读写到相邻的内存。后果可能是:
- 读到垃圾值;
- 悄悄改掉别的变量;
- 程序崩溃(段错误);
- 完全正常(最可怕,因为你以为没问题)。
这一个问题能毁掉整场比赛。所以要养成习惯:开数组时多开 5~10 个。
8.3 开多大
竞赛的标准做法:
const int N = 100005;
int a[N]; // 题目说 n ≤ 10^5,就开 100005
多开是因为:
- 题目实际数据可能比标注的多一点点(有些题会这样);
- 你用 1-based 下标(从 1 开始存),会用到
a[n],一个都不多开就正好卡在边界; - 有些算法会访问到
a[n+1](比如差分)。
经验:多开 5~10 个,成本几乎为零,收益是避免莫名其妙的 RE(运行错误)。
8.4 数组的初始化
(1)全局数组自动清零
int a[100005]; // 全局,全为 0
int main() { ... }
(2)局部数组不清零
int main() {
int a[100]; // 里面是垃圾值
}
(3)手动清零
int a[100] = {0}; // 全部为 0
int b[100]; memset(b, 0, sizeof(b)); // 全部为 0,需要 <cstring>
memset 的坑:它按字节 赋值。memset(a, 1, sizeof(a)) 不是把每个元素设成 1,而是把每个字节设成 1,结果是 0x01010101 = 16843009。只能用来设 0 或 -1(-1 的每个字节都是 0xFF),其他值不行。
(4)C++11 的 fill
int a[100];
fill(a, a + 100, 7); // 全部设为 7,需要 <algorithm>
这个是真能把每个元素设成任意值的。
(5)vector 的初始化(第二篇讲)
8.5 一维数组的常见操作
读入 n 个数
int n;
cin >> n;
for (int i = 0; i < n; i++) {
cin >> a[i];
}
如果习惯 1-based:
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
两种习惯各有好处:
- 0-based 更贴合 C++ 的底层和 STL;
- 1-based 更贴合题目的"第 i 个"表述,做前缀和、差分时也更方便(不用处理
i-1的边界)。
**建议:题目说"第 i 个"就用 1-based,用 STL 容器就用 0-based。**别混着来。
求和
long long sum = 0;
for (int i = 1; i <= n; i++) sum += a[i];
求最值
int maxv = a[1], minv = a[1];
for (int i = 2; i <= n; i++) {
if (a[i] > maxv) maxv = a[i];
if (a[i] < minv) minv = a[i];
}
计数(桶)
统计每个数出现了几次,假设数值在 0~100 之间:
int cnt[105] = {0};
for (int i = 1; i <= n; i++) {
cnt[a[i]]++;
}
for (int v = 0; v <= 100; v++) {
if (cnt[v]) cout << v << " 出现 " << cnt[v] << " 次\n";
}
这就是"桶排序"的思想:用下标当值,用数组元素当计数器。第三篇会正式讲。
反转
for (int i = 1; i <= n / 2; i++) {
swap(a[i], a[n - i + 1]);
}
找最大值的位置
int pos = 1;
for (int i = 2; i <= n; i++) {
if (a[i] > a[pos]) pos = i;
}
8.6 二维数组
int a[105][105]; // 105 行 105 列
第一个下标是行,第二个是列。访问 a[i][j] 表示第 i 行第 j 列。
读入 n 行 m 列
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> a[i][j];
}
}
输出
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cout << a[i][j] << ' ';
}
cout << '\n';
}
遍历顺序的差异(这个在动态规划里很重要):
// 按行遍历(外层 i,内层 j):访问顺序 1,1 → 1,2 → ... → 1,m → 2,1 → ...
// 按列遍历(外层 j,内层 i):访问顺序 1,1 → 2,1 → ... → n,1 → 1,2 → ...
按行遍历通常更快,因为内存是一行一行连续存的(行优先),按行访问缓存命中率高。数据量大的时候,这个差别可能是 2~3 倍。
矩阵转置
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
b[j][i] = a[i][j];
矩阵加法
c[i][j] = a[i][j] + b[i][j];
二维数组开多大的坑:
int a[1005][1005]; // 1005 * 1005 * 4 字节 ≈ 4MB
int b[2005][2005]; // ≈ 16MB
全局数组放在堆上(或者说静态区),可以开得很大。局部数组放在栈上,栈一般只有 1~8 MB ,开 int a[1000][1000](4MB)就可能爆栈崩溃。
结论:大数组一律开在全局。
const int N = 1005;
int a[N][N]; // 全局,安全
int main() {
int b[1000][1000]; // 局部,可能爆栈,别这么干
}
8.7 数组的实战例子
例 1:统计每个数字出现次数,输出出现最多的
const int MAXV = 100000;
int cnt[MAXV + 5] = {0};
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
int x;
cin >> x;
cnt[x]++;
}
int best = 0, bestCnt = 0;
for (int v = 0; v <= MAXV; v++) {
if (cnt[v] > bestCnt) {
bestCnt = cnt[v];
best = v;
}
}
cout << best << " " << bestCnt << '\n';
注意 >= 还是 > 决定了"出现次数相同时取大的还是小的",看题目要求。这种细节就是丢分点。
例 2:矩阵乘法
for (int i = 1; i <= n; i++)
for (int j = 1; j <= p; j++) {
c[i][j] = 0;
for (int k = 1; k <= m; k++)
c[i][j] += a[i][k] * b[k][j];
}
三层循环,复杂度 O(nmp)。
例 3:杨辉三角
int C[25][25] = {0};
for (int i = 0; i <= 20; i++) {
C[i][0] = C[i][i] = 1;
for (int j = 1; j < i; j++) {
C[i][j] = C[i-1][j-1] + C[i-1][j];
}
}
用 long long 更好,因为 C(20,10) 是 184756,还在 int 范围内,但再大就炸了。
例 4:冒泡排序(先看一眼,第三篇细讲)
for (int i = 1; i <= n - 1; i++) {
for (int j = 1; j <= n - i; j++) {
if (a[j] > a[j + 1]) swap(a[j], a[j + 1]);
}
}
每一轮把最大的"冒"到末尾。
第 9 章 字符与字符串
9.1 char 和 ASCII
char 存一个字符,但它本质上是一个 1 字节的整数。字符和整数之间靠 ASCII 码表对应。
必须记住的几个:
| 字符 | ASCII |
|---|---|
'0' |
48 |
'A' |
65 |
'a' |
97 |
空格 ' ' |
32 |
换行 '\n' |
10 |
由此能得到几个常用换算:
char c = '7';
int x = c - '0'; // 7,字符转数字
int d = 5;
char ch = d + '0'; // '5',数字转字符
char upper = 'a' - 32; // 'A',小写转大写
char lower = 'A' + 32; // 'a',大写转小写
判断字符类型 (用 <cctype> 里的函数):
isdigit(c) // 是否是数字 0-9
isalpha(c) // 是否是字母
islower(c) // 是否是小写
isupper(c) // 是否是大写
isspace(c) // 是否是空白字符
toupper(c) // 转大写
tolower(c) // 转小写
9.2 char 数组与字符串
C 语言风格的字符串,就是一个 char 数组:
char s[105] = "hello";
cout << s << '\n';
cout << s[0] << '\n'; // 'h'
cout << s[4] << '\n'; // 'o'
字符串在数组末尾有一个 '\0' 作为结束标记,所以 "hello" 实际占了 6 个字节。
这就是为什么开 char s[105] 来存长度 100 的字符串 ------要多留一个给 '\0'。
读入:
char s[105];
scanf("%s", s); // 不加 &,因为数组名本身就是地址
cin >> s; // 同样遇到空格停
常用操作:
strlen(s) // 长度,O(n),需要 <cstring>
strcmp(a, b) // 比较,返回负数/0/正数
strcpy(a, b) // 复制 b 到 a
strcat(a, b) // 把 b 接到 a 后面
strlen 是 O(n) 的。如果在循环条件里写 for (int i = 0; i < strlen(s); i++),那每次循环都会重新算一遍长度,总复杂度变成 O(n^2)。正确做法:
int len = strlen(s);
for (int i = 0; i < len; i++) { ... }
这个坑非常经典,考试会考。
9.3 string(推荐用这个)
string 是 C++ 的字符串类,比 char 数组好用得多。需要 #include <string>。
基本操作:
string s = "hello";
cout << s << '\n'; // hello
cout << s.length() << '\n'; // 5,或者 s.size()
cout << s[1] << '\n'; // 'e'
s[1] = 'a'; // 修改
cout << s << '\n'; // hallo
拼接:
string a = "hello";
string b = "world";
string c = a + " " + b; // "hello world"
a += "!"; // a 变成 "hello!"
比较 :按字典序,可以直接用 <``>:
if (string("abc") < string("abd")) { ... } // true
查找:
string s = "hello world";
int p = s.find("world"); // 6,找不到返回 string::npos
if (p != string::npos) cout << "found at " << p << '\n';
取子串:
string s = "abcdef";
cout << s.substr(1, 3) << '\n'; // "bcd",从下标 1 开始取 3 个
cout << s.substr(2) << '\n'; // "cdef",从下标 2 取到末尾
长度是 O(1) ,这点比 strlen 好太多。
遍历:
for (int i = 0; i < (int)s.size(); i++) {
cout << s[i];
}
// 或者 C++11 的范围 for
for (char c : s) {
cout << c;
}
注意 s.size() 返回的是 unsigned类型,和 int 比较可能报警告。强转一下 (int)s.size() 最稳。
读入整行:
string s;
getline(cin, s); // 读一整行
同样有前面说的那个坑:如果前面读过数字,要 cin.ignore()。
9.4 字符串的实战例子
例 1:统计字符
string s;
getline(cin, s);
int letters = 0, digits = 0, spaces = 0, others = 0;
for (char c : s) {
if (isalpha(c)) letters++;
else if (isdigit(c)) digits++;
else if (c == ' ') spaces++;
else others++;
}
cout << letters << " " << digits << " " << spaces << " " << others << '\n';
例 2:判断回文
bool isPalin(const string &s) {
int i = 0, j = (int)s.size() - 1;
while (i < j) {
if (s[i] != s[j]) return false;
i++; j--;
}
return true;
}
双指针从两头往中间走。这个模板第三篇还会用。
例 3:大小写转换
string s;
cin >> s;
for (char &c : s) { // 注意 &,否则改的是副本
c = toupper(c);
}
cout << s << '\n';
这里的 & 是引用,第二篇会详细讲。现在先记住:要修改容器里的元素,遍历时要用 &。
例 4:统计单词个数
string s;
getline(cin, s);
int cnt = 0;
bool inWord = false;
for (char c : s) {
if (isalpha(c)) {
if (!inWord) { cnt++; inWord = true; }
} else {
inWord = false;
}
}
cout << cnt << '\n';
"状态机"的思路:用一个标志记录"当前是否在单词里",进入单词时计数加一。
第 10 章 函数
10.1 为什么要用函数
代码写到一定长度,你会发现有些逻辑反复出现。比如判断素数,你可能在三个地方都要用。复制三遍的话:
- 代码变长;
- 改的时候要改三处,容易漏;
- 出错时不知道是哪个副本。
函数就是把这部分逻辑"打包",起个名字,之后按名字调用。
bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; (long long)i * i <= x; i++) {
if (x % i == 0) return false;
}
return true;
}
int main() {
int n;
cin >> n;
for (int i = 2; i <= n; i++) {
if (isPrime(i)) cout << i << ' ';
}
return 0;
}
10.2 函数的组成
返回类型 函数名(参数列表) {
函数体
return 返回值;
}
- 返回类型 :函数算完给你什么类型的结果。不需要返回就用
void。 - 函数名:自己起,见名知意。
- 参数列表:输入。可以有 0 个、1 个、多个,每个都要写类型。
return:结束函数并返回一个值。void函数可以用return;提前结束。
例子:各种形状
// 无参数无返回值
void sayHello() {
cout << "Hello\n";
}
// 有参数无返回值
void printArray(int a[], int n) {
for (int i = 0; i < n; i++) cout << a[i] << ' ';
cout << '\n';
}
// 有参数有返回值
long long sumOf(int a[], int n) {
long long s = 0;
for (int i = 0; i < n; i++) s += a[i];
return s;
}
10.3 数组作为参数
void f(int a[]) { ... } // 等价于 void f(int *a)
数组作为参数时,传的是地址,不是拷贝 。也就是说函数里改 a[i],外面的数组也会变。
但长度不会自动传,必须额外传一个参数:
void printArray(int a[], int n) { ... }
这是 C 语言的历史遗留问题。用 vector 就没这个麻烦(第二篇)。
10.4 作用域
int g = 10; // 全局变量,整个文件都能用
int main() {
int x = 5; // 局部变量,只在 main 里能用
{
int y = 3; // 块作用域,只在这个大括号里能用
}
// 这里 y 已经不存在了
}
规则:
- 内层可以"看到"外层;
- 外层看不到内层;
- 同名时内层遮蔽外层(能编译,但很容易搞混,别这么干)。
全局变量的好处 :自动初始化为 0,可以开很大(不受栈限制),函数之间共享方便。 全局变量的坏处:容易被意外修改,调试时难追踪。
竞赛里的常见做法:大数组开全局,其他尽量局部。
10.5 递归:函数调用自己
long long fact(int n) {
if (n <= 1) return 1; // 递归出口
return n * fact(n - 1); // 递归调用
}
算 fact(5) 的过程:
fact(5) = 5 * fact(4)
= 5 * 4 * fact(3)
= 5 * 4 * 3 * fact(2)
= 5 * 4 * 3 * 2 * fact(1)
= 5 * 4 * 3 * 2 * 1
= 120
递归必须有两个要素:
- 出口(base case):什么时候停。没有出口就会无限递归,最终栈溢出崩溃。
- 递推关系:怎么把大问题变成小问题。
经典的递归例子:
// 斐波那契(这个写法很慢,第二篇讲原因和优化)
long long fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// 最大公约数
int gcd(int a, int b) {
if (b == 0) return a;
return gcd(b, a % b);
}
// 汉诺塔
void hanoi(int n, char from, char to, char via) {
if (n == 0) return;
hanoi(n - 1, from, via, to);
cout << from << " -> " << to << '\n';
hanoi(n - 1, via, to, from);
}
递归是第三篇"搜索"的基础,这里先建立直观:
递归就像你站在两面镜子中间,看到的像是无限递归的。但你得在某个地方放个"停止"的牌子,否则会一直映下去。
10.6 函数声明(前向声明)
如果函数 A 要调用定义在它后面的函数 B,需要先"声明" B:
void hello(); // 声明,只写签名,不写函数体
int main() {
hello();
}
void hello() { // 定义
cout << "hi\n";
}
竞赛里最简单的做法是:把函数都定义在 main 之前,就不用声明了。
第 11 章 综合实战
到这里,你已经有了写出完整程序的所有零件。这一章用 8 道题把它们串起来。每道题请先自己想,再对照代码。
题 1:A+B Problem
输入 :两个整数 a, b(|a|, |b| ≤ 10^9) 输出:a + b
#include <iostream>
using namespace std;
int main() {
long long a, b;
cin >> a >> b;
cout << a + b << '\n';
return 0;
}
注意 :a 和 b 各是 10^9,加起来是 210^9,略超 int 上限(2.147 10^9 其实没超,但如果题目给到 2*10^9 就超了)。稳妥起见用 long long。
题 2:判断闰年
#include <iostream>
using namespace std;
int main() {
int y;
cin >> y;
if ((y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)) {
cout << "1\n";
} else {
cout << "0\n";
}
return 0;
}
题 3:求 n 个数的最大值、最小值、总和
#include <iostream>
using namespace std;
const int N = 100005;
int a[N];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
int maxv = a[1], minv = a[1];
long long sum = a[1];
for (int i = 2; i <= n; i++) {
if (a[i] > maxv) maxv = a[i];
if (a[i] < minv) minv = a[i];
sum += a[i];
}
cout << maxv << " " << minv << " " << sum << '\n';
return 0;
}
易错点:
maxv用a[1]初始化,不是 0;- 循环从 2 开始,因为
a[1]已经用过了; sum用long long,因为 n 个数加起来可能很大。
题 4:统计元音字母
给定一个字符串(不含空格),统计其中 aeiou 出现的总次数(不区分大小写)。
#include <iostream>
#include <string>
using namespace std;
int main() {
string s;
cin >> s;
int cnt = 0;
for (char c : s) {
char low = tolower(c);
if (low == 'a' || low == 'e' || low == 'i' || low == 'o' || low == 'u') {
cnt++;
}
}
cout << cnt << '\n';
return 0;
}
题 5:判断一个数是否为完全平方数
#include <iostream>
#include <cmath>
using namespace std;
int main() {
long long n;
cin >> n;
long long r = (long long)sqrt((double)n);
// 修正浮点误差
while (r * r > n) r--;
while ((r + 1) * (r + 1) <= n) r++;
cout << (r * r == n ? "Yes" : "No") << '\n';
return 0;
}
为什么要修正 :sqrt 是浮点运算,n 很大时可能算得差 1。这种"浮点开方再验算"的写法是竞赛里的标准做法。
题 6:输出 1~n 中所有素数
#include <iostream>
using namespace std;
bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; (long long)i * i <= x; i++) {
if (x % i == 0) return false;
}
return true;
}
int main() {
int n;
cin >> n;
for (int i = 2; i <= n; i++) {
if (isPrime(i)) cout << i << ' ';
}
cout << '\n';
return 0;
}
复杂度 O(n*sqrt(n))。n = 10^6 时约 10^9 次操作,会超时。第三篇会讲欧拉筛,O(n)。
题 7:矩阵转置
#include <iostream>
using namespace std;
const int N = 105;
int a[N][N], b[N][N];
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cin >> a[i][j];
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
b[j][i] = a[i][j];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
cout << b[i][j];
if (j < n) cout << ' ';
}
cout << '\n';
}
return 0;
}
输出格式的处理技巧:与其在每行最后一个数后面处理多余空格,不如"先输出数,再判断要不要输出空格"。这是最省心的写法。
题 8:统计词频并排序输出(本题的综合度高)
读入 n 个单词(小写字母组成),输出每个单词及其出现次数,按单词字典序输出。
#include <iostream>
#include <string>
using namespace std;
const int N = 1005;
string words[N];
int cnt[N];
int tot = 0; // 当前已收录多少个不同的单词
int findWord(const string &w) {
for (int i = 0; i < tot; i++) {
if (words[i] == w) return i;
}
return -1;
}
int main() {
int n;
cin >> n;
for (int i = 0; i < n; i++) {
string w;
cin >> w;
int p = findWord(w);
if (p == -1) {
words[tot] = w;
cnt[tot] = 1;
tot++;
} else {
cnt[p]++;
}
}
// 冒泡排序:按字典序对 words 排序,cnt 跟着换
for (int i = 0; i < tot - 1; i++) {
for (int j = 0; j < tot - 1 - i; j++) {
if (words[j] > words[j + 1]) {
swap(words[j], words[j + 1]);
swap(cnt[j], cnt[j + 1]);
}
}
}
for (int i = 0; i < tot; i++) {
cout << words[i] << " " << cnt[i] << '\n';
}
return 0;
}
这道题暴露了当前工具的局限:
findWord是 O(tot) 的,总复杂度 O(n^2);- 排序要手动带着
cnt一起换,很麻烦。
第二篇学了 map 和 sort 之后,这道题会变成三行:
map<string, int> mp;
// ... 读入时 mp[w]++
vector<pair<string,int>> v(mp.begin(), mp.end());
sort(v.begin(), v.end());
这就是学 STL 的意义。
第 12 章 常见错误与调试
12.1 编译错误清单(再列一遍,这次带原因)
| 报错 | 原因 | 修法 |
|---|---|---|
expected ';' |
漏分号 | 看报错行的上一行 |
xxx was not declared |
未定义/拼错/没 include | 检查声明和拼写 |
redefinition of 'x' |
重复定义 | 改名或删掉一个 |
expected ')' before ... |
括号不配对 | 数括号 |
no matching function for call to ... |
参数类型/个数不对 | 检查调用 |
invalid operands to binary ... |
类型不匹配(比如 string 减 string) | 换操作 |
control reaches end of non-void function |
有返回值的函数没写 return | 补 return |
调试编译错误的方法:
- 只看第一个错误;
- 看报错里的行号,但常常要往上一行找;
- 改完立刻重新编译。
12.2 逻辑错误清单(不报错但答案错)
这是最花时间的部分。按出现频率排:
- 数组越界 :开小了,或者下标算错了(比如
a[n]访问了长度为 n 的数组)。 - 未初始化:局部变量没赋初值。
- 溢出 :该用
long long用了int。 =写成==或反过来。- 循环边界错 :
i < n写成i <= n,或者从 1 开始但按 0 的规则写。 - 浮点比较 :用
==比浮点数。 - 多组数据的变量没重置:多测题里,上一组的数组没清零。
break跳错了层。- 输入格式没对上 :以为一行一个,其实是空格分隔(用
cin就没这问题)。 - 输出格式错:多空格、少换行、没保留小数位。
12.3 怎么调试
方法 1:打印中间变量
最土但最好用:
for (int i = 1; i <= n; i++) {
cin >> a[i];
cerr << "a[" << i << "] = " << a[i] << '\n'; // 用 cerr,不污染标准输出
}
cerr 是标准错误流,输出到屏幕上,但不会被评测当答案。这是竞赛调试的标准做法 ------用 cerr 打日志,交题前删掉就行(其实不删也行,评测机不管 stderr)。
方法 2:缩小数据
如果程序在 n=100000 时错,先试 n=5。手工算一遍正确答案,对比输出。大多数错误在小数据下就能暴露。
方法 3:和暴力对拍
写一个"一定能对但很慢"的版本,随机生成小数据,两个程序跑,比对输出。第三篇会详细讲对拍。
方法 4:二分定位
在程序中间输出一些"状态",看它是从哪一步开始不对的。
12.4 提交前的检查清单
这个清单建议你贴在显示器上:
□ 数据类型够不够大?(求和使用 long long)
□ 数组开够了没有?(多开 10 个)
□ 下标是 0 开始还是 1 开始?全程一致吗?
□ 所有变量都初始化了吗?
□ 循环边界对不对?(< n 还是 <= n)
□ 输出格式和题目要求一致吗?(空格、换行、小数位)
□ 有没有把调试输出用 cout 打出来?(改成 cerr 或删掉)
□ freopen 注释掉了吗?(如果用了)
□ 多组数据时,全局数组清零了吗?
□ 特殊值考虑了吗?(n=0、n=1、全负数、全相同)
□ 最终提交的是最新版本吗?
最后一条尤其重要------很多人改了代码但没重新编译,交上去还是旧版本。
第 13 章 从题目到代码:一次完整的解题演练
前面十二章讲的是"零件"。但真实的比赛不是给你一道填空题让你补全循环,而是给你一段几百字的题目描述,让你从零写出一个完整的程序。这中间有一段很关键的路------怎么把中文描述翻译成代码。
这一章我们用三个案例,把这条路完整走一遍。每一个案例我都会展示"我拿到题目时的思考顺序",包括那些最后没有采用的思路。这比直接给你一份正确答案有用得多。
13.1 解题的五个固定步骤
拿到任何一道题,不管难易,都按这五步走:
第一步:读题,划出四个要素
- 输入:给什么,什么格式,范围多大;
- 输出:要什么,什么格式,精度要求;
- 约束:数据范围、时间限制、内存限制、特殊要求;
- 样例:给的那组输入输出,先在心里验算一遍。
很多人读题只读一半就开始写,写到一半发现"原来输入是多组数据",返工。读两遍,第二遍专门看格式和范围。
第二步:想一个"一定能对"的笨办法
不管多慢,先想出一个正确的解法。这个解法通常就是"按题目的描述,一步一步做"。它可能是 O(n²) 甚至 O(n³),但在小数据上一定对。
这一步极其重要,原因有三个:
- 它保证你有分。竞赛是部分分制,暴力做法往往能拿到 30%~60%;
- 它是你后面优化时的"标准答案",用来对拍;
- 想暴力做法的过程,往往就是理解题目本质的过程。
第三步:估算复杂度,决定要不要优化
数据范围是 10^5,你想到的暴力是 O(n²),那就是 10^10 次运算,肯定超时,必须优化。数据范围是 1000,O(n²) 是 10^6,随便过。
这个"看范围定算法"的本事,第三篇会系统训练。现在先记住一个粗粒度标准:C++ 一秒钟大约能做 10^8 次简单运算。所以:
- n ≤ 10^6:只能 O(n) 或 O(n log n);
- n ≤ 10^4:O(n²) 勉强可以(10^8);
- n ≤ 1000:O(n²) 很轻松,O(n³) 也许行;
- n ≤ 100:O(n³)、O(2^n) 都可以试试。
第四步:写代码
按"输入 → 处理 → 输出"三段式写:
int main() {
// 1. 读入
// 2. 计算
// 3. 输出
return 0;
}
先把框架搭出来,再往里填。这样你不会写着写着忘了输出。
第五步:测
用样例测,用手算的小数据测,用边界数据测(n=1、全相同、全负数、最大值)。测出问题就回到第三步。
这五步看起来啰嗦,但熟练之后是几秒钟的事,而且能大幅降低"写完发现方向错了"的概率。
13.2 案例一:日期问题
题目:给定两个日期(年、月、日),求它们之间相差多少天(保证第一个日期不晚于第二个日期,年份在 1 到 9999 之间)。
第一步,读题。 输入是两个日期,输出是一个整数天数。范围:年份到 9999。这个范围很关键------9999 年 × 365 天 ≈ 365 万天,如果用"一天一天往后数"的做法,最多循环 365 万次,完全可接受。所以这题根本不需要什么高级算法,直接模拟就行。
这就是"看范围定算法"的实际应用。如果没有 9999 这个上限,而是年份到 10^9,那"一天一天数"就完蛋了,得用公式。
第二步,想笨办法。
最直白的思路:把第一个日期一天一天往后加,每加一天答案加一,直到等于第二个日期。
"日期加一天"这个操作怎么写?
- 日加 1;
- 如果日超过了这个月的天数,日变成 1,月加 1;
- 如果月超过 12,月变成 1,年加 1。
每个月的天数不一样,2 月还要看闰年。这是这题唯一的"知识点"。
第三步,复杂度。 O(天数) ≈ 3.65×10^6,秒过。
第四步,写代码。
先写辅助函数,把"一个月的天数"封装起来:
bool isLeap(int y) {
return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
}
int daysInMonth(int y, int m) {
if (m == 2) return isLeap(y) ? 29 : 28;
if (m == 4 || m == 6 || m == 9 || m == 11) return 30;
return 31;
}
注意这里把"哪些月是 30 天"用 ||连起来,比写 12 个 if 干净。这种"合并同类情况"的习惯能省很多代码。
然后写"日期加一天"。这个操作不能直接返回新日期(因为要改三个值),用引用参数最自然------但引用第二篇才讲,这里先用一个简单的办法:在 main 里直接操作三个变量。
#include <iostream>
using namespace std;
bool isLeap(int y) {
return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
}
int daysInMonth(int y, int m) {
if (m == 2) return isLeap(y) ? 29 : 28;
if (m == 4 || m == 6 || m == 9 || m == 11) return 30;
return 31;
}
int main() {
int y1, m1, d1, y2, m2, d2;
cin >> y1 >> m1 >> d1;
cin >> y2 >> m2 >> d2;
int ans = 0;
while (!(y1 == y2 && m1 == m2 && d1 == d2)) {
d1++;
if (d1 > daysInMonth(y1, m1)) {
d1 = 1;
m1++;
if (m1 > 12) {
m1 = 1;
y1++;
}
}
ans++;
}
cout << ans << '\n';
return 0;
}
几个值得说的细节:
-
while (!(y1 == y2 && m1 == m2 && d1 == d2))------"不相等就继续"。这里的括号层次要看清:先三个都比较,再&&,最后!。如果不加括号写成!y1 == y2,那是(!y1) == y2,完全错。 -
日期加一的顺序:先加日,再判断要不要进位到月,再判断要不要进位到年。顺序不能乱,先处理小的单位。
-
闰年判断里
% 400 == 0那一项的位置。因为&&优先级高于||,所以y % 4 == 0 && y % 100 != 0 || y % 400 == 0实际上等价于(A && B) || C,是对的。但为了可读性还是加括号好。
第五步,测。
样例:2020 1 1 到 2020 1 31,答案应该是 30。手算一遍:1 月 1 日到 1 月 31 日,中间隔了 30 天。程序从 1 月 1 日开始,每次加一天加一次答案,加到 1 月 31 日时循环结束,答案是 30。✓
测闰年:2020 2 28 到 2020 3 1。2020 是闰年,2 月有 29 天。28 → 29(ans=1)→ 3月1日(ans=2)。答案 2。手算:2 月 28 日到 3 月 1 日,隔了 2 天(29 日和 3 月 1 日)。✓
测平年:2019 2 28 到 2019 3 1。2019 不是闰年,2 月 28 天。28 → 3月1日,ans=1。✓
测边界:两个日期相同,答案是 0。程序一开始 while 条件就为假,直接输出 0。✓
测跨年:2019 12 31 到 2020 1 1,答案 1。程序:日变 32 > 31,进位成 1 月 1 日,月变 13 > 12,进位成 2020 年 1 月 1 日,ans=1。✓
这道题的"陷阱"在哪里?
- 闰年判断漏了
% 400; - 2 月的天数写死成 28;
- 月份进位忘了,直接
d1++到 32; - 年份进位忘了。
这四个都是很典型的错误。而且它们只会在特定数据上暴露,样例往往测不出来。这就是为什么必须自己构造边界数据测。
这题还能怎么优化?
如果年份范围是 10^9,一天一天数就废了。那时候要么用公式(累加整年天数 + 整月天数 + 剩余天数),要么把日期转成"从公元 1 年 1 月 1 日起的第几天",两个相减。第二种做法更通用,第三篇讲前缀和思想时会用到。
13.3 案例二:字符串处理
题目 :给定一个只含小写字母的字符串,把它"压缩":连续相同的字符用"字符 + 次数"表示。如果次数是 1,只输出字符本身。例如
aaabccdddd压缩成a3bc2d4。
第一步,读题。 输入一个字符串,输出一个字符串。要注意的点:次数是 1 时不写数字;次数可能是多位数(比如连续 100 个 a)。
第二步,想笨办法。 从左往右扫,一边走一边数"当前这个字符连续出现了几次"。遇到不同的字符,就把上一个字符的结果输出,然后重新开始数。
这就是"扫描 + 计数"的经典模式。
第三步,复杂度。 O(n),最佳。
第四步,写代码。
第一版思路(容易写错的地方我特意标出来):
#include <iostream>
#include <string>
using namespace std;
int main() {
string s;
cin >> s;
int n = (int)s.size();
int i = 0;
while (i < n) {
int j = i;
while (j < n && s[j] == s[i]) j++; // j 停在与 s[i] 不同的位置
int cnt = j - i; // 连续长度
cout << s[i];
if (cnt > 1) cout << cnt;
i = j; // 从新位置继续
}
cout << '\n';
return 0;
}
这段代码的关键是双指针 i 和 j 的含义:
i指向"当前这一段"的起点;j从i出发,一直走到"这一段"结束后的第一个位置。
所以"这一段"的长度是 j - i,而下一段的起点正好是 j。
这个"用两个指针框出一段区间"的技巧,是字符串和数组处理里最常用的套路之一。第三篇的滑动窗口、双指针去重,都是它的变体。
几个细节:
-
while (j < n && s[j] == s[i])------两个条件顺序不能反。如果先写s[j] == s[i],当j == n时会越界访问s[n](string的下标越界是未定义行为)。 -
i = j而不是i++。因为j已经跳过了整段,如果只i++会重复处理。 -
cnt = j - i而不是cnt = j - i + 1。因为j停在"下一个位置",区间是左闭右开的[i, j),长度就是j - i。这是 C++ 里最常见的区间约定,务必习惯。
第五步,测。
aaabccdddd → a 段:i=0, j=3, cnt=3 → 输出 "a3";i=3, j=4, cnt=1 → "b";i=4, j=5, cnt=1 → "c";i=5, j=6, cnt=2 → "c2"... 等等,让我重新看字符串:
a a a b c c d d d d
- i=0:s0='a',j 走到 3(s3='b'),cnt=3,输出 "a3",i=3
- i=3:s3='b',j 走到 4(s4='c'),cnt=1,输出 "b",i=4
- i=4:s4='c',j 走到 6(s6='d'),cnt=2,输出 "c2",i=6
- i=6:s6='d',j 走到 10(n=10),cnt=4,输出 "d4",i=10
- 结束
输出 a3bc2d4。✓
边界测试:
- 单字符
a→ 输出a(cnt=1,不输出数字)✓ - 全是同一字符
aaaaa→a5✓ - 没有重复
abc→abc✓ - 空字符串(如果题目允许)→ 什么都不输出。
while (i < n)直接不成立,安全 ✓
这题的第二问通常是"解压" :给 a3bc2d4,还原成 aaabccdddd。思路是把字符和后面的数字分开读:
string t;
cin >> t;
for (int i = 0; i < (int)t.size(); i++) {
char c = t[i];
int j = i + 1, cnt = 0;
while (j < (int)t.size() && isdigit(t[j])) {
cnt = cnt * 10 + (t[j] - '0');
j++;
}
if (cnt == 0) cnt = 1; // 没有数字就是 1 个
for (int k = 0; k < cnt; k++) cout << c;
i = j - 1; // 注意:for 循环还会 i++,所以这里要减 1
}
这个 i = j - 1 是个非常经典的坑 。因为外层是 for 循环,每轮结束会自动 i++,所以如果你把 i 设成 j,实际下一轮会从 j+1 开始,漏掉一个字符。
这种"手动控制循环变量"的循环,用 while 写会更清楚:
int i = 0;
while (i < (int)t.size()) {
char c = t[i];
i++;
int cnt = 0;
while (i < (int)t.size() && isdigit(t[i])) {
cnt = cnt * 10 + (t[i] - '0');
i++;
}
if (cnt == 0) cnt = 1;
for (int k = 0; k < cnt; k++) cout << c;
}
对比一下两种写法 :for 版本需要"往回退一步",while 版本不需要。当循环变量的推进不是简单地"每次加 1"时,while 更好用。这个判断标准值得记住。
解压题还有一个陷阱 :次数可能是多位数,比如 a12 表示 12 个 a。很多人只读一位数字,写成 cnt = t[i+1] - '0',遇到 a12 就变成 1 个 a 加一个字符 2。必须用 while (isdigit()) 把整段数字读完。
13.4 案例三:带状态的模拟
题目:一个电梯从 1 楼出发,依次响应 n 个请求,每个请求是一个目标楼层。电梯每上一层或下一层耗时 1 秒,每停一次(包括出发的那层和每次到达)耗时 2 秒。求完成所有请求的总时间。
第一步,读题。
- 输入:n,然后是 n 个楼层号;
- 输出:总秒数;
- 隐含约束:楼层号是正整数,第一个请求可能是任何楼层。
第二步,想笨办法。
维护两个状态:当前楼层 、总时间。对每个请求:
- 从当前楼层移动到目标楼层,耗时
|目标 - 当前|秒; - 停下开门关门,耗时 2 秒;
- 更新当前楼层。
出发时在 1 楼,也要算一次停靠的 2 秒吗?题目说"每停一次(包括出发的那层)",所以要算。
第三步,复杂度。 O(n),最优。
第四步,写代码。
#include <iostream>
#include <cmath>
using namespace std;
int main() {
int n;
cin >> n;
int cur = 1;
long long total = 2; // 出发时停靠一次
for (int i = 0; i < n; i++) {
int target;
cin >> target;
total += abs(target - cur); // 移动耗时
total += 2; // 停靠耗时
cur = target; // 更新状态
}
cout << total << '\n';
return 0;
}
几个值得说的点:
-
abs()需要<cmath>(或者<cstdlib>)。它返回绝对值。自己写也行:(x > 0 ? x : -x)。 -
total用long long。虽然 n 一般不大,但"总时间"是累积量,多个大数相加容易超。凡是累加,一律 long long,这是最省心的原则。 -
total = 2的初值:出发那层的停靠。这个"初值"是要根据题意推的,很多人会漏。 -
更新的位置 :
cur = target必须在计算完移动时间之后。如果写在前面,abs(target - cur)就变成 0 了。
"状态"这个概念值得多说两句。
这道题里,程序需要"记住"的东西只有两样:现在在哪(cur)、花了多久(total)。每处理一个请求,就根据当前状态算出新状态。
几乎所有模拟题都是这个结构:
初始化状态
for (每个事件) {
根据当前状态,计算这一步的结果
更新状态
}
输出最终结果
这个模板是模拟题的核心。 第三篇会有一整章讲模拟,但那章的内容本质上就是把这个模板套到各种复杂场景上------排队、银行叫号、游戏回合、细胞自动机。
第五步,测。
样例假设:n=3,请求 3、1、5。
- 初始 cur=1, total=2
- 请求 3:移动 2 秒,停靠 2 秒,total = 2+2+2 = 6,cur=3
- 请求 1:移动 2 秒,停靠 2 秒,total = 6+4 = 10,cur=1
- 请求 5:移动 4 秒,停靠 2 秒,total = 10+6 = 16,cur=5
答案 16。
边界测试:
- n=1,请求 1:cur=1,移动 0,停靠 2,total = 2+0+2 = 4。合理(出发停一次,到达停一次)。
- 请求都是同一层:每次只有停靠的 2 秒。
- 请求楼层很大(比如 10^6):
total可能到 10^6 × 10^6 = 10^12,必须 long long。这正好印证了第 2 点。
13.5 三个案例的共同点
回头看这三道题,它们的代码结构几乎一样:
读入数据
初始化状态
循环处理每个元素/每个请求
根据状态做判断或计算
更新状态
输出结果
这就是"基础题"的全部秘密。 CSP-J 的第一题、第二题,绝大多数都是这个结构加一点细节。你不需要什么高深算法,只需要:
- 准确地理解题意(特别是那些"每停一次"、"不包含"、"保证"之类的限定词);
- 把状态想清楚(需要记住哪几个量);
- 把边界想清楚(初始值是什么、最后一步要不要特判);
- 把类型想清楚(会不会溢出)。
一个非常有用的自检方法:写完之后,自己在纸上走一遍 n=1 的情况。如果 n=1 时程序还对,说明初始化和边界基本没问题。很多人的 bug 就出在"第一次"和"最后一次"。
第 14 章 两种最基础的算法思想
这一章讲两个词:枚举 和模拟。它们是所有算法的起点,也是 CSP-J 里性价比最高的两把武器。第三篇会有专门的章节展开,这里先建立观念。
14.1 枚举:把所有可能都试一遍
枚举(穷举)的核心思想:答案一定在某个有限的集合里,那就把集合里的每个元素都试一遍,看哪个符合条件。
最简单的例子:判断一个数 n 是不是素数。
"素数"的定义是"只能被 1 和它自己整除的大于 1 的整数"。那最笨的验证方法就是:把 2 到 n-1 的每个数都试一遍,看能不能整除 n。
bool isPrime(int n) {
if (n < 2) return false;
for (int i = 2; i <= n - 1; i++) {
if (n % i == 0) return false;
}
return true;
}
这是"完全按照定义"的枚举。它一定对,但很慢------O(n)。
然后我们观察:如果 n 有一个因子 d 大于 sqrt(n),那必然对应一个因子 n/d 小于 sqrt(n)。所以只需要试到 sqrt(n):
for (int i = 2; i * i <= n; i++)
复杂度降到 O(sqrt(n))。
这个"从定义出发枚举,再观察规律缩小枚举范围"的过程,就是算法优化的基本套路。 你以后学的所有优化技巧,本质上都是"少试一些不可能的情况"。
枚举的第二个例子:百钱百鸡。
公鸡 5 元一只,母鸡 3 元一只,小鸡 3 只 1 元。用 100 元买 100 只鸡,问公鸡、母鸡、小鸡各多少只。
最笨的枚举:
for (int g = 0; g <= 100; g++)
for (int m = 0; m <= 100; m++)
for (int x = 0; x <= 100; x++)
if (g + m + x == 100 && 5*g + 3*m + x/3 == 100 && x % 3 == 0)
cout << g << " " << m << " " << x << '\n';
三重循环 101³ ≈ 103 万次。能跑,但没必要。
优化一:小鸡的数量可以由前两者算出来。
for (int g = 0; g <= 20; g++) // 公鸡最多 20 只(100/5)
for (int m = 0; m <= 33; m++) { // 母鸡最多 33 只
int x = 100 - g - m; // 小鸡数量直接算
if (x % 3 == 0 && 5*g + 3*m + x/3 == 100)
cout << g << " " << m << " " << x << '\n';
}
21 × 34 = 714 次。快了 1400 倍。
优化的本质是"减少不必要的枚举":小鸡的数量不是自由变量,它被前两个决定了。
枚举的三个要点:
- 枚举范围要完整:不能漏掉可能的答案。比如公鸡 0 到 20,不能从 1 开始(题目没说不能是 0)。
- 判断条件要准确 :百钱百鸡里
x % 3 == 0很容易漏,因为小鸡是"3 只 1 元",必须成组买。 - 能算出来的就别枚举:这是优化的第一步。
什么时候用枚举?
- 数据范围很小(n ≤ 100,或者答案空间很小);
- 想不出更好的办法,但需要一个保底的正确解;
- 需要验证一个猜想(枚举小规模,看规律)。
枚举是保命符。 比赛里遇到完全没思路的题,写个枚举往往能拿到 30% 的分。这比空着强太多。
14.2 模拟:照着题目描述做
模拟的核心思想:题目怎么描述,你就怎么写代码,一步一步照做。
它不需要你想什么巧妙的公式,只需要你把"规则"准确地翻译成"操作"。
前面第 13 章的三道题全是模拟。再举一个典型:
题目:一个 n×m 的棋盘,每个格子是黑色或白色。每一秒,所有格子同时变色:如果一个格子上下左右四个邻居中,黑格的数量是奇数,它就变成黑色,否则变成白色。求 k 秒后的棋盘。
这道题如果你试图找规律("是不是有周期性"),可能要花很久。但如果直接模拟:
const int N = 105;
int a[N][N], b[N][N];
int main() {
int n, m, k;
cin >> n >> m >> k;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cin >> a[i][j];
while (k--) {
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) {
int c = a[i-1][j] + a[i+1][j] + a[i][j-1] + a[i][j+1];
b[i][j] = (c % 2 == 1) ? 1 : 0;
}
// 把 b 复制回 a
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
a[i][j] = b[i][j];
}
// 输出 a
}
这里有一个极其重要的细节:为什么要用两个数组?
因为题目说"同时 变色"。如果只用一个数组,你改了 a[i][j] 之后,处理 a[i][j+1] 时读到的邻居就是已经改过的新值,而不是这一秒开始时的旧值。结果就错了。
"同时更新"类问题的标准解法:用两个数组,一个读旧值,一个写新值,每轮结束后交换(或者复制)。
这个坑在模拟题里反复出现:细胞自动机、生命游戏、BFS 的层扩展(第三篇会讲)、多米诺骨牌倒下。看到"同时"两个字,就要条件反射地想到"双数组"。
另一个细节:边界。 代码里 a[0][j] 和 a[i][0] 这些位置是 0(全局数组默认零),相当于棋盘外面当作白格。这正好符合题意。但如果你把数组开成局部的,这些位置就是垃圾值,结果全错。
模拟题的三个要点:
- 规则要逐条落实:题目说的每一个"如果......就......",你都得有一行代码对应。漏了任何一条都会错。
- 注意"同时":需要同时更新的,用双数组。
- 注意边界:数组外面怎么处理,要明确。通常是把边界外当作 0 或特殊值,或者干脆加一圈"虚拟边界"。
模拟题的代码通常很长,但难度不高。 它的难点在"耐心"和"不出错",而不是"想不出"。这类题在 CSP-J 里往往放在第 1、2 题,属于"必须拿满"的分数。
14.3 从暴力到优化:一个直观的例子
题目:给一个长度为 n 的数组,求最大的"连续子段和"。n ≤ 10^5。
暴力做法:枚举左端点 L 和右端点 R,求和。
int ans = -1e9;
for (int L = 1; L <= n; L++)
for (int R = L; R <= n; R++) {
int s = 0;
for (int i = L; i <= R; i++) s += a[i];
ans = max(ans, s);
}
复杂度 O(n³) = 10^15,完全不行。
优化一:求和不用重新算。 固定 L,让 R 往右走,边走边累加:
for (int L = 1; L <= n; L++) {
int s = 0;
for (int R = L; R <= n; R++) {
s += a[R];
ans = max(ans, s);
}
}
复杂度 O(n²) = 10^10。还是不行,但快了 n 倍。
优化二:换个角度想。
固定右端点 R,考虑"以 R 结尾的最大子段和"。它要么是"以 R-1 结尾的最大子段和 + aR",要么是"只有 aR 自己"。取两者大的。
long long cur = a[1], ans = a[1];
for (int i = 2; i <= n; i++) {
cur = max((long long)a[i], cur + a[i]);
ans = max(ans, cur);
}
复杂度 O(n)。秒过。
这就是动态规划的雏形(第三篇的主角)。它的思想是:"不要重复计算已经算过的东西,把结果存下来。"
这个例子的价值在于展示一条完整的思路链:
定义出发的暴力 O(n³)
→ 消除重复计算 O(n²)
→ 换个定义方式 O(n)
你不需要一步跳到最优解。 从暴力开始,一步步砍,是竞赛里最可靠的工作方式。而且中间的暴力版本可以留着对拍。
14.4 为什么一定要先写暴力
很多新手一上来就想"最优解",结果想了半小时没想出来,一行代码没写,0 分。
老手的做法是:
- 5 分钟内想出暴力,立刻写出来,拿到 30~40 分;
- 然后开始想优化,想到了就改,想不到就交暴力。
分数是累加的,不是只有满分才算成功。 竞赛里 40 分和 0 分的差距,比 100 分和 40 分的差距更致命------因为 40 分已经能让你过初赛线了。
而且暴力能当"标尺"。你优化后的代码如果跑出来和暴力不一样,那肯定有一个错了。第三篇会讲"对拍",就是自动化地做这件事。
从这一篇开始,请养成一个习惯:遇到任何题,先问自己"最笨的做法是什么",写下来,然后再想能不能更快。
第 15 章 本篇小结与练习
15.1 你现在应该会的东西
✓ 能装好环境,编译运行一个程序 ✓ 知道 #include、using namespace std、main、return 0 各自的作用 ✓ 认识常见数据类型,知道 int 什么时候会溢出 ✓ 会用 cin/cout 和 scanf/printf ✓ 会写 if/else、switch、三目运算符 ✓ 会写 for/while/do-while,会用 break/continue ✓ 会开一维、二维数组,知道要大数组开全局 ✓ 会用 string 做基本字符串处理 ✓ 会定义和调用函数,理解递归的基本形态
15.2 如果这些还不熟,请重做这些
- 用循环打印 1~100 中所有能被 3 或 5 整除但不能被 15 整除的数;
- 读入 n 个数,输出它们的平均值(保留两位小数);
- 判断一个字符串是否为回文;
- 输出 n 行的乘法表;
- 求 1! + 2! + ... + n!(注意溢出);
- 输入年月日,输出这是这一年的第几天;
- 输入一个 n×n 的矩阵,输出它的两条对角线元素之和;
- 输入一串字符,统计大写字母、小写字母、数字、其他字符各多少个;
- 判断一个数是否为完全数(所有真因子之和等于它本身);
- 输出 1~1000 中所有的"各位数字立方和等于本身"的数。
15.3 练习(这些题会直接对应 CSP-J 的基础题)
练习 A(简单)
- 输入三个整数,按从小到大输出。
- 输入一个整数 n,输出 n 的所有约数。
- 输入 n 个数,输出其中第二大的数。
- 输入一个字符串,把它反转后输出。
- 计算 1 到 n 之间所有奇数的和、所有偶数的和。
练习 B(中等)
- 输入 n,输出前 n 项的斐波那契数列(用循环,不用递归)。
- 输入一个十进制数,输出它的二进制表示(提示:用数组存余数,再倒着输出)。
- 输入 n 个学生的成绩,输出最高分、最低分、平均分,以及高于平均分的人数。
- 输入一个 3×3 的矩阵,判断它是不是"魔方阵"(每行、每列、两条对角线的和都相等)。
- 输入两个字符串,判断第二个是不是第一个的子串,是的话输出首次出现的位置。
练习 C(挑战)
- 输入 n(n ≤ 10^6),输出 1~n 中所有的素数。试试你的程序要跑多久。
- 输入一个由括号
()[]{}组成的字符串,判断括号是否匹配。(提示:用一个数组当栈,第二篇会正式讲stack) - 输入 n 个数,输出其中出现次数最多的数。如果最多的有多个,输出最小的那个。
- 输入一个正整数,输出它各位数字之和,反复操作直到结果是一位数(数根问题)。
- 打印杨辉三角的前 n 行。
15.4 下一步
第一篇解决的是"能不能写出程序"。第二篇要解决的是"能不能写得高效、写得省事":
- 指针与引用:让你理解参数传递的真相,也让你能写更省内存的代码;
- 结构体:把"一个学生的姓名、成绩、学号"打包成一个整体;
- STL :
vector、string、map、set、sort,这些工具能让你少写一半代码; - 复杂度:让你在写之前就知道"这个做法会不会超时"。
这三样东西学完,你才真正具备"参加比赛"的能力------因为比赛题目里,n 动辄 10^5,你必须知道哪些写法是 O(n log n) 能过的,哪些 O(n²) 会 TLE。
下一篇见。
附录:本篇速查表
A.1 数据类型范围
char -128 ~ 127
short -32768 ~ 32767
int -2147483648 ~ 2147483647 (约 ±2.1e9)
unsigned int 0 ~ 4294967295 (约 4.3e9)
long long -9.22e18 ~ 9.22e18 (约 ±9.2e18)
float 约 7 位有效数字
double 约 15~16 位有效数字
A.2 格式符
int %d
long long %lld
unsigned %u
double scanf: %lf printf: %f
char %c
字符串 %s
保留两位小数 %.2f
A.3 常用头文件
#include <iostream> // cin cout
#include <cstdio> // scanf printf
#include <string> // string
#include <algorithm> // sort max min swap reverse
#include <cmath> // sqrt fabs pow
#include <cstring> // memset strlen
#include <cctype> // isdigit isalpha toupper
#include <bits/stdc++.h> // 万能头(竞赛用)
A.4 常用模板
// 判断素数
bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; (long long)i * i <= x; i++)
if (x % i == 0) return false;
return true;
}
// 最大公约数
int gcd(int a, int b) { return b ? gcd(b, a % b) : a; }
// 读入 n 个数
for (int i = 1; i <= n; i++) cin >> a[i];
// 求最值
int mx = a[1];
for (int i = 2; i <= n; i++) if (a[i] > mx) mx = a[i];
// 累加
long long s = 0;
for (int i = 1; i <= n; i++) s += a[i];