C++ 基础语法教程:从 Hello World 到指针

C++ 基础语法教程:从 Hello World 到指针

阅读前准备好两件事

  1. 装好 IDE:还没装的话先看《IDE 选择与配置教》(CLion / VS Code / Dev-C++ 三选一,装好 MinGW 编译器、能跑通第一个程序再回来)。
  2. 注册好洛谷luogu.com.cn 注册账号(做题和提交都要用)。

🚀 第一次提交:打开题目页(比如 P1001)→ 把代码复制进提交框 → 选 C++ 语言 → 点提交。结果含义:AC 是通过,WA 是答案错误,RE 是运行时错误(多半是数组越界或除以 0),TLE 是超时(多半是算法太慢),CE 是编译错误(多半是语法写错)。


目录


第 1 章 程序骨架与输出

本章地图

  • 学什么:程序长什么样、cout 输出、字符串、注释、编译错误长什么样
  • 前置知识:无(需要先装好 IDE,见《IDE 选择与配置教程》)
  • 配套题目:例题 1 P1000 超级玛丽游戏

1.1 程序是什么

程序就是按顺序执行的一串指令。你写的代码(源码)经过编译变成电脑能直接执行的文件,运行它,指令就一条一条被执行。

C++ 程序的源码放在后缀为 .cpp 的文件里。所有 C++ 程序,都长成下面这个样子。

1.2 第一个程序

打开你的 IDE,新建一个源代码文件,先保存成后缀为 .cpp 的文件(比如 hello.cpp),然后输入下面这整个程序:

#include <iostream>
using namespace std;

int main() {
    cout << "Hello World!" << endl;
    return 0;
}

点 IDE 上的"编译运行"按钮。如果一切正常,屏幕上会显示:

Hello World!

🖐️ 用 VS Code 的同学注意:VS Code 默认没有"编译运行"按钮,需要先装扩展并配置好编译器(《IDE 选择与配置》里有详细步骤)。嫌麻烦可以先装 Dev-C++ 起步,熟悉了再换,或者使用Clion。

🖐️ 先照抄,别慌:程序里有几行你现在看不懂?没关系。第一次写程序就是"先照着抄、跑起来、再理解"。1.5 节会把每一行都解释清楚。卡在这一步过不去的人,九成是抄错字符(少了分号、引号没配对、把 cout 打成 cour),对照上面逐字检查一定能过。

恭喜,你的第一个程序跑起来了!整个程序里,最关键的是这行输出语句,我们先把它拆开看:

  • cout:读作 "see-out",是 C++ 里负责输出(把内容显示到屏幕上)的工具。c 代表 character(字符),out 是输出。
  • <<:把右边的数据送进 cout,由它显示到屏幕上。可以连续用多个:cout << "a" << "b" << endl; 会输出 ab
  • "Hello World!":双引号括起来的内容叫字符串(string),就是一段文字。双引号里的内容会原样输出,包括空格和标点。
  • endl:end line 的缩写,意思是换行——输出完文字后光标移到下一行。(题解里也常写 '\n'(反斜杠加字母 n),效果一样是换行,以后看到别慌。)
  • 行尾的 ;分号。在 C++ 里,一条指令(一条语句)结束时必须写分号,就像中文句子以句号结束。忘写分号是新手最常见的编译错误。

💡 顺便认识一个高频词:编译。把代码"翻译"成电脑能直接执行的语言的过程叫编译,这个动作由你点的"编译运行"按钮完成;编译过程中发现的问题(比如忘写分号)叫编译错误,IDE 会高亮提示你错在哪一行。

剩下的几行——#include <iostream>using namespace std;int main()return 0;——是每个 C++ 程序都有的固定骨架,先照着抄就行,1.5 节会逐行解释。

🖐️ 动手试试:把双引号里的文字改成你的名字(如果用中文名字显示乱码,是文件编码问题,先用英文名字试即可),再运行一次。再把 << endl包括它前面的 <<)删掉,再运行一次,观察输出有什么变化(应该会变成不换行)。

1.3 例题 1 P1000 超级玛丽游戏

把刚才学的用起来。洛谷 P1000 超级玛丽游戏,题目非常"简单粗暴":

无输入,用字符画的形式原样输出一个超级玛丽场景。字符画长这样(截取前几行):

                ********
               ************
               ####....#.
             #..###.....##....
             ###.......######              ###            ###

(完整图形很长,这里只展示开头,去洛谷原题页面能看到完整版)

卡点分析:这道题不需要任何输入,不需要任何计算,只需要把 22 行字符画一行一行"念"出来。这正是 cout 最擅长的活——每输出一行字符画,就是一条 cout 语句

1.4 输出字符画

思路:字符画有几行,我们就写几条 cout。比如前两行可以这样写:

cout << "                ********" << endl;
cout << "               ************" << endl;

关键细节:字符画每一行开头的空格也必须原样保留!cout 输出字符串时,双引号里的每个字符(包括空格)都会一字不差地显示出来。很多新手第一遍提交 WA(答案错误),就是因为复制字符画时把行首空格弄丢了。

完整参考代码(建议先自己敲,实在卡住再看):

#include <iostream>
using namespace std;

int main() {
    cout << "                ********" << endl;
    cout << "               ************" << endl;
    cout << "               ####....#." << endl;
    cout << "             #..###.....##...." << endl;
    cout << "             ###.......######              ###            ###" << endl;
    cout << "                ...........               #...#          #...#" << endl;
    cout << "               ##*#######                 #.#.#          #.#.#" << endl;
    cout << "            ####*******######             #.#.#          #.#.#" << endl;
    cout << "           ...#***.****.*###....          #...#          #...#" << endl;
    cout << "           ....**********##.....           ###            ###" << endl;
    cout << "           ....****    *****...." << endl;
    cout << "             ####        ####" << endl;
    cout << "           ######        ######" << endl;
    cout << "##############################################################" << endl;
    cout << "#...#......#.##...#......#.##...#......#.##------------------#" << endl;
    cout << "###########################################------------------#" << endl;
    cout << "#..#....#....##..#....#....##..#....#....#####################" << endl;
    cout << "##########################################    #----------#" << endl;
    cout << "#.....#......##.....#......##.....#......#    #----------#" << endl;
    cout << "##########################################    #----------#" << endl;
    cout << "#.#..#....#..##.#..#....#..##.#..#....#..#    #----------#" << endl;
    cout << "##########################################    ############" << endl;
    return 0;
}

🖐️ 强烈建议:这 22 行不要复制,自己对着洛谷的字符画一行一行敲。虽然很枯燥,但这是你第一次"精确抄写",能帮你建立对空格、引号、分号的敏感度——这些细节以后会天天折磨你,现在练好不亏。

1.5 程序骨架

你 1.2 节照抄的程序里,除了 cout 那一行,还有几行"看起来不像人话"的东西。它们是每个 C++ 程序都有的固定结构:

#include <iostream>   // 第 1 行:引入输入输出库
using namespace std;  // 第 2 行:声明使用标准命名空间

int main() {          // 第 3 行:程序的主入口
    // ... 你的指令写在这里 ...
    return 0;         // 程序正常结束,返回 0
}

逐个解释:

  • #include <iostream>iostream 是 C++ 自带的输入输出库(库可以理解为别人写好的、打包好的功能集合),cout 就在这个库里。#include 的意思是"把这个库引入我的程序"。以后我们还会引入别的库(比如数学库 cmath)。用到哪个库才 include 哪个库,用不到的不用管。
  • using namespace std;:C++ 的现成功能大多住在 std 这个命名空间(可以理解为"名字的抽屉")里,这句话的意思是"我下面写的名字,默认都去 std 里找"。现在不用理解原理,照着写就行。(题解里也常见 std::cout 的写法,意思一样,只是把名字写全。)
  • int main() { ... }函数是"一组打包好的指令"。main 是特殊的函数——程序从 main 的第一行开始执行,执行到最后一行的 } 结束。一对花括号 { } 把属于 main 的指令圈起来。函数的具体知识第 9 章才讲,现在你只需要知道:你的指令都写在 main 的花括号里。
  • return 0;:告诉系统"程序正常跑完了"。写不写一般不影响判题,但建议养成习惯写上。

分号 ; 和换行:每条完整语句(一个动作)末尾都要写分号 ;,它是“这句话说完了”的句号。换行只是排版——C++ 不在乎一行写几条语句(a = 1; b = 2; 写同一行合法但难读),也不在乎一条语句占几行。惯例:一条语句一行,读起来清楚,报错也好找行号。

注释:代码里 // 开头的内容是注释,给人看的,电脑会直接忽略。写注释是很好的习惯,以后看自己旧代码时会感谢当时的自己。

