说实话,刚开始做这个课后习题训练记录的时候,我没想到自己能坚持到第 115 天。C++ 这门语言,入门容易,但是想真正把语法转换成解决问题的能力,中间有一道很长的坎。加上网上一搜全是“十小时精通 C++”“三个月进大厂”这种标题,看得人更焦虑。我这 115 天没有做什么大项目,就是老老实实刷课后习题、抠底层细节、整理笔记,反而比那些满屏飘过的速成教程踏实得多。
这篇博文,是我 Day 115 当天训练的一份完整记录,包含当天做的题目、每道题背后涉及的 C++ 核心知识点、我在 VSCode 里实际调试时踩过的坑,以及我这 115 天总结出来的一套刷题方法论。如果你正在学 C++,或者刚学完语法准备进入算法训练阶段,又或者你被指针、链表、动态内存这些概念折磨得怀疑人生,这篇内容应该能给你一些真实可参考的经验。
1. 今日训练内容概览与选题思路
1.1 今天练了哪些题
很多初学者问过我一个问题:课后习题那么多,到底该怎么选?我自己常用的一个策略就是“按知识点缺口来选题”,而不是按章节顺序盲目往下刷。今天这 5 道题,是我结合最近的学习进度,专门针对“数组、指针、字符串、链表、函数指针”这几个薄弱点挑选的。
具体题目清单如下:
- 题目 1:用 C++ 实现冒泡排序,并封装成函数。
- 题目 2:输入一个正整数,判断它是否为质数,要求尽量优化效率。
- 题目 3:使用字符串数组存储一组单词,并逆序输出。
- 题目 4:用结构体实现单向链表的头部插入和遍历。
- 题目 5:通过函数指针实现简单的计算回调。
这 5 道题看起来都不算难,但它们涵盖了 C++ 初学阶段最重要的几块内容:数组与循环、算法优化、字符串处理、结构体与动态内存、以及回调思想。尤其是第 4 题和第 5 题,几乎每个学 C++ 的人都会在这里卡一段时间。
1.2 课后习题不能瞎刷,要有目标
刷课后题最怕的就是“自我感动式努力”,今天做 20 道简单题,明天把代码抄一遍,回头一问还是什么都不会。我给自己定的规矩是:每道题必须有明确的训练目标,做完以后能说出来“这道题让我学会了什么”。
举个例子,冒泡排序很多人都会写,而且网上代码一抓一大把。但如果你只抄一遍,那这个题对你来说毫无意义。我给自己的目标是:不参考任何资料,独立写出带“提前退出优化”的版本,并说清楚为什么能提前退出。只有到这个程度,这道题才算真正吃透了。
2. 核心代码拆解:这 5 道题教会我的 C++ 细节
2.1 冒泡排序:最简单的排序,藏着最值得抠的细节
冒泡排序的代码基本是 C++ 教材里必讲的。但我还是建议初学者亲手写一遍,不要直接复制。因为写的过程中你会发现一堆“我以为我会了”的问题,比如:外层循环到底要走几趟?内层循环的边界条件为什么是n - 1 - i?交换两个变量的值有几种写法?
下面是我今天的实现:
#include <iostream> using namespace std; void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { bool swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr[j], arr[j + 1]); swapped = true; } } if (!swapped) { break; } } } int main() { int arr[] = {5, 2, 9, 1, 5, 6}; int n = sizeof(arr) / sizeof(arr[0]); bubbleSort(arr, n); for (int i = 0; i < n; i++) { cout << arr[i] << " "; } cout << endl; return 0; }这里有个关键点很多人不知道:sizeof(arr)在函数内部和main函数里得到的结果不一样。因为在main里arr是数组,sizeof(arr)返回整个数组占用的字节数;但一旦作为参数传给函数,arr会退化成一个指针,sizeof(arr)就变成指针的大小了。这也是为什么bubbleSort必须额外接收一个n参数,而不是在函数内部自己算数组长度。
另外一个细节是bool swapped这个标志位。如果某一趟排序过程中一个元素都没有交换,说明数组已经有序了,这时候可以直接跳出循环。这在最坏情况下没有帮助,但面对一个近乎有序的数组时,能大幅减少不必要的比较。我实测下来,对一个几乎排好序的 10 万元素数组,这个优化能减少大约一半的比较次数,还是挺香的。
2.2 质数判断:从暴力到开方优化的推导过程
判断质数这道题,是练习算法思想的好素材。很多新手写出来的第一版是这样的:
bool isPrime(int n) { if (n < 2) return false; for (int i = 2; i < n; i++) { if (n % i == 0) return false; } return true; }逻辑没问题,但效率很低。如果n是 1 亿左右的数,循环要跑接近 1 亿次。实际上,我们只需要检查到sqrt(n)就够了。原因很简单:如果n有一个大于sqrt(n)的因子a,那么必然存在一个小于sqrt(n)的因子b = n / a。也就是说,因子是成对出现的,检查完较小的那一半,就相当于检查完了全部。
但直接写i * i <= n有一个隐患:当n很大时,i * i可能发生整数溢出。更稳妥的写法是用除法来判断边界:
bool isPrime(int n) { if (n < 2) return false; if (n == 2) return true; if (n % 2 == 0) return false; for (int i = 3; i <= n / i; i += 2) { if (n % i == 0) return false; } return true; }这个版本做了三处优化:第一,直接把 2 单独处理,偶数全部返回false;第二,循环从 3 开始,每次步进 2,只检查奇数;第三,用i <= n / i代替i * i <= n,既避免了乘法溢出,编译器也不会每次都重新计算除法,实际性能很稳。这道题我训练时的目标是“把每一步优化的理由背下来”,这样才能在面试或者考试里写出让面试官眼前一亮的版本。
2.3 字符串数组:C 风格和 C++ 风格的对比练习
字符串处理在 C++ 里是个大坑,因为 C++ 同时存在两套字符串体系:C 风格字符数组和 STL 的std::string。很多初学者不理解为什么教材要两套都讲,等他们用 C 风格字符串写出各种乱码和越界之后,才会明白std::string到底帮你省了多少事。
今天这道题是“逆序输出单词”。先用 C 风格字符数组写一版:
#include <iostream> #include <cstring> using namespace std; int main() { char words[][20] = {"hello", "world", "cpp"}; int size = sizeof(words) / sizeof(words[0]); for (int i = size - 1; i >= 0; i--) { cout << words[i] << " "; } cout << endl; return 0; }这里比较隐蔽的一个知识点是字符串数组的初始化方式。char words[][20]是一个二维字符数组,每一行最多存 19 个字符加一个结束符\0。sizeof(words[0])是 20,sizeof(words)是整个数组的大小,两者相除就得到行数。这套写法的关键坑在于,一旦某个字符串长度超过 19,就会发生缓冲区溢出,程序可能直接崩溃,也可能运行出诡异的结果。别问我是怎么知道的,调试到想砸电脑的时候就会彻底记住了。
再看std::string的版本:
#include <iostream> #include <string> using namespace std; int main() { string words[] = {"hello", "world", "cpp"}; int size = sizeof(words) / sizeof(words[0]); for (int i = size - 1; i >= 0; i--) { cout << words[i] << " "; } cout << endl; return 0; }两版代码逻辑几乎一样,但区别在安全性上。std::string会自动管理内存,无论多长的字符串都不会有溢出的问题。而且在做逆序操作时,STL 还提供了现成的reverse()函数,配合std::vector使用会更灵活。
这里建议大家做一个对比练习:用两套写法分别实现同一个功能,然后比较代码量、可读性、出错概率。这种对比训练的价值远高于单纯抄一遍代码。
2.4 链表插入:指针引用传参的实战意义
链表是 C++ 指针学习的分水岭。很多人的指针“一看就懂,一写就废”,就是因为缺少实际的指针操作训练。今天这道题虽然只是最简单的头部插入,但涉及的知识点非常多:结构体、构造函数、动态内存分配、指针的指针(或者说引用传参)。
我第一次写链表插入的时候,代码是这样写的:
struct Node { int data; Node* next; Node(int val) : data(val), next(nullptr) {} }; void insertAtHead(Node* head, int val) { Node* newNode = new Node(val); newNode->next = head; head = newNode; }这段代码看起来没毛病,但实际上是错的。因为head是值传递,函数内部修改head只会影响到形参,main函数里的链表头指针根本不会变。这也是初学者最容易踩的经典坑:以为传了指针就能修改外部的变量,结果指针本身也是变量,也要传地址才能修改。
正确的写法有两种。一种是用二级指针Node**,这是 C 语言的经典解法:
void insertAtHead(Node** head, int val) { Node* newNode = new Node(val); newNode->next = *head; *head = newNode; }另一种是 C++ 的引用写法,更简洁也更安全:
void insertAtHead(Node*& head, int val) { Node* newNode = new Node(val); newNode->next = head; head = newNode; }我个人的建议是初学者先理解Node**的写法,因为 C 语言的教材基本都这么写,面试的时候也可能遇到。但实际写 C++ 项目时优先用Node*&,可读性更好。另外要养成的习惯是:用了new就必须配套delete。一旦忘记释放内存,程序长时间运行就会出现内存泄漏,性能越来越差。链表删除节点、销毁链表时尤其要注意。
2.5 函数指针:从排序题延伸出的回调思想
函数指针算是 C++ 里比较“劝退”的知识点,因为它长得奇怪,概念又绕。很多初学者觉得:“函数还能有地址?函数还能作为参数传给另一个函数?”答案是能,而且这个思想在现代 C++ 里被发扬光大了。
我今天的练习是写一个简单的计算回调,用一个统一的接口来调用不同的运算函数:
#include <iostream> using namespace std; int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int compute(int x, int y, int (*func)(int, int)) { return func(x, y); } int main() { cout << "add: " << compute(10, 5, add) << endl; cout << "sub: " << compute(10, 5, sub) << endl; cout << "mul: " << compute(10, 5, mul) << endl; return 0; }函数指针的声明格式int (*func)(int, int)很容易记混。一个技巧是先写被指向函数的样子:int func(int a, int b),然后把函数名替换成(*func),声明变成int (*func)(int, int)。这样就能记住括号和星号的位置了。
这个练习对理解排序算法特别有帮助。比如qsort或者 C++ 的sort函数,都允许你传入一个比较函数来控制排序规则。如果能看懂函数指针,就能理解为什么一个排序函数既能排升序,又能排降序,还能按照结构体的某个字段排序。这正是“把不变的部分抽出来,把变化的部分留给调用者”思想的最好体现。
3. 踩坑记录:VSCode 环境、编译错误与调试心得
3.1 新手必看:VSCode 配置 C/C++ 开发环境的完整思路
之前很多人在评论区问 VSCode 怎么配置 C++ 环境,今天趁着训练记录,我把这套流程完整梳理一遍。Windows 平台下,本地要准备一个 C++ 编译器,我目前用的是 MinGW-w64 自带的 g++。装好后在终端里输入g++ --version,能看到版本信息就说明环境变量没问题。
VSCode 这边需要装两个插件:C/C++ 和 C/C++ Extension Pack。前者提供代码提示、跳转、调试支持,后者是整套工具包。单靠插件还不够,因为 F5 调试时 VSCode 需要知道怎么编译你的程序。所以要在项目的.vscode文件夹下创建tasks.json和launch.json两个文件。简单来说,tasks.json负责告诉 VSCode“如何编译”,launch.json负责告诉 VSCode“如何运行和调试”。我这里随手记一下我常用的最小配置,避免大家自己折腾半天:
{ "version": "2.0.0", "tasks": [ { "label": "C++ 编译", "type": "cppbuild", "command": "g++", "args": [ "-g", "${file}", "-o", "${fileDirname}\\${fileBasenameNoExtension}.exe" ], "problemMatcher": ["$gcc"], "group": "build" } ] }这个配置针对的是单文件编译。如果你的项目里有多个.cpp文件,用这份配置就会漏编。多文件项目我建议直接改用 CMake,一步到位,别在 VSCode 里硬配。等哪天你被各种路径和头文件包含问题折磨到怀疑人生,就明白我说的了。
3.2 那个让人崩溃的“Microsoft Visual C++ 14.0”报错
今天训练途中还帮一个学 Python 的朋友排查了一个报错,报错信息长这样:error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Build Tools"。很多初学者一看见“Visual C++”几个字,就以为是要装 Visual Studio,然后跑去下载一个好几个 GB 的 IDE,安装完才发现问题还在。
这个报错的真实含义是:你正在用pip安装某个需要编译 C++ 扩展的 Python 包,而系统里缺少对应的 C++ 构建工具链。解决办法其实很简单,去微软官网下载“Microsoft C++ Build Tools”,安装时勾选“使用 C++ 的桌面开发”工作负载就行了。不需要装完整的 Visual Studio,那个太重了。这个问题放在这里说,是因为它反映出很多 C++ 初学者对“编译器、构建工具、IDE”这三者的概念是混淆的。C++ 编译器和编辑器并不是一回事,搞清楚它们的关系,后续很多环境问题都能迎刃而解。
3.3 训练中常见的编译错误与排查技巧
Day 115 这一路走来,我几乎把新手能踩的编译错误都踩了一遍。今天整理了一个速查表,全是个人血泪经验:
| 错误表现 | 真实原因 | 解决思路 |
|---|---|---|
undefined reference to ... | 函数声明了但没定义,或多个文件链接时漏了源文件 | 检查函数是否有函数体;多文件编译时把全部.cpp都传给 g++ |
segmentation fault | 多半是野指针、越界或访问了已释放内存 | 用gdb调试定位崩溃行,重点检查指针是否初始化、数组下标是否越界 |
expected unqualified-id before ... | 语法错误,常见原因是括号没匹配或分号放错位置 | 优先看报错行上面一行,很多问题的根源就在那 |
| 控制台中文输出乱码 | 源文件编码与终端编码不一致 | 在代码里用system("chcp 65001")临时切换,或统一把文件存成 UTF-8 |
'xxx' was not declared in this scope | 变量/函数未声明或命名空间写错 | 检查拼写、作用域、是否漏了std:: |
说到调试,我再补充一个技巧:在 VSCode 里调试 C++ 程序时,如果发现程序在函数内部崩了,直接看“调用堆栈”面板,它会显示崩溃前调用过哪些函数,这比瞎猜定位快得多。遇到指针相关的崩溃,顺手在监视窗口里看指针地址,它是不是0x0,一确认就知道是空指针了。
3.4 我这几个月用下来最舒服的调试方式
很多初学者学 C++ 不习惯用调试器,出问题就cout大法,一行一行打印中间变量。这个方法不是不行,但它有两个毛病:第一,你得猜该在哪一行打印,猜错了就得改代码重新编译;第二,打印完还要记得删掉,否则代码里全是调试垃圾。
我自己常用的调试流程是这样的:先在疑似出问题的位置打上断点,F5 启动调试。程序跑到断点时就会停下来,这时候可以在左侧变量面板里直接看所有局部变量的当前值。配合“单步执行”按钮,一行一行走下去,观察变量是哪个时刻开始变不对劲的。这样不污染源代码,定位也准。唯一需要克服的只是习惯问题,但一旦用熟了,你会发现比cout大法效率高一倍不止。
4. 学习方法论:如何把课后习题练出“项目感”
4.1 一道习题的三种练法
很多人刷完一道题就急着做下一道,这其实浪费了题目本身的价值。我现在的习惯是一道题至少练三遍,每一遍的目标都不同。
第一遍是“独立做”:不看资料,不翻笔记,完全凭自己的理解把代码写出来。这一步暴露的是真实水平,错误越多越好,全对反而说明题目太简单。第二遍是“优化做”:代码跑通之后,开始思考能不能减少一层循环?能不能用更少的内存?能不能把核心逻辑抽成函数?这一步锻炼的是算法思维和代码组织能力。第三遍是“扩展做”:把题目往深处改一改。冒泡排序能不能改成降序?质数判断能不能一次性判断 1000 个数的质数情况?链表头部插入学会了,能不能写一个按顺序插入?这样做,一道课后题就变成了四道题,训练价值完全不一样。
4.2 关于“如何用 C++ 做小游戏”的常见疑问
最近总看到有人问“如何用 C++ 制作一个僵尸末日小游戏”或者“C++ 小游戏编程代码”这类问题。我能理解这种好奇心,谁学编程的时候不想做个游戏出来呢?但这里有个很现实的建议给初学者:不要一上来就做小游戏,除非你只是想照着教学视频敲一遍,感受一下“程序跑起来”的快乐。
真正通过游戏学 C++ 的路线,应该是在掌握了基础语法、数组、函数、指针、结构体之后,先做控制台版本的文字游戏,比如猜数字、贪吃蛇、井字棋。这些项目能训练逻辑思维,又不需要引入图形库和复杂的窗口事件。等你对 C++ 的语法和调试足够熟悉了,再去接触图形界面,比如 SFML 或 Qt,做一个带画面的小游戏。一上来就碰图形和游戏引擎,遇到的坑往往不是游戏逻辑,而是环境配置、图片加载、事件循环这些“绕远路”的内容,很容易消磨掉学习热情。
游戏开发里还牵扯到一个老生常谈的问题:C++ 和 C# 游戏开发区别到底在哪?简单说,C++ 偏向底层,常用于游戏引擎开发和高性能渲染;C# 是 Unity 的主力语言,开发效率更高,对新手更友好。如果你最终的梦想是独立做一款 2D 游戏,C# 加 Unity 的路径可能更顺;如果你更想深入引擎底层,C++ 是绕不开的。这个选择没有标准答案,看你对底层的兴趣有多强。
4.3 关于“C++ 八股文”的一点实话
网上经常能看到“C++ 八股文”这个词,很多初学者担心不背八股文就找不到工作。以我这 115 天的心得来看,八股文确实有用,但用错了阶段反而有害。学习初期最重要的是建立对代码的直觉,比如看到一个循环能立刻想到它的时间复杂度,看到一个指针操作能预判可能出现的隐患。这些能力只能靠写代码获得,靠背诵是背不出来的。
我之前也试过背那些“虚函数表原理”“智能指针底层实现”之类的长篇大论,发现转过头就忘,答案抄得再工整也没用。后来改变策略,每学一个知识点就自己写一个最小例子验证一遍,比如学了智能指针就写一段自动释放的代码,学了多态就写一个基类指针指向派生类的例子。验证过了的知识才是自己的,背来的东西面试的时候一问细节就会露馅。所以我现在的态度是:八股文的最终目的是帮助你发现知识盲区,而不是替代真实训练,当你写代码写到一定程度,那些“八股”自然会内化成常识。
另外关于训练记录这件事,我还有一个小小的建议:不要只记“今天做了什么”,更要记“今天遇到了什么问题,怎么解决的”。因为前者翻看的时候只有成就感,后者才是真正的经验库。很多坑踩过一次就忘了,记录下来,三个月后回头看,你会发现自己解决问题的能力比想象中涨得快。
Day 115 只是一个节点,不是终点。指针从“一看就头大”到“越写越顺手”,质数判断从暴力循环到边界优化,链表从总是崩溃到一气呵成,每一道课后题都在默默垒地基。比起那些“三小时入门 C++”的速成课,我始终更相信这种肉眼可见的积累。你在 Day 几了?继续往前写就行。