今天是我做C++课后习题训练的第85天。三个多月坚持下来,最大的感受不是“我变强了”这种虚话,而是面对一道题的时候,脑子里会自然浮现出考点对应的实现路径,哪怕第一遍写不对,也知道该往哪个方向调。这个状态,和第一天相比差得太远了。
第85天记录的核心内容包括:多维数组与指针的辨析、快速幂取模、单调栈模板题,外加几道典型的入门练习题。这篇文章不打算写成一套完整的教程,而是把这一天从读题、写码到提交、查错的全过程摊开,聊聊哪些地方容易卡壳,哪些坑值得后来人绕开。如果你也在学C++、刷OJ,或者准备笔试里的算法题,这篇记录应该能给你一些参考。
1. 训练第85天的规划与选题思路
1.1 为什么到第85天还在做“课后习题”
很多朋友听到“课后习题”四个字,第一反应是这是新手才做的事。但我的看法不太一样。C++这门语言,语法细节多到离谱,很多知识点上课听懂了、看书看懂了,和真正在编译器里跑通完全是两回事。课后习题训练的价值就在于,它会逼着你把“眼睛会了”变成“手会了”。
到第85天这个阶段,我做题的目标已经不再是为了学会某个语法,而是为了验证对知识点的理解深度。比如多维数组和指针的关系,书上一句话带过,但真让你写一个函数去遍历二维数组并修改元素,不少人会卡在参数类型上。这种细节光靠看是看不出来的,只有亲手写过一遍、被编译器报过错,才能真正记住。
我的训练节奏也比较固定:白天抽1小时做2到3道题,晚上花20分钟看代码和编译报错,周末做一次复盘。这个节奏不是一开始就有的,是前30天试出来的。太猛了坚持不住,太松了没有手感,一天2到3题刚好能保持状态,又不至于挤占正常生活。
1.2 当天的题目清单与考点匹配
Day85我挑了5道题,题目来源是课程配套的OJ题库和几次笔试回忆题。选题思路很简单:先扫一眼考点分布,再挑自己最近薄弱的方向集中练。当天的题目和对应考点如下表所示:
| 题目 | 核心考点 | 对应热词 |
|---|---|---|
| 二维数组按列求和 | 多维数组、指针传参 | 多维数组 c++ 指针 |
| 快速幂取模 | 二分思想、取模运算 | 快速幂算法c++ |
| 每日温度(变体) | 单调栈、栈存储下标 | 单调栈算法c++ |
| n个整数的最小公倍数 | 数论基础、gcd | n个整数的最小公倍数怎么求c++ |
| 字符串数组初始化与输出 | 字符串数组、边界处理 | c++字符串数组初始化 |
这5道题不是随便选的。二维数组那道题是为了巩固指针和数组的关系,快速幂是为了强化二分思维,单调栈是本周专项训练的重点,最小公倍数那道题则是对数论基础的查漏补缺,最后一道字符串题是我一直容易出小错的类型,特意拿来练手感。
2. 核心知识点拆解:从Day85的题目反推C++高频考点
2.1 多维数组与指针:被无数人绕晕的一组概念
先说说二维数组按列求和那道题。这个题目本身不复杂,一个二维数组,让你对每一列求和,输出结果。但问题是,当这个数组作为函数参数传递的时候,函数形参该怎么写?
新手容易写出这样的代码:
void sumByColumn(int** arr, int rows, int cols) { // ... }但传参的时候,如果你传的是一个二维数组名,编译器很可能会报错:cannot convert int (*)[cols] to int**。原因在于,二维数组名在表达式里会退化成指向第一个元素的指针,而第一个元素是一个一维数组,所以类型应该是“指向数组的指针”,也就是int (*)[cols],而不是int**。
我后来习惯用三种方式处理这种情况:
- 直接用固定列数:
void sumByColumn(int arr[][5], int rows) - 用指针数组:
void sumByColumn(int (*arr)[5], int rows) - 用一维指针加手动换算:
void sumByColumn(int* arr, int rows, int cols),然后通过arr[i * cols + j]访问
第三种方式最灵活,适合列数不固定的情况。它的本质就是搞明白二维数组在内存中是按行连续存储的,所以一个下标运算就能定位到任意元素。多维数组看似复杂,拆到底就是“内存布局 + 指针运算”两个问题。
注意:使用
int**来处理二维数组,只有在传入的是指针数组(即int* arr[])时才成立。严格来说,它不是真正的二维数组,而是数组的元素是指针。这两者容易混淆,写代码前先想清楚你手上到底是哪种结构。
2.2 快速幂取模:递归与迭代两种写法对比
快速幂那道题是当天最有价值的训练之一。题目大意是:给定a、b、m,求a的b次方对m取模的结果,其中b的范围可以很大,大到10^9级别。
如果直接写循环累乘,b次运算肯定超时。快速幂的核心思想是二分:要求a^b,可以分成两个a^(b/2)相乘。当b是偶数时,a^b = (a^(b/2))^2;当b是奇数时,a^b = a * a^(b-1)。
我最初写的是递归版本:
long long fastPow(long long a, long long b, long long m) { if (b == 0) return 1 % m; long long half = fastPow(a, b / 2, m); half = half * half % m; if (b % 2 == 1) half = half * a % m; return half; }这个写法思路很清晰,但递归深度是O(log b),在b特别大的时候会有栈溢出风险,所以我后来改用迭代版本:
long long fastPow(long long a, long long b, long long m) { long long res = 1 % m; a %= m; while (b > 0) { if (b & 1) res = res * a % m; a = a * a % m; b >>= 1; } return res; }迭代版本的原理是:把b写成二进制,遍历每一位。如果当前位是1,就把结果乘以当前的a;每次循环把a平方,对应二进制位权。时间复杂度同样是O(log b),但空间复杂度降到了O(1),实测大数据量下稳得多。
关于取模,一个关键结论是:(a * b) % m = ((a % m) * (b % m)) % m。所以可以在每一步乘完后都取一次模,避免中间结果溢出。但注意,如果m很大(接近long long上限),a * a这一步仍然可能溢出,这时候需要用__int128临时存储,或者用快速乘来规避。
2.3 单调栈:一类问题的统一解法
单调栈这道题是“每日温度”的变体,给定一个数组,找每个元素右边第一个比它大的元素距离。直接暴力是O(n²),n超过10^5就吃力了。
单调栈的思路是维护一个栈,栈内元素保持单调(递增或递减)。以找右边第一个比当前元素大的距离为例,我从右往左遍历数组,维护一个从栈底到栈顶单调递减的栈(存下标)。每遇到一个新元素,就把栈中所有比它小的元素弹出去,栈顶元素就是右边第一个比它大的元素下标。
vector<int> dailyTemperatures(vector<int>& t) { int n = t.size(); vector<int> ans(n, 0); stack<int> st; for (int i = n - 1; i >= 0; --i) { while (!st.empty() && t[st.top()] <= t[i]) { st.pop(); } ans[i] = st.empty() ? 0 : st.top() - i; st.push(i); } return ans; }第一次看这个模板可能会觉得抽象,但它本质上就是一个“淘汰制”:每个元素入栈之前,检查栈顶元素是否比它小,如果是,说明这个栈顶元素对后续元素来说永远不可能是答案了,可以放心丢掉。每个元素最多入栈一次、出栈一次,总时间复杂度是O(n)。
我做这道题的心得是:不要死记模板,而是先理解“为什么右边的更近元素会淘汰左边的更远元素”这个逻辑。理解了淘汰规则,遇到变体题(比如找左边第一个更大元素、滑动窗口最大值)的时候,才能灵活调整遍历方向和比较符号。
3. 实操过程:从读题到提交的完整记录
3.1 环境与配置:把vscode调成一个够用的C++环境
开始解题之前,我先把环境确认了一遍。现在很多新人第一步就卡在环境配置上,其实大可不必。我用的是vscode加MinGW-w64,配合C++插件,跑课后习题完全够用。关键是配置文件要简洁,别整一堆用不上的参数。
我用的编译参数是:
g++ -std=c++17 -O2 -Wall -o main.exe main.cpp在vscode里,我直接在.vscode/tasks.json里配了一个编译任务,快捷键Ctrl+Shift+B触发,生成main.exe,然后再用终端手动运行。这样比每次敲一长串命令省事,又不会像调试配置那样繁琐。
{ "version": "2.0.0", "tasks": [ { "label": "build", "type": "shell", "command": "g++", "args": ["-std=c++17", "-O2", "-Wall", "-o", "main.exe", "${file}"], "group": { "kind": "build", "isDefault": true } } ] }提示:
-Wall一定要加上,它会把很多潜在问题变成警告提示出来。很多隐蔽bug在编译阶段就能暴露,省去后面调试的时间。等代码写完要提交OJ时,再把-Wall去掉就行。
3.2 字符串输入与数组初始化的几个经典坑
当天那道字符串相关的题目,让我又把输入处理的坑踩了一遍。最典型的坑是cin >> s和getline(cin, s)的行为差异。cin >> s读到空白字符就停,getline会一直读到换行符。如果两种方式混用,中间残留的换行符会让getline读到一个空串。
正确的做法是:如果先用了cin >> n,再想用getline读一整行,中间要加一句cin.ignore()把缓冲区的换行符吃掉。这个问题几乎每届学生都会遇到,写代码前先想清楚输入中到底有没有空格。
字符串数组初始化也有讲究。常见的写法:
string s[10]; // 10个空字符串 string s[3] = {"a", "bb", "ccc"}; char str[5] = "hello"; // 错误!长度不够,hello需要6字节最后一行是我当年踩过的坑。C风格字符串的结尾要留一个位置给'\0',所以char str[5]根本装不下"hello"。这种错误编译器不一定报错,运行时却可能悄悄越界,反应到OJ上就是莫名其妙的runtime error。
3.3 快读模板与运行时间优化技巧
做快速幂那道题的时候,我顺手把快读模板复习了一遍。虽然现在OJ的评测机配置普遍不差,但遇到大数据量的输入,cin和scanf的性能差距还是能体现出来的。
最简单的优化是关掉C++输入输出流和C标准库的同步:
ios::sync_with_stdio(false); cin.tie(0);加了这两行之后,cin和cout的性能基本能追上scanf/printf。但要注意,一旦关闭同步,就不能再混用cin和stdin家族的输入函数了,否则数据读取顺序可能出错。
如果题目给的数据量特别夸张,比如一行几百万个整数,我会直接用手写快读:
int readInt() { int x = 0, f = 1; char c = getchar(); while (c < '0' || c > '9') { if (c == '-') f = -1; c = getchar(); } while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = getchar(); } return x * f; }这个模板的原理很朴素:用getchar一个字符一个字符地读,遇到数字就累加,遇到非数字就继续跳过。它比cin快的原因在于省去了格式化解析的开销。但说实话,90%的课后习题用不到快读,掌握原理就够了,别本末倒置。
4. 当日踩坑与问题排查记录
4.1 编译错误三连:从报错信息里找线索
Day85当天遇到了三个编译或运行错误,我都记录下来了:
| 报错现象 | 原因 | 解决方案 |
|---|---|---|
error: invalid conversion from int (*)[5] to int** | 函数形参类型不匹配 | 改成int (*)[5]或手动做一维指针换算 |
warning: unused variable | 定义了变量但没使用 | 检查逻辑,删掉多余变量 |
segmentation fault | 数组越界或空指针解引用 | 用gdb定位,常见于动态规划数组开小了 |
我的排查习惯是先读完整报错信息,再定位到具体行号,最后用输出中间变量的方式确认问题。很多人一看到segmentation fault就不知所措,实际上只要在关键位置加几行cout << "reach here" << endl,就能很快缩小问题范围。等代码稳定后再把调试输出删掉就行。
4.2 一个隐蔽bug:负数参与了求余运算
最小公倍数那道题里,我一开始写了个求最大公约数的函数,然后通过a / gcd(a, b) * b来算最小公倍数。思路没问题,但有一个隐藏风险:如果输入里有负数,%运算的结果在不同语言里的行为不一样。C++中,负数取模的结果符号和被除数一致,比如-7 % 3的结果是-1而不是2。
这个细节在很多场景下不是问题,但一旦用错了地方,就是选择题全对的代码、提交却全是WA的尴尬局面。我的处理方式很简单:所有可能为负的变量,在参与取模运算前先转到非负,或者干脆在题目输入阶段就统一处理。做题前先看一眼数据范围,如果题目保证非负,就不需要额外操心;如果没有保证,一律先加偏移量转成正数。
4.3 复盘环节:为什么选择这些题目、如何沉淀
每天训练结束,我会花十分钟写一个简单的复盘,内容包括:每道题的思路摘要、写错的地方、最终优化方向。格式不需要复杂,一个Markdown表格就够:
| 题目 | 我的解法 | 耗时 | 错误点 | 收获 |
|---|
这个习惯坚持到第85天,最大的变化是:以前写错一道题,过两周就忘得干干净净;现在错过的点都会被记录在案,隔一段时间翻出来看一眼,下次再遇到类似问题,第一反应就是“这个坑我见过”。错题集的价值不亚于刷题本身。
5. 训练第85天后的调整方向
5.1 从课后习题向实战方向过渡
坚持到这个阶段,课后习题的边际收益开始下降。语法和基础算法已经比较熟了,但真要走向实际开发或更进阶的面试,还需要补一些工程向的知识。我给自己规划的新方向是:
- 多线程编程:理解
std::thread、std::mutex的基本用法,然后尝试写一个简单的线程池 - 回调函数:搞清楚回调在C++里的实现方式,包括函数指针、
std::function和lambda表达式 - Linux下的UDP通信:用
socket写一个简单的收发消息程序,理解网络编程的基本流程
这些方向不是一时兴起。从面试角度看,C++岗的高频考点就是多线程、内存管理、网络编程这几块;从项目实战角度看,课后习题的题量积累能帮我快速读懂别人写的代码,而不是对着开源项目一头雾水。
5.2 给同样在坚持训练的朋友几点建议
根据自己的实际经验,我想分享三点:
第一,训练记录不能只写“今天做了3道题”这种流水账,要记录具体的技术点、错误和复现方法,才有积累价值。第二,不要排斥做“简单题”。简单题做多了,基础会更扎实,遇到复杂题的时候,底层知识才不会掉链子。第三,坚持比速度重要。第85天回头看,真正让我进步的不是某一天爆刷10题,而是每一天都保持编码的节奏。
如果你也在这个阶段,建议你先别急着追求难题怪题,把当天做完的题目复盘清楚,比多做一道新题有用得多。毕竟,C++课后的习题训练,练的是把知识变成肌肉记忆的过程,急不来。
第85天真正教会我的一件事是:课后习题训练,练的不是“这道题我会不会”,而是“当一道我不会的题摆在面前时,我有没有一套稳定的拆解流程”。只要这个过程还在,第185天和第385天就都值得继续写下去。