⚠️ 新手三大编译错误(看到报错别慌,多半是这三种):

  1. 语句末尾忘写分号 ;
  2. 双引号只写了一边(" 不配对)
  3. 花括号 { } 不配对(少了一个 }

1.6 本章小结

知识点 一句话记忆
cout << 内容 << endl; 输出内容并换行
字符串 "..." 双引号包住的文字,原样输出
endl / '\n' 换行
; 语句结束符,忘写必报错
#include <iostream> 引入输入输出库
int main() { ... } 程序入口,指令写在花括号里
// 注释,给人看的

练手:不急着刷题,先在本地把 P1000 独立敲一遍并 AC。这是唯一一道"不用动脑、纯练手速和细心"的题,过不了多半是行首空格或引号丢了,对照题面再敲一遍即可。


第 2 章 变量与输入

本章地图

  • 学什么:变量(存数据的盒子)、cin 输入、表达式、int 溢出
  • 前置知识:第 1 章
  • 配套题目:例题 2 P1001、例题 3 P3954、例题 4 P1425

2.1 例题 2 P1001 A+B Problem

洛谷的"元老级"题目,几乎所有 OJ 的入门第一题:

输入两个整数 a、b,输出它们的和。

卡点分析:程序要学会"读数据"——数据不是写死在代码里的,而是程序运行时从键盘输入。读进来的数要存起来才能用,这就用到变量

我们分步写。第一步,先搭好程序骨架,声明两个变量:

#include <iostream>
using namespace std;

int main() {
    int a, b;   // 声明两个 int(整数)类型的变量
    return 0;
}

第二步,把键盘输入的两个数读进变量。cincout 的兄弟,负责输入>> 方向朝右,表示"把输入的数据送进变量":

#include <iostream>
using namespace std;

int main() {
    int a, b;
    cin >> a >> b;   // 读入两个整数,分别存进 a 和 b
    return 0;
}

第三步,计算并输出。由数据、变量和运算符组成的、能算出一个结果的式子叫表达式——a + b 算出来就是两数之和,cout 可以直接输出表达式的值:

#include <iostream>
using namespace std;

int main() {
    int a, b;
    cin >> a >> b;
    cout << a + b << endl;   // 输出 a+b 的结果并换行
    return 0;
}

拿去提交,样例 1 2 会输出 3,AC。

💡 从这段代码开始,你以后写的大部分程序都是这个套路:读入 → 计算 → 输出

2.2 变量:贴了标签的盒子

变量是"存数据的盒子"。声明一个变量,就是拿一个盒子贴上个名字,规定它能装什么类型的数据:

int a;        // 一个装整数的盒子,名字叫 a
int b = 10;   // 声明的同时放进去一个 10(这叫“初始化”)

(一次声明多个变量可以写一行,用逗号隔开,共用最后一个分号int a, b; 就是声明 a、b 两个盒子。)

  • 类型决定盒子里能装什么:int 装整数(如 3、-5、2026),double 装小数,char 装单个字符,后面第 3 章细讲。
  • 名字(变量名)由你起,规则:只能由字母、数字、下划线组成,不能以数字开头,不能和 C++ 的关键字(如 intifreturn)重名。用有意义的英文名是好习惯:存成绩就叫 score,存苹果数就叫 apple
  • 变量可以先声明后赋值,也可以在声明时初始化。用之前一定要有值,不然程序会拿"盒子里原来的灰尘"(未定义的值)去算,结果莫名其妙。

cin >> a >> b;按类型读入:aint,就按整数读;如果后面 bdouble,就按小数读。输入的数据之间用空格或换行隔开都行,cin 会自动跳过它们。

常量const int MAXN = 100; 声明一个“不能改的变量”(叫常量,名字习惯全大写)。它和普通变量一样用,只是赋值后不能再改。好处:把“写死”的数字(数组容量、范围上限)起个名字,代码更清楚,以后想改只改一处(第 7 章开数组会用到)。

2.3 提交与验证

把代码提交到洛谷后,判题系统会用多组数据测试你的程序:不仅测题面给的样例,还会测边界数据(比如很大的数、最小的数)。所以写题时多想想"极端情况",是竞赛的基本功。第 3 章讲 int 能装多大,你就会理解为什么要多想一步。

2.4 例题 3 P3954 成绩

牛牛的成绩:平时成绩占 20%、期中成绩占 30%、期末成绩占 50%。输入三个整数 a、b、c,输出总评成绩(整数)。

卡点分析:如果直接算 a*0.2 + b*0.3 + c*0.5,小数运算会有精度误差(浮点数第 4 章细讲),而且输出要整数。这里提前用到了 *(乘)和 /(除)运算符,先按字面理解:乘除和加减一样都是算术,第 3 章会系统讲。换个思路:先放大再缩小——把百分比当成整数算:总评 = (a×2 + b×3 + c×5) ÷ 10,这样全程整数运算,没有精度问题。

分步写。第一步读入三个成绩:

#include <iostream>
using namespace std;

int main() {
    int a, b, c;
    cin >> a >> b >> c;
    return 0;
}

第二步,按公式计算并输出。注意运算顺序:先算括号里的乘法和加法,最后除 10:

#include <iostream>
using namespace std;

int main() {
    int a, b, c;
    cin >> a >> b >> c;
    cout << (a * 2 + b * 3 + c * 5) / 10 << endl;
    return 0;
}

样例 100 100 100 输出 10090 90 90 输出 90

💡 整数除法 /向 0 截断(正数时就是向下取整):7 / 2 等于 3,不是 3.5。这道题里题目保证 a、b、c 都是 10 的倍数,所以 a×2+b×3+c×5 一定能被 10 整除,没有误差。

2.5 例题 4 P1425 小鱼的游泳时间

小鱼从 a 点 b 分游到当天的 c 点 d 分(c:d 一定晚于 a:b),问游了多久。输入 a b c d,输出"小时 分钟"。

卡点分析:时间相减会碰到"借位":比如 12:5013:10,直接 13-12=1 小时、10-50=-40 分钟,不对。统一办法:把时间换算成分钟再算,最后再拆回小时和分钟。

分步写。第一步读入四个数,并换算成"从 0 点起的总分钟数":

#include <iostream>
using namespace std;

int main() {
    int a, b, c, d;
    cin >> a >> b >> c >> d;
    int start = a * 60 + b;   // 开始时刻的总分钟数
    int end = c * 60 + d;     // 结束时刻的总分钟数
    return 0;
}

第二步,做差,再拆成小时和分钟。/ 取整、% 取余:

#include <iostream>
using namespace std;

int main() {
    int a, b, c, d;
    cin >> a >> b >> c >> d;
    int start = a * 60 + b;
    int end = c * 60 + d;
    int diff = end - start;      // 总分钟差
    cout << diff / 60 << " " << diff % 60 << endl;
    return 0;
}

样例 12 50 19 10:start=770,end=1150,diff=380,输出 6 20

💡 %取余运算符:380 % 60 等于 20(380 除以 60 余 20)。“除出小时、余出分钟”是时间题的标准套路。* / % 第 3 章会系统讲,这里先用着。

2.6 int 溢出

int 能装的整数范围是 -2147483648 ~ 2147483647(约 -21 亿到 21 亿)。算出来的结果超过这个范围,就叫溢出——变量会"装不下",结果变成一团乱码数字,而且编译器不报错,最难排查。

比如 100000 * 100000 应该等于 100 亿,但超出 int 范围,程序会输出一个完全对不上的数(实测是 1410065408)。怎么防:预感数字可能很大时,把变量声明成 long long(范围约 ±9×10^18),第 3 章细讲。

⚠️ 动手试试:写个程序输出 100000 * 100000,看看会发生什么,感受一下"溢出"。

2.7 本章小结

知识点 一句话记忆
int a, b; 声明整数变量
cin >> a >> b; 读入数据,方向朝右
cout << a + b << endl; 输出表达式的结果
变量名规则 字母数字下划线,不能数字开头
a * 2 + b * 3 表达式,先乘除后加减,括号优先
/ % 整除向 0 截断 / 取余数
溢出 int 最大约 21 亿,不够用换 long long

练手:重写 P1001(不看教程),独立完成 P3954P1425,全部 AC 后再进第 3 章。

第 3 章 数据类型与运算符

本章地图

  • 学什么:五种基本类型、算术运算符、类型转换、位运算(认识即可)
  • 前置知识:第 2 章
  • 配套题目:例题 5 P5703、例题 6 P5704

3.1 五种基本类型

C++ 最常用的五种基本类型:

类型 装什么 例子 范围(大概)
int 整数 3、-5、2026 约 -21 亿 ~ 21 亿
long long 大整数 100000000000 约 ±9×10^18
double 小数 3.14、-0.5 约 15~16 位有效数字
char 单个字符 'a'、'5'、'\n' 一个字符
bool 真/假 true、false 二选一

另外还有一个"常用组合":字符串 string,装一段文字,第 8 章专门讲。

选类型的原则:预估最大结果,再留余量。题目数据范围写着"不超过 10^9",两数相乘就可能到 10^18,int 装不下,直接用 long long。宁可大一点,不要溢出。

3.2 算术运算符

运算符 意思 例子
+ - * 加 减 乘 3 + 2 = 5
/ 整除(整数除法向 0 截断) 7 / 2 = 3
% 取余 7 % 2 = 1
+= -= *= /= 复合赋值 a += 3 等价 a = a + 3
++ -- 自增 自减 a++ 等价 a = a + 1

运算顺序(从高到低,只列常用的):括号 ( ) → 乘除取余 * / % → 加减 + - → 赋值 =。同级从左往右。拿不准就加括号,不丢人。

a++++a 有区别a++ 是“先用 a 现在的值,再自增”;++a 是“先自增,再用新值”。单独写一行(a++;)时没区别,但嵌在表达式/条件里就有区别:a[cnt++] = x 是先存到 a[cnt]、再把 cnt 加 1;while (q--) 是先看 q 是不是 0、再减 1。后面例题会用到,先记住这个规则。

⚠️ 两个高频坑:

  1. 整数除以整数得整数5 / 2 是 2,不是 2.5。想要小数,得让至少一边是小数:5.0 / 25 / 2.0
  2. 取余两边是整数5.5 % 2 编译报错,% 只用于整数。

3.3 位运算(认识即可)

电脑里所有整数最终都是二进制存的(一串 0 和 1)。直接对这串 0/1 进行运算就叫位运算。常见六个:

运算符 名字 规则 例子
& 按位与 两位都是 1 结果才是 1 5 & 3 = 1
\| 按位或 有一位是 1 结果就是 1 5 \| 3 = 7
^ 按位异或 两位不同结果才是 1 5 ^ 3 = 6
~ 按位取反 0 变 1,1 变 0 ~5 = -6
<< 左移 所有位往左移,右边补 0,等价乘 2 5 << 1 = 10
>> 右移 所有位往右移,等价除以 2 5 >> 1 = 2

怎么算的:5 的二进制是 101,3 是 011。逐位看:101 & 011 = 001 = 1;101 | 011 = 111 = 7;101 ^ 011 = 110 = 6。

现在用得上的一招:判断奇偶——x % 2 == 1 可以写成 x & 1(对非负数两者等价;负数时 x & 1 更可靠),位运算比取余快一点。其余位运算先认识、会算即可,后面学状态压缩等算法时再深入。

3.4 例题 5 P5703 苹果采购

输入两个整数 a、b(不超过 10^9),输出 a × b。

卡点分析:数据范围写着“输入不超过 10^9”——两个 10^9 级别的数相乘最坏到 10^18,int 装不下。虽然本题保证答案在 int 范围内,但既然范围给到 10^9 就该警惕,直接用 long long 最稳。养成先看数据范围、拿不准就上 long long 的习惯。

分步写。第一步,用 long long 声明并读入:

#include <iostream>
using namespace std;

int main() {
    long long a, b;
    cin >> a >> b;
    return 0;
}

第二步,输出乘积:

#include <iostream>
using namespace std;

int main() {
    long long a, b;
    cin >> a >> b;
    cout << a * b << endl;
    return 0;
}

样例 5 3 输出 15。如果这里用 int,数据一大就溢出,WA 得莫名其妙。

3.5 例题 6 P5704 字母转换

输入一个小写字母,输出它对应的大写字母。

卡点分析:字符怎么"变大写"?字符在电脑里其实是整数(ASCII 码):'a' 是 97,'A' 是 65,'b' 是 98,'B' 是 66……规律是同一个字母大小写相差 32。所以 'a' 转 'A':减去 32。

分步写。第一步,读入一个字符:

#include <iostream>
using namespace std;

int main() {
    char c;
    cin >> c;   // 读入单个字符
    return 0;
}

第二步,输出转换结果。字符可以参与算术运算(当整数用),减完 32 再用 char 转回字符输出:

#include <iostream>
using namespace std;

int main() {
    char c;
    cin >> c;
    cout << char(c - 32) << endl;
    return 0;
}

样例 a 输出 A

💡 ASCII 表:记住三个关键值就够:'0'=48,'A'=65,'a'=97。其他都可以推。附录有完整表。

3.6 类型转换

  • 隐式转换:运算时类型会自动"就高不就低"。5 / 2.0 中 int 自动变 double,结果是 2.5。int a = 3.99; 会把小数截断成 3(不四舍五入)。
  • 显式转换(强转):用 类型(值)(类型)值 明确转换。上面例题里的 char(c - 32) 就是显式转换——c - 32 算出来是 int(97-32=65),再用 char() 包一层告诉编译器"我要把它当字符输出"。

3.7 本章小结

知识点 一句话记忆
long long int 装不下的大整数用这个
double 小数类型
char 字符,本质是 ASCII 整数
5 / 2 整数除法 = 2,要小数就写 5.0/2
% 取余,只用于整数
a += 3 复合赋值,等价 a = a + 3
& \| ^ ~ << >> 位运算,认识即可,x & 1 判奇偶
char(c - 32) 显式类型转换

练手:重写 P5703P5704,再自己验证几个 ASCII 转换(本地跑着看结果就行,不用提交):输入大写输出小写(加 32),输入数字字符输出它的数值(减 48,'5' - '0' = 5)。


第 4 章 浮点数与输出格式

本章地图

  • 学什么:double 小数运算、fixed + setprecision 控制输出、scanf/printf 输入输出、向上取整技巧
  • 前置知识:第 3 章
  • 配套题目:例题 7 P5706、例题 8 P5708、例题 9 P5709、练习 P5707

4.1 例题 7 P5706 再分肥宅水

一瓶 t 毫升的肥宅水,平均分给 n 个人,每人能喝多少毫升(保留 3 位小数)?需要多少个杯子(每人一个,还要再买 n 个备用)?

卡点分析t / n 是小数,要输出保留 3 位小数cout 默认只显示 6 位有效数字、还会用科学计数法(比如 1.66667e+02),不符合要求。需要手动控制格式。

分步写。第一步,读入并计算:

#include <iostream>
using namespace std;

int main() {
    double t;
    int n;
    cin >> t >> n;
    return 0;
}

第二步,输出。fixed 表示"固定小数位",setprecision(3) 表示"保留 3 位小数",合起来就是保留 3 位小数setprecision<iomanip> 库(iomanip = io manipulation,输入输出控制)里,要 include 它:

#include <iostream>
#include <iomanip>
using namespace std;

int main() {
    double t;
    int n;
    cin >> t >> n;
    cout << fixed << setprecision(3) << t / n << endl;
    cout << 2 * n << endl;   // 杯子数:每人一个 + 备用 n 个
    return 0;
}

样例 500 3:第一行输出 166.667,第二行输出 6

⚠️ fixed << setprecision(3) 一旦设置,后面的所有输出都生效,直到你改回去。

4.2 浮点数的精度坑

double 是“近似”存小数的(二进制表示不了一些十进制小数,比如 0.1——就像十进制写 1/3 只能写成 0.333…,0.1 在二进制里也是无限循环小数,只能存个近似值),所以浮点数比较相等是不靠谱的0.1 + 0.2 == 0.3 在 C++ 里是 false!

判断两个浮点数“相等”的标准写法是看差的绝对值足够小if 是“如果”,先照抄,第 5 章正式讲):

if (abs(a - b) < 1e-9)   // 差小于 10 的 -9 次方,就认为相等

1e-9 是科学计数法写法,等于 0.000000001。abs(取绝对值)在 <cmath> 库(math = 数学)里,记得 include。

做题时优先避免浮点:能用整数算就全程整数(比如第 2 章成绩那题)。实在避不开(比如开根号),就用 double,输出时按题目要求保留位数,不要直接比较相等。

4.3 例题 8 P5708 三角形面积

输入三角形的三条边 a、b、c,用海伦公式求面积,保留 1 位小数。
海伦公式:$p = \frac{a+b+c}{2}$,面积 $s = \sqrt{p(p-a)(p-b)(p-c)}$

卡点分析:要开根号,必须用 double 和 sqrt。这题顺便第一次用 scanf/printf——先照抄,4.4 节正式讲。

分步写。第一步,读入三条边。这里用 scanf 读三个 double:

#include <iostream>
#include <cstdio>
#include <cmath>
using namespace std;

int main() {
    double a, b, c;
    scanf("%lf%lf%lf", &a, &b, &c);
    return 0;
}

第二步,套海伦公式。sqrt<cmath> 里,取平方根。注意算 p 时先算括号里的加法再除 2:

#include <iostream>
#include <cstdio>
#include <cmath>
using namespace std;

int main() {
    double a, b, c;
    scanf("%lf%lf%lf", &a, &b, &c);
    double p = (a + b + c) / 2;
    double s = sqrt(p * (p - a) * (p - b) * (p - c));
    printf("%.1lf\n", s);
    return 0;
}

样例 3 4 5:p=6,s=sqrt(6×3×2×1)=sqrt(36)=6,输出 6.0

4.4 scanf / printf:为什么题解都在用

cin/cout 写起来方便,但速度慢。数据量一大(比如 10 万个数),cin/cout 可能超时。竞赛题解里常见的是 C 语言的 scanf/printf,速度快得多。

cin / cout scanf / printf
读入 cin >> a; scanf("%d", &a);
输出 cout << a; printf("%d", a);
速度 慢(大数据可能 TLE)

常用格式符(写在双引号里):

格式 对应类型 例子
%d int printf("%d", a);
%lld long long printf("%lld", x);
%lf double scanf("%lf", &d);
%c char scanf("%c", &c);
%s char 数组(第 8 章) scanf("%s", s);
%.1lf / %.3lf double 保留 1/3 位小数 printf("%.1lf", s);
%02d int 输出两位,不足补 0 printf("%02d", 7) 输出 07

三个注意点:

  1. scanf 的 & 不能丢scanf("%d", &a) 里的 & 是"取地址"(第 10 章细讲),表示"把读到的数放到 a 的地址上"。忘了 & 程序会崩溃或乱读。
  2. printf 不需要 &:直接传值。
  3. cin/cout 和 scanf/printf 别混用:两套系统默认不同步,混用会出诡异错误。选一套用到底

如果坚持用 cin/cout(省事),在 main 开头加两行让它变快,就跟 scanf/printf 一样快了:

ios::sync_with_stdio(false);   // 关掉和 C 的同步,提速
cin.tie(0);                    // 解除绑定,提速

🖐️ 这两行是竞赛必背,以后你写每个程序都会带上。想用哪套就用哪套,但别混

4.5 例题 9 P5709 苹果和虫子

八尾勇有 m 个苹果,吃一个需要 t 分钟。过了 s 分钟,问还剩几个完整的苹果(正在吃的半个不算)?

卡点分析:吃掉的完整苹果数 = 向上取整(s / t)。比如 205 分钟、10 分钟一个,吃完了 20 个、正在吃第 21 个(半个),完整苹果少 21 个。还有三个坑:

  1. t 可能等于 0(吃一个不用时间)——除以 0 会 RE,要特判;
  2. s 可能很大,苹果可能全被吃完甚至"欠"——答案不能是负数,要和 0 取最大;
  3. 向上取整的整数写法:(s + t - 1) / t(不用浮点函数,纯整数一步到位)。

分步写。第一步,读入并特判 t=0(这里第一次出现 if,先照抄字面意思,第 5 章正式讲分支):

#include <iostream>
using namespace std;

int main() {
    int m, t, s;
    cin >> m >> t >> s;
    if (t == 0) {          // 吃一个不用时间:全吃完
        cout << 0 << endl;
        return 0;
    }
    return 0;
}

