C++ 从零入门(一):基础语法与程序结构

美味鸡汤:

这是三篇系列文章的第一篇。三篇写完之后,你应该能够独立参加 CSP-J(信息学奥赛入门级)的机试,也就是说,能读懂题目、能想出暴力做法、能把它稳稳当当地写成代码、能自己调出错误。

这一篇解决的是"从完全不会到能写出一个完整程序"的问题:环境怎么装、代码怎么跑、变量是什么、输入输出怎么写、判断和循环怎么用、数组怎么存数据、函数怎么拆。看起来都是最基础的东西,但 CSP-J 里真正让人丢分的,往往不是算法没想到,而是这些基础写错了------数组开小了、= = 写成 =、int 溢出了、cin 太慢超时了。

所以这一篇请务必认真过一遍,边看边敲。看懂和敲过是两件完全不同的事。


第 0 章 先搞清楚我们在干什么

0.1 编程到底在做什么

一句话:你把解决问题的步骤,用一种机器能精确执行的语言写下来。

举个生活里的例子。你要告诉一个从没做过饭的人"怎么煮泡面",你不能说"煮一下就好了",你得说:

  1. 找一个锅;
  2. 接 500 毫升水;
  3. 放到灶上,开大火;
  4. 等水烧开(冒大泡);
  5. 把面饼放进去;
  6. 等 3 分钟;
  7. 关火,放调料,搅拌。

注意这里面有几个特征:

  • 顺序:步骤有先后,不能先放面再烧水;
  • 条件:如果水没开就继续等,如果开了就放面;
  • 重复:如果面还没软,就继续等;
  • 数据:水量、时间、调料的数量,都是具体的值。

计算机程序就由这四样东西构成:顺序、分支、循环、数据。这一篇后面的内容,本质上就是把这几件事讲透。

0.2 为什么入门选 C++

市面上入门语言很多,Python 更简单,Java 更"工程"。但如果你要打信息学竞赛(CSP-J/S、NOIP),C++ 是唯一的主流选择,原因有三个:

  1. 快。C++ 编译成机器码直接运行,同样的算法,C++ 往往比 Python 快几十倍。竞赛里一道题限时 1 秒,Python 写同样的暴力很可能超时,C++ 就能过。
  2. 可控。内存怎么用、数组开多大、传参是拷贝还是引用,你都能精确控制。这在需要卡常数、卡内存的竞赛题里是刚需。
  3. 生态。竞赛题库、题解、模板代码,绝大多数是 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 学习方式(重要)

给你三条硬建议:

  1. 每段代码都亲手敲一遍。不要复制。复制来的代码你不会有肌肉记忆,考试时手会抖。
  2. 每敲完一段,故意改坏它 。把 i < n 改成 i <= n,看看会怎样;把 int 改成 char,看看会怎样。错误见得多,你才不会怕错误。
  3. 每章末尾的练习必须做。看懂了和写出来之间有一条很宽的河,练习就是那座桥。

好,开始。


第 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;

这句话做了两件事:

  1. 向系统申请一块能装 int 的空间,给它起名 age;
  2. 把 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 表示"这个值不允许改"。好处有两个:

  1. 防止你手滑改掉它;
  2. 数组大小必须是编译期常量,用 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 运算符优先级

完整表很长,你只需要记住这几条规则:

  1. () 最高,不确定就加括号;
  2. !、~、++、--、强制转换 高于算术;
  3. * / % 高于 + -;
  4. 移位 << >> 低于 + -(这条容易错);
  5. 关系 < > <= >= 低于算术;
  6. == != 低于关系;
  7. && 低于 ||;
  8. 赋值 = 最低。

别背,加括号。这是最省心的做法:

复制代码
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++) {
    // 循环体
}

三个部分,用分号隔开:

  1. int i = 0:初始化,只执行一次;
  2. i < n:条件,每轮开始时检查,为真才继续;
  3. 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. 题目实际数据可能比标注的多一点点(有些题会这样);
  2. 你用 1-based 下标(从 1 开始存),会用到 a[n],一个都不多开就正好卡在边界;
  3. 有些算法会访问到 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

递归必须有两个要素:

  1. 出口(base case):什么时候停。没有出口就会无限递归,最终栈溢出崩溃。
  2. 递推关系:怎么把大问题变成小问题。

