
说实话刚开始做这个课后习题训练记录的时候我没想到自己能坚持到第 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])是 20sizeof(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都传给 gsegmentation fault多半是野指针、越界或访问了已释放内存用gdb调试定位崩溃行重点检查指针是否初始化、数组下标是否越界expected unqualified-id before ...语法错误常见原因是括号没匹配或分号放错位置优先看报错行上面一行很多问题的根源就在那控制台中文输出乱码源文件编码与终端编码不一致在代码里用system(chcp 65001)临时切换或统一把文件存成 UTF-8xxx 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 几了继续往前写就行。