第二步,算完整苹果数并处理负数:

#include <iostream>
using namespace std;

int main() {
    int m, t, s;
    cin >> m >> t >> s;
    if (t == 0) {
        cout << 0 << endl;
        return 0;
    }
    int eaten = (s + t - 1) / t;   // 向上取整:吃掉(含正在吃的)的个数
    int ans = m - eaten;
    if (ans < 0) ans = 0;          // 不够吃,剩 0 个
    cout << ans << endl;
    return 0;
}

样例 50 10 200:eaten = (200+9)/10 = 20,ans = 30,输出 30

💡 向上取整技巧 (a + b - 1) / b:两个整数 a、b(b>0),算"a 除以 b 向上取整"。原理:a/b 向下取整,加上 b-1 后再除,正好把有余数的情况"顶上去"。这个技巧后面做题会反复用。

4.6 本章小结

知识点 一句话记忆
cout << fixed << setprecision(3) 保留 3 位小数输出
scanf("%d", &a) / printf("%d", a) C 风格输入输出,快,别忘 &
两套 IO 别混用 cin/cout 或 scanf/printf 选一套
ios::sync_with_stdio(false); cin.tie(0); cin/cout 提速两行咒语
(a + b - 1) / b 整数向上取整
浮点不比相等 abs(a-b) < 1e-9
sqrt / abs <cmath>

练手:完成 P5707 上学迟到(⭐ 必做,提示:总时间 = 向上取整(s/v) + 10 分钟;8:00 是第 480 分钟,往前倒推 t = 480 - 总时间,可能倒到前一天——若 t 为负就加 1440(直接取模 1440 时负数会得负值,C++ 的 % 对负数是负的),再拆成 HH:MM,用 printf("%02d:%02d", h, m) 补零)。再做 P5706P5708 的 scanf/printf 版,练熟两套 IO。

第 5 章 分支结构

本章地图

  • 学什么:比较运算符、if / else if / else 分支、逻辑运算、switch
  • 前置知识:第 2~4 章(变量、类型、算术、浮点)
  • 配套题目:例题 10 P5711、例题 11 P5710、例题 12 P5714、例题 13 P5716、练习 P5715、练习 P5717

5.1 比较运算符与 if / else

程序默认是从上往下一条一条执行的。但现实里经常要"看情况办事":成绩及格显示"过"、不及格显示"挂";年份是闰年输出 1、不是输出 0。分支就是让程序"根据条件走不同的路"。

先认识比较运算符——比较两个值,结果是"真"(成立)或"假"(不成立):

运算符 意思 例子
== 等于 a == 5:a 正好等于 5
!= 不等于 a != 5:a 不是 5
< > 小于 / 大于 a < 5
<= >= 小于等于 / 大于等于 a <= 5

⚠️ === 别搞混== 是"判断是否相等",= 是"赋值"。if (a == 1) 判断 a 是不是 1;if (a = 1) 是把 1 赋给 a(结果恒为真,经典 bug,编译器通常只给个警告)。

if 的写法:

if (条件) {
    // 条件成立(真)时,执行这里的语句
}
  • 条件 是一个判断式子(比较运算,或 5.3 的逻辑运算),成立叫"真",不成立叫"假"。
  • 条件成立 → 执行花括号里的语句,然后继续往下走;不成立 → 整块跳过,直接往下走。

if / else:加上 else,"条件不成立时做另一件事":

if (条件) {
    // 条件成立时执行
} else {
    // 条件不成立时执行
}

两条路必走一条:为真走 if,为假走 else,然后汇合继续往下。

走一遍真实的小程序:

int x;
cin >> x;
if (x > 0) {
    cout << "正数";
} else {
    cout << "非正数";
}
cout << " 结束" << endl;

输入 5x > 0 成立 → 执行 if 的花括号,输出“正数” → 跳过 else 的花括号 → 输出“ 结束”。输入 -2x > 0 不成立 → 跳过 if 的花括号 → 执行 else 的花括号,输出“非正数” → 输出“ 结束”。一句话:成立就进去执行,不成立就整块跳过

条件不一定非写比较式:存了真/假的 bool 变量可以直接当条件(if (leap) 就是“如果 leap 为真”);整数也可以(0 是假,非 0 都是真)。

花括号的作用:把“属于这个分支的多条语句”圈在一起。单条语句可以省略花括号if (a > 0) cout << a; 合法),但新手强烈建议永远写花括号——看这个经典坑:

if (a > 0)
    cout << a;
cout << b;   // ❌ 这行不在 if 里!缩进只是给人看的,程序只认花括号

以为 cout << b 也在 if 里?不是——它无条件执行。加上花括号就不会搞错。

5.2 例题 10 P5711 闰年判断

输入一个年份 y,如果是闰年输出 1,否则输出 0。闰年规则:能被 4 整除但不能被 100 整除,或者能被 400 整除。

卡点分析:规则是"或者"连接的两个条件,每个条件内部又是"并且"——正好用上刚学的比较运算符,加上 5.3 节的逻辑运算(&& 是"并且"、|| 是"或者")。先把"是不是闰年"算成一个 bool 存起来,再判断输出,逻辑清晰。

分步写。第一步,搭骨架并读入年份:

#include <iostream>
using namespace std;

int main() {
    int y;
    cin >> y;
    return 0;
}

第二步,写判断。&&|| 先按字面理解:(y % 4 == 0 && y % 100 != 0) 是"能被 4 整除且不能被 100 整除",|| (y % 400 == 0) 是"或能被 400 整除",和题面规则一一对应:

#include <iostream>
using namespace std;

int main() {
    int y;
    cin >> y;
    bool leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
    if (leap) {
        cout << 1 << endl;
    } else {
        cout << 0 << endl;
    }
    return 0;
}

样例 2000 输出 11900 输出 0

5.3 逻辑运算

判断经常不止一个条件:"x 在 0 到 10 之间"是"x > 0 并且 x < 10"。用逻辑运算符把多个条件组合起来:

运算符 名字 规则 例子
&& 两边都真才真 a > 0 && a < 10
|| 一边真就真 x < 0 || x > 100
! 真变假,假变真 !(a == 0)

真值表(把所有组合列出来,一眼看懂):

a b a && b a b !a

bool 类型的变量只装 true(真)和 false(假),正好用来存"某个条件是否成立":bool leap = (y % 4 == 0); 就是把判断结果存进变量。用 cout 输出 bool 时显示 1(真)/ 0(假)。

优先级提醒(从高到低):算术(* / % + -)> 比较(== != < >)> && > || > 赋值(=。拿不准就加括号,(a && b) || ca && b || c 清楚。⚠️ 数学里的 a < x < b 在 C++ 里不是这个意思(会先算 a < x 得 0/1,再拿它和 b 比),要写成 a < x && x < b

5.4 例题 11 P5710 数的性质

一些数字可能拥有以下性质:

  • 性质 1:是偶数;
  • 性质 2:大于 4 且不大于 12。

输入 x,依次输出四个 1/0:同时满足两个性质、至少满足一个、恰好满足一个、两个都不满足。

卡点分析:先把两个性质分别算成两个 bool,再组合。四个输出分别是:a && b(同时)、a || b(至少一个)、恰好一个、!a && !b(都不)。"恰好一个"是难点——可以写成 (a && !b) || (!a && b);还记得第 3 章位运算的 ^(异或)吗?bool 本质是 0/1,a ^ b 正好就是"恰好一个满足"。

分步写。第一步,读入 x,算出两个性质:

#include <iostream>
using namespace std;

int main() {
    int x;
    cin >> x;
    bool a = (x % 2 == 0);          // 性质1:偶数
    bool b = (x > 4 && x <= 12);    // 性质2
    return 0;
}

第二步,输出四个组合:

#include <iostream>
using namespace std;

int main() {
    int x;
    cin >> x;
    bool a = (x % 2 == 0);
    bool b = (x > 4 && x <= 12);
    cout << (a && b) << " ";        // 同时满足
    cout << (a || b) << " ";        // 至少一个
    cout << (a ^ b) << " ";         // 恰好一个(异或)
    cout << (!a && !b) << endl;     // 都不满足
    return 0;
}

样例 12:a=1,b=1 → 输出 1 1 0 0。样例 9:a=0,b=1 → 输出 0 1 1 0

5.5 else if 与例题 12 P5714 肥胖问题

判断的情况不止两个时,可以用 else if 一路排下去:

if (条件1) {
    // 条件1成立
} else if (条件2) {
    // 条件1不成立、条件2成立
} else if (条件3) {
    // 条件1、2都不成立、条件3成立
} else {
    // 上面都不成立
}

从上往下逐个检查,第一个成立的生效,后面的不再看。这天然适合"区间分段":分数 <60 不及格、<80 中等、<90 良好、否则优秀——每段一个条件,前面的都不成立才轮到后面的。

输入体重 m(kg)和身高 h(米),BMI = m / h²。按 BMI 分三档输出:BMI < 18.5 → 输出 Underweight;18.5 ≤ BMI < 24 → 输出 Normal;BMI ≥ 24 → 先输出 BMI 的值,换行再输出 Overweight

卡点分析:三档区间,用 if / else if / else 一路排下来。注意区间是左闭右开:18.5 算 Normal,24 算 Overweight——else if 天然满足"第一个匹配的生效",所以 18.5 落在第二个分支、24 落在第三个分支,不用写 >= <= 的复杂组合。还要注意输入的两个数都是浮点数(体重可能是 62.5),必须全用 double 读,用 int 会把 62.5 拆成 62 和 0.5 两个数,直接算错。

分步写。第一步,读入并算 BMI:

#include <iostream>
using namespace std;

int main() {
    double m, h;
    cin >> m >> h;              // 先体重(kg),再身高(米)
    double bmi = m / (h * h);   // h*h 是身高的平方
    return 0;
}

第二步,分段判断输出。BMI ≥ 24 时要输出两行:

#include <iostream>
using namespace std;

int main() {
    double m, h;
    cin >> m >> h;
    double bmi = m / (h * h);
    if (bmi < 18.5) {
        cout << "Underweight" << endl;
    } else if (bmi < 24) {
        cout << "Normal" << endl;
    } else {
        cout << bmi << endl;          // BMI 值,cout 默认精度
        cout << "Overweight" << endl;
    }
    return 0;
}

样例 70 1.72 输出 Normal100 1.68 输出 35.4308 换行 Overweight

5.6 switch(按值跳转)

switch 适合"一个整数变量取不同值,做不同的事"——比如月份 1~12 各输出不同天数、菜单选项 1~5 各执行不同操作。它像"查表跳转":

switch (变量) {
    case 值1:
        // 变量 == 值1 时执行这里
        break;
    case 值2:
        // 变量 == 值2 时执行这里
        break;
    default:
        // 所有 case 都没匹配上时执行这里
        break;
}

逐块解释:

  • switch (变量):括号里放整数(int 或 char 都可以),程序拿它的值去和下面的 case 逐个比对。
  • case 值::一个"分支入口"。变量等于这个值,就从这里开始执行。必须是写死的常量(如 1'A'),不能是变量。
  • case 里可以写多条语句:从 case 进去后一条一条往下执行,想写几行写几行,和 if 花括号里的语句一样。
  • break;“执行到这里,跳出整个 switch”。没有它,程序会穿透(fall-through)继续执行下一个 case 的内容——这通常是 bug:
switch (x) {
    case 1:
        cout << "A";      // 忘了写 break!
    case 2:
        cout << "B";
        break;
}
// x = 1 时输出 "AB":从 case 1 进去后一路往下,把 case 2 的代码也执行了
  • default::所有 case 都没匹配上时执行,相当于 if 的 else,可写可不写。

⚠️ case 里声明变量:如果要在 case 里定义变量(比如 bool leap),最好用花括号把那段包起来(case 2: { bool leap = ...; ... })——不然编译器可能报"跳过了初始化"的错。

为什么 case 1: case 3: case 5: 能连写? 这不是魔法:case 1: 后面没有语句也没有 break,程序"穿透"到 case 3: 继续找,直到 case 5: 后面才有真正的语句——所以 1、3、5 三个值共用同一段代码,等价于"1 或 3 或 5 都走这里"。

switch vs if:判断“等于某个值”用 switch 更清晰;判断范围(a > 0 && a < 10)或复杂条件只能用 if。竞赛里 switch 用得不多(if 更灵活),但读题解时常遇到,认识它不亏。

5.7 例题 13 P5716 月份天数

输入年份 y 和月份 m,输出该月有多少天(2 月要按闰年算)。

卡点分析:一个月有几种天数:31 天的大月、30 天的小月、28/29 天的 2 月。判断对象是"月份 m",正好用 switch。1、3、5、7、8、10、12 月都是 31 天,可以用 case 连写共用一段代码(5.6 讲的"穿透"用法)。

分步写。第一步,读入:

#include <iostream>
using namespace std;

int main() {
    int y, m;
    cin >> y >> m;
    return 0;
}

第二步,switch 分月:

#include <iostream>
using namespace std;

int main() {
    int y, m;
    cin >> y >> m;
    switch (m) {
        case 1: case 3: case 5: case 7:
        case 8: case 10: case 12:
            cout << 31 << endl;
            break;
        case 4: case 6: case 9: case 11:
            cout << 30 << endl;
            break;
        case 2: {
            bool leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
            cout << (leap ? 29 : 28) << endl;
            break;
        }
    }
    return 0;
}

⚠️ 为什么 case 2: 里要加花括号?因为 bool leap 是带初始化的变量声明——如果后面还有其他 case,程序跳到那些 case 时会“绕过”这个初始化,编译器会报错(“跳过了初始化”)。用花括号把 leap 圈在 case 2 的领地里,既合法又安全。case 里不声明变量时,花括号可写可不写(靠 break 划界)。

这里 leap ? 29 : 28三目运算符:条件成立取冒号左边的值,否则取右边的,等价于一行 if-else,返回一个值。样例 2000 2 输出 291900 2 输出 282026 8 输出 31

🔑 分号速记(什么时候要分号):普通语句(赋值、cout、声明)、return 后面要分号;if/for/while 的条件括号后面不写分号(写了就变成空语句,循环体没了);for 括号里用两个分号隔开三段;do-whilewhile(条件); 结尾分号(它是一条完整语句)。

5.8 本章小结

知识点 一句话记忆
if (条件) { } else if { } else { } 分支结构
== vs = 判断相等 vs 赋值,别写混
&& \|\| ! 与 / 或 / 非
bool 存"成立/不成立"
a ^ b 异或 = "恰好一个成立"
switch ... case ... break 按整数取值分支,break 别省
条件 ? A : B 三目运算符,返回 A 或 B

练手

  • P5715 三位数排序 ⭐:输入三个整数,从小到大输出(提示:两两比较交换,或三个 if 找出最小/最大/中间)
  • P5717 三角形分类 ⭐⭐:先判断能否构成三角形(两边之和大于第三边),再分锐角/直角/钝角,最后还要判等腰(至少两边相等)和等边(三边相等)——满足几个条件就输出几行(提示:先找出最长边,用勾股定理 $a^2+b^2=c^2$ 判断)

第 6 章 循环结构

本章地图

  • 学什么:for 循环、while 循环、break / continue、do-while
  • 前置知识:第 4 章(输出格式)、第 5 章(分支)
  • 配套题目:例题 14 P5719、例题 15 P5724、例题 16 P5723、练习 P5722、练习 P5721、练习 P5718

6.1 for 循环

“重复做某件事”用循环。for 是最常用的,它把“重复”拆成三个部分写在一起:

for (初始化; 条件; 更新) {
    // 循环体:条件成立就执行一次
}

三个部分用分号隔开,各管一件事:

  • 初始化:循环开始前做一次的准备,通常是“计数器从几开始”。比如 int i = 1 就是“拿一个计数器 i,从 1 开始数”。
  • 条件:每次进入循环体前先检查的判断——成立(真)就执行一次循环体,不成立(假)就整个循环结束,跳到 } 后面继续。比如 i <= n 是“只要 i 还没超过 n,就继续”。
  • 更新:每执行完一次循环体后做的收尾,通常是计数器往前走一步。比如 i++ 是“i 加 1”。

