写这篇文章之前先说点题外话。我在整理自己那堆C语言练习时,发现第4题到第6题几乎是每次翻出来都能有新痛点的存在——排序、二分查找、字符串处理、结构体链表,每个都单独拎出来不难,但连在一起写,就是一场对指针、数组边界和内存管理的集中体检。前阵子给朋友的项目做了个简化版的数据整理工具,又把这几道题翻出来重写了一遍,结果依然在几个老地方翻车。这篇文章就是把我重写这三道题的全过程、踩过的坑、改了三版代码才得出的最终版本,完整记下来。不管你是刚开始学指针,还是准备面试前想快速过一遍基本功,这三道题绝对值得按顺序演一遍。
1. 为什么是“4到6”:这三道题在C语言学习里的真实分量
1.1 第4到第6题到底覆盖了哪些知识点
很多初学者拿到一套练习册,会觉得“4到6”这个编号没什么特殊含义,无非就是三道普通题目。但真把这几道题摆在一起看,你会发现这完全是C语言知识链条里“承上启下”的一组:第4题通常落在数组和排序上,第5题落在字符串和字符数组上,第6题落在结构体和指针上。数组是C的骨架,字符串是数组的延伸,结构体又是数组的升级用法,三道题连在一起,等于把C语言里最密集的三个知识点串成了一条线。
以我这次练习使用的题目序列为例,第4题是“整型数组排序加二分查找”,第5题是“英文字符串单词统计”,第6题是“学生成绩的管理系统简化版”。这三题不涉及任何花哨的算法,全部都是基本功,但正是基本功最容易让人翻车。数组的索引边界差一位,字符串的结束符被忽略,结构体指针没有正确初始化,这些错误在每道题里都会出现,而且诡异的是,编译期不会报错,运行结果却千奇百怪。所以要拿这三道题练手,比的不是谁写得快,而是谁能在排查问题的过程中真正理解C语言的内存模型。
1.2 这三道题和“指针”四个字的关系
很多人学C到指针就卡住,觉得指针玄乎。其实你不必单独去啃指针概念,直接从这三道题入手会容易得多。第4题的排序里要传数组,数组名本身就是指针;第5题字符串处理里,字符指针和字符数组的关系必须搞清楚,否则sizeof和strlen一对上就出问题;第6题里结构体变量的传递、链表节点的连接,更是把指针的“指向关系”用得明明白白。
我第一次带着这三道题去给一位刚学完结构体的朋友讲解时,发现他的误区很典型:他以为把结构体变量直接赋值给另一个结构体变量就能复制,这确实可以,但到了链表里,节点和节点之间是通过指针域连接的,他写代码时却老想着给节点“改名”。这说明练习目标本身就有问题。好的做法是让这几道题自己说话——你写的代码在运行时的表现会直接告诉你,指针到底用对了没有。
2. 第4题:排序与二分查找的完整演绎与边界复盘
2.1 题目设定与我的解题思路
第4题我拿到的是这样一段描述:在一组整数中完成从小到大排序,然后对指定数值进行查找,若找到则输出下标,找不到则输出一个约定的失败值。这题的表现形式不复杂,但内部可以调整的空间非常大。我第一次做这题时,直接写了冒泡排序加线性查找,后来被一句话问住了:如果数据有几万个,线性查找还有意义吗?这才意识到,既然要求排序,排序之后应该搭配的是二分查找,否则排序环节就是多余的。
所以最终的方案定为两步:第一步,用冒泡排序或选择排序把数组排列好;第二步,用二分查找快速定位目标值。因为题目本身没规定排序算法,我选了最朴素的冒泡来示范,这是在练习时故意为之——它的交换过程直观,容易讲清楚循环边界。下面的代码是我重写后的版本,去掉了一些花哨的写法,尽量保留清晰结构:
#include <stdio.h> void bubble_sort(int arr[], int n) { int i, j, tmp; for (i = 0; i < n - 1; i++) { int swapped = 0; for (j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = 1; } } if (!swapped) { break; } } } int binary_search(int arr[], int n, int target) { int left = 0; int right = n - 1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; } else if (arr[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return -1; } int main(void) { int arr[] = {34, 7, 23, 32, 5, 62, 32, 12, 19, 3}; int n = sizeof(arr) / sizeof(arr[0]); int target = 32; bubble_sort(arr, n); printf("sorted: "); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); int idx = binary_search(arr, n, target); if (idx >= 0) { printf("found %d at index %d\n", target, idx); } else { printf("not found\n"); } return 0; }2.2 冒泡排序里最容易写错的一行
冒泡排序的代码谁都见过,但真到自己写的时候,内层循环的条件j < n - 1 - i,很多新手会下意识写成j < n - 1,甚至有人直接写j < n。这两种写法运行起来不会立刻报错,但排序结果一定有问题。原因其实不复杂:每一轮排序结束之后,数组末尾的最大值就已经固定了,下一轮根本不需要再和它比较。j < n - 1会把已经“沉底”的元素反复拿出来做无效比较,虽然没有逻辑错误,但效率浪费了;j < n更危险,因为当j等于n-1时,arr[j+1]已经越界,读取的是未知内存中的数据,这就会产生不可复现的乱序结果。
我在演示时还会加一个swapped优化标志,这个不是题目必须的,但我会习惯性加上。它的作用是一旦某一轮循环没有任何交换,说明数组已经有序,直接跳出循环。这个优化在原始数据近乎有序时能省下大量无效遍历,属于纯赚的改进。
2.3 二分查找的边界条件,这是老生常谈也要重谈
二分查找的核心要点只有三个:左闭右闭区间、mid计算防溢出、更新边界时加一或减一。上面的代码里我写的判断条件是while (left <= right),一旦left和right相等,说明区间里只剩一个元素,这个元素还要再判断一次;如果写成left < right,这个最后一元素就被漏掉了。这个细节在初学时完全看不出来,非得自己用一排单元素数组测试才会发现。
mid的写法我用的是left + (right - left) / 2,而不是(left + right) / 2。原因纯粹是为了防止大数组下的整数溢出——虽然这里的小数组根本不可能溢出,但好的习惯是从小规模练习就开始培养。left和right本身都是int,如果都是很大的数,两者相加就越界了,但如果先用差值的一半再加上left,就不会有这个问题。
另外务必记住:二分查找的前提是数组有序。如果操作对象是无序数组,直接跑二分就会得到荒谬的结果。所以在这道题里面,排序和查找的先后顺序是铁律,不能为了省事跳过排序直接调用查找函数。
2.4 第4题调试中的几个典型错误
这段代码我第一次跑的时候,输出结果是88,我当时就愣住了,因为数组里根本没有这个数字。排查了半天,才发现问题出在主函数里初始化数组时,多加了一个元素,而n的计算是sizeof(arr) / sizeof(arr[0]),这让我莫名多出了一个垃圾值。其实这道题如果只传固定长度,直接用n = 10就行,但通用性会变差。最好的排除办法,是在排序前把数组全部打印一遍,先确认原始数据干净,再往下走。
还有一次是目标值32在数组里出现了两次,我把返回值打印出来以后发现下标是6,但排序后重复元素的第一次出现位置应该是5。出现这个现象的原因是二分查找本身只会返回第一次命中的位置,并不会保证是第一个。若题目要求“第一次出现位置”,那还得在命中后向左探测,这是一层额外的逻辑,不能想当然。
3. 第5题:字符串单词统计——从缓冲区陷阱到状态机写法
3.1 一道看似“太简单”的题目
第5题是我在重写过程中最意外的一题。题目描述很直白:输入一行英文字符串,统计其中包含多少个单词,并找出最长的那一个。这题如果只论“写出来”,不到20行就能完成,但真要写得健壮、逻辑清晰,就需要处理几个字符串处理的经典痛点。第一个痛点就是输入方式:用scanf("%s", str)只能读到空格前的部分,根本拿不到整行;用gets读一行又没人用它来做输入缓冲区安全检查。标准的用法是用fgets。
#include <stdio.h> #include <string.h> #include <ctype.h> #define MAX_LINE 256 int extract_words(const char *line, int *start_pos, int *word_lens, int max_words) { int i = 0; int in_word = 0; int word_start = 0; int word_count = 0; while (line[i] != '\0') { if (!isspace((unsigned char)line[i])) { if (!in_word) { in_word = 1; word_start = i; } } else { if (in_word) { start_pos[word_count] = word_start; word_lens[word_count] = i - word_start; word_count++; in_word = 0; if (word_count >= max_words) { break; } } } i++; } if (in_word) { start_pos[word_count] = word_start; word_lens[word_count] = i - word_start; word_count++; } return word_count; } int main(void) { char line[MAX_LINE] = {0}; int starts[64] = {0}; int lens[64] = {0}; printf("input a line: "); fgets(line, sizeof(line), stdin); line[strcspn(line, "\n")] = '\0'; int n = extract_words(line, starts, lens, 64); printf("word count: %d\n", n); for (int i = 0; i < n; i++) { printf("word %d: %.*s (len=%d)\n", i + 1, lens[i], line + starts[i], lens[i]); } int max_index = 0; for (int i = 1; i < n; i++) { if (lens[i] > lens[max_index]) { max_index = i; } } if (n > 0) { printf("longest: %.*s (len=%d)\n", lens[max_index], line + starts[max_index], lens[max_index]); } return 0; }3.2 用状态机来处理,而不是硬数空格
统计单词最简单的初级思路是数空格:遇见空格就认为一个单词结束了。但这样处理连续空格、行首空格、行尾空格都会出错,比如“hello world”会数出三个单词来。我的做法是引入一个in_word状态变量,它的值要么是0要么是1,用来标记当前是否正在一个单词内部。遍历字符串时,遇到非空白字符且之前不在单词里,就标记单词开始;遇到空白字符且之前在单词里,就结束一个单词。这个逻辑本质上是一个只有两个状态的小状态机,写成代码也只有三五行,但健壮性远远高于单纯数空格。
我在代码里还把每个单词的起始位置和长度记录下来了,这样就不需要拷贝子串,直接用%.*s加长度打印即可。这里用到的. *是printf的精度通配符,传一个整数代表最多输出多少个字符,很多C语言教材不会特意提,但实际写文本处理时非常好用。
3.3 fgets残留换行符与ctype里的隐藏坑
使用fgets读取输入时,如果行的总长没有超过缓冲区大小,读进来的字符串末尾会带着一个换行符。比如输入hello后按下回车,line里实际是hello\n。如果不处理这个换行,后面统计单词时,最后的单词长度会包括\n,打印出来的长度就多1。我用strcspn(line, "\n") = '\0'来把这个换行替换成字符串结束符,这比手动循环找\n更简洁。需要注意的时,如果输入行太长,fgets只读到缓冲区最多MAX_LINE-1个字符,此时字符串里没有\n,strcspn会一直搜到结尾才停止,把最后一个字符当成了结束符的位置。这个边界情况不用太紧张,因为在定长输入场景下是可控的。
ctype里的isspace函数接收的参数原本是int,但如果传入的是char,在有些平台上char是带符号类型,遇到扩展ASCII码或负数值时会触发未定义行为。稳妥的做法是转成unsigned char,我在代码中特意写了(unsigned char)line[i]这个转换,目的是避免在非纯英文环境里出问题。很多网上的示例代码不会注意这一行,但我建议你保留这个习惯。
3.4 为什么不推荐用strtok
读者可能会问:直接用strtok按空格切分不就行了?确实行,但我建议在练习时不要这么做,原因有三。第一,strtok会修改原始字符串,它会把你传入的字符串内部的间隔符替换成'\0',所以你传进去的数组在调用之后就不再是原来的完整行内容了。如果要保留原文,就得先把内容复制一份,多一步。第二,strtok在多次连续调用时依赖全局状态,在多线程环境下是存在隐患的。虽然当前C标准也提供了strtok_s这类带上下文的版本,但教材中更常见的还是那个老接口。第三,这题的重点本来就是训练自己处理字符边界,如果调一个现成的切分函数,前面的指针和边界思考就全被跳过了,题目也就白做了。
使用状态机方式后,不仅空格可以正确处理,制表符、换页符这些空白字符也能统一识别。这种写法的通用性会好很多,而且后续如果你想改成按标点符号切分,只需要把isspace换成自定义的判断函数,其余代码完全不变。
4. 第6题:学生成绩管理——结构体与链表的落地实践
4.1 一道把结构体、数组和指针全串起来的题目
第6题的常见形式是:定义学生结构体,包含姓名、学号和成绩三个字段。要求能输入若干条记录,按成绩从高到低输出,并支持删除指定学号的记录。这题如果用数组来存,也能做:插入时把成绩低的记录向后移动,删除时也是批量向前搬数据。但我这次选择的是链表实现,因为这道题的核心价值在于练习结构体指针的连接,用数组实现就失去了这层训练意义。
用链表写这道题,最大的决策点是头节点的设计。我喜欢用一个不存储真实数据的头节点,也就是所谓的“带头节点链表”,它的next指针指向第一个真正有数据的节点。这个设计的最大好处是,插入、删除时不需要考虑“如果要操作的是第一个节点,头指针本身要变”这种特殊情况。每次新增节点时,头指针始终指向那个固定的头节点,逻辑统一性大大提高。
4.2 按成绩降序插入,一次遍历完成排序
实现代码大致分三部分:创建节点、按序插入、遍历删除。
#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct Student { char name[32]; int id; int score; struct Student *next; } Student; Student *create_node(const char *name, int id, int score) { Student *p = (Student *)malloc(sizeof(Student)); if (p == NULL) { return NULL; } strncpy(p->name, name, sizeof(p->name) - 1); p->name[sizeof(p->name) - 1] = '\0'; p->id = id; p->score = score; p->next = NULL; return p; } void insert_sorted(Student *head, Student *new_node) { Student *prev = head; Student *cur = head->next; while (cur != NULL && cur->score >= new_node->score) { prev = cur; cur = cur->next; } new_node->next = cur; prev->next = new_node; } int delete_by_id(Student *head, int id) { Student *prev = head; Student *cur = head->next; while (cur != NULL && cur->id != id) { prev = cur; cur = cur->next; } if (cur == NULL) { return 0; } prev->next = cur->next; free(cur); return 1; } void print_list(Student *head) { Student *p = head->next; while (p != NULL) { printf("id=%d name=%s score=%d\n", p->id, p->name, p->score); p = p->next; } } int main(void) { Student head; head.next = NULL; insert_sorted(&head, create_node("A", 101, 82)); insert_sorted(&head, create_node("B", 102, 91)); insert_sorted(&head, create_node("C", 103, 77)); insert_sorted(&head, create_node("D", 104, 96)); printf("before delete:\n"); print_list(&head); printf("delete 102: %d\n", delete_by_id(&head, 102)); printf("after delete:\n"); print_list(&head); Student *cur = head.next; while (cur != NULL) { Student *tmp = cur; cur = cur->next; free(tmp); } head.next = NULL; return 0; }insert_sorted函数里的排序逻辑是:从第一个实际节点开始,只要当前节点的成绩大于等于新节点成绩,就继续向后。找到第一个比新节点分数低的节点,把新节点插在它前面。这里的等于情况我特意处理成了“新节点跟在同分节点后面”,不会破坏原有节点之间的相对顺序,正是稳定排序的要求。链表插入不需要像数组那样移动大量数据,它只需要改两个指针的指向,这也正是链表存在的意义。
4.3 删除节点时,为什么要带上prev指针
删除逻辑和插入类似,核心是cur和prev两个指针一前一后地走。当cur指向待删除节点时,prev恰好指向它的前一个节点。这里最关键的代码是prev->next = cur->next,它让前一个节点直接越过当前节点,连到后面去,然后再free(cur)。如果不保存prev,当你要删除恰好是第二个节点时,你将无法知道前一个节点是哪个,也就改不了它的next字段。
很多初次接触链表的人会犯一个错误:写删除时只定义一个cur指针,遍历找到目标节点后直接free(cur),但free之后原有链表就断掉了——前一个节点还指向着这块已经释放的内存。所以,在单链表里删除节点,必须要“双指针并进”,或者退一步说,至少你要在遍历过程中把prev存下来。这是一道想通就能省很多调试时间的细节。
4.4 不要再忘记释放内存
C语言不像别的语言有垃圾回收机制,malloc出来的节点用完必须手动free。我在main函数末尾写了一个专门释放所有节点的循环,逐节点释放。这道题如果不写释放代码,程序退出时系统会回收进程内存,短时间内看不出问题,但它是一个极坏的思维习惯。现实中写服务程序时,一个进程常驻内存很久,每次插入分配的内存若不准时释放,就会积累成内存泄漏,最终把内存吃满。
在链表里释放所有节点时,务必要先用tmp = cur; cur = cur->next;然后free(tmp),而不是free(cur)之后再cur = cur->next。因为free(cur)之后,cur指向的内存就已经不属于你了,再读cur->next是未定义行为。好多人写释放代码时顺序搞反,导致程序在退出阶段段错误,这类问题在面试和笔试里也经常出现。
5. 同一套代码反复踩过三次的坑:调试与编译环境备忘
5.1 缓冲区残留换行导致的诡异输入表现
第5题里如果不清理换行符,紧接着的第6题输入就会出大问题。比如我一开始把两题的测试代码写在同一个程序里,第5题输入完一行按回车后,换行符残留在缓冲区中。第6题里再输入学号和姓名时,scanf很可能直接读到了这个残留换行,表现为“还没输入呢程序就往下跑了”。这是非常经典的C语言输入问题,几乎每个初学者都遇到过。
处理这个问题有三个常见思路:第一次调用scanf后,再用一个getchar吃掉换行;或者在每个scanf格式串里写“%d\n”,但这种方法有时会和后面的输入行为不一致;最稳妥的是统一在scanf之后调用一次getchar清理缓冲区。我在练习时倾向尽量把输入统一用fgets读进来,再用sscanf格式化解析,这样输入缓冲区管理会更有条理。
5.2 “函数传指针”和“函数传结构体本身”的区别
我在给朋友讲解第6题时发现,二叉树的题不太常见,但“结构体数组传给函数”的题很常见。很多同学的困惑是:结构体变量名传进函数后,函数里修改字段,为什么外面的变量没变化?回答是:因为传的是结构体变量的副本,修改的是副本。要想修改原结构体,就得传结构体指针。
但这个关系放在链表语境下会更绕。main函数中我声明的是Student head,然后把&head传给insert_sorted。如果你直接传head,那这个函数拿到的是结构体的副本,函数里修改head.next,外部永远感受不到。这其实是第4题数组那一节早就出现过的问题:数组名是地址,所以数组天然按引用传递;普通变量按值传递;结构体变量也是按值传递,除非显式传地址。这三者的区别如果不在脑袋里理清楚,写链表代码就会出现各种“函数运行完了链表没变”的诡异现象。
5.3 编译器差异与Debug模式下的观察技巧
同样的代码在某个环境编译能跑,换个环境就段错误,这种问题在C语言练习里并不少见。GCC和MSVC对未初始化局部变量的处理方式不同,GCC有时会用0填充,MSVC在Debug模式下则可能填充0xCC,这就会导致不同的运行结果。所以我在排查链表问题时,第一件事就是把所有节点指针初始化成NULL,避免被“看着像有值其实是随机值”的假象误导。
发表这篇文章前我自己跑了全套测试,调试时小技巧是这样的:不用担心在完整程序里反复用printf打日志,用条件编译包起来,例如#define DEBUG_ON ... #endif,等调试完成再注释掉。很多初学者一看段错误就慌,其实最好的策略是把每一步都分解开验证:排序后打印数组、插入后逐节点打印、删除后打印剩余节点。先把一段逻辑验证完,再进入下一段,整个程序就会非常可控。
6. 这三道题延伸出的下一步练习建议
6.1 从“会写”到“可复用”
把第4题到第6题完整跑通后,如果你觉得手感还可以,下一步建议不要急着跳去算法题,而是先把这几个函数封装成可复用的模块。比如把第4题的排序函数单独放到一个sort.c文件里,配合一个sort.h头文件,然后在别的地方include进去用。这个练习看着简单,但会逼你理解头文件、编译单元、函数声明这些平时写单文件时体会不到的东西。第6题里的链表可以继续封装成学生管理模块,提供add、delete、find、print四个接口。一旦你这样封装过,后面再做任何需要链表的数据结构都会顺手得多。
6.2 给链表加上反转与排序
紧接着第6题的一个经典延伸题是“单链表反转”。要求不新建节点,只通过修改指针方向把链表逆序。这个题看起来和成绩管理没关系,但它会进一步训练你多指针同步移动的能力。另一个延伸是改为按姓名排序,这时就不能直接比较数值了,要用strcmp来比较字符串,逻辑会复杂一点点,但练习价值很高。这些是我个人认为第6题之后最顺理成章的加强练习,也是面试里非常容易出现的变体。
6.3 从命令行程序到小工具
最后一个建议是把这三题组合成一个简单的命令行小工具,输入一批整数时做排序查找,输入一行文本时做单词统计,输入若干学生信息时按成绩排序。你会发现组合过程中最大的麻烦不是代码本身,而是用户输入格式的规范问题——这正是实际软件开发中比重最大的部分之一。把这些输入校验逻辑也练到位,C语言的基本功才算真的过了关。
代码的事,跑起来才算数。如果这篇文章能帮你少走几步弯路,那它就没有白写。