C语言练习题做到第五个这个节点很微妙。前面几题基本把变量、循环、条件分支、数组这些基础过了一遍再往下走就是字符串和指针这两座大山。这期练习我选了一个特别经典的题目字符串逆序输出。别小看这道题它在PTA、学校OJ、期末考里反复出现几乎每个学C的人都写过。更难的是它能把指针、数组、字符串处理、边界条件这些知识点全串起来一道题吃透后面很多题都会顺手很多。这篇就围绕这个练习展开把实现思路、完整代码、遇到的坑和排查方法全部分享出来也顺便聊聊怎么把它和冒泡排序、九九乘法表这些邻居题串成一个完整的练习节奏。1. 练习拆解字符串逆序到底在考什么1.1 需求表述先看题目本身。一个典型的描述是输入一行字符串将其逆序输出。比如输入 hello输出 olleh输入 C language输出 egaugnal C。看起来很简单对吧但仔细想就有几个问题冒出来了字符串怎么读进来读多长存在哪里逆序是就地反转还是重新生成一份输出的时候怎么保证没有多余的换行或者乱码PTA上那道“字符串逆序”题是这么要求的给定一个字符串按逆序输出。有些变体是要求原地逆序有些只要求输出结果。写之前先看清题目要求这很重要因为“只输出逆序”和“把数组真正反转过来再输出”是两种实现思路虽然结果一样但考察点完全不同。这道题表面上是“逆序”实际上是在同时考察字符串的存储本质、数组下标访问、指针运算、边界条件处理这几件事。尤其是它逼着你搞清楚一个核心概念字符串在C语言里到底长什么样。1.2 为什么它是经典题字符串逆序能成为经典练习不是因为它难而是因为它“小且全”。字符串在C语言里不是一种原生类型它是一个以\0结尾的字符数组。这意味着你操作字符串时本质上还是在操作数组但多了一个“结束标志”的概念。处理这个结束标志恰恰是新手最容易出错的地方。你可能会问我直接定义一个字符数组用循环把字符倒着打印出来不行吗行但那样你只完成了“输出”这一半没有真正理解“字符串是数组”这个底层事实。我见过很多同学学C学到指针就卡住回头看其实就是字符串这关没过去。字符串、指针、数组三者在C语言里是三位一体的数组名是首地址指针可以遍历数组字符串就是数组的特殊形态。逆序这道题三种视角都用得上确实是性价比极高的一题。还有一层这道题是很多后续算法题的“母题”。判断回文、反转单词顺序、大整数加减底层都离不开逆序操作。把逆序写得干净利落后面学那些题会轻松很多。2. 动手之前先把输入这关做好2.1 scanf 与 fgets读字符串的两种常见姿势写逆序之前第一步是读入字符串。这里就有第一个大坑到底用scanf还是fgets很多教材例题喜欢写char str[100]; scanf(%s, str);这行代码有个隐藏问题scanf配合%s遇到空格、Tab、换行就停止读取了。如果你输入的是 hello worldscanf只会读进 helloworld 被留在缓冲区里。而逆序题最常见的输入恰恰是“一行字符串”中文语境下很可能是“I love C”这种带空格的句子。用scanf会导致结果完全不对而且你还不容易排查到原因。推荐的做法是用fgetschar str[100]; fgets(str, sizeof(str), stdin);fgets会读取一整行包括空格直到遇到换行符或者达到指定的长度上限。这个函数的第三个参数是文件流stdin表示标准输入也就是键盘输入。关于fgets有一个细节必须注意如果读入的行长度小于缓冲区容量换行符\n也会被存进字符串里。比如输入 abc 然后回车str里的实际内容是abc\n\0。这时候直接做逆序结果会变成\nba输出看起来就是有个换行位置不对特别难看。所以在逆序之前要么手动把末尾的\n去掉要么在计算长度时排除它。我常用的处理方式比较笨但很稳size_t len strlen(str); if (len 0 str[len - 1] \n) { str[len - 1] \0; }这个操作就一句话如果最后一个字符是换行就把它替换成字符串结束符。这样字符串才真正干净了。2.2 数组长度、越界和 \0 的边界感接下来要决定字符数组开多大如果题目没给最大长度一般开个 100 或 1000 就够用了如果给了比如“长度不超过 80”那就开 81。为什么是 81 不是 80因为还要留一个位置给\0。这个 80 vs 81 的问题就是边界感的起点。实际输入的长度往往和你声明的大小没直接关系。fgets的第二个参数会限制最多读入多少个字符比如fgets(str, 100, stdin)最多读 99 个字符第 100 个位置是\0。如果你声明的数组实际只有 50 个字节却把 100 传给了fgets那就会造成缓冲区溢出程序直接崩掉。所以老实写sizeof(str)是比较稳妥的只要str是数组这个表达式会自动换算成数组的字节数不会多读。还有一点写逆序循环时最容易出错的是边界。假设字符串长度为len下标范围是0到len-1str[len]是\0。很多新手写逆序时会把结束符当普通字符处理或者在判断到\0后还继续往下走结果越界读到未知内存。这里的关键就是字符串的长度不包括\0但你分配内存时必须给它留位置。2.3 双指针交换的核心逻辑字符串逆序最经典的做法是双指针原地交换一个指针从前往后走一个指针从后往前走交换两个位置的字符直到两个指针相遇或者交错。str是数组名也是首地址。定义两个下标或者两个指针int i 0; int j strlen(str) - 1;这里j一定要减一因为最后一个有效字符的下标是长度减一。循环条件是i j不是i j。为什么因为当i j时两个指针指向同一个位置交换同一个元素没有意义徒增一次无用的操作。更重要的是如果字符串长度是偶数i和j会在中间某处“交错”用i j可能会导致中间两个元素交换两次结果又变回去了。这个细节在面试或者考试时经常被拿来扣分。交换的过程就是经典的temp a; a b; b temp;只不过这里的a、b是数组元素。理解了这一步后面学冒泡排序会发现其实冒泡排序里面的交换也是同一套动作知识就是这么串起来的。3. 三种逆序实现代码和运行分析一起给3.1 方法一双指针原地交换这是最推荐掌握的写法因为它空间复杂度是 O(1)只用了常数额外空间而且逻辑非常直观。#include stdio.h #include string.h int main() { char str[100]; printf(请输入一行字符串); fgets(str, sizeof(str), stdin); // 去掉末尾换行符 size_t len strlen(str); if (len 0 str[len - 1] \n) { str[len - 1] \0; len--; } // 双指针原地交换 int i 0; int j (int)len - 1; while (i j) { char temp str[i]; str[i] str[j]; str[j] temp; i; j--; } printf(逆序结果%s\n, str); return 0; }运行一次看看效果请输入一行字符串hello world 逆序结果dlrow olleh这段代码的关键点有几处。len用的是size_t类型是strlen的返回类型但在循环里int i和size_t混用容易产生编译警告特别在严格编译选项下可能直接报错。所以我这里直接强转成int学习阶段这样反而省心。另一个点是循环内部交换后i、j--必须同时进行只改一个会导致死循环或者只交换一半。3.2 方法二辅助数组拷贝原题如果要求“不修改原串”那就用辅助数组。思路是把原字符串从后往前逐个字符拷贝到另一个数组里然后输出。#include stdio.h #include string.h int main() { char str[100]; char reversed[100]; int i, j; fgets(str, sizeof(str), stdin); size_t len strlen(str); if (len 0 str[len - 1] \n) { str[len - 1] \0; len--; } for (i (int)len - 1, j 0; i 0; i--, j) { reversed[j] str[i]; } reversed[j] \0; printf(%s\n, reversed); return 0; }注意最后一行reversed[j] \0;容易漏掉。reversed是局部数组初始内容是未定义的“脏数据”如果不在末尾手动加结束符printf在输出完有效字符后会继续打印内存里的随机内容直到碰巧遇到一个\0。这正是很多新手输出乱码的常见来源。运行结果和第一种方法一样但代价是多用了 100 字节的空间。在嵌入式等内存受限场景下第一种方法明显更优。3.3 方法三递归实现递归实现逆序是进阶玩法适合已经掌握前两种方法之后再来研究。思路是每次取出字符串的第一个字符递归处理剩下的部分处理完再把这个字符放到最后。#include stdio.h #include string.h void reverse_recursive(char str[], int start, int end) { if (start end) { return; } char temp str[start]; str[start] str[end]; str[end] temp; reverse_recursive(str, start 1, end - 1); } int main() { char str[100]; fgets(str, sizeof(str), stdin); size_t len strlen(str); if (len 0 str[len - 1] \n) { str[len - 1] \0; len--; } reverse_recursive(str, 0, (int)len - 1); printf(%s\n, str); return 0; }递归的边界是start end每次递归交换两端字符然后向中间收缩。这个写法的好处是代码结构清晰但它有一个致命的限制递归深度等于字符串长度的一半。字符串是 998 个字符时要递归约 499 层C 语言的函数调用栈默认一般是 1MB 到 8MB 不等太深会栈溢出。考试题目的测试数据一般不算太大但你要知道这个边界在哪里免得在实际开发里写出一个递归版本然后被线上数据打爆。实体上我建议初学者先掌握双指针法辅助数组法作为对照递归法作为思维扩展三种都会写才算真的吃透。3.4 主函数组织和实际运行不管哪种方法主函数的组织都有一些小技巧。比如多组测试数据的场景fgets要放在循环里而且要注意每次读取后缓冲区是否残留了字符。如果上一组数据有剩余内容下一组fgets可能读到的是残留值导致结果错得莫名其妙。一个稳妥的多组输入框架长这样while (fgets(str, sizeof(str), stdin) ! NULL) { // 去换行、逆序、输出 }fgets返回NULL表示读到文件末尾或者出错配合循环就能一直读下去。在本地环境里想结束输入按 CtrlZWindows或 CtrlDLinux就行。运行验证时除了正常的hello我建议专门测试三种边界情况空字符串、单个字符、带空格的长字符串。空字符串时len是 0j是 -1循环根本不会执行程序直接输出空行这是正确的。单个字符时i和j相等循环不执行原样输出也是正确的。这两个边界能通过说明代码基本健壮了。4. 调试现场我踩过的坑和排查方法4.1 坑一输出多了一个换行这是我最早写这道题时遇到的情况。明明逻辑看着没问题但逆序后输出的第一个字符原字符串的最后一个字符前面莫名其妙多个空行或者输出结果中间夹了一个换行。原因前面说过fgets把回车也读进去了。比如输入abc回车str的内容是abc\n\0逆序后变成\ncba\0用printf输出自然先换了一行再打cba。排查方法很简单在逆序之前打印一下每个字符的 ASCII 码你会看到末尾多了一个10那就是换行符的 ASCII 码。写代码时顺手在fgets后面加三行去换行的逻辑这是几乎所有字符串练习题都要做的标准动作建议形成肌肉记忆。4.2 坑二中文字符串乱码练习里可能遇到中文输入比如输入你好世界逆序输出变成乱码。这其实不是逆序逻辑的错而是编码问题。在 GBK 或 UTF-8 编码下中文字符通常占用多个字节按字节逆序会把一个汉字的字节拆散比如你在 UTF-8 中是三个字节\xE4\xBD\xA0逆序后这三个字节被打乱重组输出的东西自然没法看。这道题的常规解法是按字节处理的所以设计上就只适合纯英文、数字、标点这类单字节字符。遇到中文需要按字符边界处理那牵扯到编码库了。考试和练习一般用英文用例这个问题了解即可。但我见过不少同学在自己电脑上输入中文然后怀疑代码写错了先确认输入内容是否包含多字节字符能少走很多弯路。4.3 坑三用 %d 读入字符后面全乱了热搜词里有一条“c语言变量用%d输入一个字符后的值”这确实是很多初学者的迷惑点。比如你写了char c; scanf(%d, c);然后输入一个数字比如65scanf会按整数的规则解析把 65 这个值存进c的一个字节里。由于char只有 1 个字节存储结果其实是 65 对应的二进制截断值。再往后用%c或%s输出结果完全不是你想的那样。%d对应的是int*%c对应的是char*这两种格式符的解析规则完全不同混用轻则数据错乱重则产生编译警告在严格模式下直接报错。我有一回调试别人的代码发现整个程序输出全是问号最后定位到就是这里写错了。规范做法是格式符和变量类型严格匹配用%d读整数就声明int用%c读字符就声明char不要为了省一个变量类型自作聪明。4.4 常见问题速查表现象可能原因排查方式逆序结果前多一个换行fgets读入了\n打印字符串长度和最后一个字符的 ASCII 码输出乱码字符串未加\0或中文多字节检查辅助数组末尾的结束符确认输入编码程序崩溃数组越界或缓冲区溢出检查fgets长度参数是否与数组大小匹配死循环循环内i、j更新遗漏单步调试看变量变化编译警告size_t与int混用统一类型或显式强转5. 题库里的邻居题冒泡排序与九九乘法表5.1 冒泡排序另一种“交换”思想字符串逆序里用到的交换操作在冒泡排序里会再次遇见。冒泡排序的核心也是交换只不过它交换的条件不再是“位置对称”而是“顺序不对”。一个典型的冒泡排序实现#include stdio.h void bubble_sort(int arr[], int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } } int main() { int arr[] {5, 2, 9, 1, 6}; int n sizeof(arr) / sizeof(arr[0]); bubble_sort(arr, n); for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); return 0; }内层循环条件j n - 1 - i里的- i是冒泡排序的核心优化每一轮都会把一个最大值“冒”到最后下一轮就没必要再比较已经排好的位置。这个- i很多新手会漏掉漏掉后排序结果依然正确但比较次数变多性能差些。如果你想在练习里做一个对比实验把- i去掉打印内外层循环的总执行次数你会直观看到优化前后的差距。逆序和排序的共同本质是“交换”区别在于交换的驱动力不同。逆序是位置对称强制交换排序是大小关系条件交换。把两题连着写你会发现交换的临时变量temp用法已经熟练到不用思考了。5.2 九九乘法表双重循环的格式控制九九乘法表是另一个必写的基础题它和逆序题在结构上没有直接关系但它能训练你对循环边界的敏感度。#include stdio.h int main() { for (int i 1; i 9; i) { for (int j 1; j i; j) { printf(%d*%d%-2d , j, i, i * j); } printf(\n); } return 0; }%-2d表示左对齐、最小宽度 2这样输出表格才能对齐不然不同位数乘积会错落难看。内层循环条件是j i只打印下三角部分。如果你想输出完整的 9×9 方阵把条件改成j 9就行。这一题同样在考循环边界但它的陷阱比逆序少适合在写逆序题前后作为暖身练习。5.3 练习节奏建议如果你按照“C 练习”这个系列往下走我建议的节奏是这样的先写九九乘法表熟悉双重循环和格式控制再做字符串逆序吃透数组、指针、字符串这三者的关系最后写冒泡排序把交换、比较、嵌套循环融会贯通。三题练完C语言的核心循环和数组的基础就算是打牢了。做题的时候不要只盯着“能跑出正确结果”。我见过太多同学题目过了就丢过了两周再写同样的题又卡住了。更好的做法是每道题都问自己三个问题能不能换一种实现方式边界条件是什么这个思路还能用在什么场景比如逆序的双指针思路在判断回文时其实就是两个指针从两端向中间移动逐一比较字符。你把逆序吃透了回文题基本就是顺手的事。我自己的习惯是每道练习题至少写三遍第一遍看着书写理解每一行在做什么第二遍合上书自己写卡住了就回去看但要做记号第三遍隔几天再写要求一次性通过并且能说出为什么这样写。这个过程比较费时间但效果比大量刷题要好得多。最后再分享一个小技巧。学C语言时手里一定要有一个趁手的调试环境VSCode 配置 C/C 环境其实是几分钟的事装好编译器、配好 launch.json 就能单步调试。遇到逆序这种边界问题单步执行比反复读代码快得多你能直观看到i、j怎么移动、每次交换后数组变成什么样。这个习惯从练习题阶段就养成后面做项目会感谢自己的。