我这些年帮人改代码、带复习,见得最多的就是这种“C/C++ 编程练习题”。很多人刷到中间那一段就开始卡壳,前面基础题还能靠直觉,到了 31 到 40 题这种位置,题目开始把数组、指针、字符串、函数揉在一起考,不再是一道题只考一个点了。这其实是复习的分水岭,能跨过去,后面刷题会顺很多;跨不过去,就会感觉题目越做越难,信心直线下降。
我拿“50道C/C++编程练习题 复习必备(31-40)”这套题来说,这 10 道题覆盖的知识点其实非常典型,基本上就是 C 语言核心语法和数据结构的基础组合。这篇文章我会把这 10 道题背后的考点拆开讲透,告诉你哪些题是考思路,哪些题是考细节,哪些题是拿来拉开差距的。同时我会给出可以直接抄的代码模板、踩坑记录,以及我自己练习时总结的刷题方法。不管你是正在准备期末考试、计算机等级考试,还是为了面试前热身,这篇都值得你花一刻钟读完。
1. 内容整体设计与思路拆解
1.1 31 到 40 题处在整套题的什么位置
从整套 50 题的结构来看,1 到 10 题通常是输入输出、变量、基本运算;11 到 20 题开始引入分支和循环;21 到 30 题一般会加入数组、函数、字符串等概念。到 31 到 40 题,题目开始明显向“综合应用”过渡。
这个阶段的题目不会单纯让你打印一个图案,也不会只让你算一个阶乘,而是会要求你:
- 用排序算法处理数组,再进一步计算统计值。
- 用指针遍历字符串,并完成某种变换。
- 把循环、数组、条件判断结合在一个函数里解决实际问题。
- 偶尔还会出现文件读写操作,把输入方式从键盘换成文件。
换句话说,31 到 40 题考验的不再是“你会不会写某个语法”,而是“你能不能把多个语法组合在一起解决问题”。这也是为什么很多人觉得题目突然变难了,不是因为你基础差,而是因为你之前学的知识点是零散的,还没有形成组合拳。
1.2 这 10 道题真正想考你的能力
我拆解过不少版本的 C 语言练习题,31 到 40 题虽然在不同教材里具体题目不完全一样,但核心考核点非常稳定。我列一下常见的几个方向:
- 排序与查找:冒泡排序、选择排序、二分查找。
- 数组操作:数组逆置、去重、元素插入删除。
- 字符串处理:字母统计、回文判断、去空格、大小写转换。
- 数字拆分:水仙花数、回文数、反序数、数字各位求和。
- 数学计算:最大公约数、最小公倍数、素数判断、累加累乘。
你可能会觉得这些都是老掉牙的题。确实,它们不新,但恰恰因为经典,才能有效检验你对内存、指针、边界条件的理解。比如数组越界这一个问题,就能刷掉一大部分人。所以这套题的核心价值不是题目本身,而是它逼着你开始注意那些“代码能跑起来,但运行结果不对”的细节问题。
2. 核心细节解析与实操要点
2.1 数组题:从遍历到双指针的思维转变
31 到 40 题里最容易出现的就是数组题。常见的问法是“给定一个整型数组,将数组逆序输出”,或者“把数组中的负数移到前面,正数移到后面”。
先说逆序输出。最简单粗暴的方法是倒着遍历数组下标:
for (int i = n - 1; i >= 0; i--) { printf("%d ", arr[i]); }这是能拿到分的写法。但如果题目要求“原地逆置数组”,也就是把数组本身反序,那就不能只靠打印了,需要双指针交换:
void reverse_array(int arr[], int n) { int left = 0, right = n - 1; while (left < right) { int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } }这里有个很多人会犯的错误:循环条件写成left <= right。当数组元素个数为偶数时,left和right永远不会相等,所以这个条件一般不会出问题;但当数组元素个数为奇数时,中间那个元素会和自身交换,也问题不大。真正要小心的是初始值right = n - 1,如果写成right = n,访问arr[n]就越界了。
再比如“两数之和等于目标值”这类题,初学者最容易写出双重循环暴力遍历:
for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (arr[i] + arr[j] == target) { // 找到了 } } }这个解法没有任何问题,尤其在数据量小的时候完全够用。但如果题目要求“在有序数组中查找两数之和”,更优的方案是双指针:一个指针从头开始,一个从尾部开始,根据两数之和与目标值的大小关系移动指针。这个思路一定要理解,因为后续很多算法题都是它的变种。
2.2 字符串处理题:回文判断中的指针技巧
判断一个字符串是否是回文,是 C/C++ 练习题的常客。所谓回文,就是正着读和倒着读都一样的字符串,比如abcba。
用双指针解决特别直观:
#include <stdbool.h> #include <string.h> bool is_palindrome(char str[]) { int left = 0; int right = strlen(str) - 1; while (left < right) { if (str[left] != str[right]) { return false; } left++; right--; } return true; }这题看起来简单,但有几个坑特别值得注意:
strlen返回的是字符串长度,不包含结尾的\0。比如"abc"长度是 3,最后一个字符下标是 2,right要取strlen(str) - 1。- 如果题目要求忽略大小写比较,比如
"Aba"也算回文,那就需要先转换大小写再比较。C 语言里可以用tolower()函数,注意它接收的是单个字符。 - 如果字符串中间有空格,比如
"a b a",就需要先去除空格再判断,或者比较时跳过空格。
我见过最典型的错误是循环里用了while (left <= right)。对于奇数长度的字符串,中间字符会和自己比较,倒也无所谓;但当你用left < right时,逻辑更干净,不容易出边界问题。
2.3 数组去重与计数:哈希表的简单替代
数组去重有两种常见场景:一种是删除数组中的重复元素,保证每个元素只出现一次;另一种是统计每个元素出现的次数。
对于“统计一段英文中每个字母出现次数”这类题,很多同学一上来就想着用哈希表。实际上 C 语言里用数组就能模拟,因为英文字母只有 26 个:
char str[] = "hello world"; int count[26] = {0}; for (int i = 0; str[i] != '\0'; i++) { if (str[i] >= 'a' && str[i] <= 'z') { count[str[i] - 'a']++; } }这段代码的核心技巧是用字符的 ASCII 码差值做数组下标。字符'a'的 ASCII 码是 97,'b'是 98,所以str[i] - 'a'恰好得到 0 到 25 的下标。这个技巧考试经常出现,比手写哈希表简单得多,而且不易出错。
如果是去除数组中的重复元素,最简单粗暴的还是双重循环:
int new_len = 0; for (int i = 0; i < n; i++) { int flag = 0; for (int j = 0; j < new_len; j++) { if (arr[j] == arr[i]) { flag = 1; break; } } if (!flag) { arr[new_len++] = arr[i]; } }这个算法的时间复杂度是 O(n^2),在数据量小的时候完全能接受。如果题目数据量很大,那就先排序再去重,代码反而更好写。我建议初学者先把 O(n^2) 的写法练熟,再去追求优化版本,因为暴力写法的正确性更容易保证。
2.4 文件操作题为什么总出现在复习题里
C 语言课程学到后期,文件操作几乎是必考内容。很多人看到题目里出现“从文件读入”就直接懵了,其实文件操作和键盘输入本质上没有太大区别,只是数据来源不同。
文件操作的基础套路非常固定:
FILE *fp = fopen("input.txt", "r"); if (fp == NULL) { printf("文件打开失败\n"); return 1; } // 读取数据 int num; while (fscanf(fp, "%d", &num) != EOF) { // 处理数据 } fclose(fp);这里我特别想强调:文件打开失败的处理一定不能省略。有些人会偷懒不写if (fp == NULL)判断,结果程序运行时文件路径不对,直接段错误或者读取到垃圾数据。这是考试中非常常见的失分点。
另外,fscanf的返回值也要理解清楚。它返回成功读取的参数个数,如果读到文件末尾会返回EOF。所以判断条件写成!= EOF、== 1都是常见写法,但要明白它们的含义。
3. 实操过程与核心环节实现
下面我挑出几道在 31 到 40 题中高频出现的题目,给出完整代码和注释。这些代码我都自己跑过,可以直接拿去练习。
3.1 冒泡排序:基础排序的实现与优化
排序是整个 C 语言编程题里绕不开的内容,冒泡排序又是其中最基础的一种。题目一般要求把一个整型数组按从小到大排序并输出。
#include <stdio.h> void bubble_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; // 标记本轮是否发生了交换 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; swapped = 1; } } // 如果一轮下来没有交换,说明数组已经有序,提前结束 if (!swapped) { break; } } } int main() { int arr[] = {64, 34, 25, 12, 22, 11, 90}; int n = sizeof(arr) / sizeof(arr[0]); bubble_sort(arr, n); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } return 0; }这个版本比普通冒泡多了一个swapped标志位。当数组已经有序时,能提前退出,效率更高。考试时如果你写的是这个版本,阅卷老师通常会认为你理解了排序的本质。
大家要注意n - 1 - i这个边界。外层循环每跑一轮,最大值就会沉到数组末尾,所以内层循环的比较范围就缩短一格。如果这里写成j < n - 1,虽然结果可能也是对的,但会有一些多余的比较,不够严谨。
3.2 水仙花数:数字拆分与循环结合
水仙花数是一个三位数,它的各位数字的立方和等于它本身,比如153 = 1^3 + 5^3 + 3^3。
这道题的核心是数字拆分,也就是把一个三位数按个位、十位、百位拆开。
#include <stdio.h> int main() { int hundreds, tens, ones; for (int num = 100; num < 1000; num++) { hundreds = num / 100; tens = (num / 10) % 10; ones = num % 10; if (hundreds * hundreds * hundreds + tens * tens * tens + ones * ones * ones == num) { printf("%d\n", num); } } return 0; }更通用的拆位写法是用while循环配合取余和整除:
int temp = num, sum = 0; while (temp > 0) { int digit = temp % 10; sum += digit * digit * digit; temp /= 10; } if (sum == num) { printf("%d\n", num); }我建议直接学第二种写法,因为它对任意位数的数字都适用,后面遇到“判断回文数”“求各位数字之和”这类题就能共用一套思路了。
3.3 字符串去空格:双指针在字符数组中的应用
这道题经常以“输入一个字符串,删除所有空格后输出”的形式出现,既考查字符数组的操作,也考查双指针思想。
#include <stdio.h> #include <string.h> void remove_spaces(char str[]) { int len = strlen(str); int write_index = 0; for (int read_index = 0; read_index < len; read_index++) { if (str[read_index] != ' ') { str[write_index] = str[read_index]; write_index++; } } str[write_index] = '\0'; } int main() { char str[] = "hello world, c programming"; remove_spaces(str); printf("%s\n", str); return 0; }这里面最关键的是write_index和read_index两个下标。read_index负责遍历原字符串,write_index记录“下一个有效字符该放到哪里”。遇到空格就跳过,遇到非空格就往前搬。最后不要忘记在末尾补上\0,否则输出时可能会读到乱码。
这个双指针思路后面会经常遇到,比如数组原地去重、字符串压缩等场景都可以复用。
3.4 最大公约数与最小公倍数:辗转相除法
求两个数的最大公约数,最常见的算法是辗转相除法,也叫欧几里得算法。它用一句话概括就是:两个数相除取余,再用余数去除除数,直到余数为 0,此时的除数就是最大公约数。
#include <stdio.h> int gcd(int a, int b) { while (b != 0) { int temp = b; b = a % b; a = temp; } return a; } int lcm(int a, int b) { return a / gcd(a, b) * b; } int main() { int a = 12, b = 18; printf("gcd = %d\n", gcd(a, b)); printf("lcm = %d\n", lcm(a, b)); return 0; }这里有一个容易踩坑的性能细节:求最小公倍数时,要写成a / gcd(a, b) * b,而不是a * b / gcd(a, b)。因为a * b可能会先溢出,尤其是当 a 和 b 都接近整型上限时,先除法能有效降低溢出风险。
3.5 从文件读整数求和:把知识点串起来
有些 31 到 40 题会尝试把数组、循环、文件操作融合在一起。比如这道经典题:从data.txt文件中读入若干个整数,计算它们的总和。
#include <stdio.h> int main() { FILE *fp = fopen("data.txt", "r"); if (fp == NULL) { perror("文件打开失败"); return 1; } int sum = 0, num = 0; while (fscanf(fp, "%d", &num) == 1) { sum += num; } fclose(fp); printf("总和: %d\n", sum); return 0; }我特意写了fscanf(fp, "%d", &num) == 1这个判断条件。fscanf成功读取一个整数时返回 1,读取失败或者遇到文件末尾时返回 EOF。用这个条件可以避免把无效数据计入总数。
另外提醒一下,很多同学测试时容易忘记:文件必须放在程序运行时的当前工作目录下,不是源码文件所在的目录。所以写代码之前,先确认你的data.txt到底放哪了。实在找不到就打印一下当前目录,Windows 下用system("dir"),Linux 下用system("pwd")。
3.6 二分查找:有序数组的高效查找
二分查找几乎是所有 C 语言复习题里必考的查找算法,也是后面学习数据结构时的重要基础。题目通常会提供一个已经排好序的数组,然后输入一个目标值,让你返回其下标。
#include <stdio.h> 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() { int arr[] = {1, 3, 5, 7, 9, 11}; int n = sizeof(arr) / sizeof(arr[0]); int target = 7; int index = binary_search(arr, n, target); if (index != -1) { printf("目标值所在下标: %d\n", index); } else { printf("未找到\n"); } return 0; }mid = left + (right - left) / 2这个写法值得单独拎出来说一下。如果写成(left + right) / 2,当left和right都很大时,两者相加可能溢出,导致mid变成负数。用left + (right - left) / 2就可以避免这个问题。这个细节在面试和竞赛里经常被拿出来问,理解之后能证明你不只是背代码,而是真的懂了原理。
4. 常见问题与排查技巧实录
做题过程中,大家遇到的报错和异常结果其实高度重复。我把自己这些年见到的高频问题整理成了一份排查清单,按“编译期错误、运行期错误、逻辑错误”三个类别展开。
4.1 编译错误:从报错信息里找到真正的病根
很多人一看到编译错误就紧张,其实编译器的报错信息已经告诉了你怎么修。我归纳下来,初学者最常见的编译错误无非这几类:
- 忘记写分号,报错位置往往指向下一行。
- 括号不匹配,经常出现在嵌套循环或复杂表达式里。
- 变量未声明就使用,多半是拼写错误或作用域问题。
- 函数定义在
main函数之后但没有提前声明。
关于最后一点,我多说一句。C 语言要求在调用函数之前,编译器至少要见过函数声明。如果你把函数定义写在main后面,就必须在文件开头先声明一次,或者直接把函数整体移到main前面。我见过很多同学在这个坑里浪费时间,报错信息明明是undefined reference,却搞不清楚是链接问题还是声明问题。
4.2 运行期崩溃:段错误的常规定位法
段错误是所有 C 语言初学者的噩梦,因为程序运行时直接退出,连一句提示都没有。定位段错误最笨但最有效的方法是二分定位法,也就是在代码里逐步添加输出语句。
比如二分查找如果崩了,可以在循环里打印:
printf("left=%d, right=%d, mid=%d\n", left, right, mid);看到哪一次打印之后程序崩溃,就知道问题出在哪一轮循环。最常见的段错误原因就是数组越界,尤其是把n当作最大下标使用。一个长度为n的数组,合法下标是0到n-1,“第 n 个元素”根本不存在,访问arr[n]就是未定义行为。
4.3 输入异常:scanf 的缓冲区残留问题
scanf读取整数后,按下的回车键会留下一个换行符在输入缓冲区里。如果后面马上用getchar或gets读取字符,就会直接读到这个残留的换行符,程序表现会非常诡异。
解决办法是在scanf之后加一句getchar()来吸收残留字符:
int n; scanf("%d", &n); getchar(); // 吃掉换行符另外,我不太建议在scanf的格式串里写太多空格。比如scanf("%d %d", &a, &b)这种写法,虽然能正常工作,但一旦输入格式稍有不同,就容易匹配失败。我更推荐先用fgets整行读入,再用sscanf解析,这样的稳定性更高,也方便调试。
4.4 文件相关的坑:从路径到权限的排查顺序
文件操作题目一旦显示“打不开文件”,可以按顺序排查下面几个点:
- 程序当前的工作目录是哪里。
- 文件名是否写错,包括大小写和扩展名。
- 文件是否真的存在,还是只放在了源码目录。
- Windows 下路径里的反斜杠是否需要转义,比如
"C:\\test\\data.txt"。 - 文件是否被其他程序占用,或者没有读取权限。
我记得有一次自己调试时,明明文件就在当前目录,但程序就是打不开。后来发现是文件名后缀被系统隐藏了,实际文件名是data.txt.txt。这种低级错误,排查一次就长记性了。
4.5 常见问题速查表
我把典型问题整理成了表格,做题时遇到问题可以直接对照查看。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
编译报undefined reference to main | 缺少 main 函数或函数签名错误 | 检查 main 是否存在且拼写正确 |
| 运行崩溃,无任何输出 | 数组越界、空指针 | 加 printf 定位崩溃前最后的正常输出点 |
| 中文乱码 | 源文件编码与终端编码不一致 | 统一使用 UTF-8 编码,或在编译选项中指定编码 |
| scanf 读不到预期值 | 输入格式串不匹配或缓存残留 | 检查格式串,必要时用 getchar 清缓存 |
| fopen 返回 NULL | 路径错误、文件不存在、权限不足 | 确认工作目录、文件名、绝对路径 |
| 排序后数组仍然无序 | 内层循环边界写错 | 检查j < n - 1 - i的边界 |
| 二分查找陷入死循环 | left/right 更新条件错误 | 画出 left、right、mid 变化表,检查是否永远不相等 |
5. 刷题方法与实战经验分享
代码题光是看懂了没用,一定要自己动手敲一遍。但怎么敲、怎么总结,也有方法。我结合自己的刷题经验,分享几条我认为最有用的实战策略。
5.1 先暴力,再优化:不为最优解卡住自己
很多初学者看到一个题目,第一反应是“我要写出最优解”,结果卡在思路阶段迟迟不肯动笔。我强烈建议:先把暴力解写出来,确保答案正确,再考虑优化。
比如数组去重题,先用双重循环,能跑出正确结果就算成功。再比如判断素数的题,for (int i = 2; i < n; i++)的写法虽然慢,但逻辑简单。通过了以后,再改成i * i <= n的优化版本。这个过程既保证了你对题目的理解是准确的,也不会因为一开始就追求高效写法而打击信心。
5.2 建立自己的模板库:把题归类,考前才能高效复习
我在刷 50 道题时,会做一件事:每做完一道题,把它归到某一类里。我的分类大概是这样的:
- 数组类:排序、查找、逆序、去重、元素移动。
- 字符串类:统计、反转、大小写转换、回文判断。
- 数学类:素数、公约数、水仙花数、数列求和。
- 文件操作类:读写整数、逐行处理、合并文件。
这样分类的好处是,复习时不用把 50 道题全部重刷一遍,只要从每一类中抽一两道代表性题目重做就行。模板库越用越顺,考试时一眼就能看出题目考的是哪个模板。
5.3 手动模拟:理解递归和排序的最好方式
我特别推荐在做递归和排序算法题时,拿笔在纸上手动过一遍执行过程。比如二分查找,你假设数组是{1, 3, 5, 7, 9},目标值是 7,然后把每一轮left、right、mid的值写下来:
- 初始:left=0, right=4, mid=2,arr[2]=5 < 7,所以 left=3。
- 第二轮:left=3, right=4, mid=3,arr[3]=7 = 7,找到。
这个过程看起来简单,但当你手动模拟过一遍,就会真正理解为什么每次范围能缩小一半,而不是死记代码。
5.4 从 C 过渡到 C++:什么时候开始用 STL
如果你学完 C 语言,接下来要学 C++,我的建议是:先别急着深究类与对象、继承多态,而是先把cin、cout、string、vector、sort这些标准库组件用熟。因为它们能显著提升刷题效率。
举个例子,C 语言里要对一个整型数组排序,得手写冒泡或调用qsort回调函数;C++ 里直接写:
#include <algorithm> #include <vector> std::vector<int> arr = {5, 2, 8, 1}; std::sort(arr.begin(), arr.end());代码简洁了很多。但我要强调一点:用 STL 不等于理解算法。你至少要知道sort底层是基于快速排序的思想,知道vector能自动扩容是因为内部维护了一个动态数组。有了这些底层认知,使用 STL 才踏实。
5.5 做题时随手写心得注释
我有个习惯,每做完一道题,会在代码注释里写一句话总结,比如这题考了什么、我哪里踩坑了。举两个例子:
// 考点:数组越界。我上次把 right 写成 n,导致读了 arr[n],注意边界是 0 ~ n-1。 int right = n - 1;// 考点:scanf 残留换行符。读完整数后要 getchar(),否则后续 getchar 会读到 '\n'。复习时翻看这些注释,比重新读一遍代码效率高多了。因为它们记录的是你当时的思维盲区,而不是教材上的标准答案。
6. 一些想单独聊聊的体会
到了 31 到 40 题这个位置,我想跟你说的不是“多做几套题”这种空话,而是“吃透手头的题”这种实在话。
我刷这套题的时候,最大的感受是:题目数量并不重要,重要的是每一道题是否真正解决了你认知上的一个盲点。比如你以前写循环总是从 1 开始而不是从 0 开始,那做数组题时就会频繁越界;你以前不懂scanf缓存机制,那做混合输入题时就会莫名其妙读错数据。这 10 道题如果能分别帮你暴露几个这样的盲点,把它们补齐,那你的 C 语言基础就真正扎实了。
再分享一个我用了很久的小技巧:遇到调试不明白的问题,不要急着反复改代码,而是打开一个记事本,把“期待行为”和“实际行为”分别写下来。很多时候,写着写着就想通了,因为问题不过是你自己与程序之间的“信息不对称”。这个习惯,对以后学任何语言都有帮助。
最后再说一个几乎所有初学者都绕不开的坎:数组下标。我没有见过任何一个学 C 语言的人没被数组越界坑过,包括我自己。当你把一个长度为n的数组的下标从 0 数到n-1时,程序就会老老实实;一旦你把n当成了可以访问的下标,后面的事情就完全不可预测了。希望每一位读到这里的同学,都能把这个教训提前消化掉,而不是继续在下一道题里踩进同一个坑。
31 到 40 题,是整套复习题里承上启下的 10 道题。把这段啃下来,后面的题目对你来说大概率只是新瓶装旧酒。祝大家刷题顺利,考试稳稳通过。