
每次C语言练习推进到第17次我最大的感受是基础这东西不反复折腾几遍你真的以为自己会了。这一轮练习里既有字符串逆序、二维数组、文件读写这些老面孔也有while和do-while的边界条件这种一不留神就翻车的细节题。同时计算机英语也到了day11每天背术语这件事坚持下来之后再看报错信息、查官方文档明显比之前顺畅得多。这篇博文就当作一次阶段复盘把这两条线的内容都整理出来给同样在C语言道路上慢慢磨的朋友做个参考。我这个系列练习的定位一直很明确不追求偏题怪题而是把教材里那些容易被跳过的基础点逐个锤实。第17次练习依旧围绕“看得懂、写得对、跑得通”这三个标准来选题。下面我开始拆解这次练习里的核心内容代码和踩坑记录都会一并写上。1. 本次练习的整体安排与选题思路1.1 为什么练到第17次还要回头补基础有些朋友可能会觉得练了十几次了怎么还在搞字符串逆序、二维数组这些入门内容这里我想认真说一句C语言的基础题不是做一遍就完事的。同一个知识点在你刚学指针时写一遍在你理解内存分配后再写一遍写出来的代码完全不一样。举个很直白的例子字符串逆序这道题我大一第一次写的时候老老实实用一个临时字符数组从后往前拷贝后来练到第17次再写我会自然地用两个指针从首尾向中间遍历边交换边移动。代码更短思路也更清晰。这种进步不是靠背题得来的是靠重复练习中不断把新学的概念融进旧题目里。所以这次选题我有意混合了三种难度一部分是纯语法巩固比如while和do-while的区别验证一部分是C语言核心机制比如指针操作字符串还有一部分是工程上一定会遇到的场景比如文件读写。这样练下来每天都能感觉到自己在往上走一个台阶。1.2 本次练习覆盖的知识点地图为了避免“今天想到什么练什么”这种低效模式我每次都会先把要覆盖的知识点列成清单。第17次练习的知识点大概可以分四块字符串处理逆序、长度计算、字符串函数边界情况。数组与指针二维数组的遍历方式、指针作为函数参数的传递。循环控制while、do-while、for的适用场景和差异。文件操作fprintf、fscanf的格式化读写、文件打开失败的处理。文件读写这一块我承认以前是有点躲着走的。因为控制台程序跑起来就能看到输出文件操作却要处理路径、缓冲区、关闭文件这些额外的东西稍不注意就出乱子。但实际做项目、写工具的时候文件交互几乎是绕不开的这轮咬着牙把它练了后面的练习里很多题目反而变得顺手了。选好知识点范围之后我会按照“先读题、再手写思路、最后上机验证”的顺序来。手写思路这一步特别重要它逼着我在写代码之前想清楚逻辑而不是打开编辑器边写边改。真正到了上机阶段基本就只剩语法细节的调整了。2. 几道典型练习题目的拆解与实现2.1 字符串逆序从数组下标到指针写法的演进字符串逆序这道题是每次练习几乎都会出现的题型但它每次都能考出一点新东西。PTA上有道题很经典输入一个字符串将其逆序输出要求不另外开辟数组空间。这里考察的核心就是原地逆序。我先说一下最常见的实现思路用两个下标一个指向字符串开头一个指向结尾交换这两个位置的字符然后头下标向后移、尾下标向前移直到两者相遇。代码写出来大概是这样#include stdio.h #include string.h void reverse(char str[]) { int left 0; int right strlen(str) - 1; while (left right) { char temp str[left]; str[left] str[right]; str[right] temp; left; right--; } } int main() { char str[100]; gets(str); // 注意实际工程中建议用fgets reverse(str); printf(%s\n, str); return 0; }这道题容易错的地方有两处。第一处是right的初始值很多人会写成strlen(str)这就错了因为字符串最后一个有效字符的下标是长度减1strlen返回的是不包括\0的字符个数如果从strlen(str)开始交换时会把\0换到前面来输出就会出问题。第二处是循环条件用left right而不是left right因为当两者相等时不需要交换用了虽然不算错但多了一次无意义的操作。练到指针部分之后我又把这份代码改成指针版本void reverse(char *str) { char *left str; char *right str strlen(str) - 1; while (left right) { char temp *left; *left *right; *right temp; left; right--; } }指针版本的好处是函数形参更直观能明确看出这个函数接收的是一个字符串起始地址。在实际工作中我们看别人的C代码指针写法出现频率比数组写法高得多早点熟悉这种写法没有坏处。还有一个我自己总结出的小经验遇到字符串题目先在草稿纸上画出内存布局把字符数组每个下标对应的内容写出来再用箭头模拟left和right的移动。画过一遍之后代码基本不会写错。2.2 二维数组题目转置与日期计算的一个共性思维二维数组这轮我练了两道题一道是矩阵转置一道是“输入年月日计算这是一年中的第几天”。这两道题表面上完全不相关但实际上都在考同一个核心能力怎么把一个复杂的数据关系抽象成下标运算。矩阵转置的逻辑很直白把a[i][j]和a[j][i]交换但要注意只需要遍历上三角或下三角部分否则交换两次又恢复原样了。代码如下#include stdio.h #define N 3 void transpose(int a[N][N]) { for (int i 0; i N; i) { for (int j i 1; j N; j) { int temp a[i][j]; a[i][j] a[j][i]; a[j][i] temp; } } }这里的关键点就在内层循环的j从i1开始而不是从0开始。很多人第一次写会写成j从0开始结果转置之后矩阵没变因为每个元素被交换了两次。这种错误在编译阶段完全不会暴露只有拿数据去验证才能发现。日期计算的题目稍微绕一点它需要有一个数组记录每月的天数然后累加之前月份的天数最后加上当月的日。如果涉及闰年还要注意二月是29天。这种“查表累加”的思路在很多场景下都通用比如统计一段文本里每个字符出现的次数也是开一个数组做计数器。我当时的实现是#include stdio.h int isLeap(int year) { return (year % 4 0 year % 100 ! 0) || (year % 400 0); } int main() { int year, month, day; int daysInMonth[] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; scanf(%d %d %d, year, month, day); if (isLeap(year)) { daysInMonth[1] 29; } int total 0; for (int i 0; i month - 1; i) { total daysInMonth[i]; } total day; printf(%d\n, total); return 0; }这道题容易忽略的是闰年判断的边界条件能被4整除但不能被100整除或者能被400整除。有人只写year % 4 0遇到1900年这种整百年就错了。另外我建议在累加天数时用for循环遍历月份数组而不是手动写出十二个月的加法虽然结果一样但遍历的方式更通用改成任意个月份都能用。2.3 while和do-while的区别不只差一次执行关于while和do-while的区别网上的解释文章一搜一大把但真正能把这俩用对的人反而是要把边界条件想透的。最大的区别大家都清楚while先判断后执行可能一次都不执行do-while先执行再判断至少执行一次。但实际做题的时候这个区别会以很隐蔽的方式出现。比如有一道题输入一个正整数输出它的位数。用while写int n, count 0; scanf(%d, n); while (n 0) { count; n / 10; } printf(%d\n, count);这个写法有个问题如果输入的是0count就是0但0其实是1位数。改成do-while就合理了int n, count 0; scanf(%d, n); do { count; n / 10; } while (n 0); printf(%d\n, count);输入0的时候先执行一次count得到1就对了。这就是do-while最典型的应用场景无论输入是什么至少要处理一次。另一个常见的应用是菜单程序先显示菜单、读取用户输入然后再判断是否退出这时候用do-while比while自然得多。所以我的建议是拿到题目先问自己“这段逻辑有没有可能一次都不该执行”。如果是用while如果无论什么情况都必须先跑一次用do-while。判断标准不是背定义而是代入具体场景。2.4 文件读写实操fprintf和fscanf的成对使用文件操作这轮练习我选了一个很实用的场景把一批学生的姓名和成绩写入文件然后再从文件里读出来计算平均分。这个场景在学习成绩管理、日志记录、配置文件解析时都很常见。写入部分的代码#include stdio.h int main() { FILE *fp fopen(students.txt, w); if (fp NULL) { printf(文件打开失败\n); return 1; } fprintf(fp, Alice 85\n); fprintf(fp, Bob 92\n); fprintf(fp, Cindy 78\n); fclose(fp); return 0; }读取部分的代码#include stdio.h int main() { FILE *fp fopen(students.txt, r); if (fp NULL) { printf(文件打开失败\n); return 1; } char name[50]; int score; int total 0; int count 0; while (fscanf(fp, %s %d, name, score) 2) { total score; count; } fclose(fp); if (count 0) { printf(平均分: %.2f\n, (double)total / count); } return 0; }这段代码里有几个细节值得多说两句。第一个是fopen之后必须立刻判断返回值如果文件不存在或者没有权限fp会是NULL直接在上面操作必然崩溃。第二个是fscanf的返回值它返回成功匹配并赋值的参数个数这里我判断等于2说明一次读入了一个名字和一个成绩如果返回EOF或者小于2说明文件读完了或者格式不对循环就该停止。第三个是fclose不能省程序结束前不关闭文件缓冲区的数据可能没有完全写入磁盘。文件读写最容易出的问题是路径不对。有时候代码看着没问题但程序找不到文件就是因为文件不在当前工作目录下。我习惯把数据文件和源代码放在同一个目录然后用相对路径这样出问题的概率小很多。3. 计算机英语Day11今天积累的术语与记忆方法3.1 今日术语清单与场景化解释计算机英语学到第11天已经积累了不少高频词。我每天不会贪多控制在15个词左右但要求每个词都能在代码、报错信息或文档里认出来。今天的词单里有几个特别值得展开allocate分配。C语言里最常出现在动态内存分配里malloc的全称就是memory allocation。deallocate / free释放。有分配就应该有释放这两个词通常成对出现。buffer缓冲区。printf的输出、文件读取的暂存区域都叫buffer键盘输入时那个临时存储空间也是。overflow溢出。缓冲区溢出、整数溢出都用这个单词是个很关键的警告词。null pointer空指针。野指针、未初始化指针解引用的时候容易遇到这个词。edge case边界情况。工程师讨论测试用例时经常说这个指输入值处于边界附近的场景。我的记忆方法不是死背单词表而是把这些词放进具体语境里。比如学malloc时我会在注释里写// 分配一块可以存放10个int的内存 int *arr (int *)malloc(10 * sizeof(int)); // 判断分配是否成功 if (arr NULL) { printf(Memory allocation failed\n); }这样一边写代码一边记单词allocate和null pointer都不用刻意背多写几遍自然就记住了。有些时候看英文报错提示比如“segmentation fault (core dumped)”里面单词都认识但连起来不知道什么意思。这种时候我会查一下segmentation指的是内存分段fault是错误整个就是在说访问了不允许访问的内存段对应到代码里多半就是数组越界或者空指针解引用。3.2 把英语术语嵌进C语言学习的三个技巧第一遇到报错信息别急着翻译先眼睛扫一遍把关键词划出来。比如“expected ; before }”这句关键词是expected和before意思是编译器期待一个分号位置在右花括号之前。知道了expected是“期待、预期”整句报错就很好理解了。第二官方文档和问答社区的阅读习惯要慢慢建立。很多人觉得看英文文档慢就总用翻译工具但术语在翻译之后往往会失真比如signature这个词在程序语境里是“函数签名”用翻译工具可能翻成“签名”意思就差了一层。我现在的做法是第一遍快速扫标题和代码第二遍再细读需要理解的部分不追求每个单词都认识。第三把术语做成自己的术语卡每张卡片上写三样东西英文单词、中文释义、我在哪段代码或报错里见过它。比如“warning: implicit declaration of function”卡上会写隐式声明出现场景是忘记包含头文件就调用函数。这个方法比单纯背单词效率高得多因为每个词都关联了一个具体的记忆锚点。3.3 每天坚持的节奏和量怎么安排day11这个进度说实话已经过了最容易放弃的坎儿。刚开始那几天我雄心壮志地一天背50个词结果第二天忘了一半特别打击信心。后来调整策略每天只精学15个左右半小时以内完成但要求每个词都能默写出来并且能造一句和编程相关的例句。我的固定流程是这样先花10分钟看今天预定的主题词汇比如今天的主题是“文件操作和内存管理相关词汇”再花10分钟从今天写的C代码里挑出涉及这些词汇的句子朗读、抄写、理解语境最后花10分钟不看资料自己把单词、释义、例句默写一遍。默写是检验学习效果最快的方式光看不算掌握能写出来才算。量方面我强烈建议不要贪多。15个精学词加上在代码里见到的其他生词一天能稳定消化20个左右就很好了。这个速度听起来不快但坚持一个月也有600个词已经能覆盖大部分常见报错和基础文档。4. 练习中踩过的坑和排查心得4.1 编译报错unreferenced label怎么解决这个报错我在网上经常看到有人问其实意思是“未引用的标签”这里的label指的是goto语句的标记符号。如果代码里写了一个标签但没有任何goto跳到它编译器就会提示unreferenced label。比如我有一段时间调试用的临时代码里写了#include stdio.h int main() { int x 10; if (x 0) { goto error; } return 0; error: printf(x should not be positive\n); return 1; }如果我把goto那行注释掉或者删掉但保留了error标签编译器就会报unreferenced label。解决的办法很简单要么删掉没人用的标签要么补上对应的goto语句。很多同学看到这个报错会懵是因为他们没意识到某个标签是之前调试时留下的清理干净就好。排查的时候我建议先看一眼报错里提示的label名称再在代码里搜索这个名称出现的位置。如果全代码只出现一次那一定是标签定义没有被引用如果出现了多次再确认是否有goto语句跳到它。4.2 浮点数比较不能直接判等浮点数比较这个坑我在练习里专门设计了一道题来验证。直接比较两个浮点数是否相等比如if (a b)在数学上可能成立但在计算机里却经常不成立。原因是浮点数的二进制表示往往是近似的0.1 0.2不等于0.3这在很多语言里都有这个问题C语言更明显。正确做法是比较两个数的差值的绝对值是否小于某个很小的数比如#include stdio.h #include math.h int main() { double a 0.1 0.2; double b 0.3; if (fabs(a - b) 1e-9) { printf(相等\n); } else { printf(不相等, a%.20f, b%.20f\n, a, b); } return 0; }用%.20f打印出来的效果非常直观a实际是0.20000000000000001110b实际是0.29999999999999998890两者并不相等。我刚开始接触这个知识点时总觉得小题大做直到自己写了段判断浮点数等于0的代码if (x 0.0)结果因为累积误差x实际上是类似0.000000000001的值判断永远为假程序跑出来的结果完全不对。从那以后凡是浮点数比较我统一用误差范围判断。4.3 VSCode配置C语言环境的一个建议很多初学者在VSCode里配置C语言环境时会被tasks.json和launch.json搞蒙。我个人的建议是如果只是练习基础语法第一步只需要装好MinGW或者GCC编译器装好C/C扩展然后直接在终端里用命令编译运行比搞复杂的调试配置省心得多。比如我写了一个hello.c打开终端执行gcc hello.c -o hello ./hello这样就能看到输出。什么时候需要配调试环境呢等你要单步跟踪、查看变量值、设置断点的时候再配。配的时候注意launch.json里program的路径必须指向你编译生成的可执行文件路径写错是最常见的启动失败原因。我见过太多人花一整个下午配环境结果一行代码没写。环境是为写代码服务的不要在工具上过度纠结。能用命令行编译就先命令行等需求出现了再补环境配置效率高很多。4.4 数组越界编译不报错不代表没问题数组越界很有意思它通常不会在编译期报错甚至在运行期也不一定立刻崩溃但可能悄悄破坏其他变量的值导致一些莫名其妙的bug。在二维数组题目里尤其常见遍历时行数或列数写错就会访问到非法地址。我之前写矩阵转置时有一次循环条件写漏了内层循环多跑了几次程序没有崩溃但输出结果乱七八糟。排查了很久才发现是下标越界把数组后面内存里的“垃圾值”也当成了矩阵的一部分。排查数组越界的比较笨但有效的方法是在关键循环里打印当前的i和j下标观察是否超出了数组维度。更专业一点的做法是使用AddressSanitizer比如在gcc编译时加上-fsanitizeaddress选项运行时如果发生越界它会直接告诉你出错的行号。我学会这个之后排查越界问题的时间缩短了一大截。5. 练习记录的整理方式与后续计划5.1 我如何做练习笔记练了17次之后我积累的代码和notes已经不少了。我现在的整理方式是每道练习题单独放一个文件夹里面有题目描述、解题思路、最终的代码、以及运行结果截图。命名规则统一比如“day17_reverse_string”这样回头找非常方便。除了代码本身我还会在笔记里记下这道题里我觉得值得注意的点。比如字符串逆序这道题我会写“注意right从strlen-1开始”文件读写那道题我会写“fscanf返回值为2时表示读取成功”。这些点在当时可能都知道但过一个月再回头看很多细节会模糊有了笔记就能快速唤醒记忆。我还给每道题标了难度和掌握程度用像“熟悉”“需要复习”“完全没见过”这样的程度词来标注。每次开始新的练习前先翻一遍需要复习的题目这样就能形成循环不会前面学后面忘。5.2 下一步练习的方向第17次练习结束之后我给自己规划了两个方向。一个是把指针相关的题目加深难度尤其是二级指针、指针数组、函数指针这些平时容易绕晕的概念光靠教材上的例子不够还得自己写一些实际场景的代码。另一个是开始接触动态内存分配和链表的简单实现这部分是C语言从“写练习”迈向“写程序”的重要分水岭。计算机英语部分会继续保持每天15个词的节奏但接下来的主题会偏向数据结构相关的术语比如linked list、node、recursion、iteration这类词因为后面练链表和树的时候一定会频繁用到。英语和编程一起学两边都能受益这个搭配我觉得是长期可以坚持的。记录这些练习过程本身也是复习的过程。写这篇复盘的时候我又把代码重新看了一遍发现有些细节如果不写下来再过两天可能就忘了。所以有句话我想对所有自学C语言的朋友说学编程别怕慢就怕不记录、不复盘。每练一道题留下一点思考和总结这些东西最后都会变成你自己的经验库。