1. 先聊两句:这道“大杂烩”到底在练什么
别被标题里一堆名词吓住,数字反转、除数累加、差分数组这些点,说白了就是C++学习和面试里高频出现的“基础组合拳”。我见过不少人单独刷题时都见过,但把它们放到一个项目里串一遍,反而暴露出很多细节问题。比如差分数组和vector插入访问放在一起,你是不是第一时间想到“到底该用insert还是push_back?”再比如矩阵逆置,看起来很基础,但如果你没想清楚“原地转置”和“拷贝转置”的区别,十有八九会在边界上出bug。
这篇就围绕标题里这些点,按我实际练习的顺序挨个拆开讲,每个都会带上代码、坑点和适用场景。最后会重点讲文件IO——这部分理论性强,也是新手最容易卡住的地方。特别适合那些学完语法但做题老出错、或者准备面试但心里没底的读者,把这篇过一遍,基础这一层基本就稳了。
2. 数字反转与除数累加:先把手上的“数字感”练出来
2.1 数字反转的那些“反直觉”细节
先说数字反转,题目本身不难:给你一个整数,把它倒过来。很多教材给的解法是循环取余、乘10累加,代码确实短。但我第一次写的时候就漏了一个关键点——反转一个数时,最高位不能是0。比如120反转之后应该是21,而不是021。
int reverseNum(int x) { int result = 0; while (x != 0) { result = result * 10 + x % 10; x /= 10; } return result; }这段代码本身没问题,但实际场景里真正的坑在溢出。比如一个接近int上限的数,反转之后很可能直接爆掉。如果题目要求返回int,那么在result = result * 10 + x % 10这一步之前就要判断溢出。判断的写法是:
if (result > (INT_MAX - x % 10) / 10) { // 溢出,按题目要求返回0或做其他处理 }为什么这么写?因为乘法溢出是未定义行为,必须先除法/减法推断是否越界再去计算,顺序反了检查就形同虚设了。
再说一个容易被忽略的小点:负数反转怎么办?比如-123反转成-321,大多数人会先处理符号,再对绝对值操作,最后拼回来。但如果直接用上面的循环,也能正确处理负数——因为C++的取余运算是“向零取整”,%的结果与左操作数符号一致,不信你可以自己打印-123 % 10,会是-3。所以上面的函数天然兼容负数。这是C++和其他一些语言不一样的地方,搞清楚一次,后面就不会再因为符号问题绕路了。
2.2 除数累加:不只是“暴力遍历”
除数累加这个说法相对少见,其实指的是求一个数的所有正因数之和。比如12的因数有1、2、3、4、6、12,累加结果是28。最直观的写法是遍历1到n:
int sumDivisors(int n) { int sum = 0; for (int i = 1; i <= n; i++) { if (n % i == 0) sum += i; } return sum; }这个写法没问题,但复杂度是O(n),n只要到10的7次方级别就明显卡顿。优化思路非常经典:因数成对出现,比如(i, n/i),所以循环只需要跑到根号n:
int sumDivisorsFast(int n) { int sum = 0; for (int i = 1; i * i <= n; i++) { if (n % i == 0) { sum += i; if (i != n / i) sum += n / i; } } return sum; }这里有个特别容易漏的边界:当i恰好等于n/i时,比如n=16,i=4,两个因数其实是同一个数,只能加一次,不加if (i != n / i)判断就会重复累加。我最早踩这个坑时,结果总是比预期大一点,排查半天才发现是这里多加了。
顺带提醒一句,i * i <= n这种写法在n接近int上限时有溢出风险,更稳妥的写法是i <= n / i,每次循环做一次除法,但安全性远高于乘法。
这类问题练的不只是代码,而是“当你能找到规则的对称性时,时间复杂度往往能降一个量级”。后面遇到任何成对出现的数学结构,我都会条件反射地先想“能不能只遍历一半”。
3. 差分数组与vector操作:区间修饰问题的实战利器
3.1 差分数组:把“多次区间修改”变成O(1)操作
差分数组很多人第一次听觉得抽象,其实思路特别直观。想象你在一个数组上频繁做“区间加某个数”的操作,比如给[2, 5]都加3,给[3, 7]都减1。如果每次都遍历区间,总复杂度就变成O(N×M),数据一大就跪了。
差分数组的套路是:维护一个diff数组,diff[i]表示原数组第i个数与第i-1个数的差值。对区间[l, r]加v,只需要改两个位置:diff[l] += v,diff[r+1] -= v,然后通过一次前缀和还原原数组。
// 假设原数组长度为n,diff大小为n+2(预留边界) void rangeAdd(vector<int>& diff, int l, int r, int v) { diff[l] += v; diff[r + 1] -= v; } vector<int> restoreArray(vector<int>& diff, int n, int baseValue) { vector<int> arr(n, baseValue); int cur = baseValue; for (int i = 0; i < n; i++) { cur += diff[i]; arr[i] = cur; } return arr; }几个实操要点:
- diff数组要比原数组多开几位,因为要操作r+1这个位置,开n+2最稳,省得越界判断。
- 多次修改之间没有先后依赖时,可以顺序修改diff,最后统一还原。如果修改操作有依赖关系,那就必须按顺序边做边还原,不能攒到最后。
- 差分数组的原数组通常不是真实值,而是“基底值 + 累计增量”的结构,这条思路在二维场景下还能扩展成二维差分,但先把一维吃透更重要。
3.2 vector插入和访问:push_back优势背后的性能账
vector可能是C++里最常用的容器,但多数人只在“用”的层面,没有想清楚“该用哪个接口”。我在实际编码里见过不少性能问题,源头都是对vector接口理解不透。
先给结论,再解释为什么:
- 尾部插入用push_back/emplace_back,均摊O(1)。
- 头部或中间插入用insert,O(n),数据量大时不要频繁使用。
- 随机访问用[]或at(),后者带越界检查,但性能略慢。
- 遍历时不要用insert擦除元素后再继续用同一个迭代器,迭代器会失效。
我见过一个真实案例:有人用insert往一个已经reserve过的vector头部反复插入,以为内存够就不慢,但问题根本不在内存,而在于尾部移动。每次头部插入,后方所有元素都要后移一位,1万条数据每次插入都要移动1万个元素,你算算这个成本。
再聊聊访问。at()会做越界检查,越界直接抛std::out_of_range;operator[]不检查,性能高但风险大。我在写比赛代码时习惯用[],写工程代码时反而更倾向于at(),因为工程代码更看重安全性和可维护性。这个取舍没有绝对答案,但你要主动去选,而不是永远只用一种写法。
vector和insert还有一个搭配技巧:如果你明确需要频繁在中间插入,但插入次数有限,可以先把数据存到头部,最后整体reverse一次,有时候比每次insert要快很多——核心思路是把多次O(n)合并成一次O(n)。
3.3 矩阵逆置:原地转置的操作边界
矩阵逆置,也就是转置,趁着vector的话题正好一起讲。因为题目通常要求“矩阵就地转置”或者“返回新矩阵”,这两种做法本质不同,但很多初学者分不清。
先看返回新矩阵的做法,这个简单:
vector<vector<int>> transpose(vector<vector<int>>& matrix) { int m = matrix.size(), n = matrix[0].size(); vector<vector<int>> result(n, vector<int>(m)); for (int i = 0; i < m; i++) for (int j = 0; j < n; j++) result[j][i] = matrix[i][j]; return result; }关键点是行数m和列数n在转置后交换,所以result必须初始化为n行m列,而不是保持m行n列。很多人在这犯迷糊,一看到result[j][i]就忘了提前把维度也调换。
再看原地转置。只有方阵(m == n)才支持原地,因为转置本质是对对角线做元素交换。代码不复杂:
void transposeInPlace(vector<vector<int>>& matrix) { int n = matrix.size(); for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { swap(matrix[i][j], matrix[j][i]); } } }注意j从i+1开始,因为i==j的对角线元素不需要交换。如果j从0开始,你会把已经换过的元素又换回去,结果等于没转置。
这里还有一个容易被忽略的问题:vector<vector >的“行”之间并不保证内存连续。网上常说的“二维数组在物理上是一段连续内存”只对静态数组成立,vector套vector每一行是独立分配的堆内存。你想通过内存连续性提升性能,要么改用一维数组模拟二维坐标(i*cols + j),要么使用std::array或自定义扁平存储。这也是为什么工程性能优化里经常把扁平化作为第一个手段。
4. 小数四舍五入与向上取整:格式化输出的精确控制
4.1 printf圆整:银行家舍入还是四舍五入?
C/C++的printf家族对浮点数的舍入行为,很多人没深究过。其实printf使用的舍入模式通常是由当前浮点环境决定的,默认情况下是“四舍六入五成双”,也叫银行家舍入或Round Half to Even。这意味着printf("%.0f", 2.5)可能输出2,而printf("%.0f", 3.5)输出4——因为2是偶数,3.5离4是偶数。这里就很容易踩坑:想要“严格四舍五入”时,printf不一定给你想要的答案。
我在实际项目中曾经遇到一次金额展示差异,排查到最后就是这种舍入模式导致的。如果业务明确要求“四舍五入”,那么用printf格式化之前,先给你要的数加上一个微小偏移,或者干脆不用printf做舍入,而是用标准库函数处理后输出。
标准库提供三个方向:
std::round():向最近的整数舍入,远离零方向处理0.5,即2.5变3,-2.5变-3,这是“标准四舍五入”。std::floor():向下取整,2.99变2。std::ceil():向上取整,2.01变3。
这三个函数都在 头文件里,接受double/float参数,返回浮点数或整数,视重载版本而定。
4.2 整数向上取整:数学公式远比调库快
如果你是做整数之间的向上取整,比如把文件大小按块对齐,调库反而是绕远路,因为double运算有精度问题且慢。正确姿势是用公式:
int ceilDiv(int a, int b) { return (a + b - 1) / b; }比如a=5,b=2,结果是(5+1)/2=3,正好是向上取整。但注意这个公式在a和b都是非负数时成立;如果a或b可能是负数,写法就要变成(a + b - 1) / b配合符号讨论,或者用(a > 0) ? (a + b - 1) / b : a / b。
另一个容易忽略的坑:(a + b - 1)在a接近INT_MAX时可能溢出,稳妥写法是a / b + (a % b != 0),这样既避免溢出,语义也更清晰。我建议在实际项目里优先用后者。
同理,对浮点数做正数方向的“三位小数截断”时,可以先乘1000向上取整后再除回来,但精度会受影响。如果追求精确,最好转成定点的整数计算,比如金额按“分”存储为long long,全程不碰浮点。
5. 基础文件IO:从fopen到流式读写,理解“缓冲”才不迷糊
5.1 fprintf家族的常规操作
文件IO里最基础的是C风格的fopen/fprintf/fread/fwrite这套。很多人一上来就背函数签名,没人解释底层发生了什么,所以换个场景就不知道怎么用了。
FILE* fp = fopen("data.txt", "w"); if (fp == NULL) { perror("fopen error"); return -1; } fprintf(fp, "%d", 100); fclose(fp);这段代码基本都能看懂,但有几个点值得展开。
第一,fopen的打开模式,细节决定行为差异。"w"会直接清空已有文件内容;"a"是追加,不会丢旧数据;"r+"要求文件已存在。我在一次写日志程序时误用"w"打开旧日志,结果所有历史记录被一次性清空。这种教训只能记住:写文件前,先确认打开模式。
第二,fclose除了释放资源,还承担了“刷新缓冲区”的职责。fwrite之后没fclose,数据可能还在内存缓冲区里,程序崩溃后文件是空的。很多人以为不关文件也无所谓,其实这是最危险的假象。为了保险,我养成的习惯是:打开一个文件,立刻在相邻写一个fclose。这样即使中途出错,也能有意识地回头检查。
5.2 二进制读写的坑:fprintf和fread的“错配”
再来看fread/fwrite,它们是二进制读写的基础函数。一个常见的坑是:写入用fwrite,读出却用fscanf。大多数情况下fscanf读二进制文件读不出来字符串或数字,因为fscanf是按文本格式去解析的。
我自己写过一个小工具,用fwrite存一个结构体数组,然后忘记用fread去读,改用fscanf按"%d"的方式读,结果全部读取失败。二进制读写的原则就是:配套使用,不要混搭。fwrite一次写入多少字节,fread就按多少字节去读。下面是一个结构体读写的最小示例:
struct Data { int id; double value; }; // 写入 FILE* fp = fopen("data.bin", "wb"); Data d = {1, 3.14}; fwrite(&d, sizeof(Data), 1, fp); fclose(fp); // 读取 fp = fopen("data.bin", "rb"); Data out; fread(&out, sizeof(Data), 1, fp); fclose(fp);这里还有一个非常隐蔽的问题:结构体里如果有指针,绝不能直接fwrite整个结构体。因为指针保存的是进程地址空间里的内存地址,下次运行地址完全变了,读回来是一个无效地址。正确做法是单独保存指针指向的内容,或者用序列化方案。
5.3 文件流式读写:缓存区与性能调节
基础文件IO往往绕不开“缓冲区”这个概念。fopen默认是全缓冲模式,缓冲区大小通常是4KB或8KB。这意味着你调用fprintf时,数据并不是立刻进入内核,而是攒在用户态的缓冲区里,等缓冲区满了再一次性提交。
这种设计的好处是省系统调用,坏处是程序崩溃时容易丢数据。一个常见的性能调优动作是修改缓冲区大小:setvbuf(fp, buffer, _IOFBF, buffer_size),可以按需调整。我曾在写入上百MB日志的场景下,把默认8KB缓冲区改成1MB,写盘耗时下降了约30%级别,代价是内存占用上升,这需要你在内存和吞吐之间做权衡。
6. 深入文件IO:从FILE到文件描述符,内核视角下的读写
6.1 文件描述符与open/write/read体系
如果你了解基础文件IO,但没接触过POSIX系统调用,那深入文件IO这块最好和基础版本对照着理解。基础版的fopen/fread/fwrite是C标准库封装,最底层最终也会调用系统调用,但中间加了一层缓冲和线程安全的封装。而这一章要讲的open/write/read/close,是内核直接提供的接口。
#include <fcntl.h> #include <unistd.h> int fd = open("data.txt", O_WRONLY | O_CREAT, 0644); if (fd < 0) { perror("open failed"); return -1; } write(fd, "hello", 5); close(fd);文件描述符(file descriptor)是进程级的整数句柄,0、1、2分别对应标准输入、标准输出、标准错误。每次open之后,系统会把这个fd挂到进程的文件表上,读写都通过这个整数去找内核里对应的文件对象。
要注意的点:open的返回值是int,但每次open都可能返回3、4、5……,如果一个进程打开了过多fd没有close,到一定数量后open会返回EMFILE错误。这在循环里频繁打开文件、忘记close时特别容易发生。合理的做法是用完即关,或者用RAII风格封装(比如unique_ptr配合自定义deleter)。
6.2 偏移量与pread/pwrite:并发场景下的利器
大多数人在read/write时只关注了读写多少字节,忽略了当前文件偏移量。read后再read,第二次读的位置其实已经在第一次读到的位置之后了,因为内核自动推进了偏移量。这是默认的连续读行为。
但某些场景需要你在指定位置读、不改变偏移量,比如多线程并发读同一个文件的不同区域。此时可以用pread和pwrite,它们接受一个offset参数,读写结束后不会移动fd对应的文件偏移量。
ssize_t pread(int fd, void* buf, size_t count, off_t offset); ssize_t pwrite(int fd, const void* buf, size_t count, off_t offset);并发环境下如果共用同一个fd去read,偏移量的维护是竞态条件,你根本没法保证两个线程读的地方不打架。换成pread,每个线程自己指定offset,就没有这个问题。这个细节在面试里经常被问,能答说出“读写偏移量独立控制”的人往往会被高看一眼,因为说明你了解系统调用和文件偏移机制。
6.3 缓冲区细节:用户态缓冲 vs 内核页缓存
深入文件IO绕不开另一个层次:内核页缓存。write系统调用把数据从用户空间拷贝到内核空间,先写进页缓存,然后由内核的写回机制在合适时机把脏页刷到磁盘。用户态有fopen的缓冲区,内核态有页缓存,两者是不同的东西,别混在一起。
这就引出一个非常常见的问题:“我调用了write,数据保证写进磁盘了吗?”答案是不保证。要强制落盘,用fsync(fd),它会等待内核把文件数据和元数据写入磁盘。对于数据库、日志系统等对持久性敏感的场景,每次关键事务后都要考虑调用fsync,但高频调用性能代价很大,所以工程上通常会做组提交或批量fsync。
我当时的练习是写一个简易日志模块:
- 小日志实时落盘用fprintf + fflush,简单够用。
- 关键错误路径上,用fsync强制刷盘,确保系统崩溃后日志也尽量不丢。
- 高性能批量写场景,直接收集日志到内存buffer,攒批统一write一次。
这样的分层设计让我理解了两端缓冲各自的角色,也对“为什么有些日志框架有多级buffer”有了直观认识。
7. 实操练习题:把这八个知识点一次串起来
7.1 题目设计思路
为了不做“看了就忘”的浏览式学习,我强烈建议自己动手做一个综合小练习。我这里提供一个自己设计的组合型题目,覆盖前面大部分知识点:
从文件
input.txt中按行读取整数,每行可能包含多个空格分隔的数字,也可能包含小数(如3.14)。每次读到一个整数就反转并累加其所有正因子;如果是小数,则单独统计四舍五入及向上取整的结果。最后将处理后的数据输出到output.txt,要求输出格式为:反转后整数、因子累加结果、向上取整结果,各占一列。中间用差分数组思想维护一个长度可变的统计数组,要求使用vector存储并练习insert/at/push_back等多种访问方式。最终再用自定义write输出到新文件,并尝试用pread/pwrite实现随机位置的读取验证。
这个题目等于把所有点强行揉在一起,适合自测。个人实践时不要怕代码难看,重点在于完整走通一遍。
7.2 实现时的避坑清单
- 反转时要防溢出,尤其是10位数以上的整数,建议先用long long试算再决定返回类型。
- 除数累加不要重复加相同因子,用
i != n / i判断。 - 差分数组越界问题提前预留,多开两个位置。
- 写文件之前确认mode是"a"还是"w",这个错误很致命。
- 用fread/fwrite时,结构体内部不要含指针。
- printf舍入不等于业务四舍五入,金额类场景不要依赖printf。
实现过程中我特别推荐把每一小步的运行结果都打印出来,分阶段验证,不要等全部写完再去调试。这样一旦出错,排查范围会小很多。
8. 最后再分享两个实用经验
首先,如果你刚接触这类综合练习,瓶颈往往不是语法,而是调试方法。我建议练习时打开编译器的地址消毒器(Address Sanitizer),这个工具可以帮你在运行时捕获数组越界、use-after-free等内存问题,能把很多隐蔽bug直接暴露出来。基础练熟了,再关掉这些辅助,体验完整的系统行为。
其次,文件IO这块后续还可以继续扩展。试一下用mmap做内存映射文件,或者用文件锁解决多进程写同一个文件的问题,都是很自然的进阶方向。基础文件IO和深入文件IO不是两个割裂的东西,你可以把它们想象成同一辆车的方向盘和发动机——C标准库给你的是好开的车,系统调用是引擎内部的结构件。懂了一层再去下一层,就会有一种“原来如此”的确认感。
按题目顺序把这八个点逐一练完,你会发现原来零散的知识开始有了联系:vector的访问方式会影响差分数组的实现;数字反转的溢出判断同样适用于除数累加的边界处理;文件IO的缓冲机制又直接决定了你在什么时候调用fflush合适。这种“点连成线”的感觉,才是做综合练习最大的收获。