经典的递归例子:

复制代码
// 斐波那契(这个写法很慢,第二篇讲原因和优化)
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

调试编译错误的方法:

  1. 只看第一个错误;
  2. 看报错里的行号,但常常要往上一行找;
  3. 改完立刻重新编译。

12.2 逻辑错误清单(不报错但答案错)

这是最花时间的部分。按出现频率排:

  1. 数组越界 :开小了,或者下标算错了(比如 a[n] 访问了长度为 n 的数组)。
  2. 未初始化:局部变量没赋初值。
  3. 溢出 :该用 long long 用了 int。
  4. = 写成 == 或反过来。
  5. 循环边界错 :i < n 写成 i <= n,或者从 1 开始但按 0 的规则写。
  6. 浮点比较 :用 == 比浮点数。
  7. 多组数据的变量没重置:多测题里,上一组的数组没清零。
  8. break 跳错了层。
  9. 输入格式没对上 :以为一行一个,其实是空格分隔(用 cin 就没这问题)。
  10. 输出格式错:多空格、少换行、没保留小数位。

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³),但在小数据上一定对。

这一步极其重要,原因有三个:

  1. 它保证你有分。竞赛是部分分制,暴力做法往往能拿到 30%~60%;
  2. 它是你后面优化时的"标准答案",用来对拍;
  3. 想暴力做法的过程,往往就是理解题目本质的过程。

第三步:估算复杂度,决定要不要优化

数据范围是 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;
}

几个值得说的细节:

  1. while (!(y1 == y2 && m1 == m2 && d1 == d2))------"不相等就继续"。这里的括号层次要看清:先三个都比较,再 &&,最后 !。如果不加括号写成 !y1 == y2,那是 (!y1) == y2,完全错。

  2. 日期加一的顺序:先加日,再判断要不要进位到月,再判断要不要进位到年。顺序不能乱,先处理小的单位。

  3. 闰年判断里 % 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。

这个"用两个指针框出一段区间"的技巧,是字符串和数组处理里最常用的套路之一。第三篇的滑动窗口、双指针去重,都是它的变体。

几个细节:

  1. while (j < n && s[j] == s[i])------两个条件顺序不能反。如果先写 s[j] == s[i],当 j == n 时会越界访问 s[n](string 的下标越界是未定义行为)。

  2. i = j 而不是 i++。因为 j 已经跳过了整段,如果只 i++ 会重复处理。

  3. 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 个楼层号;
  • 输出:总秒数;
  • 隐含约束:楼层号是正整数,第一个请求可能是任何楼层。

第二步,想笨办法。

维护两个状态:当前楼层 、总时间。对每个请求:

  1. 从当前楼层移动到目标楼层,耗时 |目标 - 当前| 秒;
  2. 停下开门关门,耗时 2 秒;
  3. 更新当前楼层。

出发时在 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;
}

几个值得说的点:

  1. abs() 需要 <cmath>(或者 <cstdlib>)。它返回绝对值。自己写也行:(x > 0 ? x : -x)。

  2. total 用 long long。虽然 n 一般不大,但"总时间"是累积量,多个大数相加容易超。凡是累加,一律 long long,这是最省心的原则。

  3. total = 2 的初值:出发那层的停靠。这个"初值"是要根据题意推的,很多人会漏。

  4. 更新的位置 :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 的第一题、第二题,绝大多数都是这个结构加一点细节。你不需要什么高深算法,只需要:

  1. 准确地理解题意(特别是那些"每停一次"、"不包含"、"保证"之类的限定词);
  2. 把状态想清楚(需要记住哪几个量);
  3. 把边界想清楚(初始值是什么、最后一步要不要特判);
  4. 把类型想清楚(会不会溢出)。

一个非常有用的自检方法:写完之后,自己在纸上走一遍 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 倍。

优化的本质是"减少不必要的枚举":小鸡的数量不是自由变量,它被前两个决定了。

枚举的三个要点:

  1. 枚举范围要完整:不能漏掉可能的答案。比如公鸡 0 到 20,不能从 1 开始(题目没说不能是 0)。
  2. 判断条件要准确 :百钱百鸡里 x % 3 == 0 很容易漏,因为小鸡是"3 只 1 元",必须成组买。
  3. 能算出来的就别枚举:这是优化的第一步。

