一直跟着我做“C语言日常练习题”系列的同学应该知道,前面第21到30题我们重点磨了指针、函数和数组的配合,到了第31题开始,整套练习的难度曲线会明显抬升一个台阶。这次第31到40题,我的安排是覆盖数组原地操作、字符串统计、回文判断、经典排序、结构体排名、文件读写、函数指针、位运算、自定义字符串函数和洗牌算法,一共十道题,知识点基本横跨了C语言从语法到工程习惯的绝大多数核心场景。如果你是自学C语言,或者正在准备考试、面试,这套题刷完,你会发现自己对“指针能做什么”“结构体怎么用才不亏”“文件操作到底卡在哪”这些问题的理解,会比单纯看书清楚得多。
1. 这套练习在练什么:题目设计与知识点布局
1.1 为什么第31到40题是这个阶段的分水岭
很多初学者刷题有个误区,就是题目做得多,但类型高度重复。前面二十题把变量、分支、循环练熟之后,如果你继续在“求最大值”“判断闰年”这类题里打转,进步会非常有限。第31到40题这个区间,我特意把单点的语法题压缩,换成了“一道题同时考两到三个能力”的组合题。
举个例子,第31题数组逆序,表面上是循环和下标交换,实际考察的是你对“边界条件”的敏感度;第36题统计文件单词数,表面上是文件读写,实际上还考察了状态机的设计思路,也就是“在单词内”和“在单词外”这两种状态的切换。这种思路一旦建立,后面学什么状态机、解析器、网络协议处理,都会顺很多。
所以我的定位很简单:第31到40题不是用来背答案的,是用来“逼”你把前面学过的零散语法串成体系的。它的意义在于让你从“会写代码”过渡到“会设计代码”。
1.2 十道题的知识点覆盖与难度曲线
先放一张我整理的知识点分布表,方便你对照自己的薄弱环节。
| 题号 | 核心考点 | 延伸能力 | 易错点 |
|---|---|---|---|
| 31 | 数组、双指针交换 | 原地操作、边界控制 | 循环终止条件写错 |
| 32 | 字符串、ASCII映射 | 计数数组 | gets/fgets混用 |
| 33 | 字符串比较 | 双指针扫描 | strlen反复调用 |
| 34 | 冒泡排序 | 轮次与优化 | 内层循环边界 |
| 35 | 结构体数组 | 选择排序、字段交换 | 直接赋值vs逐字段复制 |
| 36 | 文件读写 | 状态机、EOF处理 | 打开失败未判断 |
| 37 | 函数指针 | 回调机制、数值积分 | 函数指针声明语法 |
| 38 | 位运算 | 无符号类型、移位 | 优先级混淆 |
| 39 | 字符串处理 | 自实现strlen/strrev | 传参是数组还是指针 |
| 40 | 随机数 | 洗牌算法、随机种子 | srand位置错误 |
从表格能看出来,难度曲线不是直线上升,而是波浪式的:数组、字符串这类基础题先热身,排序和结构体进入应用层,文件、函数指针、位运算和洗牌算法则是接近中级水平的内容。这样错落安排的好处是,你每一轮练习都会有一个“新东西”刺激你继续往下走,不会因为在某个点上卡太久而放弃。
2. 数组与字符串:热身关卡的考题拆解
2.1 第31题:数组原地逆序——边界条件是第一道坎
题目要求:输入一个数组,将其元素顺序原地反转,不使用额外数组。
这个题很多人第一反应是新建一个数组,从后往前赋值。但题目明确要求“原地”,也就是说空间复杂度是O(1)的。最经典的做法是用两个下标i和j,一个从头走,一个从尾走,每次交换两个位置的元素,直到i >= j。
#include <stdio.h> #define N 8 int main(void) { int arr[N] = {1, 3, 5, 7, 9, 11, 13, 15}; int i, j, tmp; for (i = 0, j = N - 1; i < j; i++, j--) { tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } for (i = 0; i < N; i++) { printf("%d ", arr[i]); } putchar('\n'); return 0; }我见过不少初学者把循环条件写成i <= j。如果数组长度是奇数,中间那个元素跟自己交换一次,没有问题;但如果数组长度是偶数,i <= j会导致中间两个元素被交换两次,相当于没换。所以这个题的考点不在“会不会交换”,而在“循环条件的临界值”。
实际刷题的时候,我还习惯把数组长度改成奇数、偶数各跑一遍。这样做不是为了炫技,而是为了确认边界条件在两个场景下都成立。这种“用极端用例验证代码”的习惯,越早养成越好。
2.2 第32、33、39题:字符串问题的三种经典操作
字符串在C语言里就是字符数组,但正因为没有原生字符串类型,操作起来反而容易出问题。这三道题分别考察了字符频率统计、回文判断和字符串逆序,我会把核心思路放在一起说,因为它们的底层都是“用下标和指针访问字符”。
第32题,输入一个字符串,统计其中各字母出现的次数。很多人的第一版代码是用if判断每个字符,然后对应地给某个变量加1,比如if (c == 'a') cnt_a++; if (c == 'b') cnt_b++;,这种做法不仅代码冗余,而且几乎没法扩展。正确思路是把26个字母映射到数组下标:
char s[128]; int cnt[26] = {0}; int i, len; fgets(s, sizeof(s), stdin); s[strcspn(s, "\n")] = '\0'; len = strlen(s); for (i = 0; i < len; i++) { if (s[i] >= 'a' && s[i] <= 'z') { cnt[s[i] - 'a']++; } }这里的关键是s[i] - 'a'这个表达式。C语言里字符本质上就是整数,'a'到'z'的ASCII码是连续的,所以s[i] - 'a'就能把'a'映射成0,'b'映射成1,依此类推。这是一种非常典型的“用数组下标代替条件分支”的套路,在很多算法题里都会用到。
第33题判断回文,思路是双指针从两端向中间走,比较每一对字符是否相同。如果你直接拿strlen(s)在循环条件里反复调用,效率会低一些,所以建议先存到临时变量:
int i, j, len = strlen(s); for (i = 0, j = len - 1; i < j; i++, j--) { if (s[i] != s[j]) { printf("NO\n"); return 0; } } printf("YES\n");这个题的核心是“提前返回”的思想:只要发现一处不匹配,就直接判定不是回文,不需要继续扫描。这看起来很简单,但很多初学者会把逻辑写反,用flag标记再在循环结束后判断,增加了代码复杂度。
第39题要求不调用库函数,自己实现字符串逆序。这道题的目的不是让你重复造轮子,而是让你理解底层机制。比如自实现计算字符串长度,本质上就是用指针遍历到结尾:
size_t my_strlen(const char *s) { const char *p = s; while (*p) { p++; } return (size_t)(p - s); }注意这里我用的是const char *,不是char *。原因是计算长度不应该修改原字符串内容,加const是接口设计上的好习惯。后面再交换字符时,再传char *进去。这种“只读参数用const,要修改的参数不用”的习惯,面试官看到了会有很大好感。
2.3 字符串题目的通用调试技巧
字符串问题最常见的报错是段错误,也就是访问了不该访问的内存。我的经验是:动手调试前,先在纸上画出数组的内存布局,标出下标位置,再对照代码走一遍。比如逆序的时候,下标0和N-1交换,下标1和N-2交换,这样画一遍,边界条件就非常清楚了。
另外,使用fgets读输入时,末尾会带上换行符,需要手动处理。我看到很多写法是s[strlen(s)-1] = '\0',这样在极端情况(比如输入空行)下会访问到-1的位置,属于未定义行为。更稳妥的是用strcspn或者手动判断长度大于0。这一点在第32题里尤其重要,否则统计字符时会把换行符也统计进去。
3. 排序与结构体:从“会写”到“会设计”
3.1 第34题:冒泡排序的二三事
排序题是C语言练习里绕不开的经典。第34题的要求有变化:不仅要完成冒泡排序,还要在每一轮排序结束后打印数组当前的状态,便于观察排序过程。
冒泡排序的基本思想是重复走访要排序的序列,依次比较两个相邻元素,顺序错误就交换。每一轮结束后,最大的元素会“冒泡”到末尾。代码大体如下:
#define N 6 int a[N] = {6, 5, 3, 1, 8, 7}; int i, j, tmp, swapped; for (i = 0; i < N - 1; i++) { swapped = 0; for (j = 0; j < N - 1 - i; j++) { if (a[j] > a[j + 1]) { tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; swapped = 1; } } printf("第%d轮: ", i + 1); for (j = 0; j < N; j++) { printf("%d ", a[j]); } putchar('\n'); if (!swapped) { break; } }这里用了swapped标志位,作用是在某一轮没有任何交换时提前结束排序。初学者一开始多半不会想到这个优化,但实际数据接近有序时,这个标志位能把时间复杂度从O(n^2)降到接近O(n)。我建议你把swapped去掉再跑一遍,观察输出轮次的变化,这样能直观感受到优化的价值。
另一个值得注意的细节是内层循环的终止条件是j < N - 1 - i,不是j < N - 1。因为每一轮结束后,最后i个元素已经是排好序的,无需再比较。如果写成j < N - 1,虽然结果可能还是对的,但会做很多无意义比较,轮次打印效果也会乱。
3.2 第35题:结构体数组排序的交换陷阱
第35题是定义一个学生结构体(姓名、成绩),然后按成绩从高到低排序输出排名。这个题综合了结构体定义、结构体数组和排序三个点。
我认为这道题最大的坑在“结构体变量的交换”。很多初学者第一反应是写一个swap函数,参数传两个Student:
void swap(Student a, Student b) { Student tmp = a; a = b; b = tmp; }但如果按值传递,函数内部确实交换了,主函数里的原数组并没有变。这种错误在我带过的初学者里反复出现。正确的做法有两种:一是直接在主函数里用临时结构体变量交换:
Student tmp; tmp = stu[i]; stu[i] = stu[j]; stu[j] = tmp;这样写,tmp是Student类型,不是指针,赋值运算符会完整复制结构体的所有字段。这个行为在C语言里是合法的,因为结构体类型支持整体赋值。另一种做法是传指针,然后逐字段交换,但那样代码会长很多。
还有一个细节是:如果结构体里有指针字段或动态分配的内存,整体赋值就会出问题,因为两个结构体变量会指向同一块内存,释放时会产生悬垂指针。第35题用固定数组存姓名,所以整体赋值没问题,但如果你以后写链表节点交换,就要小心了。
4. 指针、函数指针与文件:进阶三连
4.1 第36题:文件单词统计
第36题要求统计一个文本文件里有多少个单词。这个题用文件操作加状态机就能解决。
很多人第一次做这题时,会想着用fscanf("%s")循环读,然后计数器直接加1。这其实是最简单也最不容易错的写法:
FILE *fp = fopen("words.txt", "r"); char word[256]; int count = 0; if (fp == NULL) { perror("无法打开文件"); return 1; } while (fscanf(fp, "%255s", word) == 1) { count++; } fclose(fp); printf("单词数: %d\n", count);但这里有个隐患:fscanf以空白字符为分隔符,文本里有"don't"它算一个词,有"hello-world"也算一个词,这会跟题目对“单词”的定义产生歧义。如果你想完全按照ASCII字母连续性来定义单词,更严谨的做法是逐字符读取,维护一个“当前是否在单词内”的状态:
int ch, in_word = 0, count = 0; while ((ch = fgetc(fp)) != EOF) { if (isspace(ch)) { in_word = 0; } else if (!in_word) { in_word = 1; count++; } }这段代码的思路是:遇到空白字符就认为一个单词结束了;遇到非空白字符,如果之前不在单词内,就认为新单词开始,计数加1。这就是最简单的“状态机”,只有两个状态:in_word=0表示在单词外,in_word=1表示在单词内。
我自己在刷这题时还有一个习惯:先在命令行用echo "hello world test" > words.txt生成测试文件,再运行程序。开发机上文件路径和编译运行路径不一致时,fopen经常返回NULL,所以我在正式工序里第一步就是判断fopen的返回值,而不是直接去读。很多初学者不检查返回值为NULL的情况,程序一运行就段错误。
4.2 第37题:函数指针实现积分器
函数指针是C语言里让初学者比较头疼的概念,因为语法比较反直觉。第37题我用了一个很经典的场景:用函数指针实现一个通用积分函数,传入不同的数学函数,就能计算不同函数的数值积分。
先看一个简单的梯形法或中点法的实现:
double integrate(double (*func)(double), double a, double b, int n) { double h = (b - a) / n; double sum = 0; int i; for (i = 0; i < n; i++) { sum += func(a + (i + 0.5) * h); } return sum * h; }这里最难理解的就是参数声明double (*func)(double)。我的记忆方法是:先看标识符func,它先被*修饰,说明它是一个指针;再往右看有一个括号,里面是double;再往左看,返回类型是double。所以合起来就是:func是一个函数指针,指向一个接收double类型参数并返回double的函数。如果写成double *func(double),含义就完全不同,那是“返回double指针的函数”了。
怎么调用这个积分器?传入函数名即可。C语言里函数名本身就是函数地址:
double f1(double x) { return x * x; } int main(void) { double result = integrate(f1, 0, 1, 1000); printf("%.6f\n", result); // 约等于 1/3 }因为f1(x) = x^2在[0,1]上的解析积分是1/3,数值计算结果会非常接近0.333333。这题的意义在于让你理解“把函数作为参数传递”,也就是回调机制。后面学C++的虚函数、学各种库的回调接口,你会觉得这段经历帮了大忙。
4.3 第36和第37题之外的隐藏考点
除了题目本身,这两道题还有一个隐藏考点:头文件管理。做文件操作时你需要#include <stdio.h>,做数学相关计算时可能需要#include <math.h>并用-lm链接数学库。很多初学者在VSCode或Code::Blocks里编译时遇到“undefined reference topow”之类的错误,其实不是代码问题,而是没链接数学库。
在Linux下编译时,写gcc main.c -lm就能解决。Windows下的IDE通常默认勾选了数学库,所以本地跑通了,换到比赛环境又挂掉。我建议你从一开始就养成“编译命令写清楚”的习惯,别把IDE的便利当默认能力。
5. 位运算与随机数:两个容易忽略的细节
5.1 第38题:二进制位反转
第38题是把一个无符号整数的二进制位完全反转。比如输入0x12345678,输出应该是0x1E6A2C48这样的结果。
核心思路是循环32次,每次取当前最低位,然后把结果整体左移一位,再把取出的最低位放进去:
unsigned int reverse_bits(unsigned int x) { unsigned int r = 0; int i; for (i = 0; i < 32; i++) { r = (r << 1) | (x & 1); x >>= 1; } return r; }这道题最大的坑是运算符优先级。r >> 1 | x & 1如果不加括号,实际解析结果会和你预期的完全不同。C语言里&的优先级低于<<和>>,而|则更低。所以最保险的写法是能加括号就加括号,不要靠记忆优先级来保证正确性。
为什么反转后结果是反直觉的?因为位索引是从0到31,0x12345678在内存里的位分布不是简单从高到低,反转后自然不是一眼能看出来的数。我建议你用一个十六进制数手动推一遍前8位,多推几次就熟练了。
5.2 第40题:洗牌算法与随机数陷阱
第40题是生成一个随机排列,也就是洗牌。这题背后是一个很经典的Fisher-Yates算法,从数组末尾开始,每次用随机数选一个位置交换:
#include <stdlib.h> #include <time.h> void shuffle(int a[], int n) { int i, j, tmp; for (i = n - 1; i > 0; i--) { j = rand() % (i + 1); tmp = a[i]; a[i] = a[j]; a[j] = tmp; } }为什么从后往前,而不是从前往后?因为从后往前时,随机选择范围逐渐缩小,每个位置被交换到最后一个未被确定位置的概率是均等的。如果你从前往后,只是局部“看起来像洗牌”,均匀性很差。
关于随机数,最经典的坑是:把srand(time(NULL))放在循环内部。如果程序运行很快,time(NULL)返回值相同,rand()生成的序列也就完全相同,每次运行结果都一样。我建议只在main函数开头调用一次srand。另外,不要用srand(time(NULL)); rand() % 100这种方式生成6位随机数,因为rand()的范围取决于实现,低位随机性并不好,这在写仿真程序时尤其需要注意。
6. 常见问题与排查技巧实录
6.1 编译期问题速查
刷题过程中,编译错误是最容易劝退新手的。我这里把我在带练时最常看到的编译错误整理成了一个速查表:
| 错误现象 | 常见原因 | 修改方向 |
|---|---|---|
undefined reference to 'pow' | 没链接数学库 | 编译加-lm |
expected ';' before '}' | 缺少分号或花括号不匹配 | 检查上一行结尾 |
conflicting types for 'func' | 函数定义了两次,或未声明就使用 | 加函数声明或调整顺序 |
array subscript is above array bounds | 数组越界 | 检查下标范围 |
format '%d' expects argument of type 'int' | 类型不匹配 | 检查参数类型 |
我的经验是,编译错误不要一上来就改,先读第一条错误信息。编译器给出的行号可能因为宏定义、头文件而偏移,但绝大多数情况下指向的位置就是问题所在。你盯着错误看几秒,再回头看代码,往往能发现问题。
6.2 运行时错误的典型场景
运行时错误里最常见的是段错误,也就是非法内存访问。第31、32、39题都容易出现这种情况。
一个经典的段错误原因:用scanf("%s", s)读入超长字符串,超过了本身数组的大小。scanf("%s")不检查长度限制,如果输入超过数组长度,会直接越界写。稳妥的做法是用fgets(s, sizeof(s), stdin)限制最大长度,或者用scanf("%127s", s)指定宽度。
另外,字符串结尾没有'\0'也是段错误的高发原因。很多人自己实现字符串拼接时忘记在末尾补'\0',后续调用strlen就会一直往后扫,直到碰到随机内存里的某个0为止。排查这类问题,用gdb在崩溃时打印调用栈,或者用工具检测内存越界,会非常高效。
6.3 我从这十题里总结出的三条经验
第一,写代码前先在注释里写清楚思路,不管是多简单的题。比如数组逆序,先写“左边下标从0开始,右边下标从N-1开始,直到两者相遇”,然后再动手。这个习惯能过滤掉一半的低级错误。
第二,当一个程序行为不符合预期时,先不要急着改代码,尝试用printf打印中间变量。比如第40题洗牌,你觉得结果不够随机,那就在每一轮打印i、j、a[i]、a[j],你会立刻发现问题在随机数,而不是在交换逻辑。
第三,同一个练习题用不同方法各写一遍。第31题用下标写一遍,再用指针写一遍;第34题用冒泡排序写一遍,再试一次选择排序或插入排序;第39题用下标法写一遍,再用指针法写一遍。这种对比训练非常值钱,它能帮你把“数组和指针的等价关系”彻底内化成直觉。
做完这十道题,你再回头翻C语言教材里的函数和指针章节,会觉得那些以前看不懂的代码开始变得有逻辑了。这就是练习带来的正反馈。下一套题我打算加入更多链表和动态内存分配的内容,到时候咱们再继续。