最近整理自己的练习记录,翻到一份包含八个知识点的题目清单:数字反转、除数累加、差分数组、vector插入和访问、小数四舍五入及向上取整、矩阵逆置、基础文件IO、深入文件IO。这些知识点单独看都很基础,但基础不等于简单,我重新动手做了一遍,踩了不少意料之外的坑,尤其是浮点舍入方式和文件写入的返回值处理,稍不注意就是隐蔽的bug。这篇博客就把我全部重新做一遍的过程、原理和教训整理出来,希望对正在学C/C++和Linux系统编程的读者有帮助。
这份清单适合两类人:一类是刚把语法学完、想通过小题目巩固“运算、容器、系统调用”的初学者;另一类是已经工作一两年、但平时写应用层代码比较多、对底层I/O和容器细节有些生疏的人。我自己属于第二类,重做一遍之后收获比想象中大得多。
1. 这份清单到底在练什么
先说整体观感。这八个知识点并不是零散的“八道题”,它们其实覆盖了三个层面:纯数值处理、数据结构和算法、操作系统接口。
数字反转、除数累加、小数四舍五入和向上取整,本质上都是在练习“一个数在计算机里如何被拆分、如何被舍入”。这些题不涉及外部状态,纯粹考验你对整数位权、浮点表示、标准库函数的理解。我最早刷这类题时觉得“太简单了”,结果做数字反转时没想到负数取模的行为差异,做舍入时又被printf的规则坑了一次,这才明白简单题最能暴露知识盲区。
差分数组、vector插入和访问、矩阵逆置,属于“数据结构与算法”层面的训练。差分数组是经典的空间换时间思路,vector的插入和访问则直接关系到STL容器的复杂度语义。矩阵逆置看起来是二维下标互换,实际上要思考内存布局和遍历顺序。这组题练的是“同一个数据,用不同的组织方式,性能会差多少”的直觉。
基础文件IO和深入文件IO,则是从“计算”跨到“系统”的关键一环。open/read/write这些函数不是简单地读写文件,每一次调用都涉及用户态到内核态的切换、文件偏移的管理、缓冲区的协调,甚至还有多进程并发写入的原子性问题。只有真的写一遍代码、用strace观察过系统调用,才会对“一切皆文件”这句话有实感。
所以这篇整理不是按顺序复述八道题的解答,而是按“数值处理 → 容器与算法 → 文件系统”三个层次重新组织。每一层我都会先解释原理,再给代码,最后说踩坑点。
2. 数字反转与除数累加:整数位权分解的两个基本功
2.1 数字反转背后的“取模+整除”二重奏
数字反转的核心操作就两行:x % 10取出个位,x /= 10把个位丢掉。每一次循环,把取出的数字乘10累加到结果上。比如1234,第一次取出4,第二次取出3,累加结果是4 * 10 + 3 = 43,第三次是43 * 10 + 2 = 432,第四次是432 * 10 + 1 = 4321。
#include <climits> #include <iostream> int reverse(int x) { int result = 0; while (x != 0) { int digit = x % 10; if (result > INT_MAX / 10 || result < INT_MIN / 10) { return 0; // 溢出保护 } result = result * 10 + digit; x /= 10; } return result; }我最早写这题时没有溢出保护。如果输入是2147483647,反转后会变成7463847412,早就超过int范围了。C++里int溢出是未定义行为,在调试版本里可能还能得到“看起来合理”的值,但换编译器、换优化级别结果就可能变了。所以这类“把数字拆开再拼回去”的题,第一步就要问自己:乘法会不会溢出?
负数取模值得多说一句。C++11之前,%的结果到底是正还是负由实现定义;C++11之后,规定结果与左操作数的符号一致。所以-1234 % 10得到的是-4,不是4。这带来的影响是:直接对负数做反转,结果会带负号,比如-1234会反转成-4321,过程没问题。但如果你的代码里先取了绝对值再处理,就要注意INT_MIN取绝对值会越界,需要单独判断。
2.2 除数累加:从逐个判断到等差数列求和
“除数累加”按常见的练习形式,是求某个区间内所有能被给定数整除的整数之和。最朴素的做法是循环for (int i = 1; i <= n; i++) if (i % d == 0) sum += i;。这个写法容易理解,但一旦n到10的9次方级别,循环就不可接受了。
关键优化是把“能被d整除”看成等差数列。1到n之间能被d整除的数是:d、2d、3d……最多k*d个,其中k = n / d(向下取整)。它们的和就是d * (1 + 2 + ... + k) = d * k * (k + 1) / 2。
long long sumDivisible(long long n, long long d) { long long k = n / d; return d * k * (k + 1) / 2; }这里有个小坑:k * (k + 1)可能超过int范围,所以要么先用long long计算,要么在乘法时先除以2再乘,但要注意k和k+1之一必然是偶数,可以先除以2的那个。我在实际跑数据时发现,n到10的12次方、k到10的6次方时,int版本直接溢出成负数,所以这种题目只要涉及中间乘法就无脑上long long。
这题给我的一个提醒:凡是“重复循环做某件事”的代码,都可以先算一算循环次数。不是所有循环都需要优化,但如果题目给出的n上限明显超过亿级别,就一定存在数学规律可以绕过循环。
3. 小数四舍五入和向上取整:浮点数舍入的边界问题
3.1 四个取整函数的区别
C++标准库里有四个容易混淆的函数:floor(向下取整)、ceil(向上取整)、round(四舍五入到最近整数)、trunc(向零取整)。它们在正数域行为一致,负数域差别立刻显现。
| 输入 | floor | ceil | round | trunc |
|---|---|---|---|---|
| 3.7 | 3 | 4 | 4 | 3 |
| 3.2 | 3 | 4 | 3 | 3 |
| -3.7 | -4 | -3 | -4 | -3 |
| -3.2 | -4 | -3 | -3 | -3 |
大多数“向上取整”需求其实是ceil,但很多初学者用int(int(x) + 1)去模拟。这个做法在x本身是整数时会多1,必须先判断是否是整数,或者直接用ceil。更常见的正确整数实现是(a + b - 1) / b,比如分页计算总页数:总记录数100条,每页30条,(100 + 30 - 1) / 30 = 4页,而不是100 / 30 = 3页。这个公式的原理是:a + b - 1把a往上补一个足够大的偏移量,使得整数除法能自动向上进位。
3.2 保留两位小数的“四舍五入”并没有那么简单
一个更隐蔽的坑发生在“保留两位小数”时。如果只要求保留两位,很多人第一反应是round(x * 100) / 100。这个方案在商业计算里经常出错,原因是浮点数二进制表示无法精确表达0.1、0.2这类十进制小数。
我实测过一个例子:2.675,在C/C++的double里实际存储的值是2.6749999999999998...,round(267.49999...) / 100得到的就是2.67而不是2.68。如果你用printf("%.2f", 2.675),结果同样是2.67。
printf的舍入规则也和“四舍五入”不完全一致,它按当前浮点环境变量确定的舍入模式,默认是“四舍六入五成双”。也就是说,5前面是偶数就舍,是奇数就进,典型例子是printf("%.0f", 2.5)输出2而printf("%.0f", 3.5)输出4。
如果业务上必须要严格十进制意义上的“四舍五入”,最好做两步:先把浮点数按10的幂放大,转成整数或十进制字符串处理,计算完成后再缩小。简单示例:
#include <cmath> #include <iostream> double roundTo2PlacesPlain(double x) { double scaled = round(x * 100.0 + 1e-9); return scaled / 100.0; }+1e-9是为了补偿二进制浮点误差,但补偿量写多大是个经验问题,太小没用,太大又会破坏原值。更稳妥的做法是干脆用整数。比如价格计算,先输入分,再转成元显示。这套思路在金融系统里几乎是强制约定,我在自己写练习外的一些小工具时也养成了习惯:涉及金额、数量统计,能不用浮点就不用浮点。
3.3 向上取整在工程里的一个实际场景
向上取整最常见的应用之一,是按块读写文件。比如文件大小是file_len,每次处理BUF_SIZE字节,最后一次不满一块也要处理,循环次数就是(file_len + BUF_SIZE - 1) / BUF_SIZE。这个问题在文件IO部分会反复出现,所以这里先记住公式,后面直接用。
4. 差分数组:区间批量加减的时间复杂度压缩
4.1 从“每次改区间”到“只动两个端点”
差分数组解决的问题场景是:有一个长度为n的数组,初始全是0,接下来有m个操作,每个操作把[l, r]区间内的每个元素加上一个值v,问最终数组长什么样。
朴素做法是每个操作循环一遍从l到r,复杂度是O(n*m),n和m一旦都到10的5次方就完全跑不动。差分数组的思路是构造一个“变化量”数组diff,使得diff[i] = a[i] - a[i-1](约定a[-1]为0)。对原数组做[l, r]区间加v,等价于diff[l] += v、diff[r+1] -= v。原因很简单:从l开始,后面的差值相对前面多了v;到r+1时,区间加结束,差值要再减回来。
最后恢复原数组只需要做一次前缀和:a[i] = a[i-1] + diff[i]。
#include <vector> #include <iostream> std::vector<long long> rangeAdd(int n, const std::vector<std::tuple<int,int,long long>>& ops) { std::vector<long long> diff(n + 1, 0); for (auto [l, r, v] : ops) { diff[l] += v; if (r + 1 < n) diff[r + 1] -= v; // 防止数组越界 } std::vector<long long> a(n); long long cur = 0; for (int i = 0; i < n; ++i) { cur += diff[i]; a[i] = cur; } return a; }4.2 边界条件:r+1越界问题
上面代码里的if (r + 1 < n)是很多人会漏掉的细节。如果r恰好是n-1,也就是区间一直延伸到数组末尾,那么diff[r+1]就会越界。两种处理方式:diff数组开到n+1大小,且最后还原时忽略最后一位;或者直接在更新前判断。我个人倾向于diff开n+1,因为这样代码更统一,也方便处理“区间右端点正好是n-1”的情况。刚才回复时的代码是if (r + 1 < n),其实也可以省掉判断,只看“还原数组时要不要处理diff[n]”。
4.3 差分数组的适用范围与局限
它擅长的是“多区间批量加减,最后统一查询”。如果操作过程中需要频繁查询某个位置的当前值,差分数组反而吃力,因为每次查询都要重做前缀和。这种需求更适合线段树或树状数组。选择算法的关键不是哪个高级,而是题目“先批量修改、后一次性查询”还是“修改和查询交替出现”。前者用差分数组,代码量小,常数小;后者再考虑树状数组。
工程上的一个典型场景是图像处理里对一行像素做亮度批量调节,或者日志系统里按时间段累加计数。其实只要看到“连续区间、同样增量、最终只关心结果”这三个特征,就可以直接往差分数组方向想。
5. vector插入和访问:复杂度语义与迭代器失效
5.1 insert的性能代价在哪个位置
std::vector的底层是一段连续内存。push_back在容量足够时是O(1),insert在任意位置插入则可能是O(n),因为插入点之后的元素要整体向后搬移。很多人写代码时下意识觉得“往中间插一个元素而已”,没意识到这是搬迁整个后半段。
实测一个最简单的例子:往一个容量10000的vector的头部循环插入10000个元素,耗时比尾部插入多几个数量级。原因就在每次头部插入都让全部已有元素后移。所以STL容器选型的第一堂课就是:频繁头插用deque或list,尾部插入用vector,频繁随机插入就不要用vector。
如果你不得不在vector里插入,一个常见的优化是提前reserve。reserve只改变容量,不改变大小,可以减少多次扩容带来的反复拷贝。reserve应该在明确知道最终元素数量级时调用,比如准备插入10000个元素,先v.reserve(10000),之后push_back就不会触发扩容搬移。
5.2 迭代器失效:insert之后旧迭代器不能再用
insert之后,如果插入导致重新分配内存,那么所有迭代器、指针、引用都会失效。即使没有重新分配,插入点之后的迭代器也会失效,因为元素位置发生了移动。这是新手最容易踩的坑,我最初写过一个循环,打算遍历时插入元素:
for (auto it = v.begin(); it != v.end(); ++it) { if (condition(*it)) { v.insert(it, newValue); // 错误!it此后未定义 } }这个循环在insert之后,it已经失效,++it行为未定义,可能死循环,可能崩溃,也可能“碰巧正常”。正确的做法是先把所有要插入的位置和值收集到另一个容器里,遍历结束后再统一插入;或者用insert的返回值更新迭代器继续前进。这个坑的本质是:任何改变容器结构的操作,都要先问一句“我手里这个迭代器还能继续用吗”。
5.3 访问方式:operator[]和at的取舍
operator[]不检查边界,at()会检查,越界时抛std::out_of_range。调试阶段建议多用at(),能快速发现问题;对性能敏感且确认不会越界的代码再用operator[]。还有一个容易忽略的点:对vector<bool>,operator[]返回的是代理对象而不是bool&,不能直接取地址,这也是为什么很多项目明确禁用vector<bool>,改用vector<char>或bitset。
5.4 与其他容器的插入访问对比
| 容器 | 尾部插入 | 头部/中间插入 | 随机访问 |
|---|---|---|---|
| vector | O(1) | O(n) | O(1) |
| deque | O(1) | O(n)(头部O(1)) | O(1) |
| list | O(1) | O(1)(需已定位) | O(n) |
| set/map | O(log n) | O(log n) | O(log n) |
这组对比看起来简单,但实际选择时要考虑“插入之后还需不需要随机访问”。list插入很快,可如果后续要按下标查找第k个元素,list会慢到让你后悔;反过来,vector随机访问快,但头部插入频繁就是灾难。所以容器选择没有银弹,只能先分析操作分布。
6. 矩阵逆置:二维下标交换背后的存储布局
6.1 方阵的原地转置
矩阵逆置最直接的需求是方阵原地转置。转置的本质是mat[i][j]与mat[j][i]交换。因为是对称操作,只需要遍历上三角,否则每个元素会被交换两次,等于没换。
#include <vector> #include <algorithm> void transposeSquare(std::vector<std::vector<int>>& mat) { int n = mat.size(); for (int i = 0; i < n; ++i) { for (int j = i + 1; j < n; ++j) { std::swap(mat[i][j], mat[j][i]); } } }注意内层j从i+1开始。从0开始的话,对角线元素自己交换自己没问题,但非对角线会交换两次恢复原状。我自己第一次写这个循环时就从0开始,结果矩阵纹丝不动,排错时还以为swap写错了。
6.2 非方阵的转置必须开辟新空间
当矩阵是3x4时,转置结果是4x3,形状都变了,原地交换完全不可能。必须新建一个cols行、rows列的二维数组。这时候才真正体现“二维vector”的灵活性:
using Matrix = std::vector<std::vector<int>>; Matrix transposeRectangular(const Matrix& mat) { int rows = mat.size(); int cols = mat[0].size(); Matrix res(cols, std::vector<int>(rows)); for (int i = 0; i < rows; ++i) { for (int j = 0; j < cols; ++j) { res[j][i] = mat[i][j]; } } return res; }6.3 缓存友好性:遍历顺序决定性能差距
在大学阶段做矩阵题,最容易被忽略的是“遍历顺序”。C++的多维数组在内存里是行优先存储的,也就是mat[i]的所有元素在内存中是连续的。访问mat[i][j]时,如果按行遍历(外层i、内层j),CPU缓存命中率很高;如果按列遍历(外层j、内层i),每次都会跳到不同行,缓存命中率低,在大矩阵上性能差距可能达到一个数量级。
转置这个操作天然就要按列读取、按行写入,所以没法完全规避缓存问题,但在实际工程中,矩阵转置常用“分块转置”:把矩阵切成小块,对每一块内部做转置,使得访问集中在缓存能覆盖的范围内。这种做法在小练习里不需要,但理解它背后的原因,就能明白为什么同样是O(n^2)的代码,运行时间会差很多。
7. 基础文件IO:open/read/write的系统调用语义
7.1 文件描述符与三个标准输入输出
Linux里文件操作的第一步是open,返回的是文件描述符(fd),本质是一个非负整数,内核用它来索引进程打开的文件表。标准输入是0,标准输出是1,标准错误是2。我们平时用printf、scanf、cout、cin,最终都会落到这几个fd对应的内核文件对象上。
#include <fcntl.h> #include <unistd.h> #include <cstring> #include <cerrno> #include <cstdio> int main() { int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd < 0) { // 这时errno会告诉我们具体错误:权限、路径不存在、磁盘满等 perror("open failed"); return 1; } const char* msg = "hello file io\n"; ssize_t n = write(fd, msg, strlen(msg)); if (n != (ssize_t)strlen(msg)) { // 部分写入也是异常 fprintf(stderr, "write partial: %ld\n", (long)n); } close(fd); return 0; }7.2 flags和mode的“三组参数”分别干什么
open的第二个参数是访问模式标志,最常见的组合有:
O_RDONLY:只读打开O_WRONLY:只写打开O_RDWR:读写打开O_CREAT:如果文件不存在则创建,需要第三个参数指定初始权限O_TRUNC:打开时把文件长度截断为0O_APPEND:每次写入前自动定位到文件末尾
第三个参数0644是创建新文件时的权限位,它是八进制数,含义是“属主读写、组和其他人只读”。这里有个容易混淆的点:open里的mode最终还会受到进程umask影响,实际权限是mode & ~umask。比如umask是022时,请求0666最终得到0644。要精确控制权限,要么手动设置umask,要么创建后再用chmod系统调用调整。
7.3 read返回值要处理的三个分支
read的返回值是本次实际读取的字节数。一个完整的读循环必须处理三种情况:
- 返回值大于0:正常读取,把指针和剩余长度往后调整。
- 返回值等于0:读到文件末尾(EOF),结束。
- 返回值小于0:出错,但如果
errno等于EINTR,说明是被信号打断,不是真正错误,应该重试。
新手最容易犯的错误是假设“read一次就能读满缓冲区”。默认情况下文件在本地磁盘时还好,但如果是管道、终端、网络socket,read在没有读到足够数据时可能提前返回。所以可靠的文件读取必须用循环:
bool readAll(int fd, char* buf, size_t len) { size_t done = 0; while (done < len) { ssize_t n = read(fd, buf + done, len - done); if (n == 0) break; if (n < 0) { if (errno == EINTR) continue; perror("read failed"); return false; } done += n; } return done == len; }7.4 文本模式和二进制模式在Linux下的区别
Linux上read/write不做任何换行符转换,写入\n就是\n(0x0A)。而在Windows上如果以文本模式打开,\n会转换成\r\n。这就导致同一个文件在两个平台上看到的字节数可能不同,很多初学到Linux上写文件后,用十六进制工具一查才发现“文本模式”其实不存在。这是平台差异,不是Windows和Linux谁对谁错,跨平台开发时尤其要注意。二进制数据(结构体、图片、音视频)读写时更应该完全按二进制方式,不要做任何字符转换,所以文件IO题目里经常强调“以二进制方式打开文件”。
8. 深入文件IO:偏移、空洞、原子追加与落盘语义
8.1 lseek与文件偏移:读写位置不像“光标”那么简单
每个打开文件的内核对象里都有一个当前文件偏移,read和write都会从当前偏移处开始,操作完成后自动后移。lseek就是手动调整这个偏移,它本身不触发任何磁盘I/O,只是修改内核里一个数字,所以lseek在编程中经常被用来实现随机访问。
比较意外的行为发生在“偏移越过文件末尾”时。如果先lseek到文件末尾之后100字节,再写一段数据,中间那100字节不会被写,但文件会形成一个“空洞”。空洞在实际文件系统里不占物理块,但用read读出来时全是0字节。这是很多学生不理解的现象:磁盘上文件大小显示为若干K,但du命令查看占用块却很小。
#include <fcntl.h> #include <unistd.h> void createSparseFile(const char* path) { int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644); write(fd, "START", 5); lseek(fd, 4096, SEEK_CUR); // 跳过4096字节 write(fd, "END", 3); close(fd); }这个文件逻辑大小是4104字节,但实际占用可能只有两个扇区的物理存储。文件空洞是数据库、虚拟磁盘镜像里的常见手法,也是学习文件系统布局时的经典例子。
8.2 O_APPEND与“并发写同一文件”的原子性
多个进程同时向同一个日志文件追加日志时,最怕两行日志互相穿插。如果每个进程都先lseek到末尾再write,就会出现时间窗口:两个进程都lseek到同一个位置,然后各自write,后write的覆盖了先write的。解决办法是打开文件时加O_APPEND,这样每次write前内核会原子地把偏移移动到EOF,再执行写入。关键点是“定位偏移”和“写入”这两个动作被内核合并成了一个原子操作。
必须强调的是:O_APPEND保证的是每次write的追加操作是原子的,它不保证两个进程的多条write不会交错完整。如果一条日志超过一次write写不完,多条write之间还是可能被其他进程穿插。要保证一整条日志的完整,要么构建更大的单次写入缓冲,要么用一行加锁。我在做并发日志练习时,就在JSON日志上踩过这个坑,最后是把每条日志先格式化成一块连续buffer,再用一次write写出去。
8.3 read/write与缓冲区:用户态缓冲的内核态缓冲
C标准库的fread/fwrite带用户态缓冲区,read/write不带。用户态缓冲减少的是系统调用次数,因为每次系统调用都要切到内核态,开销不小。但内核里对文件页也有一套页缓存(page cache),所以我们调用write后,数据先进入内核页缓存,并不是立刻写到磁盘。要强制落盘,用fsync或fdatasync。数据库、配置落盘这类场景要求数据不能丢,必须在写完后调用fsync;但对普通日志、临时文件,频繁fsync会让性能急剧下降,因为每一次都可能触发实际磁盘I/O。
| 数据位置 | 何时可见 | 掉电后是否安全 |
|---|---|---|
| 用户态buffer | 立即(仅进程内) | 不安全 |
| 内核页缓存 | 对其他进程立即可见 | 不安全,可能丢失 |
| 磁盘介质 | fsync之后 | 安全 |
我当时写一个快速演示程序,想把“write之后立刻调用fsync”和“不调用fsync”的对磁盘占用耗时做对比,结果差距几乎差了一个数量级。这个体验比看任何原理文章都直观:批量写合并比零散写快得多。
8.4 部分写:write返回值不一定是完整长度
和read一样,write也可能“部分写入”,比如磁盘满、或写入管道时对端缓冲区不足。处理方法是类似read的循环写入,直到剩余长度写完。高并发网络编程里,非阻塞socket上的EAGAIN/EINTR还需要特殊处理。很多网络服务的bug就是“write没写完,直接丢弃了剩余数据”。
8.5 pread/pwrite:不改变当前偏移的读写
正常情况下read/write会移动文件偏移,这在多线程场景下可能互相干扰。pread(fd, buf, count, offset)和pwrite(fd, buf, count, offset)可以在指定偏移处读/写,且完全不改变当前文件偏移。它们非常适合多线程同时读写文件的不同区域,比如数据库系统里多个线程分别写各自负责的区块,用pread/pwrite就省去了每线程一把“偏移锁”的麻烦。
9. 练习之外:把这些知识点串起来的几个实用习惯
重写这八个知识点的过程里,我用几个工具做了验证,效果比单纯看教材好得多,这里分享三个实用的习惯。
第一个习惯是用strace观察系统调用。编写文件IO相关代码时,运行strace -o trace.txt ./your_program,然后查看生成的trace文件。你会看到程序究竟调用了哪些open、read、write、lseek,每个调用的参数和返回值是什么。读这个文件比读十篇博客更能理解“write怎么会返回18而不是20”这种问题。我记得第一次用strace时,发现自己以为的“高效读写”实际被用户态缓冲区切成了几十次系统调用,当时特别震撼。
第二个习惯是写之前先想清楚数据模型。数字反转要问“负数怎么办,溢出了怎么办”;浮点舍入要问“这个值是否允许二进制误差”;差分数组要问“修改和查询是交替还是一次性”;文件IO要问“短读写能不能容忍,要不要落盘,多进程并发会不会出现穿插”。这些问题都是题目中的隐含约束,提前想清楚能省下大量调试时间。
第三个习惯是练习题目时故意造一些边界数据。INT_MAX、文件末尾+1字节、空文件、1字节文件、刚好等于缓冲区大小、缓冲区大小的倍数加减1,这些数据能把大部分边界bug逼出来。我自己写日志冲刺代码时就靠“1字节剩余”发现了一个读循环的漏尾bug,这个bug在正常大小文件下完全不会触发,标准测试用例也测不出来。
最后说一个和这些知识点紧密相关的延伸方向:把这套内容应用到“日志系统”的小项目里。数字反转练整数边界、浮点舍入练格式化、vector练缓冲管理、差分数组练区间统计、文件IO练追加与落盘。把它们串成一个小工具,比单纯刷题更接近真实开发。我就是这么做的,整个过程比预期多花了一倍时间,但学习效果也比预期扎实得多。如果你也想巩固这八个知识点,强烈建议不要只“看答案”,而是每个都手写一遍,再用边界数据跑一遍,再考虑串成一个小系统,收获会明显不同。