做到C经典100例第37题时,我停下的时间比预想中长很多。前面35道题大多是单点知识点,这道“对10个数进行排序”看起来人畜无害,却把数组、循环、输入输出、变量交换全部串在一起,题面只有一句话,程序分析也只给了“选择法”三个字的提示。这篇文章就是我把这道题的完整做题过程写下来,从题目拆解、两种排序算法的推导,到实际运行中踩过的三个坑,最后再说说怎么把固定10个数泛化成n个数、结构体排序,以及C标准库里的qsort。适合正在刷这套题、以及刚学完数组想找排序入门练习的朋友,我把能想到的细节都放在里面了。
1. 练习37真正的考点:数组、循环和交换,以及“排序”到底在干什么
1.1 题目原文与我的第一反应
题面:对10个数进行排序。
原题自带的程序分析是这么写的:可以利用选择法,即从后9个比较过程中,选择一个最小的与第一个元素交换,下次类推,即用第二个元素与后8个进行比较,并进行交换。
我第一次读到这段分析,第一反应是:这不就是废话吗?但真正动手写代码前,如果只是“感觉懂了”就直接开写,十有八九会在循环边界上翻车。我的建议是,先把这句话翻译成自己能复述的步骤:
- 从10个数中找出最小的那个,放到第1个位置;
- 从剩下9个数中找出最小的,放到第2个位置;
- 重复这个过程,直到前9个位置都确定,最后一个位置不用再处理。
这样一说,核心逻辑就清楚了:所谓“排序算法”,就是设计一套“比较谁更小、然后在哪个位置交换”的规则,让计算机反复执行这些动作,最终让整个数组有序。
1.2 把“排序”翻译成机器动作
“排序”这个词对人来说太自然了,人眼扫一遍就能排出大小。但计算机没有“一目了然”的能力,它只会做最基本的两个动作:比较两个数的大小,以及交换两个数的位置。所有排序算法,本质上都是把“排序”这个抽象目标,拆解成大量重复的“比较-交换”操作。
拿“在数组里找出最小值的下标”这个子任务举例,机器实际做的事情是:
- 先假设第0个位置上的数是最小的,把这个位置的下标记录在变量里;
- 用这个位置的值,依次和后面第1、2、…、9个位置的值比较;
- 一旦发现某个值比当前记录的最小值还小,就把记录的下标换成那个位置;
- 全部比较完之后,记录的下标就是真正最小值的下标;
- 把该下标位置的值和第0个位置的值交换,最小值就位。
这个流程翻译成C语言,就是两个for循环、一个if判断、三步交换。选择排序和冒泡排序的区别,只是在“每轮怎么选、选完放哪、下一轮从哪里继续”这几条规则上不同。
1.3 动手前必须确认的五个基础点
写这道题之前,我建议先自查一下下面五个点,任何一个不清楚都会导致卡壳:
- 数组下标从0开始,a[0]是第一个元素,a[9]是第十个元素,没有a[10];
- for循环的边界习惯写成i < n而不是i <= n,尤其当n代表数组长度时;
- scanf接收整数时,必须传变量的地址,也就是&a[i],漏掉&程序会直接出问题;
- 交换两个变量要用临时变量temp,分三步完成;
- 临时变量直接在main函数内部声明即可,不必搞成全局变量。
这几点看着基础,但刷到第37题时,很多人反而会因为“觉得太简单”而大意。我周围好几个朋友都是在这道题上第一次接触到“数组越界”这个概念,所以千万别跳过。
2. 第一版实现:每轮“挑最小的放前面”,选择排序的完整推导
2.1 为什么第一版用选择排序
我第一版写的是选择排序,原因很直接:它正好对应题目程序分析里那句“选择一个最小的与第一个元素交换”,思路最直白。说白了就是:第1轮从10个数里挑最小的放到第0位,第2轮从剩下9个数里挑最小的放到第1位,以此类推。
10个数最多只需要9轮,因为前9个位置都确定了,最后一个位置自然就确定了。这个“为什么是9轮而不是10轮”的问题,初学者一定要想明白,它直接关系到外层循环怎么写。
两层循环的分工是:
- 外层循环变量i,控制“当前轮到哪个位置”,取值0到8;
- 内层循环变量j,在i+1到9这段范围内,找出比a[i]更小的值的下标;
- min变量始终记录“目前见过的最小值的下标”,注意是下标,不是值本身。
2.2 选择排序完整代码与逐段解释
#include <stdio.h> int main() { int a[10]; // 用来存放10个数的数组 int i, j, min, temp; // min保存最小值的下标,temp用于交换 printf("请输入10个整数:\n"); for (i = 0; i < 10; i++) { scanf("%d", &a[i]); } // 外层循环:确定第i个位置应该放哪个数,i从0到8,共9轮 for (i = 0; i < 9; i++) { min = i; // 先假设第i个位置上的数就是当前范围内最小的 // 内层循环:在i+1到9之间寻找真正的最小值下标 for (j = i + 1; j < 10; j++) { if (a[j] < a[min]) { min = j; // 找到更小的,就更新下标 } } // 如果最小值不在第i个位置,就交换 if (min != i) { temp = a[i]; a[i] = a[min]; a[min] = temp; } } printf("排序后的结果是:\n"); for (i = 0; i < 10; i++) { printf("%d ", a[i]); } printf("\n"); return 0; }这段代码的关键在于:内层循环结束之后,我们只做了“比较”和“记录下标”,真正的“交换”留到内层循环外面。这是一个很容易被忽略的细节——如果每找到一个更小的数就立刻交换,代码会变得又慢又乱。先记下标、最后交换一次,就是选择排序“每轮只交换一次”的本质。
2.3 一个朴素问题:为什么交换非得用temp
很多教材都会提醒,交换a和b必须引入第三个变量temp。原因其实很简单:如果直接写a = b; b = a;,第一句执行完,a原来的值已经被覆盖掉了,第二句里的b = a拿到的其实是b原来的值,结果就是把一个数复制了两份,另一个数彻底丢失。
我也见过有人用异或玩法:
a ^= b; b ^= a; a ^= b;这样确实不需要临时变量,看着很酷,但可读性大幅下降,而且在处理浮点数、结构体时容易出问题。做练习的阶段,老老实实写temp三步走,把逻辑讲清楚,比追求省一个变量重要得多。面试的时候,面试官更看重的是你能不能把交换过程说得清清楚楚,而不是炫技。
3. 第二版实现:冒泡排序,以及我的边界条件为什么总是差一个
3.1 冒泡排序的思路和形象记忆
做完选择排序,我又用冒泡排序写了一遍。冒泡的思路和选择完全不同:不直接去找最小值,而是让相邻的两个数两两比较,把较大的数往后移,这样每一轮结束,当前范围内最大的数就会像气泡一样“浮”到最右边。
第一轮结束,第9个位置排好;第二轮结束,第8个位置排好。所以外层循环同样是9轮,但每一轮需要比较的范围都在缩小。这个“范围不断缩小”的感觉很关键,我用一个长度为5的数组举例,假设原数组是{5, 3, 8, 1, 4}:
- 第1轮:j从0到3,比较(0,1)(1,2)(2,3)(3,4),最大值8会一路换到第4位;
- 第2轮:j从0到2,比较(0,1)(1,2)(2,3),最大值5会换到第3位;
- 第3轮:j从0到1,比较(0,1)(1,2),最大值4换到第2位;
- 第4轮:j从0到0,比较(0,1),3和1交换,排序完成。
可以看到,第i轮结束,末尾就多了一个已经确定好的大数,所以下一轮内层循环的终点就要向前收一位。
3.2 冒泡排序完整代码与逐段解释
#include <stdio.h> int main() { int a[10]; int i, j, temp; printf("请输入10个整数:\n"); for (i = 0; i < 10; i++) { scanf("%d", &a[i]); } // 外层循环:控制跑多少轮,10个数跑9轮 for (i = 0; i < 9; i++) { // 内层循环:相邻比较,把较大的往后移 // 9 - i 是因为每轮结束后,末尾都已经排好了,不用再参与比较 for (j = 0; j < 9 - i; j++) { if (a[j] > a[j + 1]) { temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; } } } printf("排序后的结果是:\n"); for (i = 0; i < 10; i++) { printf("%d ", a[i]); } printf("\n"); return 0; }这段代码里最容易写错的,就是内层循环的结束条件。我一开始写出了j < 10 - i,结果程序一运行就崩溃。原因后面单独说。
3.3 边界推导:为什么内层是 j < 9 - i 而不是 j < 10 - i
这是冒泡排序里最经典的“差一个”问题。数组长度为10,最后一个合法下标是9。内层循环要同时访问a[j]和a[j+1],当j等于9时,a[j+1]其实是a[10],已经越界了。所以内层循环的上限必须保证j+1不超过9,也就是j最大只能取到8。
当i等于0时,j的范围是0到8;当i等于1时,j的范围是0到7;依次类推,所以写成9 - i。写成10 - i的话,i等于0时j会取到9,一访问a[10]就出事。这个“为什么减一”的道理,比死记“外层n-1,内层n-1-i”重要得多。建议自己在纸上画一个长度为5的数组,把每一轮j实际取到的值写出来,两三遍之后就再也不会错了。
4. 调试记录:三个实际运行中的错误与排查思路
写代码不可能一遍过,至少我做不到。这一节把我实际踩过的三个坑、排查过程和最终修复方式都记录下来,这几个坑非常有代表性。
4.1 坑1:scanf少写了&,编译通过但运行闪退
第一次写选择排序时,我输入第一个数之后,程序直接表现得完全不受控,要么闪退,要么读进来的数全是垃圾值。我第一反应是逻辑写错了,但反复看排序代码也没发现问题。后来我加了一行printf,把输入之后的数组打印出来,发现a[0]读进来的根本不是我这个数,才意识到问题出在输入,不在排序。
一检查,果然是scanf("%d", a[i])少写了&。scanf的工作原理是“把键盘读到的值写到某个内存地址里去”,它需要的是变量的地址,而不是变量本身。漏掉&,等于把一个变量的值当成地址去写内存,轻则数据错乱,重则直接段错误。排查思路很简单:先用printf确认输入是否正确,再往下查逻辑。很多“排序结果不对”的问题,源头其实在输入环节。
4.2 坑2:把外层循环写成 i <= 9,数组越界
第一次写冒泡的时候,我把外层循环写成了for (i = 0; i <= 9; i++),觉得“10个数嘛,就循环10次”。结果程序运行起来,排序结果里偶尔会出现一个莫名其妙的巨大数字,有时候甚至直接崩溃。
原因很明确:数组a[10]的合法下标只有0到9,循环条件是i <= 9时,i会取到9,但真正的问题是如果内层或外层有人用i去访问a[i + 1]或者类似位置,就会碰到a[10]这个不存在的元素。访问越界数组是未定义行为,编译器不会报错,但运行结果完全不可预期。
排查方式是在循环入口加一行printf("i = %d\n", i);,看到i取到9或者10时,基本就可以确定越界了。以后凡是看到“结果里出现超大随机数”,优先怀疑数组越界。
4.3 坑3:交换顺序写反,导致相邻两个数变成相同值
第三个坑很有意思,我是在写冒泡的交换时犯的。当时手滑,写成了:
a[j] = a[j + 1]; a[j + 1] = a[j];表面看是交换,实际上第一句执行完,a[j]原来的值已经被a[j + 1]覆盖了,第二句又把同一个值复制回去,结果是两个位置变成同一个数,原来的数直接消失。我用{3, 1, 2}测试时,排序后变成了{1, 2, 2},少了一个数。
排查这个问题的办法,是在交换前后各打印一次a[j]和a[j + 1]的值,对比就能发现“为什么两个数变得一模一样”。这提醒我,哪怕是最简单的三步交换,也不能凭肌肉记忆写,必须确认temp把原值保存住了。
4.4 给初学者的排查顺序清单
经过这几轮折腾,我总结出一个适合新手排查排序类程序错误的顺序:
- 先确认输入正确:打印一遍读进来的数组,看看是不是自己输入的那几个数;
- 再检查循环边界:重点看i和j的起止条件,确认没有访问到不存在的下标;
- 然后检查交换逻辑:打印交换前后两个位置的值,确认没有发生覆盖;
- 最后再看排序规则:是不是比较符号写反了、min初始值是不是设对了。
这个顺序的核心思想是:从数据进入程序的源头开始,一步一步往后查,不要一上来就埋头看排序逻辑。我遇到的坑里面,有两三个其实都处在“输入”和“边界”这种不起眼的地方。
5. 从“对10个数排序”延伸出去:n个数、结构体排序与qsort
5.1 把固定长度改成函数:从magic number到参数化
练习37要求的是10个数,但实际工作中几乎不会遇到“永远排序10个”的需求。我做完这道题之后,做的第一件事就是把10改成参数,封装成一个函数:
#include <stdio.h> #define N 10 void selection_sort(int a[], int n) { int i, j, min, temp; for (i = 0; i < n - 1; i++) { min = i; for (j = i + 1; j < n; j++) { if (a[j] < a[min]) { min = j; } } if (min != i) { temp = a[i]; a[i] = a[min]; a[min] = temp; } } } int main() { int a[N]; int i; printf("请输入%d个整数:\n", N); for (i = 0; i < N; i++) { scanf("%d", &a[i]); } selection_sort(a, N); printf("排序后的结果是:\n"); for (i = 0; i < N; i++) { printf("%d ", a[i]); } printf("\n"); return 0; }改成函数之后,main函数立刻清爽了很多。这里有个C语言初学者容易忽略的点:数组作为函数参数时,传进去的是地址,不是整个数组的拷贝。所以在函数内部修改a,实际上就是在修改main函数里的原数组,不需要返回值。这也解释了为什么很多教程里排序函数都是void类型——它根本不是“返回一个排好序的新数组”,而是“直接在原数组上操作”。
5.2 结构体排序:按成绩排序时,为什么不能只交换成绩
如果你以为排序到此为止,那就太小看这道题了。刷完练习37,下一个常用的扩展就是结构体排序。举个最常见的场景:一个模拟项目里有5名学生的学号和成绩,要求按成绩从高到低排序,同时学号必须跟着成绩一起走。
#include <stdio.h> #include <string.h> struct Student { int id; char name[20]; int score; }; void sort_by_score(struct Student stu[], int n) { int i, j; struct Student temp; for (i = 0; i < n - 1; i++) { for (j = i + 1; j < n; j++) { if (stu[j].score > stu[i].score) { temp = stu[i]; stu[i] = stu[j]; stu[j] = temp; } } } }这里的重点是:交换时必须是整个结构体变量整体交换,而不是只交换score字段。如果只把score交换了,学号和名字还留在原来的位置,成绩表就彻底错乱了。C语言里结构体可以直接用=整体赋值,这比某些语言方便得多,但也让不少初学者误以为“只需要交换成绩”。记住一个原则:参与排序的是结构体数组,交换的单位就应该是整个结构体。
5.3 C标准库的qsort:会手写之后再了解它
练习做完之后,我想再说说C标准库自带的快速排序函数qsort。它的原型在stdlib.h里,是这么用的:
#include <stdio.h> #include <stdlib.h> int compare(const void *a, const void *b) { return *(int *)a - *(int *)b; } int main() { int a[10] = {5, 3, 8, 1, 4, 9, 2, 7, 6, 0}; qsort(a, 10, sizeof(int), compare); for (int i = 0; i < 10; i++) { printf("%d ", a[i]); } printf("\n"); return 0; }qsort需要你写一个比较函数,返回负数表示a排在b前面,返回正数表示a排在b后面,返回0表示相等。这个设计的好处是通用性强:不管是int、double还是结构体,只需要定义不同的比较函数,同一套qsort都能排。
但我必须强调一个顺序问题:先自己手写几遍选择排序和冒泡排序,再去用qsort。为什么?因为语言库函数是个黑盒,你只知道它能排序,不知道它内部怎么处理边界、怎么处理稳定性。自己写过一遍之后,再去读qsort的底层思路,才能真正理解“快速排序为什么快”,也才能在面试官问你排序原理时,不只是回答一句“我用qsort”。
5.4 三种方式的适用场景
最后整理一个简单的对比表,是我自己在不同场景下的选择习惯:
| 排序方式 | 时间复杂度 | 额外空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| 选择排序 | O(n²) | O(1) | 不稳定(可优化) | 数据量小、代码简单、初学者练手 |
| 冒泡排序 | 最好O(n),最坏O(n²) | O(1) | 稳定 | 数据量小、几乎有序的数据、教学演示 |
| qsort快速排序 | 平均O(n log n) | O(log n) | 不稳定 | 大规模数据、通用场景 |
稳定性的意思,是值相同的两个元素在排序后是否保持原来的先后顺序。冒泡是稳定的,选择排序默认不稳定,这个细节在面试里经常被问到。但练习37阶段,先不用死记这些结论,把两种排序代码写顺了,后面的理解水到渠成。
我在实际做这道题时最深的体会是:不要满足于“能跑出正确结果”。每次把10改成100、把数组改成结构体、把排序规则从升序改成降序,都是对同一个代码块的一次压力测试。能做到这些之后,练习37的价值才真正发挥出来——它不是一道让你记答案的题,而是帮你把数组操作、循环边界和交换逻辑练成肌肉记忆的起点。后面再做矩阵、插入、逆序之类的题目,你会发现顺手很多。