执行顺序(记住这个节奏):

初始化 → 判断条件 →(真)执行循环体 → 更新 → 判断条件 →(真)执行循环体 → 更新 → …… → 判断条件 →(假)结束

for (int i = 1; i <= 5; i++) 完整走一遍:

阶段 i 的值 判断 i <= 5 动作
初始化 1 开始
第 1 次 1 执行循环体,然后 i 变 2
第 2 次 2 执行循环体,然后 i 变 3
第 3 次 3 执行循环体,然后 i 变 4
第 4 次 4 执行循环体,然后 i 变 5
第 5 次 5 执行循环体,然后 i 变 6
第 6 次 6 循环结束,往下走

一共执行 5 次循环体。注意:条件在“执行前”判断,所以如果条件一开始就不成立(比如 i <= 0),循环体一次都不执行。

循环三要素:从哪开始(初始化)、到哪结束(条件)、怎么走(更新)。写循环前先想清楚这三件事——很多“死循环”(程序卡住不结束)都是更新忘写或条件写反。

⚠️ for 那一行的结尾没有分号for (int i = 1; i <= 5; i++); 多写一个分号,循环体就变成空的了——后面的花括号只会执行一次,i 还白白加到 6。

6.2 例题 14 P5719 分类平均

输入 n 和 k,输出 1~n 中所有能被 k 整除的数的平均值,以及所有不能被 k 整除的数的平均值,都保留 1 位小数。(题目保证两组都不为空)

卡点分析:要遍历(从第一个到最后一个,挨个把每个数访问一遍)1 到 n 的每个数,分别累加“能整除的”和“不能整除的”,最后各自除以个数。遍历用 for,累加用变量。

分步写。第一步,搭骨架,准备四个累加变量:

#include <iostream>
using namespace std;

int main() {
    int n, k;
    cin >> n >> k;
    long long sum1 = 0, sum2 = 0;   // 两组的和
    int cnt1 = 0, cnt2 = 0;         // 两组的个数
    return 0;
}

第二步,for 循环遍历 1~n,用 if 分流累加:

#include <iostream>
using namespace std;

int main() {
    int n, k;
    cin >> n >> k;
    long long sum1 = 0, sum2 = 0;
    int cnt1 = 0, cnt2 = 0;
    for (int i = 1; i <= n; i++) {
        if (i % k == 0) {
            sum1 += i;
            cnt1++;
        } else {
            sum2 += i;
            cnt2++;
        }
    }
    return 0;
}

第三步,输出平均值。注意:整数相除是整除,要先转成 double 再除;fixed << setprecision(1) 保留 1 位小数:

#include <iostream>
#include <iomanip>
using namespace std;

int main() {
    int n, k;
    cin >> n >> k;
    long long sum1 = 0, sum2 = 0;
    int cnt1 = 0, cnt2 = 0;
    for (int i = 1; i <= n; i++) {
        if (i % k == 0) {
            sum1 += i;
            cnt1++;
        } else {
            sum2 += i;
            cnt2++;
        }
    }
    cout << fixed << setprecision(1);
    cout << (double)sum1 / cnt1 << " " << (double)sum2 / cnt2 << endl;
    return 0;
}

样例 100 16:能整除的有 16,32,48,64,80,96(和 336,平均 56.0),其余平均 50.1,输出 56.0 50.1

6.3 while 循环

for 适合“次数明确”的循环;不知道要循环多少次、只知道“满足条件就继续”时,用 while

while (条件) {
    // 条件成立就执行;每执行完一次,重新判断条件
}

和 for 对照:while 只有“条件”和“循环体”两样——没有初始化、没有更新(需要的话自己在循环体里写)。所以 while 更适合“循环次数由运行时数据决定”的场景。

经典用法是读入直到某个值

int x;
cin >> x;              // 先读第一个
while (x != 0) {       // 不是 0 就处理
    // ...处理 x...
    cin >> x;          // 再读下一个(“更新”就藏在这里)
}

读一个、处理一个、再读一个……直到读进 0,条件变假,循环结束。for 能写的 while 都能写,反之亦然,看哪个顺手。

⚠️ while 最容易犯的错:忘写“让条件最终变假”的那一步(上面例子里的 cin >> x)——条件永远成立,死循环。

6.4 例题 15 P5724 求极差

输入 n 和 n 个数,输出这 n 个数中最大值减最小值。

卡点分析:"擂台法":先假设第一个数是擂主(最大和最小都是它),然后让后面的数一个一个来打擂,赢了就换擂主。这样一遍扫描就同时找到最大和最小。

分步写。第一步,读入 n 和第一个数,初始化擂台:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int x;
    cin >> x;
    int mx = x, mn = x;   // 最大值、最小值都先设为第一个数
    return 0;
}

第二步,循环读入剩下 n-1 个数,逐个打擂:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int x;
    cin >> x;
    int mx = x, mn = x;
    for (int i = 2; i <= n; i++) {
        cin >> x;
        if (x > mx) mx = x;
        if (x < mn) mn = x;
    }
    cout << mx - mn << endl;
    return 0;
}

样例 4 1 2 3 4 输出 3(4-1)。

💡 擂台法(边读边比)是省内存的经典套路:不存全部数据,读一个处理一个。第 7 章学了数组后会看到"先存起来再统一处理"的另一种思路。

6.5 break 与 continue

  • break立刻跳出当前这层循环(循环结束,去执行循环后面的代码)。⚠️ 循环套循环时,break 只跳出离它最近的那一层(6.6 例题 16 会用到);它和第 5 章 switch 里的 break 是一回事——都是“立刻停在这层,跳到这层的后面”。
  • continue跳过本次循环的剩余部分,直接进入下一次。
for (int i = 1; i <= 10; i++) {
    if (i == 3) continue;   // 跳过 3,继续 4、5...
    if (i == 7) break;      // 到 7 直接结束整个循环
    cout << i << " ";
}
// 输出:1 2 4 5 6

6.6 例题 16 P5723 质数口袋

从 2 开始,依次把质数装进口袋,直到口袋里的质数之和将要超过 L(L≤100000)。输出装进去的每个质数(每行一个),最后一行输出个数。

卡点分析:两层循环:外层从 2 开始试每个数是不是质数,内层判断质数。判断质数用"试除法":看 2 到 sqrt(x) 有没有能整除 x 的。注意两个细节:判断到 j * j <= i 就够了(超过 sqrt 就不用试了);口袋里和超过 L 就停。

分步写。第一步,读入 L,搭好外层循环框架:

#include <iostream>
using namespace std;

int main() {
    int L;
    cin >> L;
    int sum = 0;   // 口袋里的质数和
    int cnt = 0;   // 个数
    for (int i = 2; ; i++) {   // 从 2 开始逐个试
        // ...判断 i 是不是质数,是就装袋...
    }
    return 0;
}

⚠️ 这一步是中间状态:for 的条件空着(; ;),现在直接编译运行会死循环。先别运行,第二步把它补完整。

第二步,内层试除判断质数:

#include <iostream>
using namespace std;

int main() {
    int L;
    cin >> L;
    int sum = 0, cnt = 0;
    for (int i = 2; ; i++) {
        bool prime = true;
        for (int j = 2; j * j <= i; j++) {
            if (i % j == 0) {
                prime = false;
                break;        // 找到因子,不是质数,跳出内层
            }
        }
        if (!prime) continue; // 不是质数,试下一个数
        if (sum + i > L) break;  // 装不下了,结束
        sum += i;
        cnt++;
        cout << i << endl;
    }
    cout << cnt << endl;
    return 0;
}

样例 100:装入 2 3 5 7 11 13 17 19 23(和恰好 100,下一个 29 装不下),最后输出 9

⚠️ 注意 sum + i > L 用的是"将要超过",所以和恰好等于 L 时还能继续装(样例里 2+3+...+23=100 刚好装满)。判断顺序:先判断质数,再判断装不装得下,最后才装袋——三个动作别乱。

6.7 do-while(认识即可)

do-whilewhile 几乎一样,唯一区别:先执行一次循环体,再判断条件——所以至少会执行一次:

do {
    // 至少执行一次
} while (条件);   // ⚠️ 结尾这个分号不能丢!

⚠️ 结尾分号不能丢:for/while 那一行结尾不写分号,do-while 的 while(条件); 结尾必须写分号——它是这条 do-while 语句的一部分,漏了编译报错。

适用场景:操作本身要先做一次,做完看情况决定要不要继续。比如"先输出再问要不要继续"。后面学 STL 的 next_permutation(生成全排列)时,标准用法就是:

do {
    // 处理当前排列
} while (next_permutation(...));

现在知道它的存在和“至少执行一次”这个特性就行,第 7 章例题 20(冰雹猜想)会看到实际用法。

6.8 本章小结

知识点 一句话记忆
for (int i=1; i<=n; i++) 次数确定的循环
while (条件) 条件成立就一直循环
break 跳出整个循环
continue 跳到下一次循环
do { } while (条件) 至少执行一次
擂台法 边读边比,找最大/最小
试除法判质数 j*j <= i 为止,找到因子 break

练手

  • P5722 数列求和 ⭐:循环累加 1~n(不许用公式)
  • P5721 数字直角三角形 ⭐:输出数字三角形,第 i 行输出 n-i+1 个数(行数递减),每个数 2 位补前导 0(提示:用 printf("%02d", cnt),正好练第 4 章的格式)
  • P5718 找最小值 ⭐:擂台法找最小

第 7 章 数组

本章地图

  • 学什么:一维数组、二维数组、多组输入(读到 0 结束)、vector 与 sort(顺带)
  • 前置知识:第 4 章(printf 格式)、第 5~6 章(分支、循环)
  • 配套题目:例题 17 P1046、例题 18 P1428、例题 19 P1427、例题 20 P5727、例题 21 P5731、例题 22 P1059、练习 P5725、练习 P5732、练习 P5726、练习 P1047、练习 P2141
  • ⚠️ 本章是全书最重的一章,内容多,可以拆成 2~3 天学

7.1 为什么需要数组

第 6 章的擂台法"边读边比"很省内存,但有个致命局限:数据读进来就丢了。如果题目要"先全部读进来,再倒序输出"或"再统一处理",就得把数据存起来。存一串同类型数据,用数组

7.2 一维数组

int a[100];   // 声明一个能装 100 个 int 的数组,名字叫 a
  • 数组像一排编了号的盒子,编号从 0 开始:a[0]a[1]、……、a[99]。没有 a[100]!(为什么从 0 起而不是 1?第 10 章学了指针——a[i] 就是“首地址往后数 i 个”——自然就懂了,先记规则)
  • 下标就是盒子的编号:a[i] 读作“a 的第 i 号盒子”,i下标(也叫索引)。a[0] 是第一个盒子,a[1] 是第二个……所以“第 i 个元素”对应 a[i-1]。用下标能存取任意一个盒子:a[2] = 99; 是“把 99 放进 2 号盒子”,cout << a[2]; 是“把 2 号盒子里的数拿出来输出”。
  • 声明时 [100]容量(最多装几个),必须是写死的常量,不能是运行时读入的变量cin >> n; int a[n]; 不行);但可以用第 2 章学的 const 常量:const int N = 105; int a[N];——编译器知道 N 是固定值,可以当容量。给大不给小:题目说 n≤100,开 a[105],留余量防越界。
  • 用循环配合数组是标配:读入 for (int i = 0; i < n; i++) cin >> a[i];,输出同理。循环变量正好可以当下标:i=0 读进 a[0]、i=1 读进 a[1]……i=n-1 读进 a[n-1],正好 n 个数。
  • 初始化int a[100] = {}; 把每个格子都清零;int b[5] = {1, 2, 3}; 前三个填 1、2、3,剩下的自动补 0;int dx[] = {0, 1, 0, -1}; 不写容量,按大括号里元素个数自动定大小。⚠️ 局部数组不初始化的话,里面是垃圾值(随机旧数据)——用数组做计数、标记之前一定要初始化。
  • ⚠️ 数组越界:访问 a[-1]a[100](超过容量)不报编译错,但运行时可能 RE 或读出垃圾值。这是 RE 的头号原因。

7.3 例题 17 P1046 陶陶摘苹果

10 个苹果,给出每个苹果离地面的高度(100~200 之间的整数)。陶陶身高 h,站在 30 厘米的板凳上,能摘到几个苹果?(够得到的标准:苹果高度 ≤ h + 30)

卡点分析:先读入 10 个高度存进数组,再读陶陶身高,最后遍历数组数一数有多少个 ≤ h+30。数组在这里是必须的——苹果高度要等身高读进来之后才用得上。

分步写。第一步,读入 10 个高度到数组:

#include <iostream>
using namespace std;

int main() {
    int a[10];
    for (int i = 0; i < 10; i++) {
        cin >> a[i];
    }
    int h;
    cin >> h;
    return 0;
}

第二步,遍历计数:

#include <iostream>
using namespace std;

