十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C语言进阶练习题精讲:从数组到指针、文件与位运算

C语言进阶练习题精讲:从数组到指针、文件与位运算 一直跟着我做“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映射成0b映射成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函数参数传两个Studentvoid 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以空白字符为分隔符文本里有dont它算一个词有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_word0表示在单词外in_word1表示在单词内。我自己在刷这题时还有一个习惯先在命令行用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到310x12345678在内存里的位分布不是简单从高到低反转后自然不是一眼能看出来的数。我建议你用一个十六进制数手动推一遍前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没链接数学库编译加-lmexpected ; 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语言教材里的函数和指针章节会觉得那些以前看不懂的代码开始变得有逻辑了。这就是练习带来的正反馈。下一套题我打算加入更多链表和动态内存分配的内容到时候咱们再继续。
返回列表