C语言字符串逆序实战:函数传参、指针运算与工程化实现
C经典100例练到第43题说实话已经过了最容易劝退的阶段。前面那些变量、循环、数组题目做完基本语法都摸过一遍了这一题开始转向“函数指针字符串处理”的综合运用需要你从“写代码能跑”过渡到“写代码有章法”。第43题的题目本身不复杂编写一个函数将一个字符串中的字符逆序存放然后输出。比如输入字符串”abcdef”处理后输出”fedcba”。但就是这道看似简单的题背后几乎覆盖了C语言最核心的几个知识点参数传递的方式、数组名作为函数参数时的本质、指针运算、字符串的表示与终止符、原地修改与额外空间的区别。我把这道题从“会写”到“写对”再到“写得像工程代码”完整拆一遍。顺便把我在练习过程中踩的坑、查的错、总结的调试方法一并分享出来对刚学到函数和指针这部分的朋友应该会有帮助。1. 题目到底在考什么先别急着动手拿到习题集很多人习惯直接看参考答案抄一遍跑通就算过。我建议停下来先拆解一下这道题表面上让你实现字符串逆序实际上至少考了4个点。第一函数如何接收字符串。字符串可以用字符数组char str[]存储也可以用它退化成指针后的char *str来传递。题目要求“编写一个函数”那么参数应该写成什么形式是写void reverse(char s[])还是void reverse(char *s)这两个在语法上等价但理解上有差别。数组形式说“我传进来一个数组”指针形式说“我传进来一个地址”。后者更接近机器层面的真实情况因为数组名作为实参传递时压根不会复制整个数组只传首地址。第二如何求字符串长度。字符串在C语言里没有“长度”这种内置属性全靠结尾的\0字符来判定边界。所以需要自己遍历一遍找到\0的位置才算得到长度。很多人直接用sizeof(str)想拿长度跑出来发现是100数组声明的大小或者864位机器上指针的大小就是没搞清楚sizeof在数组和指针上下文里的区别。第三如何交换字符。逆序的本质是“对称位置互换”s[0]和s[len-1]换s[1]和s[len-2]换直到中间点。交换需要临时变量这个写多了自然就会。但要考虑循环次数是len/2不能是len否则换过去又换回来等于没换。第四如何体现“原地”还是“复制”。如果新开一个数组把原始字符串倒着拷进去再拷回原数组也是一种实现但多用了O(n) 的额外空间。如果直接在原数组上交换就是O(1) 的额外空间。前者代码更直观后者更考察对内存的理解。面试里经常追问的就是这层区别。2. 动手前必须搞懂的3个基础概念与其直接上代码不如先花点时间把三个基础概念理清楚。这节内容不鸡肋直接决定你后面能否独立写出各种变体。2.1 数组名、指针和sizeof的爱恨情仇在绝大多数表达式中数组名会“退化”为指向首元素的指针。char str[100]里的str在传给函数、参与运算时它的值就是str[0]。所以函数形参写成char s[]也好char *s也罢编译器最终都当成指针处理。这套机制带火了一个经典陷阱在函数内部写sizeof(s)如果s是形参它的大小就是指针的大小——64位系统上通常是8字节哪怕你实参传进来的数组有100字节。这就是为什么求字符串长度必须用strlen或者自己遍历不能指望sizeof。与它对应的是函数外部的sizeof(str)这里的str还是完整数组sizeof的结果是100。如果整个程序里都用sizeof试图求长度一会儿得100一会儿得8结果必然乱套。想知道当前字符串实际有多长任何情况下都只认\0。2.2 字符串字面量和字符数组的差异写char *p hello时hello是存于只读区的字符串字面量p只是指向它的指针如果你试图p[0] H很多环境下直接段错误。写char arr[] hello时编译器在栈上开了一个6字节数组5个字符1个\0然后把字面量的内容拷贝进去这时候改arr[0]没问题。第43题如果要原地逆序就要求传入的字符串必须是“可修改的”也就是字符数组形式。如果实参是字符串字面量逆序操作会涉嫌改动只读区域这是程序设计上的错误。虽然有的编译器放行但行为不可控。最好养成习惯练习这一类函数时统一用char s[] ...来准备测试数据。2.3strlen的工作原理strlen是标准库函数头文件string.h里声明。它的实现思路简单粗暴从起始地址开始一个字节一个字节往后比对直到发现\0返回走过的字符个数不包含\0本身。时间复杂度O(n)。这个细节之所以重要是因为你在reverse函数里要先strlen(s)获取长度再基于这个长度做首尾交换。如果字符串没有以\0结尾比如你用了char s[3] {a,b,c}strlen会一直往后越界找\0直到碰巧遇到一个0字节为止这种未定义行为非常危险可能读穿栈帧导致奇怪的结果甚至崩溃。3. 三种实现方案照着写一遍比只看不练强十倍方案无所谓绝对对错不同阶段选不同的写法。我建议三种都敲一遍互相比较体会差别。3.1 方案一数组下标法最直白的思路这个方案借助临时变量首尾对调。核心代码块#include stdio.h #include string.h void reverse(char s[]) { int len strlen(s); for (int i 0; i len / 2; i) { char temp s[i]; s[i] s[len - 1 - i]; s[len - 1 - i] temp; } } int main() { char str[] abcdef; reverse(str); printf(逆序后: %s\n, str); return 0; }为什么循环只到len/2而不是len如果是len前半段把后半段换过来后半段又把它换回去转一圈等于没动。中间那条“对称轴”在字符串长度为奇数时正好是中间那个字符它不需要交换。这个版本是“解码器”级别的写法逻辑最清晰适合验证思路。数组下标s[len - 1 - i]是标准的首尾映射一眼能看懂。3.2 方案二双指针法锻炼指针思考方式用两个指针p指向开头q指向末尾然后while (p q)交换交换完p、q--。核心代码块#include stdio.h #include string.h void reverse(char *s) { char *p s; char *q s strlen(s) - 1; while (p q) { char temp *p; *p *q; *q temp; p; q--; } } int main() { char str[] hello world; reverse(str); printf(逆序后: %s\n, str); return 0; }指针版本的好处是你不再纠结“下标到底从0还是从1开始”而是盯着两个地址比较大小。p q这个条件保证只处理到中间天然避开了“换回来”的问题。指针运算在这里直观体现出来s strlen(s) - 1指向最后一个有效字符而不是\0。很多教材推荐先学指针再做这类题但我个人认为你先把方案一写熟再切换到方案二会比较顺。下标版本帮你把“位置”这个抽象概念建立起来指针版本帮你在“内存地址”层面再理解一遍。3.3 方案三递归反转递归版本的思路是每次把第一个字符存起来把剩余部分递归逆序再将第一个字符拼接到末尾。实现上有两种常见写法。一种是“边递归边输出”不修改原字符串#include stdio.h void reverse_print(const char *s) { if (*s \0) { return; } reverse_print(s 1); putchar(*s); } int main() { char str[] abcdef; printf(递归逆序输出: ); reverse_print(str); printf(\n); return 0; }另一种是“递归修改字符串内容”需要额外注意哨兵字符的位置。这种写法在很多参考书上只给代码不给解释我建议你自己推演一遍递归到底层时\0要安放在哪个下标返回上一层时才能把首字符放到正确位置。推不出来的话用调试器单步看几轮就明白了。递归版本在面试里常被用来考察“函数调用栈”的理解但在第43题这种教学场景下我建议把它作为“理解递归”的练习题而不是日常逆序的首选方案。递归有栈深度限制如果字符串有几万个字符递归很容易爆栈。4. 完整代码工程化从“能跑”到“能复用”题目练习到第43题你已经具备把一段代码组织成小工程的能力了。别老是把所有函数都塞在一个main.c里尝试一下拆文件、写头文件、配Makefile对后续学习大有好处。4.1 目录结构我习惯这样组织一个练习项目reverse/ ├── include/ │ └── str_util.h ├── src/ │ ├── str_util.c │ └── main.c └── Makefileinclude放头文件src放源文件Makefile负责构建。规模不大但结构清晰以后项目变大时直接套这个骨架。4.2 头文件怎么写str_util.h内容#ifndef STR_UTIL_H #define STR_UTIL_H void reverse(char *s); #endif#ifndef外加#define和#endif是头文件保护防止同一个头文件被多个源文件包含时重复声明。这个细节在小型练习里看不出问题但项目一大就会遇到“重复定义”“重复声明”的报错。趁早养成习惯。4.3 源文件怎么写str_util.c内容#include string.h #include str_util.h void reverse(char *s) { int len strlen(s); for (int i 0; i len / 2; i) { char temp s[i]; s[i] s[len - 1 - i]; s[len - 1 - i] temp; } }main.c内容#include stdio.h #include str_util.h int main(void) { char str[] abcdef; reverse(str); printf(逆序后: %s\n, str); return 0; }注意源文件包含头文件时如果是自己项目的头文件用双引号如果是标准库头文件用尖括号。这是一个约定俗成的规则双引号优先在当前文件所在目录找找不到再去系统头文件路径找尖括号直接去系统头文件路径找。虽然混着用也能编译过但按照规范能避免很多“头文件找不到”的困惑。4.4 Makefile 配置以下是可以直接用的最小MakefileCC gcc CFLAGS -Wall -Wextra -stdc11 -Iinclude all: reverse reverse: src/main.c src/str_util.c include/str_util.h $(CC) $(CFLAGS) -o $ src/main.c src/str_util.c clean: rm -f reverse-Wall和-Wextra开起来让编译器把可疑代码都提示出来。-stdc11指定语言标准避免一些老旧写法被默认标准惯坏了。-Iinclude告诉编译器去include目录找头文件。生成二进制后运行make ./reverse4.5 一个更健壮的边界测试程序题目只要求输入一个示例字符串但实际写代码时我强烈建议你额外测试三个边界场景空字符串、只有一个字符的字符串、全部相同字符的字符串。可以写成这样的测试结构#include stdio.h #include string.h #include str_util.h static int test_reverse(const char *input, const char *expected) { char buf[128]; strcpy(buf, input); reverse(buf); if (strcmp(buf, expected) ! 0) { printf(FAIL: reverse(\%s\) \%s\, 期望 \%s\\n, input, buf, expected); return 0; } printf(PASS: \%s\ - \%s\\n, input, buf); return 1; } int main(void) { int pass 0; pass test_reverse(abcdef, fedcba); pass test_reverse(hello world, dlrow olleh); pass test_reverse(a, a); pass test_reverse(, ); pass test_reverse(aaaa, aaaa); printf(通过 %d/5 项测试\n, pass); return pass 5 ? 0 : 1; }把测试用例和主逻辑分离这在工作后是家常便饭但很多人从学习阶段就没有这个意识导致代码越写越难维护。哪怕只是做练习这个习惯也值得提前培养。5. 我练这题时踩过的一堆坑按真实代价排序下面这些坑不是杜撰出来的理论风险都是实际敲代码时遇到过的问题。按我记忆中翻车频率从高到低排列。5.1 用sizeof代替strlen求长度新手最容易犯的第一个错。示例char str[] abcdef; int len sizeof(str); // 结果是7不是6因为str数组长度是7包含结尾的\0。逆序时如果按7来处理str[6]是\0交换之后\0跑到开头后面打印直接空字符串或者产生乱码。这是最典型的“编译通过但结果全错”的情况。用strlen能得到6正确处理。或者自己写个my_strlen循环计数这题练习时可以故意不用标准库手动实现一遍加深印象。5.2 实参传了字符串字面量下面这种写法放在很多编译器上能跑但实际上是危险操作char *str abcdef; reverse(str); // 试图修改只读区在我的环境上直接段错误。因为字符串字面量通常存放在只读段。真要用指针方式也必须指向可写内存比如char str[] abcdef; char *p str; reverse(p);这样就安全了。记住这句话字符数组可改字符串字面量不可改。5.3 循环次数多算一个写for (i 0; i len; i)的后果是逆了个寂寞字符串完全没变。我见过有人查了很久最后发现是循环边界写错。调试方法很简单输出中间状态的数组看一眼就明白了。5.4 输入带空格时用了scanf(%s, ...)如果你想让用户输入一个类似hello world的字符串再用程序逆序scanf(%s, str)只能读到空格前的内容world丢了。正确做法是用fgetschar str[128]; fgets(str, sizeof(str), stdin); str[strcspn(str, \n)] \0; // 去掉末尾换行符然后传给reverse。注意fgets会把换行符也读进去不加处理的话逆序后换行符会跑到最前面打印时产生奇怪的空行类似“摆错位”的效果。5.5 忽略中文编码问题如果你测试的字符串里有中文比如你好世界在UTF-8编码下一个汉字占3字节strlen返回的是字节数而不是字符数。简单按字节逆序会把汉字内部字节顺序打乱输出乱码。这不是逆序算法不对而是“字节”和“字符”不是一回事。想正确处理中文就得引入宽字符、多字节字符处理函数或者直接限定题目场景为纯英文字符串。练习第43题时我建议先用纯ASCII字符串把指针和内存机制吃透。等学了宽字符处理再回来扩展。5.6 忽视头文件忘了#include string.h直接用strlen编译器会报警告有些环境强行编过但运行行为诡异。因为老标准下没有声明的函数会隐式声明为返回int而strlen返回size_t位数可能不一致导致长度计算出错逆序结果不可预期。现在的编译器普遍把隐式声明当错误处理但为了兼容老代码还是建议把该包含的头文件写全。6. 用GDB和Valgrind给程序做体检如果你只会“运行完看结果对不对”那排查能力会受限。这道题虽然小但验证工具链提前上手能受益很久。6.1 用GDB单步看交换过程编译时加-g参数gcc -g -o reverse src/main.c src/str_util.c gdb ./reverse在GDB里打断点(gdb) break reverse (gdb) run (gdb) print s (gdb) print strlen(s) (gdb) next (gdb) print s[0] (gdb) print s[len-1]你会发现print s显示的是字符串print s[0]显示的是字符这个区别很直观。单步走几轮就能看清指针在函数内怎么指向内存。我在初学阶段对指针一直半懂不懂后来靠GDB把指针的地址和值打出来看到p和q的地址动态变化一下子通了。记忆犹新的场景是(gdb) print p $1 0x7fffffffe580 abcdef (gdb) print q $2 0x7fffffffe585 f地址相差5因为我打印的是q而不是*q。q作为一个指针它的值是一个地址*q才是地址上的字符。这种情况下容易混淆打印时刻意加个*区分一下就行。6.2 用Valgrind检查越界和内存泄漏项目在Linux/macOS下可以装Valgrind。用法极简valgrind --leak-checkfull ./reverse如果代码没问题它会输出类似All heap blocks were freed -- no leaks are possible的话。如果检测到非法读写会直接提示非法的地址和调用堆栈。虽然这题没有动态内存分配但非法读\0之外的内存照样可以查出来前提是你测试时构造了越界场景。Windows 环境下可以用Dr. Memory或者Application Verifier效果类似。6.3 自己写日志式调试在没有调试器的环境里最笨也最有效的办法就是打印中间态。比如逆序函数的每次交换都打印出来printf(交换 %d 和 %d: %c - %c\n, i, len-1-i, s[i], s[len-1-i]);这个输出能直观告诉你程序执行的路径。测试完再把这些临时打印删掉或注释掉。很多人觉得打印调试低级但在简单练习场景下它比GDB省时间而且打印信息好看懂。7. 这一题之后的延伸思考比题目本身更值钱第43题做完不代表完事了。我列几个练完这题可以思考的方向每个方向都是一道新题。7.1 从逆序到反转单词给定一个句子字符串不反转字符顺序而是反转每个单词的顺序。比如hello world c language变成language c world hello。常见做法是整体逆序一次再对每个单词内部逆序一次。这正是第43题变体的典型代表。想清楚两次逆序为什么能凑出单词倒序的效果你对“逆序”这个操作的认识就不一样了。7.2 从原地到考虑内存重叠标准库里有memcpy和memmove两个函数。memmove专门处理内存重叠的情况。第43题的逆序如果改成“把字符串的前半段移到后半段”就会遇到内存重叠问题你得想想为什么不能用memcpy以及怎么判断两个区间是否重叠。掌握这个点在实现内存缓冲区的数据移动时会少很多潜在bug。7.3 从C字符串到动态字符串char s[100]是定长数组如果用户输入超过99个字符fgets读不完整缓冲区就可能溢出。练习完这道题可以写一个my_reverse_dynamic(const char *s)用malloc分配所需空间返回一个逆序的新字符串并记得释放内存。这自然就锻炼了指针、动态内存分配、返回指针的规范谁分配谁释放等知识。7.4 从单文件到多文件的Makefile进阶之路推荐给想进阶的人一份自发练习清单把reverse做成一个静态库让主程序链接静态库写一个.gitignore把编译产物和备份文件忽略掉给reverse函数添加注释规范写清参数、返回值、注意事项扩展测试代码自动生成随机字符串测试对比reverse(reverse(s))是否等于原字符串这些练习完全不依赖新知识只是把工程化的惯用做法过一遍但带来的收获比单纯刷题大得多。7.5 算法复杂度意识第43题的时间复杂度是O(n)因为strlen要O(n)交换要O(n/2)总共O(n)。空间复杂度是O(1)因为只用了临时变量。很多人在初学阶段对复杂度不敏感但如果你将来想进大厂或者做底层开发面试必问复杂度。这道题可以当复杂度分析的第一课为什么O(n)能不能省掉strlen这趟遍历有没有方法同时逆序和求长度如果允许额外空间能否做出O(n) 空间但更简单的实现在脑子里回答这些问题比背答案有意义。8. 关于这道题我最后想说的几句话第43题的价值不在于“把字符串倒过来”这个动作本身而在于它强制你同时面对数组、指针、字符串终止符、函数传参、内存可写性、边界处理这几座大山。能独立写出双指针版本并且讲清楚为什么循环条件是p q说明C语言的核心基础已经立住了。我在学这道题的时候花了一个晚上搞明白sizeof和strlen的区别又在第二天用GDB验证了指针地址的变化。回头想那些“绕不过去的弯”恰恰是真正让我学会C语言的地方。如果你也在练习中卡住了别急着找答案先按我前面说的方式打印中间状态、用调试器看看内存多折腾一会儿收获绝对比5分钟看到答案多得多。等这道题做明白了下一步可以去试试反转句子里单词顺序的那道变体或者把reverse接到你自己写的小项目里。祝学得顺利。