int main() {
    int a[10];
    for (int i = 0; i < 10; i++) {
        cin >> a[i];
    }
    int h;
    cin >> h;
    int reach = h + 30;
    int cnt = 0;
    for (int i = 0; i < 10; i++) {
        if (a[i] <= reach) cnt++;
    }
    cout << cnt << endl;
    return 0;
}

7.4 例题 18 P1428 小鱼比可爱

n 条鱼排成一排,输入每条鱼的可爱程度。对每条鱼,输出它左边比它不可爱(可爱程度更低)的鱼的数量。

卡点分析:对每一条鱼 i,都要回头数一遍 0~i-1 里有多少比它小的——双重循环:外层定"当前鱼",内层数"左边的鱼"。

分步写。第一步,读入 n 条鱼:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int a[105];
    for (int i = 0; i < n; i++) {
        cin >> a[i];
    }
    return 0;
}

第二步,双重循环统计。外层 i 遍历每条鱼,内层 j 数左边比 a[i] 小的:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int a[105];
    for (int i = 0; i < n; i++) {
        cin >> a[i];
    }
    for (int i = 0; i < n; i++) {
        int cnt = 0;
        for (int j = 0; j < i; j++) {
            if (a[j] < a[i]) cnt++;
        }
        cout << cnt << " ";
    }
    cout << endl;
    return 0;
}

样例 6 4 3 0 5 1 2 输出 0 0 0 3 1 2

7.5 例题 19 P1427 小鱼的数字游戏

输入一串正整数,以 0 结束。把这串数倒序输出(0 本身不输出)。

卡点分析:两个新东西:① 输入不知道有几个数,读到 0 才停——这是"多组输入"模式;② 要倒序输出,必须先全部存下来。

分步写。第一步,用 while 边读边存。cin >> x 成功读入且 x 不等于 0 就存进数组,同时用 n 记个数。为什么下标正好是 n? 第 1 个数存 a[0]、第 2 个存 a[1]……个数和下标都从 0 数起,所以存到第 n 个数时下标恰好是 n:

#include <iostream>
using namespace std;

int main() {
    int a[105];
    int n = 0;
    int x;
    while (cin >> x && x != 0) {
        a[n] = x;
        n++;
    }
    return 0;
}

第二步,倒序输出。下标从 n-1 往 0 走:

#include <iostream>
using namespace std;

int main() {
    int a[105];
    int n = 0;
    int x;
    while (cin >> x && x != 0) {
        a[n] = x;
        n++;
    }
    for (int i = n - 1; i >= 0; i--) {
        cout << a[i] << " ";
    }
    cout << endl;
    return 0;
}

样例 1 2 3 4 5 0 输出 5 4 3 2 1

💡 while (cin >> x && x != 0) 的含义:先读一个数,读到了且不是 0,就进入循环。这种"读到特定值结束"的模式,以及更常见的"一直读到文件结束"(while (cin >> x)),都是竞赛输入的标准姿势,多组数据的题全靠它。判题时输入文件读完,cin >> x 会返回"失败",循环自动结束。

7.6 例题 20 P5727 冰雹猜想

对一个正整数 n:如果是偶数就除以 2,如果是奇数就乘 3 加 1,重复直到变成 1。从最后的 1 开始,倒序输出整个过程中出现的所有数(含 n 和 1)。

卡点分析:这个过程叫"角谷猜想",任何正整数最终都会到 1。注意题面要求倒序——从 1 往前输出。正着生成序列很容易,但没法边生成边倒着输出(下一个数依赖上一个数),所以必须先把整个序列存进数组,再倒着遍历输出。这正是数组存在的意义:有些东西算完之前,你不知道要按什么顺序输出。

序列至少包含 n 本身(n=1 时序列只有 1)——"至少存一次"正好用上第 6 章的 do-while

分步写。第一步,搭骨架,读入 n,准备数组:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int a[1005];   // 存变化过程的每个数(n ≤ 100,开大点)
    int cnt = 0;   // 已存了几个数
    return 0;
}

第二步,用 do-while 边变换边存进数组。do-while 保证 n 本身一定先被存进去;while (true) 配合 if (n == 1) break; 在存完 1 之后结束:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int a[1005];
    int cnt = 0;
    do {
        a[cnt++] = n;        // 存进数组,cnt 加 1
        if (n == 1) break;
        if (n % 2 == 0) n /= 2;
        else n = n * 3 + 1;
    } while (true);
    return 0;
}

第三步,倒序输出——下标从最后一个(cnt-1)往 0 走:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int a[1005];
    int cnt = 0;
    do {
        a[cnt++] = n;
        if (n == 1) break;
        if (n % 2 == 0) n /= 2;
        else n = n * 3 + 1;
    } while (true);
    for (int i = cnt - 1; i >= 0; i--) {
        cout << a[i] << " ";
    }
    cout << endl;
    return 0;
}

样例 20:变化过程是 20 10 5 16 8 4 2 1,倒序输出 1 2 4 8 16 5 10 20;输入 1 时序列只有 1,输出 1

7.7 二维数组

一维数组是"一排盒子",二维数组是"一张表格":

int a[3][4];   // 3 行 4 列的表格,共 12 个格子
  • 访问:a[行][列],行和列都从 0 开始。a[0][0] 是左上角第一个。
  • 双重循环遍历:外层管行,内层管列。
for (int i = 0; i < 3; i++) {        // 行
    for (int j = 0; j < 4; j++) {    // 列
        cin >> a[i][j];
    }
}

矩阵、棋盘、地图,都是二维数组的天下。

7.8 例题 21 P5731 蛇形方阵

输入 n,输出 n×n 的方阵:从 1 开始,按顺时针螺旋填到 n²。每个数占 3 个字符(右对齐,printf("%3d"))。

卡点分析:填数顺序是“右 → 下 → 左 → 上 → 右……”循环转向。两个技巧:

  1. 方向数组:把四个方向写成两个数组 dx[]dy[],用下标 0~3 代表右/下/左/上,转向就是 dir = (dir + 1) % 4
  2. 撞墙(越界或格子已填)就转向。判断“格子已填”:二维数组初始全 0,填过的格子非 0。

💡 这里故意从 1 开始编号(第 0 行第 0 列空着不用),这样判断“撞墙”(nx < 1 || nx > n)更直观——下标从 0 还是从 1 开始是你的选择,写清楚就行。

分步写。第一步,读入 n,准备方向数组:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int a[10][10] = {};        // 全部初始化为 0
    int dx[] = {0, 1, 0, -1};  // 右、下、左、上 的行变化
    int dy[] = {1, 0, -1, 0};  // 右、下、左、上的列变化
    int x = 1, y = 1, dir = 0; // 从 (1,1) 出发,先向右
    return 0;
}

第二步,循环填数。每次先填当前格,再试探下一步:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    int a[10][10] = {};
    int dx[] = {0, 1, 0, -1};
    int dy[] = {1, 0, -1, 0};
    int x = 1, y = 1, dir = 0;
    for (int i = 1; i <= n * n; i++) {
        a[x][y] = i;                     // 当前格填入 i
        int nx = x + dx[dir];            // 试探下一步位置
        int ny = y + dy[dir];
        if (nx < 1 || nx > n || ny < 1 || ny > n || a[nx][ny] != 0) {
            dir = (dir + 1) % 4;         // 越界或已填:转向
            nx = x + dx[dir];
            ny = y + dy[dir];
        }
        x = nx;
        y = ny;
    }
    return 0;
}

第三步,输出。%3d 表示每个数占 3 个字符右对齐(还记得第 4 章的 printf 吗,这里正好用上):

#include <iostream>
#include <cstdio>
using namespace std;

int main() {
    int n;
    cin >> n;
    int a[10][10] = {};
    int dx[] = {0, 1, 0, -1};
    int dy[] = {1, 0, -1, 0};
    int x = 1, y = 1, dir = 0;
    for (int i = 1; i <= n * n; i++) {
        a[x][y] = i;
        int nx = x + dx[dir];
        int ny = y + dy[dir];
        if (nx < 1 || nx > n || ny < 1 || ny > n || a[nx][ny] != 0) {
            dir = (dir + 1) % 4;
            nx = x + dx[dir];
            ny = y + dy[dir];
        }
        x = nx;
        y = ny;
    }
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            printf("%3d", a[i][j]);
        }
        printf("\n");
    }
    return 0;
}

样例 n=3:

  1  2  3
  8  9  4
  7  6  5

💡 方向数组 dx/dy 是竞赛高频技巧,走迷宫、搜索、BFS 全都要用,现在记住这个模式。

7.9 vector 与 sort(顺带认识)

遇到"不知道要存多少个数据"的情况,C++ 还提供动态数组 vector——可以随时往末尾加元素,不用提前定容量:

#include <vector>
vector<int> v;        // 空的动态数组(能装 int)
v.push_back(5);       // 末尾加一个 5
v.push_back(3);       // 现在里面是 [5, 3]
v.size();             // 元素个数,现在是 2
v[0];                 // 按下标访问,是 5(下标也从 0 开始)

配套的还有排序函数 sort,一行把数组/vector 从小到大排好:

#include <algorithm>
sort(v.begin(), v.end());   // 对 vector 排序
sort(a, a + n);             // 对普通数组 a[0..n-1] 排序(a+n 是数组结尾的“尾后指针”,第 10 章讲)

v.begin()v.end()迭代器,可以理解为“指向开头/结尾的指针”,现在先照抄这个用法,第 10 章学了指针、后面学 STL 时会深入。v.size() 返回的是无符号整数,和 int 混用比较有坑,所以后面都写 (int)v.size() 强转(第 8 章细讲)。vector 和 sort 都在 <vector> / <algorithm> 库里。

7.10 例题 22 P1059 明明的随机数

生成 N 个 1~1000 的随机整数(可能有重复),要求去重从小到大输出。第一行输出去重后的个数,第二行输出这些数。

卡点分析:排序 + 去重。有两条路:① 用 sort 排序,然后遍历时跳过重复的;② 因为数值范围只有 1~1000,用"桶标记"(bool 数组记录每个数出没出现过)天然有序且去重。两条都值得会。

路线 A:vector + sort。第一步,读入全部数到 vector:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> v;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        v.push_back(x);
    }
    return 0;
}

第二步,排序 + 输出去重结果。排完序后重复的数一定相邻,遍历时"和上一个不一样才输出":

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> v;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        v.push_back(x);
    }
    sort(v.begin(), v.end());
    int cnt = 0;
    for (int i = 0; i < (int)v.size(); i++) {
        if (i == 0 || v[i] != v[i - 1]) {   // 第一个数,或和上一个不同
            cnt++;
            cout << v[i] << " ";
        }
    }
    cout << endl;
    return 0;
}

等等,第一行还要先输出个数。调整:先数 cnt,再换行输出序列。最终完整代码:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> v;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        v.push_back(x);
    }
    sort(v.begin(), v.end());
    int cnt = 0;
    for (int i = 0; i < (int)v.size(); i++) {
        if (i == 0 || v[i] != v[i - 1]) cnt++;
    }
    cout << cnt << endl;
    for (int i = 0; i < (int)v.size(); i++) {
        if (i == 0 || v[i] != v[i - 1]) {
            cout << v[i] << " ";
        }
    }
    cout << endl;
    return 0;
}

路线 B:桶标记(更简单,值得掌握)。数值范围 1~1000,开一个 bool 数组当"桶",出现过的数在桶里打勾,最后从小到大把打过勾的数全倒出来:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;
    bool appear[1005] = {};
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        appear[x] = true;
    }
    int cnt = 0;
    for (int i = 1; i <= 1000; i++) {
        if (appear[i]) cnt++;
    }
    cout << cnt << endl;
    for (int i = 1; i <= 1000; i++) {
        if (appear[i]) cout << i << " ";
    }
    cout << endl;
    return 0;
}

桶标记(计数数组)是超级常用的技巧:用"值"当下标,用"出现过没有/出现几次"当内容。以后统计字符频率、哈希思想都是它。两条路线对比:桶标记快且简单,但要求数值范围小(这里 ≤1000 完美契合);vector+sort 通用,什么数据都能排。

7.11 本章小结

知识点 一句话记忆
int a[105]; 一维数组,下标从 0 开始,容量用常量
数组越界 RE 头号原因,容量开大点
while (cin >> x && x != 0) 读到特定值/读到结束的多组输入
int a[3][4]; 二维数组=表格,双重循环遍历
方向数组 dx[] dy[] 右/下/左/上 = {0,1,0,-1} {1,0,-1,0}
vector<int> v; v.push_back(x) 动态数组,不用定容量
sort(v.begin(), v.end()) 排序,一行搞定
桶标记 用值当下标,天然有序去重