什么时候用枚举?

  • 数据范围很小(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(全局数组默认零),相当于棋盘外面当作白格。这正好符合题意。但如果你把数组开成局部的,这些位置就是垃圾值,结果全错。

模拟题的三个要点:

  1. 规则要逐条落实:题目说的每一个"如果......就......",你都得有一行代码对应。漏了任何一条都会错。
  2. 注意"同时":需要同时更新的,用双数组。
  3. 注意边界:数组外面怎么处理,要明确。通常是把边界外当作 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 分。

老手的做法是:

  1. 5 分钟内想出暴力,立刻写出来,拿到 30~40 分;
  2. 然后开始想优化,想到了就改,想不到就交暴力。

分数是累加的,不是只有满分才算成功。 竞赛里 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. 用循环打印 1~100 中所有能被 3 或 5 整除但不能被 15 整除的数;
  2. 读入 n 个数,输出它们的平均值(保留两位小数);
  3. 判断一个字符串是否为回文;
  4. 输出 n 行的乘法表;
  5. 求 1! + 2! + ... + n!(注意溢出);
  6. 输入年月日,输出这是这一年的第几天;
  7. 输入一个 n×n 的矩阵,输出它的两条对角线元素之和;
  8. 输入一串字符,统计大写字母、小写字母、数字、其他字符各多少个;
  9. 判断一个数是否为完全数(所有真因子之和等于它本身);
  10. 输出 1~1000 中所有的"各位数字立方和等于本身"的数。

15.3 练习(这些题会直接对应 CSP-J 的基础题)

练习 A(简单)

  1. 输入三个整数,按从小到大输出。
  2. 输入一个整数 n,输出 n 的所有约数。
  3. 输入 n 个数,输出其中第二大的数。
  4. 输入一个字符串,把它反转后输出。
  5. 计算 1 到 n 之间所有奇数的和、所有偶数的和。

练习 B(中等)

  1. 输入 n,输出前 n 项的斐波那契数列(用循环,不用递归)。
  2. 输入一个十进制数,输出它的二进制表示(提示:用数组存余数,再倒着输出)。
  3. 输入 n 个学生的成绩,输出最高分、最低分、平均分,以及高于平均分的人数。
  4. 输入一个 3×3 的矩阵,判断它是不是"魔方阵"(每行、每列、两条对角线的和都相等)。
  5. 输入两个字符串,判断第二个是不是第一个的子串,是的话输出首次出现的位置。

练习 C(挑战)

  1. 输入 n(n ≤ 10^6),输出 1~n 中所有的素数。试试你的程序要跑多久。
  2. 输入一个由括号 () [] {} 组成的字符串,判断括号是否匹配。(提示:用一个数组当栈,第二篇会正式讲 stack)
  3. 输入 n 个数,输出其中出现次数最多的数。如果最多的有多个,输出最小的那个。
  4. 输入一个正整数,输出它各位数字之和,反复操作直到结果是一位数(数根问题)。
  5. 打印杨辉三角的前 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];
相关推荐
Pniubi1 小时前
力扣438.找到字符串中所有字母异位词
算法·leetcode·职场和发展
zjxtxdy2 小时前
C++面向对象程序开发-类与对象
开发语言·c++
東隅已逝,桑榆非晚3 小时前
算法6. 双指针法-查找总价格为目标的两个商品
c++·学习·算法
Eyfcom4 小时前
〖物联网终端实战 01〗UWB 室内高精度定位:仓储 AGV 与人员的厘米级轨迹追踪
物联网·算法·物流系统·快递系统·跨境物流快递系统·物联网终端实战
qq_406981034 小时前
c++拷贝构造函数
开发语言·c++
bro_Java6664 小时前
《排序的底层逻辑:比较、交换与分治》
java·算法·排序算法
爱喝雪碧的可乐5 小时前
MATLAB
开发语言·算法·matlab
叼烟扛炮5 小时前
Linux 学习笔记(六十):单线程的逆袭——epoll 高并发服务器实战
linux·服务器·网络·笔记·学习·算法
careathers6 小时前
【数据结构】基于比较的排序算法 - 交换排序
数据结构·算法·排序算法