news 2026/10/15 6:43:16

C语言经典100例第37题:选择排序与冒泡排序实战详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言经典100例第37题:选择排序与冒泡排序实战详解

做到C经典100例第37题时,我停下的时间比预想中长很多。前面35道题大多是单点知识点,这道“对10个数进行排序”看起来人畜无害,却把数组、循环、输入输出、变量交换全部串在一起,题面只有一句话,程序分析也只给了“选择法”三个字的提示。这篇文章就是我把这道题的完整做题过程写下来,从题目拆解、两种排序算法的推导,到实际运行中踩过的三个坑,最后再说说怎么把固定10个数泛化成n个数、结构体排序,以及C标准库里的qsort。适合正在刷这套题、以及刚学完数组想找排序入门练习的朋友,我把能想到的细节都放在里面了。

1. 练习37真正的考点:数组、循环和交换,以及“排序”到底在干什么

1.1 题目原文与我的第一反应

题面:对10个数进行排序。

原题自带的程序分析是这么写的:可以利用选择法,即从后9个比较过程中,选择一个最小的与第一个元素交换,下次类推,即用第二个元素与后8个进行比较,并进行交换。

我第一次读到这段分析,第一反应是:这不就是废话吗?但真正动手写代码前,如果只是“感觉懂了”就直接开写,十有八九会在循环边界上翻车。我的建议是,先把这句话翻译成自己能复述的步骤:

  • 从10个数中找出最小的那个,放到第1个位置;
  • 从剩下9个数中找出最小的,放到第2个位置;
  • 重复这个过程,直到前9个位置都确定,最后一个位置不用再处理。

这样一说,核心逻辑就清楚了:所谓“排序算法”,就是设计一套“比较谁更小、然后在哪个位置交换”的规则,让计算机反复执行这些动作,最终让整个数组有序。

1.2 把“排序”翻译成机器动作

“排序”这个词对人来说太自然了,人眼扫一遍就能排出大小。但计算机没有“一目了然”的能力,它只会做最基本的两个动作:比较两个数的大小,以及交换两个数的位置。所有排序算法,本质上都是把“排序”这个抽象目标,拆解成大量重复的“比较-交换”操作。

拿“在数组里找出最小值的下标”这个子任务举例,机器实际做的事情是:

  1. 先假设第0个位置上的数是最小的,把这个位置的下标记录在变量里;
  2. 用这个位置的值,依次和后面第1、2、…、9个位置的值比较;
  3. 一旦发现某个值比当前记录的最小值还小,就把记录的下标换成那个位置;
  4. 全部比较完之后,记录的下标就是真正最小值的下标;
  5. 把该下标位置的值和第0个位置的值交换,最小值就位。

这个流程翻译成C语言,就是两个for循环、一个if判断、三步交换。选择排序和冒泡排序的区别,只是在“每轮怎么选、选完放哪、下一轮从哪里继续”这几条规则上不同。

1.3 动手前必须确认的五个基础点

写这道题之前,我建议先自查一下下面五个点,任何一个不清楚都会导致卡壳:

  1. 数组下标从0开始,a[0]是第一个元素,a[9]是第十个元素,没有a[10];
  2. for循环的边界习惯写成i < n而不是i <= n,尤其当n代表数组长度时;
  3. scanf接收整数时,必须传变量的地址,也就是&a[i],漏掉&程序会直接出问题;
  4. 交换两个变量要用临时变量temp,分三步完成;
  5. 临时变量直接在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 给初学者的排查顺序清单

经过这几轮折腾,我总结出一个适合新手排查排序类程序错误的顺序:

  1. 先确认输入正确:打印一遍读进来的数组,看看是不是自己输入的那几个数;
  2. 再检查循环边界:重点看i和j的起止条件,确认没有访问到不存在的下标;
  3. 然后检查交换逻辑:打印交换前后两个位置的值,确认没有发生覆盖;
  4. 最后再看排序规则:是不是比较符号写反了、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的价值才真正发挥出来——它不是一道让你记答案的题,而是帮你把数组操作、循环边界和交换逻辑练成肌肉记忆的起点。后面再做矩阵、插入、逆序之类的题目,你会发现顺手很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 6:42:45

TensorFlow 2.x实战指南:从张量计算图到模型部署的完整路径

1. 从零开始理解TensorFlow&#xff1a;它到底在算什么很多人第一次接触TensorFlow&#xff0c;脑子里冒出来的第一个问题不是“怎么用”&#xff0c;而是“这东西到底在算什么”。我刚开始学的时候也有这个困惑&#xff0c;文档里满屏的“张量”“计算图”“会话”&#xff0c…

作者头像 李华
网站建设 2026/10/15 6:42:45

TensorFlow深度学习实战:从核心概念到工程化部署的完整指南

1. 为什么“tensorflow”这个标题值得单独拎出来聊如果你在技术社区混过一段时间&#xff0c;大概率见过这样的场景&#xff1a;有人发帖问“我想入门深度学习&#xff0c;该从哪个框架开始”&#xff0c;底下回复五花八门&#xff0c;但出现频率最高的那个名字&#xff0c;往往…

作者头像 李华
网站建设 2026/10/15 6:42:36

RAG检索增强生成实战:从最小可用系统到生产级优化

1. RAG检索增强到底在解决什么问题1.1 从一个尴尬场景说起大语言模型有个让人又爱又恨的特点&#xff1a;它什么都懂一点&#xff0c;但问到具体细节就容易一本正经地胡说八道。你问它某个内部技术文档里的接口参数&#xff0c;它能给你编出一套看起来煞有介事的答案&#xff0…

作者头像 李华
网站建设 2026/10/15 6:42:28

初识Java方法+数组从0开始:定义、调用、遍历与越界排查

先聊点实在的。我见过不少Java新手&#xff0c;前几节课还跟着打印“Hello World”&#xff0c;一到方法和数组就开始蒙圈&#xff1a;明明代码照抄了&#xff0c;运行却报错&#xff1b;数组里存了5个数&#xff0c;遍历时却总越界&#xff1b;方法写了一大堆&#xff0c;调用…

作者头像 李华
网站建设 2026/10/15 6:41:47

上下文模式设计:从请求级到会话级的架构决策与避坑指南

1. 从“上下文模式”说起&#xff1a;一个被低估的架构决策第一次看到context-mode这个词&#xff0c;很多人会下意识觉得它是个小到不值得一提的配置项。但如果你真正在多个项目里踩过坑&#xff0c;就会明白&#xff1a;上下文模式的选择&#xff0c;往往决定了一个系统在后期…

作者头像 李华
网站建设 2026/10/15 6:41:36

HarmonyOS 7.0端侧AI实战:相册文搜图全链路设计与性能调优

1. 项目缘起与整体设计思路相册搜图这件事&#xff0c;听起来像是“给照片打个标签&#xff0c;然后搜标签”这么简单&#xff0c;但真正落到端侧、落到 HarmonyOS 7.0 的工程环境里&#xff0c;坑远比想象中多。我这次做的项目&#xff0c;核心目标很明确&#xff1a;在设备本…

作者头像 李华