C++课后习题训练Day78:从语法基础到算法与工程实战复盘

发布时间:2026/10/3 15:17:52
C++课后习题训练Day78:从语法基础到算法与工程实战复盘 写这份《C课后习题训练记录》的时候刚好是我坚持这件事的第78天。说句实话到了这个阶段每天面对已经不再是那种今天学会一个新语法的兴奋感更多是原来这块理解得还不够扎实的反复打磨。今天的整体感觉比较典型没有引入全新概念但把语言基础、算法模板、工程环境三块内容混在一起过了一遍一天下来脑子像是被重新翻了一遍。C这门语言越往后学越容易产生一个错觉——书看懂了课后题应该也能做对。但真实情况往往是代码跑到第十行编译器给你报一个让你愣住的错误或者输出结果和预期差一位数。Day78这天我就撞上了好几次这类情况。这篇文章把今天实际做的题目、踩到的坑、以及每道题背后真正要考的点都记下来算是一份实战向的训练复盘也希望对正在用类似方式学习C的人有个参考。1. 第78天的训练背景与今日目标1.1 为什么到了第78天还要继续做课后题很多人会问课后题不就是课程配套的练习吗学到后面不是应该去刷项目、做工程还练这些基础题做什么我一开始也是这么想的。但后来我发现课后题真正的作用不是帮你应付作业而是帮你验证是否真的理解了一件事的边界条件。比如排序算法书上的冒泡排序代码短得不得了背都能背下来但只要你实践得不够多写出来的代码往往在边界条件上出问题——内层循环该跑几次、边界该不该减一这些细节全靠手感而手感只能由一次次的课后训练来磨。今天已经是第78天我的训练内容早就不是课本原题而是围绕课本知识点做的变体题和综合题。另一个更现实的原因是C的东西彼此之间是有关联的。字符串操作、链表构建、指针和引用、STL容器这些看似独立的章节其实在很多综合题里会被一起用到。如果只在学某一章时才写某一章的例子等到后面做综合题就会感觉手特别生。所以我现在给自己定的原则是每天的训练一定包含语言机制、算法模板、工程向问题三个模块保证每天都至少碰一次不同类型的东西。1.2 今天题单安排与做题顺序的取舍今天的目标很明确查漏补缺。课程进度已经进入综合复习阶段所以我把题单按语言基础、算法专项、工程向练习分开顺序上先做语言基础再做算法最后处理工程问题。这样做是有讲究的——语言基础题通常比较碎需要高度专注才能不犯低级错误算法题需要动脑放上午容易困放下午正好工程向的问题往往容易卡得比较久放最后比较合适。具体来说今天处理语言基础题四道字符串数组初始化、结构体链表基本语法、指针/引用/值传递三连问、回调函数实现。算法专项四道排序边界问题、前缀和与单调栈、快速幂与卢卡斯定理、广搜模板。工程向三件事VSCode 中 C 函数变量跳转失效、64位环境下 fopen 报安全错误、TDengine 绑定写入的完整链路。下面按实际做题顺序展开记录。2. 语言基础题的底层用意字符串、链表与指针课堂作业的真实考点今天的语言基础题大多是那种一眼看过去这不是很基础吗但真写到编辑器里却会停顿一下的题目。每道题代码量都不大但考的都是很底层的理解。2.1 字符串数组初始化一个看似简单的课内题引出三种常见写法课后题第一道是让用不同方式初始化一个字符串数组并比较它们之间的区别。我在初学阶段会直接写std::string但为了把语言底子打牢练习时还是会把 C 风格字符串和 C 的 string 都写一遍#include iostream #include string using namespace std; int main() { char s1[] hello; // 数组形式存储在栈上内容可修改 char s2[6] {h,e,l,l,o,\0}; // 显式写明每一个字符和结尾的\0 const char* s3 hello; // 字符串字面量指向只读内存不可修改 string s4 hello; // C标准库字符串动态管理内存 cout sizeof(s1) endl; // 6包含末尾的\0 cout sizeof(s2) endl; // 6同左 cout sizeof(s3) endl; // 864位系统下指针本身的大小 cout sizeof(s4) endl; // 取决于实现通常是32字节左右 // s3[0] H; // 运行时错误字符串字面量是只读的 s1[0] H; // 合法s1是数组在栈上分配 return 0; }这道题隐藏着一个容易踩的坑char s2[5] hello这种写法在 C 里是不允许的因为hello实际上占 6 个字节必须带上结尾的\0。很多人在刷题时为了省事直接用char str[100]虽然运行没问题但如果你真的需要精确控制内存就必须搞清楚数组长度和字符串长度之间差一个结束符这件事。另一个值得记的点是sizeof(s3)和sizeof(s1)的区别。前者是一个指针的大小在64位系统下固定为8后者是数组本身的字节数取决于数组长度。如果被问到strlen(s3)返回值又是5此时不会计入\0。字符串这块每次练习都能见到有人把这几个概念混在一起属于模板级的高频考题。2.2 结构体链表的基本语法节点不是数组别用数组思维第二道基础题是构建一个单链表并实现头插法。结构体链表的语法本身不难真正容易出错的是节点到底在哪块内存上。#include iostream using namespace std; struct Node { int val; Node* next; Node(int x) : val(x), next(nullptr) {} }; void insertAtHead(Node* head, int x) { Node* newNode new Node(x); newNode-next head; head newNode; } void printList(Node* head) { while (head) { cout head-val ; head head-next; } cout endl; } int main() { Node* head nullptr; for (int x : {3, 2, 1}) { insertAtHead(head, x); } printList(head); // 输出 1 2 3 // 练习结束记得释放简单写一下 while (head) { Node* tmp head-next; delete head; head tmp; } return 0; }这里有一个很重要的点链表节点必须通过new在堆上创建不能直接定义一个局部 Node 变量然后用它来连接链表。比如有人会写Node node(1); head node;这样一旦函数出栈node的内存就失效了。你拿着一个悬空指针去访问输出的数据可能碰巧正确但本质上已经踩进了未定义行为的范畴。今天的练习里我就把这个错误故意写了一版然后用 gdb 观察地址变化比单纯看书印象深得多。链表题还有一个必考细节头插法需要修改头指针本身所以参数要写成Node* head。如果写成Node* head你在函数里改了局部拷贝调用结束后原来的头指针还是老的链表等于没插进去。这个问题和后面指针相关的题目刚好呼应上了。2.3 指针、引用与值传递课后题里最经典的一组三连问指针、引用和值传递的问题是 C 面试题里的常客也是课后训练里绕不过去的一组题。今天做的是经典的交换函数变体——给三个版本让你判断哪个能真正交换两个变量#include iostream using namespace std; void swapByValue(int a, int b) { int tmp a; a b; b tmp; } void swapByRef(int a, int b) { int tmp a; a b; b tmp; } void swapByPtr(int *a, int *b) { if (!a || !b) return; // 判空这行非常关键 int tmp *a; *a *b; *b tmp; } int main() { int x 3, y 5; swapByValue(x, y); // x3, y5没变 swapByRef(x, y); // x5, y3变了 swapByPtr(x, y); // x3, y5又换回来了 return 0; }这道题的讲解在一些教材里只是简单说值传递是拷贝但真实做题时你会发现当参数变成自定义类型例如一个很大的结构体或 vector值传递的拷贝开销会变得很明显。这也是为什么后来大量代码里用const T来传参既能防止拷贝又能避免修改原对象。引用在这里本质上是一个带语法糖的指针但它在形式上不用解引用、也更安全因为它非空。今天的题目还延伸了一个问题如果swapByPtr里的判定空不写当外部调用swapByPtr(nullptr, y)时程序会直接解引用空指针崩溃。课后题往往不会直接告诉你需要判空但实践场景里有这种情况。这算是从会写语法到会写防御性代码的一条分界线。2.4 回调函数把函数当成参数的入门练习回调函数这块教科书一般会讲函数指针。今天这道题要求写一个排序函数允许传入比较规则以此实现升序或降序排列。#include iostream using namespace std; bool asc(int a, int b) { return a b; } bool desc(int a, int b) { return a b; } void selectSort(int arr[], int n, bool (*cmp)(int, int)) { for (int i 0; i n - 1; i) { int idx i; for (int j i 1; j n; j) { if (cmp(arr[j], arr[idx])) { idx j; } } if (idx ! i) { swap(arr[i], arr[idx]); } } } int main() { int arr[] {5, 2, 8, 1, 9}; selectSort(arr, 5, asc); for (int x : arr) cout x ; // 1 2 5 8 9 cout endl; selectSort(arr, 5, desc); for (int x : arr) cout x ; // 9 8 5 2 1 return 0; }函数指针类型bool (*cmp)(int, int)读起来比较别扭我的记忆技巧是从中间向外读先读变量名cmp向左看*说明它是指针再向右看(int,int)说明它指向一个带两个 int 参数的函数最左边bool是返回类型。后面学到更高级的写法可以用std::function或 lambda 取代函数指针写法更灵活但底层思路是一样的。我发现这类回调题非常适合用来理解解耦。排序算法本身不应该关心你是升序还是降序、是排成绩还是排年龄它只需要知道什么时候需要交换即可。把比较规则以参数的形式传进去代码的复用性一下就上来了。C 标准库里的 sort 也是这个设计思路只是它进一步通过模板实现效率更高。3. 算法题专项从排序到组合数取模课后题如何逼你建立方法链条下午的算法训练我安排的是排序边界问题、前缀和与单调栈、快速幂与卢卡斯定理、广搜模板。这四个方向看起来彼此独立实际做下来会发现它们共享同一套底层能力把大问题拆成小问题、通过预处理降低复杂度、熟练套模板但不盲目套。3.1 排序题的能力进阶冒泡和插入排序为何依然值得练今天看了一个热搜词是冒泡排序算法c确实冒泡排序是很多人入门写的第一个排序。用起手式写一遍很简单但今天的题目在冒泡基础上加了条件结构体数组按分数从高到低排序分数相同保持原顺序。后者马上就把排序的稳定性顶到台面上来。手动实现的插入排序对稳定性是天然的#include iostream #include vector using namespace std; struct Student { string name; int score; }; void insertSort(vectorStudent stu) { for (int i 1; i (int)stu.size(); i) { Student key stu[i]; int j i - 1; while (j 0 stu[j].score key.score) { stu[j 1] stu[j]; j--; } stu[j 1] key; } } int main() { vectorStudent stu {{Tom, 88}, {Amy, 95}, {Bob, 88}}; insertSort(stu); for (auto s : stu) { cout s.name s.score endl; } return 0; }注意这里比较条件是stu[j].score key.score不是。写成会破坏稳定性因为当两个分数相同的时候后一个会继续往前移动导致顺序反转。手写一遍插入排序并思考这个比较符号的细微差别比直接调sort更能理解稳定性的来源。实际工程里我一般直接用std::stable_sort或std::sort加 lambda但手写过程的价值在于一旦sort的结果和预期不符你能更快判断是自己比较函数写错了还是迭代器范围搞错了。3.2 前缀和与单调栈一道区间统计题的两种解法下午第二题是求一个整数数组中所有长度为 k 的连续子数组的最大值之和。这类问题如果不做预处理直接每个位置都扫一遍 k 个元素复杂度是 O(n*k)。当 n 到十万级别、k 接近 n 时基本必挂。我第一反应是先写前缀和但后来意识到这题更自然的方向是单调栈用来求以当前位置为最大值的区间范围。先把前缀和的经典写法留在这它是很多区间题的基石vectorlong long pre(n 1, 0); for (int i 1; i n; i) { pre[i] pre[i - 1] a[i]; } // 区间[l, r]的和 pre[r] - pre[l - 1]前缀和理解起来容易但有个隐藏坑是数据范围。如果数组元素是 int 且 n 比较大区间和的累加很容易溢出。今天我就吃了个亏pre开了vectorint后面一个测试点直接溢出成负数排查了很久才反应过来应该用long long。这种问题在题目描述里不会特意提醒属于典型经验才能避开的坑。单调栈的模板如下用于求每个元素左边第一个比它小的位置stackint st; vectorint left(n 1, 0); for (int i 1; i n; i) { while (!st.empty() a[st.top()] a[i]) { st.pop(); } left[i] st.empty() ? 0 : st.top(); st.push(i); }单调栈的思路可以理解为维护一个栈栈内元素从底到顶是单调递增的。每次新元素入栈前把栈顶所有不符合单调性的元素弹掉。这样每个元素出栈入栈各一次总体复杂度是 O(n)。这个结构一开始不太好理解后来我用一个比喻记住了它就像排队买奶茶你个子比前面的人都高那前面低个子的人挡不住你看柜台在算法里它们就会被弹出栈。3.3 快速幂、分治与卢卡斯定理从模板到看懂原理快速幂算法c和卢卡斯定理c怎么写这两个热搜词今天也顺带过了一遍。快速幂解决的核心问题是计算a^b % mod当 b 很大时不能直接循环乘。原理是把指数 b 拆成二进制b k0*2^0 k1*2^1 ...原式就变成若干次幂的乘积每次迭代把底数平方对应二进制每一位。long long qpow(long long a, long long b, long long mod) { long long res 1; a % mod; while (b) { if (b 1) { res res * a % mod; } a a * a % mod; b 1; } return res; }这段代码的经典之处在于它完美体现了分治的思想不是把问题变小一次而是每次把规模缩半。我从一开始死记这段代码到后来能自己推出来中间隔了大概二三十道题。真正的转折点是意识到b 1只是在判断当前二进制最低位是否为 1而b 1是去掉最低位继续看下一位。卢卡斯定理解决的问题是组合数C(n, m) % p其中 n 和 m 可能非常大但 p 是素数且相对较小。这时候不能直接算阶乘取模因为n!根本装不下。卢卡斯定理把 n 和 m 按 p 进制拆开然后递归求解long long C(int n, int m, int p, vectorlong long fact) { if (m n) return 0; return fact[n] * qpow(fact[m], p - 2, p) % p * qpow(fact[n - m], p - 2, p) % p; } long long lucas(long long n, long long m, long long p, vectorlong long fact) { if (m 0) return 1; long long ni n % p, mi m % p; return C(ni, mi, p, fact) * lucas(n / p, m / p, p, fact) % p; }这里p - 2次幂是在求逆元因为 p 是素数费马小定理可用。今天做这道题时我花了不少时间梳理为什么C(ni, mi)里 ni 和 mi 的范围都小于 p因为取余就是把 n 和 m 的 p 进制最低位拆出来了。而lucas(n / p, m / p)是在处理更高位。一旦想通这一点整段代码就不再是一堆符号堆在一起。3.4 广搜模板状态空间题不靠聪明靠框架傍晚时分做了一道走迷宫的最短步数题从头写广搜BFS。广搜模板我已经写过很多次但今天特意不翻以前代码要求自己一次写对#include iostream #include queue #include vector using namespace std; int bfs(vectorvectorchar grid, int sx, int sy) { int n grid.size(), m grid[0].size(); vectorvectorint dist(n, vectorint(m, -1)); int dx[4] {1, -1, 0, 0}; int dy[4] {0, 0, 1, -1}; queuepairint, int q; q.push({sx, sy}); dist[sx][sy] 0; while (!q.empty()) { auto [x, y] q.front(); q.pop(); if (grid[x][y] E) return dist[x][y]; for (int k 0; k 4; k) { int nx x dx[k], ny y dy[k]; if (nx 0 || nx n || ny 0 || ny m) continue; if (grid[nx][ny] #) continue; if (dist[nx][ny] ! -1) continue; dist[nx][ny] dist[x][y] 1; q.push({nx, ny}); } } return -1; }广搜的关键在于每一层扩散。为什么它能求最短步数因为队列保证同层节点按顺序出队每个节点第一次被访问到的时候距离一定是最小的后面再走到它只会更远所以用dist ! -1来挡后续访问就够了。这个逻辑我一开始总是和深搜混淆后来靠画队列模拟图才彻底分清。写广搜最容易犯的错是入队时忘记同步修改距离数组。如果只在出队时才标记访问同一个节点可能被多次入队在最坏情况下队列会膨胀得很厉害。我今天的做法是入队即标记这算是一个可以直接抄的效率习惯。4. 工程向练习VSCode 环境排错与 TDengine 绑定写入到了晚上我把白天的代码从能运行推进到能在实际环境里被高效编写和跨服务使用于是开始处理三件和环境、数据库相关的事情。这些事严格说不算课后配套题目但它们恰恰是我自己练习 C 时最常被卡住的地方。4.1 VSCode 中 C 函数变量跳转失效的两个常见原因关于vscode c所有的函数变量都没办法跳转我在近两个月的训练里至少遇到三四次。今天又一次出现我干脆把排查链路完整走了一遍最后锁定在两个原因上。第一个原因是 VSCode 里的 C/C 扩展没有正确配置 includePath。这个配置保存在.vscode/c_cpp_properties.json里。如果你的项目引用了自定义头文件或第三方库但没有把对应目录告诉编辑器那么代码补全和跳转会大面积失效。我今天的修复方法是打开设置里的C/C: Edit Configurations (UI)手动添加头文件路径{ configurations: [ { name: Linux, includePath: [ ${workspaceFolder}/**, /usr/include/**, /usr/local/include/** ], defines: [], compilerPath: /usr/bin/g, cStandard: c11, cppStandard: cpp17, intelliSenseMode: linux-gcc-x64 } ], version: 4 }第二个原因是扩展的标签解析数据库过期。当你改了头文件结构、新增了接口但没触发重新解析跳转就会是旧的缓存结果。解决办法很常用命令面板里执行C/C: Reset IntelliSense Database或直接重载窗口。这个操作不会影响编译产出纯粹是让编辑器重新扫描一遍。这里顺带提一个判断技巧如果编译没问题、代码高亮也正常只有跳转不行那 90% 是 IntelliSense 的配置/缓存问题如果连编译都报找不到头文件那才是环境变量或编译参数的问题。很多人一上来就翻系统 PATH其实方向就错了。4.2 64位下 fopen 报安全错误别急着忽略警告c 64位 fopen报安全错误这个话题今天也在我的训练里出现了。我一开始写的是一段很传统的文件读取代码FILE* fp fopen(data.txt, r); if (fp nullptr) { perror(open file failed); return -1; }在 Windows Visual Studio 的环境里编译器直接给出报错提示fopen: This function or variable may be unsafe. Consider using fopen_s instead. To disable deprecation, use _CRT_SECURE_NO_WARNINGS.这里要说清楚一点它本身不是编译语法错误而是 MSVC 对某些 CRT 函数的安全检查机制因为fopen这类函数在历史上容易因为缓冲区/文件路径处理不当产生安全问题。解决方式通常有三种。第一使用fopen_s替代FILE* fp nullptr; errno_t err fopen_s(fp, data.txt, r); if (err ! 0) { perror(open file failed); return -1; }第二使用 C 标准库的ifstream这是我最推荐的方式因为不必纠结 MSVC 的安全警告#include fstream #include string std::ifstream fin(data.txt); if (!fin.is_open()) { return -1; } std::string line; while (std::getline(fin, line)) { // ... }第三在项目配置里定义宏_CRT_SECURE_NO_WARNINGS让编译器不再报告这一条。当年用这种办法的人很多但它本质上是关掉了安全检查长期来看不如fopen_s或ifstream合适。今天我在记录里给自己的结论是跨平台项目尽量用ifstream只在需要 C 接口对接老代码时才用fopen_s。4.3 TDengine 绑定写入taos_stmt_prepare 的完整链路今天的工程练习里有一个比较综合的任务把课后题产生的测试数据写入 TDengine。这个词在热搜里是tdengine, c绑定写入数据库, taos_stmt_prepare。TDengine 是时序数据库C 客户端里最推荐的高效写入方式就是参数绑定binding而不是拼 SQL 字符串。原因有两个一是避免 SQL 注入二是复用预编译语句减少解析开销。绑定写入的核心调用链大致是#include taos.h #include cstring #include cstdint // taos 为连接句柄这里假设已通过 taos_connect 获得 TAOS_STMT* stmt taos_stmt_init(taos); const char* sql INSERT INTO ? VALUES (?, ?); taos_stmt_prepare(stmt, sql, (int)strlen(sql)); // 指定子表名 taos_stmt_set_tbname(stmt, sensor_1); // 准备绑定参数 int64_t ts 1700000000000; // 毫秒时间戳 float value 36.5f; TAOS_BIND params[2]; memset(params, 0, sizeof(params)); params[0].buffer_type TSDB_DATA_TYPE_TIMESTAMP; params[0].buffer_length sizeof(int64_t); params[0].buffer ts; params[1].buffer_type TSDB_DATA_TYPE_FLOAT; params[1].buffer_length sizeof(float); params[1].buffer value; taos_stmt_bind_param(stmt, params); taos_stmt_execute(stmt); taos_stmt_close(stmt);注意taos_stmt_bind_param绑定的是 TAOS_BIND 数组每个字段的buffer指向对应内存buffer_length表示该缓冲区大小。如果不清楚字段类型是 TIMESTAMP 还是 BIGINT很容易因为类型不匹配而写入异常。时间戳字段在我看的版本里是用 int64_t 存放毫秒值这比直接用字符串时间高效得多。另外一个小技巧如果是批量写入同一子表的多条记录可以在循环里反复执行重新赋值缓冲区 bind_param execute最后只 close 一次。这样做比每次重新 prepare 要快很多。如果数据来自不同子表则每条数据都要重新set_tbname。今天的练习中我遇到的坑是忘了在调用taos_stmt_prepare前初始化 TAOS_STMT 指针直接传了空指针导致段错误。这个错误从语法上完全看不出来只能靠运行时检查所以在工程题里它比算法题更考验耐心。5. 今晚复盘三个我差点浪费一晚上的错误5.1 今日错误清单把现象、原因和最短补救方式都记下来每次训练记录我都会在末尾维持一张错误清单今晚整理出来发现有几个很有代表性。错误现象根本原因最短补救方式链表输出乱码节点用局部栈变量函数返回后内存失效节点必须用 new 在堆上创建区间求和为负数前缀和数组用了 int溢出前缀和数组使用 long longVSCode 跳转全部失效includePath 未配置或缓存过期配置 c_cpp_properties.json 并重置 IntelliSensefopen 编译报错MSVC 的安全检查机制改用 ifstream 或 fopen_sTDengine 写入段错误stmt 指针未初始化就传入 prepare先调 taos_stmt_init 再 prepare这张表里的前两个错误都属于看着对、跑起来错的类型。链表打印乱码的排查过程很痛苦因为程序没有崩溃只是数据不对最后我用调试器观察地址发现每个节点的地址都在栈上才确认是内存生命周期问题。前缀和溢出则更隐蔽它只在某些测试数据下出现如果你只看小样例输出永远是对的。另外一个写法上的问题是今天做链表题时我没有在每次new Node后马上想着释放而是在 printList 之后才统一清理。这在小程序里没问题但如果你是写一个长时间运行的服务器程序或者把链表写到函数里反复调用内存泄漏就会越积越多。我现在的习惯是每写一个用到 new 的练习函数立刻配套写一个释放函数从第一步就养成配对习惯。5.2 对训练记录的思考课后题不是任务是反馈回路坚持 78 天做课后习题训练我最大的体会是这些题目真正的价值不在于答对的结果而在于暴露你的机制含糊。今天做的多道题单独拉出来知识点我都知道但把它们混在一起时我还是在字符串数组的 sizeof、链表的生命周期、前缀和的溢出这些地方卡住了。这恰好说明知道规则和熟练运用规则之间还有很大的距离。我开始理解为什么有的老师反复强调题目要自己动手写不要只看。看别人代码时你看到的是干净的思路和美观的结构自己写时你会看到编译器报警告、调试器指向莫名地址、程序偶尔输出垃圾值。后面这些才是真实编码时的反馈回路。每一次我居然在这里错了都会在脑子里刻下一道痕迹下次再遇到类似场景时你会不自觉地停下来检查。这大概就是练习的意义所在。明天 Day79 我打算换一批完全没做过的题重点练字符串处理加 STL 容器混合场景同时把今天的错误清单重看一遍确保同类型的坑不再踩第二次。这份训练记录写到今天已经慢慢变成我自己的问题索引了——哪一天某块想不起来了翻翻对应的日志当时排查的每个细节都还记得清清楚楚。