练手

  • P5725 求三角形 ⭐:先输出 n×n 正方形(2 位补 0),空一行,再输出右对齐数字三角形(提示:printf("%02d", x) 补 0;右对齐就是在行首补 2*(n-i) 个空格)
  • P5732 杨辉三角 ⭐:输出前 n 行杨辉三角(提示:每行首尾是 1,中间 a[i][j] = a[i-1][j-1] + a[i-1][j]
  • P5726 打分 ⭐⭐:n 个评委打分,去掉一个最高分一个最低分(同分也只去一个),求平均(提示:先找最大最小;平均 = (double)(总和 - 最大 - 最小) / (n - 2)先转 double 再除,直接整数除会截断小数;保留 2 位小数)
  • P1047 校门外的树 ⭐⭐:马路 0~L 每个整数点一棵树,移走 M 个区间(含端点)的树,求剩余(提示:bool 数组标记被移走的树;树总数是 L+1 棵;区间含两端点)
  • P2141 珠心算测验 ⭐⭐⭐:集合中有多少个数,恰好等于集合中另外两个不同数之和(提示:双重循环枚举两个加数,把“和”标记出来,最后数集合中哪些元素被标记了——注意只数集合里的数,别把不在集合里的“和”也数进去;每个数只计一次)

第 8 章 字符串

本章地图

  • 学什么:string 类型、getline 读整行、常用方法(substr/find/insert)、sscanf 提取数据
  • 前置知识:第 3 章(char 与 ASCII)、第 4 章(scanf/printf)、第 6 章(循环)、第 7 章(数组下标、倒序遍历套路)
  • 配套题目:例题 23 P5733、例题 24 P5705、例题 25 P5015、例题 26 P5734、例题 27 P1957、练习 P1055、练习 P1597、练习 P1308

8.1 string 基础

为什么需要 stringchar 只能装一个字符,装一段文字要一连串字符。C 风格的做法是 char 数组(char s[100]),但长度要自己管、容易越界、不能直接拼接和比较,很麻烦——C++ 提供了 string 类型,自动管理长度、随便拼随便比。

string(字符串)在 <string> 库,iostream 通常已带:

string s = "hello";
string t;
cin >> t;              // 读入一个单词(遇空格/换行停)
cout << s << t << endl; // 直接输出

s += " world";         // 拼接:s 变成 "hello world"
s.length();            // 长度:11
s.size();              // 也是长度
s[0];                  // 按下标取字符:'h'(下标从 0 开始)

字符串可以当"字符数组"用:s[i] 取第 i 个字符,配合 for 遍历:

for (int i = 0; i < (int)s.length(); i++) {
    // 处理 s[i]
}

⚠️ s.length() 返回的类型是 size_t(无符号整数),和 int 比较/做减法容易出诡异 bug,习惯性在前面加 (int) 强转,或直接用 int i < s.size() 配合强转。

8.2 例题 23 P5733 自动修正

输入一个字符串(可能含大写字母),把所有大写字母转成小写输出。

卡点分析:遍历每个字符,判断是不是大写(ASCII 在 'A'~'Z' 之间),是就加 32 变小写(第 3 章学过大小写相差 32)。也可以用 <cctype> 库的 tolower

分步写。第一步,读入字符串并遍历:

#include <iostream>
using namespace std;

int main() {
    string s;
    cin >> s;
    for (int i = 0; i < (int)s.length(); i++) {
        // 处理 s[i]
    }
    return 0;
}

第二步,大写转小写。判断 s[i] >= 'A' && s[i] <= 'Z',然后 s[i] += 32(char 参与运算按 ASCII 整数算,算完还是存回字符):

#include <iostream>
using namespace std;

int main() {
    string s;
    cin >> s;
    for (int i = 0; i < (int)s.length(); i++) {
        if (s[i] >= 'A' && s[i] <= 'Z') {
            s[i] += 32;
        }
    }
    cout << s << endl;
    return 0;
}

样例 Luogu 输出 luogu。等价写法:s[i] = tolower(s[i]);tolower<cctype> 库)。

8.3 例题 24 P5705 数字反转

输入一个不小于 100、小于 1000 且小数点后只有一位的小数(如 123.4),把它的数字顺序倒过来输出(4.321)。

卡点分析:当成字符串处理最省事——不用管小数点位置,整体倒序输出每个字符即可。倒序输出的套路第 7 章例题 19 见过:下标从 length()-1 走到 0。

分步写:

#include <iostream>
using namespace std;

int main() {
    string s;
    cin >> s;
    for (int i = (int)s.length() - 1; i >= 0; i--) {
        cout << s[i];
    }
    cout << endl;
    return 0;
}

样例 123.4 输出 4.321

8.4 getline:读一整行

cin >> s 遇到空格会停。想读"包含空格的一整行",用 getline

string line;
getline(cin, line);   // 读一整行(含空格),存进 line

⚠️ 经典大坑:getline 和 cin 混用cin >> n 读完数字后,行尾的换行符还留在缓冲区,紧接着的 getline 会先把这个换行读走,得到一个空字符串:

int n;
cin >> n;
string line;
getline(cin, line);   // ❌ line 是空的!

解决:在 getline 之前先把残留的换行吃掉:

cin >> n;
getchar();            // 吃掉换行(getchar 在 <cstdio> 里)
// 或 cin.ignore();
string line;
getline(cin, line);   // ✅ 正常读到整行

8.5 例题 25 P5015 标题统计

输入一行标题(可能含空格),统计非空格字符的个数。

卡点分析:要读整行(含空格),必须 getline;然后遍历数非空格字符。

分步写。第一步,吃掉可能残留的换行,再 getline:

#include <iostream>
using namespace std;

int main() {
    string s;
    getline(cin, s);   // 这题输入就一行,直接读
    return 0;
}

第二步,遍历统计:

#include <iostream>
using namespace std;

int main() {
    string s;
    getline(cin, s);
    int cnt = 0;
    for (int i = 0; i < (int)s.length(); i++) {
        if (s[i] != ' ') cnt++;
    }
    cout << cnt << endl;
    return 0;
}

样例 Ca 45 输出 4(C、a、4、5 四个字符,空格不算)。

8.6 string 常用方法

方法 作用 例子
s.length() / s.size() 长度 string s="abc"; s.length() = 3
s.substr(a, b) 从第 a 个字符开始取 b 个(a 从 0 起) string s="hello"; s.substr(1, 3) = "ell"
s.find(t) 找子串 t 第一次出现的位置,找不到返回 string::npos string s="hello"; s.find("ll") = 2
s.insert(a, t) 在第 a 个字符前插入 t string s="hello"; s.insert(2, "xx") = "hexxllo"
s.erase(a, b) 删除从第 a 个起的 b 个字符 string s="hello"; s.erase(1, 2) = "hlo"
s += t 尾部追加 string s="a"; s += "b"; 后 s = "ab"
to_string(x) 数字转字符串 to_string(123) = "123"
stoi(s) 字符串转 int stoi("123") = 123
s1 < s2 直接比较(字典序) string a="abc", b="abd"; a < b

⚠️ 注意:这些方法都要用变量调用(先 string s = ...s.xxx()),不能直接写在字符串字面量上("abc".length() 会编译失败);+ 拼串也不能直接加两个字面量("a" + "b" 不合法),先存进变量再操作。

⚠️ find 找不到返回 string::npos,判断时写 if (s.find(t) == string::npos)不要-1 或者省略判断,npos 是很大的数不是 -1(虽然数值上常等于 -1 的转换,但写 npos 最规范)。

8.7 例题 26 P5734 文字处理软件

初始有一段文档,接下来 q 次操作:

  • 1 str:在文档末尾追加 str
  • 2 a b:截取文档第 a 个字符开始的 b 个字符作为新文档
  • 3 a str:在文档第 a 个字符前插入 str
  • 4 str:输出 str 在文档中第一次出现的位置,找不到输出 -1

操作 1/2/3 执行后要输出当前文档,操作 4 输出位置。

卡点分析:这就是 string 方法全家桶——+=(追加)、substr(截取)、insert(插入)、find(查找)各就各位。注意 q 和初始文档分两行输入,cin >> q >> s 自动跳过换行,连续读两个即可;操作 2 截取后文档就变成截取出来的部分了。

分步写。第一步,读入 q 和初始文档(cin 连续读两个即可,自动跳过换行):

#include <iostream>
using namespace std;

int main() {
    int q;
    string s;
    cin >> q >> s;
    return 0;
}

第二步,循环处理 q 个操作。操作 1:追加并输出:

#include <iostream>
using namespace std;

int main() {
    int q;
    string s;
    cin >> q >> s;
    while (q--) {        // q--:先用 q 当前值判断(非 0 就进循环),再减 1——正好循环 q 次
        int op;
        cin >> op;
        if (op == 1) {
            string t;
            cin >> t;
            s += t;
            cout << s << endl;
        }
        // 其余操作继续写...
    }
    return 0;
}

第三步,补全操作 2/3/4:

#include <iostream>
using namespace std;

int main() {
    int q;
    string s;
    cin >> q >> s;
    while (q--) {
        int op;
        cin >> op;
        if (op == 1) {
            string t;
            cin >> t;
            s += t;
            cout << s << endl;
        } else if (op == 2) {
            int a, b;
            cin >> a >> b;
            s = s.substr(a, b);      // 文档变成截取的部分
            cout << s << endl;
        } else if (op == 3) {
            int a;
            string t;
            cin >> a >> t;
            s.insert(a, t);
            cout << s << endl;
        } else {
            string t;
            cin >> t;
            int pos = s.find(t);   // find 返回位置;用 int 存,找不到时是 -1(string::npos)
            if (pos == string::npos) {
                cout << -1 << endl;
            } else {
                cout << pos << endl;
            }
        }
    }
    return 0;
}

8.8 sscanf:从字符串里提取数据

scanf 是从键盘/输入流读,sscanf从字符串里按格式读——把字符串当输入源(格式符和 scanf 完全一样:%d/%lf/%c…,第 4 章那张表):

#include <cstdio>
char buf[100];           // 字符数组(C 风格字符串)
sscanf(buf, "%d %d", &a, &b);   // 从 buf 中按格式提取两个整数

// string 转成字符数组再提取:
string s;
sscanf(s.c_str(), "%d %d", &a, &b);   // c_str() 把 string 变成 C 风格字符串

什么时候用:数据"藏"在字符串里(比如一行里混着运算符和数字、格式不规则),直接 cin 拆不开,就上 sscanf 按格式抠。顺带认识字符数组char buf[100] 是 C 语言风格的字符串(一串 char),scanf("%s", buf) 能读入单词,但不如 string 安全方便,现在知道它长什么样即可。

8.9 例题 27 P1957 口算练习题

输入 n 道口算题。每题一行,可能是"运算符 + 两个数"(运算符 a=加、b=减、c=乘),也可能只有两个数(运算符沿用上一题)。输出完整算式(如 64+46=110)和它的长度。

卡点分析:难点在每行的数据个数不定(2 个或 3 个)。思路:整行读进来,看第一个字符是不是运算符(a/b/c):

  • 是 → 用 sscanf 按"字符 整数 整数"提取;
  • 不是 → 用 sscanf 按"整数 整数"提取,运算符沿用上一个。

另外注意 getline 前要先吃掉 cin >> n 残留的换行(8.4 的坑,这里实际踩一遍)。

分步写。第一步,吃掉换行,整行循环读入:

#include <iostream>
#include <cstdio>
using namespace std;

int main() {
    int n;
    cin >> n;
    getchar();          // 吃掉 cin >> n 留下的换行
    char op = 'a';      // 运算符('a'加/'b'减/'c'乘),题目保证第一行是完整算式,这里先随便给
    while (n--) {
        string line;
        getline(cin, line);
        // 解析 line...
    }
    return 0;
}

第二步,判断并解析。line[0] 是行首字符,是 a/b/c 就是运算符行,否则是纯数字行:

#include <iostream>
#include <cstdio>
using namespace std;

int main() {
    int n;
    cin >> n;
    getchar();
    char op = 'a';
    while (n--) {
        string line;
        getline(cin, line);
        int x, y;
        if (line[0] == 'a' || line[0] == 'b' || line[0] == 'c') {
            sscanf(line.c_str(), "%c %d %d", &op, &x, &y);
        } else {
            sscanf(line.c_str(), "%d %d", &x, &y);
        }
        // 计算并输出...
    }
    return 0;
}

第三步,计算并输出算式和长度。用 to_string 拼算式(顺便求长度),op 是 'a'/'b'/'c',算的时候转成 + - *:

#include <iostream>
#include <cstdio>
using namespace std;

int main() {
    int n;
    cin >> n;
    getchar();
    char op = 'a';
    while (n--) {
        string line;
        getline(cin, line);
        int x, y;
        if (line[0] == 'a' || line[0] == 'b' || line[0] == 'c') {
            sscanf(line.c_str(), "%c %d %d", &op, &x, &y);
        } else {
            sscanf(line.c_str(), "%d %d", &x, &y);
        }
        int z;
        char sign;   // 算式里的运算符符号
        if (op == 'a') { z = x + y; sign = '+'; }
        else if (op == 'b') { z = x - y; sign = '-'; }
        else { z = x * y; sign = '*'; }
        string expr = to_string(x) + sign + to_string(y) + "=" + to_string(z);
        cout << expr << endl;
        cout << expr.length() << endl;
    }
    return 0;
}

样例第一题 a 64 46:z=110,expr=64+46=110,长度 9(6、4、+、4、6、=、1、1、0 共 9 个字符),输出 64+46=1109

8.10 本章小结

知识点 一句话记忆
string s; s[i] 字符串,可当下标访问的字符序列
s.length() 长度,配合遍历时记得 (int) 强转
getline(cin, s) 读一整行(含空格)
getline 前吃换行 cin >> ngetchar()cin.ignore()
s.substr(a, b) s.insert(a, t) s.find(t) 截取 / 插入 / 查找,找不到是 string::npos
to_string(x) / stoi(s) 数字↔字符串互转
sscanf(s.c_str(), ...) 从字符串按格式提取数据

练手

  • P1055 ISBN 号码 ⭐⭐:ISBN 前 9 位逐位加权(1~9)求和 mod 11,校验位对得上输出 Right,否则输出修正后的完整 ISBN(提示:跳过 '-' 字符;字符转数字用 c - '0';结果 10 用 'X' 表示)
  • P1597 语句解析 ⭐⭐⭐:解析一串 a:=3;b:=4;c:=a; 格式的赋值语句,输出 a b c 最终值(提示:用 getline 读整行,从下标 0 手写解析:读一个变量名 → 跳过 := → 读值(是数字就转成数,是变量名就读它的当前值)→ 跳过 ;;用数组 val[256] 按字符存变量当前值,未赋值过的默认 0)
  • P1308 统计单词数 ⭐⭐⭐:给定单词和文章,统计该单词作为独立单词(整词匹配,部分匹配不算)出现的次数和第一次出现的位置(从 0 起),没出现输出 -1(提示:文章含空格,必须 getline;大小写不敏感,全部转小写再比;位置是首字母下标。整词匹配的配方:用 find 找到位置后,检查前一个字符必须是空格或开头、后一个字符必须是空格或结尾,否则继续往后找;或把文章按空格拆成单词逐个比)

第 9 章 函数与递归

本章地图

  • 学什么:函数(打包指令)、参数与返回值、全局变量与作用域、递归
  • 前置知识:第 6 章(循环)、第 7 章(数组)
  • 配套题目:例题 28 P5735、例题 29 P5736、例题 30 P5739、例题 31 P1035、练习 P5737、练习 P5738、练习 P1028

9.1 为什么需要函数

先看一个场景:第 6 章例题 16(质数口袋)里判断质数的代码写了一大坨。如果一道题里要判断 10 次质数,难道复制 10 遍?代码复制粘贴有三个坏处:占地方(主程序又长又乱)、改起来要改 10 处(漏一处就出 bug)、读起来费劲(满屏重复,看不出重点)。

函数就是把一段指令打包,起个名字,随用随调

  • 代码只写一遍,到处调用;
  • 主程序变短,逻辑清晰(看函数名就知道在干嘛);
  • 出 bug 只改一处。

打个比方:函数就像"食堂的固定菜谱"——做红烧肉的步骤只写一遍贴在厨房,谁点这道菜,后厨按菜谱做一份端出来,不用每次重新发明做法。

9.2 函数基础

返回类型 函数名(参数列表) {
    // 函数体:要执行的指令
    return 返回值;
}

拆开看每一块:

  • 返回类型:函数算完,吐出一个什么类型的值(intdoublebool…)。不需要吐值的用 void(这时可以不写 return 或写 return;)。
  • 函数名:你给这段指令起的名字,调用时就用它。
  • 参数列表:函数需要的"原料"。可以没有(空括号),也可以多个(逗号隔开)。调用时传进去的值叫实参,函数定义里接收的变量叫形参——实参按顺序对应形参。
  • 函数体:打包的指令,写在大括号里。
  • return 返回值;:把结果交出去,同时函数立刻结束(return 后面的语句不再执行)。

一个完整的例子:

#include <iostream>
using namespace std;

int add(int x, int y) {    // 返回 int,两个 int 形参
    return x + y;          // 把 x+y 的结果交出去
}

int main() {
    int s = add(3, 5);     // 调用:实参 3、5 按顺序给 x、y,s = 8
    cout << s << endl;
    return 0;
}

调用 add(3, 5) 时发生了什么:程序跳到 add 函数,把 3 交给 x、5 交给 y,执行 return x + y 算出 8 交回来,再跳回 main 继续——调用时程序暂停当前的事,跳去执行函数体,return 带着结果跳回调用处

上面这种传法是值传递(传的是副本,函数里改不到外面):

#include <iostream>
using namespace std;

void change(int x) {
    x = 100;              // 改的是副本
}

int main() {
    int a = 1;
    change(a);
    cout << a << endl;    // 输出 1:a 没变
    return 0;
}

想改到外面的变量怎么办?第 10 章的指针/引用传参就是干这个的。

⚠️ 函数要写在 main 前面,main 才能调用它——C++ 按从上到下的顺序找名字。也可以把函数写在 main 后面,但必须在 main 前面加一行函数声明(也叫原型,以分号结尾、没有函数体):long long fac(int n);——告诉编译器“这个函数存在,定义在后面”。

9.3 例题 28 P5735 距离函数

输入平面三个点的坐标(实数),输出它们围成的三角形周长,保留 2 位小数。
两点距离公式:$d = \sqrt{(x_1-x_2)^2 + (y_1-y_2)^2}$

卡点分析:三条边都要算距离,而"算两点距离"是同一段计算用了三遍(只是坐标不一样)——这正是函数最典型的用法:把重复计算打包,调用三次。这题也展示 double 类型函数怎么写。

分步写。第一步,把"算两点距离"打包成函数:

#include <iostream>
#include <cmath>
#include <iomanip>
using namespace std;

double dist(double x1, double y1, double x2, double y2) {
    double dx = x1 - x2;
    double dy = y1 - y2;
    return sqrt(dx * dx + dy * dy);   // 距离公式
}

注意:函数里的 x1y1 这些是形参,和 main 里要读的变量名字可以一样也可以不一样,各是各的。

第二步,main 里读三个点、调三次 dist、求和输出:

#include <iostream>
#include <cmath>
#include <iomanip>
using namespace std;

double dist(double x1, double y1, double x2, double y2) {
    double dx = x1 - x2;
    double dy = y1 - y2;
    return sqrt(dx * dx + dy * dy);
}

int main() {
    double x1, y1, x2, y2, x3, y3;
    cin >> x1 >> y1 >> x2 >> y2 >> x3 >> y3;
    double c = dist(x1, y1, x2, y2) + dist(x2, y2, x3, y3) + dist(x3, y3, x1, y1);
    cout << fixed << setprecision(2) << c << endl;
    return 0;
}

如果不用函数,这三条边得把距离公式抄三遍,又长又容易抄错。函数一次写好,三处调用,清清楚楚。

9.4 例题 29 P5736 质数筛

输入 n 个正整数,去掉非质数,输出剩下的质数。注意 1 不是质数。

卡点分析:判断质数在第 6 章写过一次(试除法),现在把它封装成 bool 函数——返回"是不是质数"的判断结果。以后任何地方要判质数,一行调用。这是函数第二个典型用法:把一段逻辑打包成"是/否"的判断器

分步写。第一步,封装判断质数的函数:

bool isPrime(int x) {
    if (x < 2) return false;          // 1 和负数不是质数
    for (int j = 2; j * j <= x; j++) {
        if (x % j == 0) return false; // 找到因子,不是质数,立刻结束函数
    }
    return true;                      // 试完都没有因子,是质数
}

return false 在循环里出现也没问题——return 会让函数立刻结束,后面的代码不再执行。

第二步,main 里逐个判断输出:

#include <iostream>
using namespace std;

bool isPrime(int x) {
    if (x < 2) return false;
    for (int j = 2; j * j <= x; j++) {
        if (x % j == 0) return false;
    }
    return true;
}

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        if (isPrime(x)) {      // 一行调用,main 里完全看不到试除法的细节
            cout << x << " ";
        }
    }
    cout << endl;
    return 0;
}

样例 5 3 4 5 6 7 输出 3 5 7

💡 对比 9.3:dist 是"算一个值"(double 函数),isPrime 是"判断是/否"(bool 函数)——函数可以吐出任意的类型,这就是"返回类型"的意义。

9.5 递归:函数调用自己

递归:函数在函数体里调用自己。听起来像"自己生自己",但它不是死循环——关键在于每次调用自己时,问题的规模都变小了一点,直到小到不用再调自己。

阶乘是最经典的例子:$n! = n \times (n-1)!$,而且 $0! = 1! = 1$。翻译成函数:

long long fac(int n) {
    if (n <= 1) return 1;        // ① 递归出口:不再调用自己
    return n * fac(n - 1);       // ② 递归调用:调用自己,规模从 n 变 n-1
}

递归三要素(缺一不可):

  1. 递归出口:某个情况下不再调用自己,直接返回(否则会无限调用,栈溢出 RE);
  2. 递归调用:函数体里调用自己;
  3. 规模越来越小:每次调用都往出口靠近(n → n-1 → n-2 → … → 1)。

执行过程:先"递"后"归"。算 fac(3) 时:

fac(3) = 3 × fac(2)          ← 先往下递:算 fac(2)
       = 3 × (2 × fac(1))    ← 再递:算 fac(1)
       = 3 × (2 × 1)         ← 触底:fac(1) 走出口,直接返回 1
       = 3 × 2               ← 然后一层层往回归:把结果带上来
       = 6

像一列火车:车头先一路往前开(递),到终点后每节车厢依次往后传消息(归)。初学觉得绕很正常,把上面的展开式手写几遍就懂了。

两个容易懵的点:① 每层调用里的 n各自的副本——fac(4) 里的 n=4 和 fac(3) 里的 n=3 互不影响;② 每次调用都会占一小块内存记下“回到哪、还差什么没算”,层数太深(比如出口写错)内存耗尽就会 RE——这就是“爆栈”。

递归 vs 循环:能递归的都能循环,能循环的也都能递归,但擅长的事不同:

循环 递归
适合 次数明确、线性推进 问题天然分层(树的遍历、分治)
代码 长一点但直接 短、符合数学定义
风险 边界写错死循环 出口忘写爆栈(栈溢出 RE)

初学阶段:能用循环就用循环(好调试);递归先把"怎么写出口、怎么缩小规模"练熟,后面学搜索、分治时它就是主力。

9.6 例题 30 P5739 计算阶乘

输入 n(≤20),输出 n!(1×2×…×n)。

卡点分析:n≤20 时 13! 就超过 int 范围了,返回值用 long long。阶乘有两种写法:循环累乘(第 6 章学过的思路)或递归——上一节刚讲过,这题就是把递归用起来。

分步写。第一步,写递归函数(就是 9.5 那个 fac):

long long fac(int n) {
    if (n <= 1) return 1;        // 递归出口:n=0 或 1 时直接返回 1
    return n * fac(n - 1);       // 递归:规模缩小
}

第二步,main 里读入并调用:

#include <iostream>
using namespace std;

long long fac(int n) {
    if (n <= 1) return 1;
    return n * fac(n - 1);
}

int main() {
    int n;
    cin >> n;
    cout << fac(n) << endl;
    return 0;
}

样例 5:fac(5) = 5×4×3×2×1 = 120。跟着 9.5 的展开式走一遍:fac(5) → fac(4) → … → fac(1) 触底,再一层层乘回来。

9.7 例题 31 P1035 级数求和

已知 $S_n = 1 + \frac{1}{2} + \frac{1}{3} + \dots + \frac{1}{n}$。输入 k,求最小的 n 使得 $S_n > k$。

卡点分析:要从小到大试 n,每次在上一个 Sn 的基础上加一项(增量累加),而不是从头重算——否则 k 稍大就 O(n²) 超时(k=12 时实测能跑到 5 秒以上)。这题体现的是"性能意识",竞赛里 TLE 的头号原因就是这种无谓的重复计算。

分步写。第一步,搭骨架:

#include <iostream>
using namespace std;

int main() {
    int k;
    cin >> k;
    double s = 0;   // 当前的 Sn
    int n = 0;
    return 0;
}

第二步,循环累加,直到 Sn > k。注意 1.0 / n 要写成浮点除法(1 / n 是整数除法恒为 0):

#include <iostream>
using namespace std;

int main() {
    int k;
    cin >> k;
    double s = 0;
    int n = 0;
    while (s <= k) {   // 还没超过就继续加
        n++;
        s += 1.0 / n;  // 增量累加:只加新的一项
    }
    cout << n << endl;
    return 0;
}

样例 1:S1=1 不大于 1,继续;S2=1.5 > 1,n=2,输出 2

⚠️ 性能坑:如果写成"每试一个 n 就重新算一遍完整的 Sn",n 到几千就明显卡顿。凡是"求第 n 项/前 n 项和",优先想增量:上一轮的结果留着,只算新变化的部分

9.8 全局变量与作用域

前面写的变量都声明在函数里面,它们有个共同特点:只在所属函数的花括号里有效。看代码:

#include <iostream>
using namespace std;

int total = 0;          // 全局变量:写在所有函数外面

void addScore(int s) {  // void:不需要返回值
    total += s;         // 直接改全局变量 total
}

int main() {
    addScore(80);
    addScore(90);
    cout << total << endl;   // 输出 170:两次调用改的是同一个 total
    return 0;
}
  • 全局变量(写在所有函数外面):所有函数都能访问,整个程序运行期间都活着。上面的 total 被 addScore 改了,main 里能读到改后的值。
  • 局部变量(写在函数里面):只能在它所在的花括号里用。比如 addScore 里如果声明 int x = 1;,main 里用 x 会编译报错"未声明"——出了花括号它就不存在了。
  • 作用域 = "名字有效的地盘"。写在哪对的花括号里,作用域就到哪。

对比一下:如果 total 是 main 里的局部变量,addScore 根本看不到它(编译报错);正因为 total 是全局的,两个函数才共享它。这就是全局变量的用途:跨函数共享数据

⚠️ 两个注意点:

  1. 全局变量能不用就不用(局部变量优先)——全局意味着"谁都能改",代码一多就分不清是谁改的,难调试;
  2. 局部变量和全局变量重名时,函数里用的是局部变量("就近原则")。这种代码很难读,别这么写。

💡 全局变量有一个实用场景:数组开在 main 外面会自动全部清零(局部数组不会)。如果一道题需要很大的数组、又希望它初始全是 0,可以声明成全局数组。但要注意局部数组不初始化是垃圾值(第 7 章讲过),能写 = {} 就用 = {}

9.9 本章小结

知识点 一句话记忆
返回类型 函数名(参数) { return 值; } 函数:打包指令,随用随调
void 不需要返回值的函数
函数典型用法 ① 重复计算打包(dist 调三次)② 判断逻辑打包(isPrime)
递归 函数调用自己,三要素:出口/调用/规模缩小,先递后归
递归 vs 循环 能用循环先用循环,递归强在分层问题
增量累加 只算新变化的部分,别重复计算(防 TLE)
全局变量 / 作用域 写在函数外、全体共享;能不用就不用

练手

  • P5737 闰年展示 ⭐⭐:输出区间 [x, y] 内所有闰年(提示:把闰年判断写成一个 bool 函数,main 里循环调用,先输出个数再输出列表)
  • P5738 歌唱比赛 ⭐⭐:n 名选手 m 个评委打分,每人去掉一个最高分一个最低分求平均,输出最高平均分(提示:写一个"算平均"的函数,里面先找最大最小,平均 = (double)(总和 - 最大 - 最小) / (m - 2)——先转 double 再除;保留 2 位小数)
  • P1028 数的计算 ⭐⭐⭐:对 n,可以在数列末尾不断添加一个不超过末项一半的正整数,求能生成多少种不同数列(提示:设 f[i] 为"以 i 开头的数列个数",f[i] = 1 + f[1] + f[2] + ... + f[i/2],从小到大算,不要用纯递归——n=1000 时纯递归会超时,用数组递推,数组用 long long 更稳)

第 10 章 指针与引用

本章地图

  • 学什么:地址、指针、指针算术、指针传参 vs 引用传参
  • 前置知识:第 7 章(数组)、第 9 章(函数)
  • 配套题目:例题 32(改写 P1046)、练习(改写 P1427 等)
  • 🎬 学习建议:指针用纯文字讲比较抽象,建议先找 B 站的"C++ 指针入门"视频看一遍(15 分钟那种就够),再回来读本章做系统梳理和练习。本教程的目标是"会用":会加加减减、会查元素值、会用指针和引用传参、能区分两者区别。更深的指针玩法留到以后。

注:洛谷没有专门的"指针题",本章例题和练习都是用指针重写前面章节的题,检验你是否真的会用了。

10.1 地址:变量的门牌号

内存就像一栋公寓楼,每个变量占据一个房间。地址就是房间的门牌号。用 & 可以问“这个变量住在几号房”(还记得第 4 章 scanf 里那个不能丢的 & 吗?就是它):

int a = 5;
cout << &a << endl;   // 输出类似 0x7ffd3a2b1c44 的一串数字 = a 的地址

每次运行地址都可能不同(房间是程序运行时分配的),这很正常。

10.2 指针:装地址的变量

指针(pointer)就是"用来装地址的变量"。声明时写 类型*

int a = 5;
int* p = &a;   // p 是一个指针,里面装的是 a 的地址

有了地址,就能顺着门牌号找到房间、取出里面的值——用 * 解引用:

cout << *p << endl;   // 到 p 存的地址去取值 = 5
*p = 10;              // 顺着地址改值:a 变成了 10
cout << a << endl;    // 10

* 的三个身份(看上下文区分):

  1. 声明指针:int* p;
  2. 解引用取值:*p
  3. 乘法:a * b

10.3 指针与数组

数组名就是数组第一个元素的地址a 等价于 &a[0]。所以指针可以直接指向数组,配合指针算术(加加减减)遍历:

int a[5] = {10, 20, 30, 40, 50};
int* p = a;                // p 指向 a[0]
cout << *p << endl;        // 10
cout << *(p + 1) << endl;  // 20:p+1 指向下一个元素
cout << *(a + 2) << endl;  // 30,等价于 a[2]
p++;                       // 指针加一:指向 a[1]

注意:p + 1 不是"地址加 1 字节",而是"指向下一个元素"(int 指针 +1 实际跳 4 字节)。这就是指针"加加减减"的含义——在数组里前后移动。

用指针遍历数组(和下标遍历等价):

for (int* q = a; q < a + 5; q++) {
    cout << *q << " ";   // 从 a[0] 走到 a[4]
}

10.4 例题 32 改写 P1046(指针遍历)

第 7 章例题 17 的陶陶摘苹果,原来用下标 a[i] 遍历,现在改用指针。第一种:指针加偏移:

#include <iostream>
using namespace std;

int main() {
    int a[10];
    for (int i = 0; i < 10; i++) {
        cin >> a[i];
    }
    int h;
    cin >> h;
    int reach = h + 30;
    int cnt = 0;
    for (int* p = a; p < a + 10; p++) {   // 指针从 a[0] 走到 a[9]
        if (*p <= reach) cnt++;           // 解引用取值判断
    }
    cout << cnt << endl;
    return 0;
}

第二种:指针加下标(p[i]*(p+i) 完全等价):

#include <iostream>
using namespace std;

int main() {
    int a[10];
    for (int i = 0; i < 10; i++) {
        cin >> a[i];
    }
    int h;
    cin >> h;
    int reach = h + 30;
    int cnt = 0;
    int* p = a;                          // p 指向数组开头
    for (int i = 0; i < 10; i++) {
        if (p[i] <= reach) cnt++;        // p[i] 等价 *(p+i) 等价 a[i]
    }
    cout << cnt << endl;
    return 0;
}

两种写法 AC 效果一样。看懂 a[i]*(a+i) 的等价关系,你就算真正理解数组和指针了。

10.5 传参:值传递 / 指针 / 引用

函数传参有三种方式,区别在能不能改到外面的原变量。以"交换两个数"为例:

// 方式一:值传递 —— 传的是副本,改不到外面 ❌
void swap1(int x, int y) {
    int t = x;
    x = y;
    y = t;
}

// 方式二:指针传参 —— 传地址,函数里解引用操作原数据 ✅
void swap2(int* x, int* y) {
    int t = *x;
    *x = *y;
    *y = t;
}

// 方式三:引用传参 —— 参数是实参的别名,直接操作原数据 ✅(int& 的引用语法马上 10.6 讲)
void swap3(int& x, int& y) {
    int t = x;
    x = y;
    y = t;
}

int main() {
    int a = 3, b = 5;
    swap1(a, b);     // ❌ a b 还是 3 5
    swap2(&a, &b);   // ✅ 调用时要传地址
    swap3(a, b);     // ✅ 调用时和普通函数一样
    return 0;
}

10.6 引用

引用(reference)是变量的"别名":声明 int& r = a; 之后,r 和 a 是同一个房间的两个名字,改 r 就是改 a:

int a = 5;
int& r = a;   // r 是 a 的别名
r = 99;       // a 也变成 99

引用和指针都能"间接操作原数据",区别:

指针 引用
声明 int* p = &a; int& r = a;
使用 要解引用 *p 直接用 r(就是 a 的别名)
传参调用 要传地址 swap2(&a, &b) 和普通调用一样 swap3(a, b)
安全性 可能指向空/野地址 必须初始化,绑定后不能换
空值 可以(危险) 不可以

结论:现代 C++ 写函数,能用引用就用引用——写法像值传递(舒服),效果是直接操作原数据(高效),还不会像指针那样踩空指针的坑。指针要掌握(看题解、理解底层都需要),但自己写代码优先引用。

⚠️ 顺带澄清:& 在 C++ 里有三个身份,看上下文区分——① 位运算的“按位与”(第 3 章 a & b);② “取地址”(第 4 章 scanf、本章 &a);③ “引用声明”(本章 int& r)。

💡 后面学 STL、结构体时会看到 const int& 这种写法:const 表示“只读不修改”,& 表示“不复制直接引用”。传大东西(比如长字符串、结构体)时避免复制开销,这是竞赛里的常见优化。

10.7 本章小结

知识点 一句话记忆
&a 取变量 a 的地址(门牌号)
int* p = &a; 指针 = 装地址的变量
*p 解引用:顺着地址取值/改值
p + 1 指向下一个元素(不是 +1 字节)
a[i] 等价 *(a+i) 数组名是首元素地址
值传递 传副本,改不到外面
指针传参 传地址,函数里 *p 操作原数据
引用传参 参数是实参别名,直接操作原数据,首选

完整的知识点↔题目对照见附录 C 总题单。

练手(全部用指针/引用重写):

  1. 改写 P1427 ⭐⭐:倒序输出——指针从 a + n - 1 一路减到 a,每步解引用输出
  2. 改写 P1046 ⭐⭐:三种遍历(下标 / 指针偏移 / p[i])各写一遍
  3. swap 三版对比 ⭐⭐:把 10.5 的 swap1/2/3 完整跑一遍,验证只有值传递那版失败,理解"为什么"
  4. 改写 P1428 ⭐⭐⭐(选做):用指针完成双重循环统计

🏁 教程完成标准:全部例题能不看教程独立 AC;练习至少完成每个 ⭐ 和 ⭐⭐;挑战题(⭐⭐⭐)至少完成 3 道。达成后,你的 C++ 语法关就正式打通——可以开始学结构体、STL 和算法了。

附录

A. 知识点速查表

程序骨架

#include <iostream>   // 输入输出库
#include <cstdio>     // printf/scanf(用 C 风格 IO 时)
#include <cmath>      // sqrt/abs 等数学函数
#include <iomanip>    // setprecision 等格式控制
#include <vector>     // vector 动态数组
#include <algorithm>  // sort 等算法
using namespace std;

int main() {
    ios::sync_with_stdio(false);   // 用 cin/cout 时提速
    cin.tie(0);
    // 你的代码
    return 0;
}

变量与类型

类型 范围 备注
int 约 ±21 亿 默认整数
long long 约 ±9×10^18 大数用这个
double 约 15~16 位有效数字 小数;浮点别直接比相等
char 一个字符 本质是 ASCII 整数
bool true / false 存条件成立与否
string 一段文字 <string>(iostream 通常已带)

输入输出

写法 说明
cin >> a; 读入,遇空格/换行停
cout << a << endl; 输出并换行('\n' 等价)
scanf("%d", &a); C 风格读入,别忘了 &
printf("%d", a); C 风格输出
%d %lld %lf %c %.2lf int / long long / double / char / 两位小数
fixed << setprecision(3) 保留 3 位小数(iomanip)
getline(cin, s); 读一整行(含空格),前面有 cin 先 getchar()
while (cin >> x) 读到文件结束(多组数据)
sscanf(s.c_str(), "%d", &x) 从字符串里提取数据

运算符(从高到低,常用部分)

优先级 运算符
( )
* / %
+ -
<< >>(位运算左移右移)
< > <= >=
== !=
&(位与)
^(位异或)
\|(位或)
&&(逻辑与)
\|\|(逻辑或)
= += 等赋值

拿不准就加括号。

分支与循环

if (条件) { } else if (条件) { } else { }
switch (整数变量) { case 值: ... break; default: ... }
for (int i = 1; i <= n; i++) { }
while (条件) { }
do { } while (条件);          // 至少执行一次
break;   // 跳出循环
continue; // 跳到下一次

数组

int a[105];        // 一维,容量写常量,开大一点
int b[10][10] = {}; // 二维(表格),{} 全部初始化为 0
a[i] / b[i][j]     // 下标从 0 开始

字符串常用方法

s.length() / s.size()      // 长度
s.substr(a, b)             // 从 a 开始取 b 个
s.find(t)                  // 找子串位置,找不到 string::npos
s.insert(a, t)             // 在 a 前插入
s += t                     // 尾部追加
to_string(x) / stoi(s)     // 数字↔字符串

函数与递归

返回类型 函数名(参数) { return 值; }   // 定义
函数名(实参);                          // 调用
// 递归:函数调用自己,必须有出口

指针与引用

&a        // 取 a 的地址
int* p = &a;   // 指针:装地址
*p        // 解引用:取/改地址里的值
p + 1     // 指向下一个元素
a[i] 等价 *(a + i)
int& r = a;    // 引用:a 的别名
void f(int& x) { }   // 引用传参:直接改原数据

B. ASCII 表

常用字符(码值 32~126,另附 127 DEL;其余是控制字符):

码值 字符 码值 字符 码值 字符 码值 字符
32 (空格) 56 8 80 P 104 h
33 ! 57 9 81 Q 105 i
34 " 58 : 82 R 106 j
35 # 59 ; 83 S 107 k
36 $ 60 < 84 T 108 l
37 % 61 = 85 U 109 m
38 & 62 > 86 V 110 n
39 ' 63 ? 87 W 111 o
40 ( 64 @ 88 X 112 p
41 ) 65 A 89 Y 113 q
42 * 66 B 90 Z 114 r
43 + 67 C 91 [ 115 s
44 , 68 D 92 \ 116 t
45 - 69 E 93 ] 117 u
46 . 70 F 94 ^ 118 v
47 / 71 G 95 _ 119 w
48 0 72 H 96 ` 120 x
49 1 73 I 97 a 121 y
50 2 74 J 98 b 122 z
51 3 75 K 99 c 123 {
52 4 76 L 100 d 124 |
53 5 77 M 101 e 125 }
54 6 78 N 102 f 126 ~
55 7 79 O 103 g 127 DEL

常用控制字符'\0'(字符串结束符)=0、'\t'(制表符)=9、'\n'(换行)=10、'\r'(回车)=13。

规律'0'~'9' 连续(48~57),'A'~'Z' 连续(65~90),'a'~'z' 连续(97~122),大小写相差 32。数字字符转数值:c - '0';大写变小写:c + 32

C. 总题单

全部为洛谷题目。例题(正文讲解过)建议独立重写一遍;练习(只给提示)是自己检验掌握程度的试金石。⭐ 必做 / ⭐⭐ 巩固 / ⭐⭐⭐ 挑战。

例题清单

章节 题号 题名 核心知识点
1 P1000 超级玛丽游戏 cout、字符画、空格敏感
2 P1001 A+B Problem cin、变量、加法
2 P3954 成绩 表达式、运算顺序
2 P1425 小鱼的游泳时间 换算分钟、/ %
3 P5703 苹果采购 long long、溢出
3 P5704 字母转换 char、ASCII
4 P5706 再分肥宅水 浮点、fixed+setprecision
4 P5708 三角形面积 sqrt、scanf/printf
4 P5709 苹果和虫子 向上取整、除零特判
5 P5711 闰年判断 if、逻辑运算
5 P5710 数的性质 bool、异或
5 P5714 肥胖问题 多分支、浮点输入
5 P5716 月份天数 switch
6 P5719 分类平均 for、累加
6 P5724 求极差 擂台法
6 P5723 质数口袋 嵌套循环、break、试除法
7 P1046 陶陶摘苹果 数组读入、遍历计数
7 P1428 小鱼比可爱 双重循环
7 P1427 小鱼的数字游戏 while 读到 0、倒序
7 P5727 冰雹猜想 数组存储、倒序输出
7 P5731 蛇形方阵 二维数组、方向数组
7 P1059 明明的随机数 vector、sort、去重、桶标记
8 P5733 自动修正 字符串遍历、ASCII
8 P5705 数字反转 字符串倒序
8 P5015 标题统计 getline
8 P5734 文字处理软件 substr/insert/find
8 P1957 口算练习题 sscanf、to_string
9 P5735 距离函数 double 函数(重复计算打包)
9 P5736 质数筛 bool 函数
9 P5739 计算阶乘 递归
9 P1035 级数求和 增量累加(防 TLE)
10 P1046(改写) 陶陶摘苹果 指针遍历

练习清单

难度 章节 题号 题名 提示
4 P5707 上学迟到 向上取整 s/v + 10;倒推时间,为负则 +1440
5 P5715 三位数排序 两两比较交换
⭐⭐ 5 P5717 三角形分类 先判能否构成,勾股分类,再判等腰/等边
6 P5722 数列求和 循环累加
6 P5721 数字直角三角形 嵌套循环,%02d 补 0
6 P5718 找最小值 擂台法
7 P5725 求三角形 正方形+空行+右对齐三角
7 P5732 杨辉三角 二维数组递推
⭐⭐ 7 P5726 打分 去一个最高一个最低求平均
⭐⭐ 7 P1047 校门外的树 区间标记,L+1 棵树
⭐⭐⭐ 7 P2141 珠心算测验 枚举两数之和,数集合中被标记的元素
⭐⭐ 8 P1055 ISBN 号码 跳过 '-'、字符转数字、mod 11
⭐⭐⭐ 8 P1597 语句解析 getline 手写解析,val[256] 存变量
⭐⭐⭐ 8 P1308 统计单词数 getline、整词匹配(前后界判定)、全转小写
⭐⭐ 9 P5737 闰年展示 闰年判断写成函数
⭐⭐ 9 P5738 歌唱比赛 求平均写成函数
⭐⭐⭐ 9 P1028 数的计算 递推 f[i]=1+Σf[1..i/2],别纯递归
⭐⭐ 10 P1427(改写) 小鱼的数字游戏 指针从尾走到头
⭐⭐ 10 P1046(改写) 陶陶摘苹果 三种遍历各写一遍
⭐⭐ 10 swap 三版 值/指针/引用传参对比
⭐⭐⭐ 10 P1428(改写) 小鱼比可爱 指针双重循环

完成标准

  • 所有例题能不看教程独立写出来并 AC
  • 完成全部 ⭐ 和 ⭐⭐ 练习;
  • 挑战题(⭐⭐⭐)至少完成 3 道。

达成以上三条,你的 C++ 语法关就正式打通。接下来就可以学结构体、STL(set/map/sort/lower_bound/迭代器)这些进阶内容了——你的基础语法已经全部就位,专注学新东西就行。加油 💪