
很多初学C的人第一次被两层循环绕晕就是在“2039【例5.6】冒泡排序”这道题上。题目编号看起来挺神秘实际拆开就三件事用数组存下一组整数通过相邻比较和交换让它们从小到大排列再把结果按格式输出。作为数组章节里最经典的例题它不要求你掌握多高级的算法却能把数组下标、循环边界、变量交换这几个基本功一次性串起来。这篇内容就围绕这道题展开从冒泡排序的直观原理讲到标准代码、边界易错点、常见优化再到我平时实际调试数组排序时踩过的坑——正在学C基础的人或者准备带学生讲例题的老师都能直接拿里面的代码和思路去用。1. 做题前先把“冒泡”原理想清楚1.1 题目拆解存储、比较、交换三件事这类例题的题面通常很短输入一般长这样5 5 4 3 2 1第一行的 5 代表有 5 个整数第二行是待排序的数组。输出要求也简单把这 5 个数从小到大重新排列后输出即可。你仔细看题目真正要你干的活就三件正确地把数据存进数组用两层循环完成相邻比较满足条件时交换两个变量的值。这三件事恰好对应了 C 里三个最基础的知识点数组、for循环、变量赋值。很多同学代码写不对并不是因为“冒泡排序”这个算法本身有多难而是第二层循环的边界条件没想清楚。所以这里我不建议你急着背模板先花五分钟想明白“一趟冒泡”到底做了什么后面写代码会顺很多。1.2 相邻交换为什么能把最大值推到末尾你可以把数组想象成一排并排站的人每个人手里拿一个数字。现在要求按数字从小到大站队但规则限制得很死每次只能比较相邻两个人的数字如果左边比右边大就让他俩换位置。从第一个位置开始一路比较到最后一个位置你会发现一个很有意思的结果最大的那个数字每遇到比自己小的就往后换一步一路被“顶”到了队伍的最后。这个过程很像水里冒气泡大的气泡往上浮所以叫冒泡排序。这里的关键是“相邻比较”。每一趟排序本质上是从数组开头走到结尾把所有“逆序”的相邻对纠正过来。一趟结束之后数组最后一个位置一定是当前未排序部分的最大值。你不需要记住严谨的定义只要抓住这句话一趟冒泡必有一个当前最大值沉底或者最大元素冒到末尾。1.3 用一组手推数据理解“每轮少一次”拿 [5, 2, 9, 1] 来手动走一遍第一趟。一开始比较 a[0]5 和 a[1]25 比 2 大交换数组变成 [2, 5, 9, 1]。接着比较 a[1]5 和 a[2]9不用动。最后比较 a[2]9 和 a[3]19 比 1 大交换数组变成 [2, 5, 1, 9]。你会发现最大值 9 已经确定在最后一个位置下一趟完全不需要再碰它。第二趟只需要处理前三个数 [2, 5, 1]比较 2 和 5 不动比较 5 和 1 交换得到 [2, 1, 5, 9]。这时 5 到了倒数第二个位置。第三趟处理前两个数 [2, 1]比较后交换得到 [1, 2, 5, 9]。到这里只用三趟就完成了排序而不是四趟。规律已经出来了有 n 个数最多需要 n-1 趟而且每趟要比较的次数会依次减少。这个“依次减少”就是后面代码里 j n - i - 1 的来源现在先有个直观印象。2. 标准C代码怎么写才能一次过2.1 最朴素的完整代码与逐行拆解这里先给你一段最适合入门理解的实现没有任何花哨优化所有步骤都摊开写。假设数据范围不大题目没给特殊要求数组开得比 n 上限大一点就行。#include iostream using namespace std; int main() { int n; cin n; int a[1005]; for (int i 0; i n; i) { cin a[i]; } // 冒泡排序核心代码 for (int i 0; i n - 1; i) { for (int j 0; j n - i - 1; j) { if (a[j] a[j 1]) { int temp a[j]; a[j] a[j 1]; a[j 1] temp; } } } for (int i 0; i n; i) { cout a[i] ; } cout endl; return 0; }外层循环的变量 i 代表“已经排好了几个数”也可以理解成“第几趟”。内层循环的 j 是当前要比较的一对元素中左边那个元素的下标。if 判断用的是 a[j] a[j1]意味着如果左边比右边大就交换。如果 a[j] 小于或等于 a[j1]则什么都不做。这里有一个很容易被忽略但很重要的细节等于的时候不交换。正因为这个细节冒泡排序才是稳定排序后面我会再展开说。2.2 内层循环上限“n-i-1”的计算过程很多初学者最困惑的就是这一句为什么内层写 j n - i - 1而不是 j n也不是 j n - 1你从第一趟开始推。数组下标是 0 到 n-1第一趟要比较到最后一个可比较的相邻对也就是 a[n-2] 和 a[n-1]所以 j 最大取到 n-2。用 for 循环的条件来表达就是 j n-1。第一趟 i0所以 n-0-1 n-1对得上。第二趟开始前末尾已经确定了一个最大值不用再参与比较因此只需要比较到 a[n-3] 和 a[n-2]j 最大取到 n-3也就是 j n-2。此时 i1n-i-1 n-2仍然对得上。依此类推第 i 趟开始时末尾已经有 i 个数排好了所以这一趟只需比较前 n-i 个数而相邻对是从下标 0 到 n-i-1因此循环条件就是 j n-i-1。这个式子不用死记你就记着还剩几个没排好的数最多比较次数就是“剩余个数减一”。写代码时在纸上画一个 4 个或 5 个元素的数组走两趟就彻底明白了。2.3 交换两个变量的三种写法与选择交换是排序里最频繁的操作写法至少要掌握两种。第一种是临时变量法int temp a[j]; a[j] a[j 1]; a[j 1] temp;这个写法的原理可以类比成两个杯子换水你想把 A 杯的水倒进 B 杯不能直接倒因为 B 杯原有水会没地方放所以先拿出第三个空杯把 A 杯水倒进去再把 B 杯水倒进 A 杯最后把第三杯水倒进 B 杯。代码里 temp 就是那个第三只杯子。第二种是用标准库函数#include algorithm swap(a[j], a[j 1]);swap 是 STL 里现成的代码确实简洁很多。但我建议初学者在学排序时至少亲手写三次临时变量版本因为竞赛和考试里不一定允许你用 STL而且手写能帮你真正理解内存里两个变量怎么互相赋值。网上还能看到一种用异或实现的交换比如 a ^ b; b ^ a; a ^ b。这种写法看起来很酷但它没有实际性能优势可读性还差一旦你用错会出现两个变量变成 0 的诡异问题。刷题初期没必要碰它。3. 从“能跑”到“能查”封装、测试与调试3.1 把排序封装成函数的正确姿势平时练习时不建议把所有代码都堆在 main 里。把排序过程抽成函数可以让你把“算法逻辑”和“输入输出”分离后面换其他排序算法时也只需要改函数内部。void bubbleSort(int a[], int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - i - 1; j) { if (a[j] a[j 1]) { int temp a[j]; a[j] a[j 1]; a[j 1] temp; } } } }调用的时候直接写bubbleSort(a, n);这里有一个 C 新手很容易迷糊的点数组作为函数参数时传进去的是“首地址”所以函数内部对 a[j] 的修改会直接作用到 main 里的原数组。你不需要返回值也不需要额外写什么 符号。很多同学学到这里会习惯性地写 bubbleSort(a, n) 之后还去赋值接收其实是没必要的。如果你之前学过“函数传值复制一份”注意数组传参是个例外它传递的是地址不是整个数组的拷贝。3.2 用随机数据验证排序结果的测试技巧题目样例过了不代表代码真没问题。我自己的习惯是除了用手动数据还要准备好用随机数据做测试。最朴素的做法是先写出一个绝对正确的冒泡排序再对比一个简单但正确性显然的检查每次跑完用循环判断 a[i] 是否都小于等于 a[i1]。bool sorted true; for (int i 0; i n - 1; i) { if (a[i] a[i 1]) { sorted false; break; } }构造测试数据也有套路。别只用随机乱序数据要专门测几类边界已经升序的数组用于检查你的算法会不会做多余交换降序数组这是冒泡排序最坏情况全部相同的数组用来确认不会因为比较条件写错而进入死循环只有一个元素的数组两个元素的数组。这几类数据加起来并不多但基本能把一个排序代码里 90% 的边界问题逼出来。手动构造时你直接在本地把数组初始化为固定值就行int a[] {5, 4, 3, 2, 1}; int n 5;不用每次都在控制台重新输入省事很多。3.3 在VS Code里配置C环境并观察变量如果你想在自己电脑上跑这道题并单步调试最常见的组合是 VS Code 加 C/C 扩展再配合一个 g 编译器。你只需要新建一个 .cpp 文件写好代码后在扩展市场里装上微软官方的 C/C 插件然后按 F5 选择“C (GDB/LLDB)”启动调试。第一次会提示你配置 tasks.json 和 launch.json选择 g 编译当前文件即可。这个配置过程可能稍微折腾但配好之后体验很好。单步调试对理解冒泡排序特别有帮助。把断点打在内层循环的 if 条件上然后反复按 F10 单步执行可以清楚看到每比较一次j 怎么变化什么时候进入交换交换后数组变成什么样。很多人靠脑补理解不了内层循环的边界用调试器看几分钟就全明白了。我之前带朋友学这段时发现只要他能把一个 5 元素的倒序数组完整单步走一遍后面再也不会把 j n-i-1 写成 j n。4. 边界易错点与问题排查实录4.1 数组越界j 的终点为什么不取 n最常见的翻车写法是内层循环写成 j n 或者 j n-1。这样写会带来两个问题第一当 j 等于 n-1 时代码会访问 a[j1]也就是 a[n]而数组下标最大只有 n-1越界了。C 不会每次都提示你越界这次可能看起来运行正常下次可能排出一个神秘结果或者直接崩溃。第二即使侥幸没有崩溃最后一位已经被确定好了再去比较它和它后面的内存垃圾逻辑上没有意义。我排查过不少这类问题症状往往是“大多数样例能过但有一个样例结果不对”。原因就是内存越界后读到了不确定的值这个值可能碰巧不影响结果也可能让排序直接出错。所以一定要记住比较的是 a[j] 和 a[j1]j 的范围就必须保证 j1 最大是 n-1。只要坚持这一点越界问题基本不会犯。4.2 下标从1开始存数时循环怎么写有些教材考虑到读入习惯会让你把数组从下标 1 开始存也就是 for (int i 1; i n; i) cin a[i];。那排序循环也要跟着改。外层仍然是 i 从 1 到 n-1表示“已经排好了几个数”内层就要从 j 1 开始条件写成 j n - i也就是 j 1 最大等于 n - i 1 的话会错。我建议你能把下标从 0 和从 1 这两种写法都练一遍因为不同教材、不同题目的存数习惯不一样你越是死记“统一是 j n-i-1”换个题目就越容易懵。我见过很多同学把两种写法混着用数组从 1 开始存但内层循环还写 j 0 且 j n-i-1结果第一个元素 a[0] 根本没被使用排序后第 0 位还是残留值输出的时候又把 a[1] 到 a[n] 打出来于是整个序列少看了一个数特别难排查。解决的办法只有一个在动手写排序之前先确定好“数据存在下标 0 还是下标 1”然后所有的循环都顺着同一种下标习惯写。4.3 输入输出格式与提交OJ时的小坑题目排完序之后输出格式通常是每个数后面跟一个空格这在你本地跑没问题。有些 OJ 判题是“忽略行尾空格”的所以多一个少一个空格通常不致命但保险起见我更推荐写成第一个数前不空格、每个数之间空一格、最后再换行的形式。用最简单的做法就是for (int i 0; i n; i) { if (i) cout ; cout a[i]; } cout endl;另外当 n 比较大时cin 和 cout 的默认同步机制会拖慢速度。如果提交后显示超时而你能确认排序算法本身没有大问题可以试着在 main 开头加上这两行ios::sync_with_stdio(false); cin.tie(0);这两行能显著加快 C 标准输入输出的速度。但注意加上之后不要混用 printf 和 cout否则输出顺序可能错乱。对于这道例题本身 n 通常不大不加也能过但养成这个习惯对后面做其他题很有用。5. 优化和复杂度冒泡排序的进阶认识5.1 提前结束的优化写法前面提到如果数组一开始就是有序的最朴素的冒泡排序仍然会跑完 n-1 趟每一趟都做无意义的比较。一个很自然的想法是如果一整趟下来一个交换都没发生说明所有相邻元素都已经有序数组已经排好了后面的趟数完全可以跳过。实现上只需要加一个标记void bubbleSort(int a[], int n) { for (int i 0; i n - 1; i) { bool swapped false; for (int j 0; j n - i - 1; j) { if (a[j] a[j 1]) { swap(a[j], a[j 1]); swapped true; } } if (!swapped) break; } }这个优化对有序或接近有序的数组特别有效。最好情况下数组已经升序第一趟比较 n-1 次一次交换都没有直接 break时间复杂度降为 O(n)。对“基本有序”的数据这比朴素写法快了非常多。而且这个标记本身只有几行代码完全不增加什么复杂性强烈建议在写冒泡排序时顺手加上。5.2 记录最后交换位置的再优化还有一个更技巧性的优化记录每一趟最后一次发生交换的位置。这个位置之后的元素都已经处于正确位置下一趟不需要再扫描到 n-i-1只需要扫描到上次记录的位置即可。void bubbleSort(int a[], int n) { int last n - 1; while (last 0) { int newLast 0; for (int j 0; j last; j) { if (a[j] a[j 1]) { swap(a[j], a[j 1]); newLast j; } } last newLast; } }这段代码初看比普通双层循环难理解一点但它背后的思想很有价值既然一次交换都没发生在后半段那后半段一定已经有序列下次没必要再去碰。这种“利用上一轮信息收缩扫描区间”的思路在往后学快速排序、KMP 算法时还会反复出现。初学者可以先把这段收藏等把基础版本写熟之后再来理解它。5.3 复杂度分析表与适用场景判断看完优化有必要把复杂度完整梳理一遍。这里我整理了一张表情况时间复杂度说明最好情况已升序含优化O(n)第一趟比较 n-1 次后直接结束平均情况O(n²)比较和交换次数都接近 n(n-1)/4 到 n(n-1)/2最坏情况逆序O(n²)比较次数 n(n-1)/2交换次数 n(n-1)/2空间复杂度O(1)只需要临时变量原地排序稳定性稳定相等元素不交换相对顺序不变最好情况可能有人会疑惑不是还要比较 n-1 次吗怎么是 O(n)因为冒泡排序在有序数组上只需要做一轮扫描扫描本身是线性的所以是 O(n)。但普通朴素版本即使有序也会跑 n-1 轮最好情况是 O(n²)这就是优化的意义。适用场景上冒泡排序不适合大数据量n 到几千还可以到几万就明显变慢十万级别基本不可用。它真正的优势是代码简单、稳定、容易调试适合 n 小、数据基本有序、或者教学演示。真正开发里我会直接用 std::sort它内部是更高效的算法但对学习阶段来说把冒泡排序吃透依然是理解其他排序的重要一步。6. 常见问题速查与给初学者的练习建议6.1 常见问题速查表我在改作业和帮人看代码时遇到的冒泡排序问题翻来覆去就那么几类。整理成一张速查表方便你定位问题现象可能原因解决办法输出结果乱序或某几个数不对内层循环边界写错存在越界访问检查 j n-i-1确保 j1 不越界排完序后第一个数变成 0 或丢失数组从 0 存但循环从 1 开始统一下标风格别混用程序运行很久不结束交换条件写成 导致相等元素反复交换改成 保持稳定且避免无意义交换有重复数字时结果顺序不稳定交换条件处理不当只有 a[j] a[j1] 才交换提交超时n 大或用了未优化的双层循环加提前结束优化必要时换排序算法本地输出正常但提交 OJ 格式错多了或少了换行行尾空格过多使用逐项分隔输出最后换行这些坑我几乎全踩过。最典型的是一次朋友写的冒泡把内层循环写成了 j n结果有一组数据排序错误查了很久才发现是越界后读到了不确定内存。从那以后我写数组循环第一反应永远是检查最大下标而不是检查逻辑对不对。6.2 三个能加深理解的练习方向第一个练习是把代码改成降序排序。方法很简单把 if (a[j] a[j1]) 改成 if (a[j] a[j1]) 即可。但你要能说出为什么而不是随手改完就过。第二个练习是把排序方向反过来内层循环从最后一个位置向前扫描让最小元素不断向前“冒泡”。这种反向写法会让你对数组下标更敏感。第三个练习是给结构体排序比如每个学生有学号和成绩请你按成绩从小到大排序成绩一样时保持学号顺序不变。冒泡排序稳定做这个练习天然合适也能帮你提前理解 sort 类算法的稳定性价值。这三个练习都不难但比单纯抄十遍模板有效得多。算法这个东西只看懂了不算会只有能根据题目要求随意变形才算真正掌握。我在实际带人学这段内容时最深的体会是数组循环边界能力决定了一个人能不能把排序学明白。今天你愿意多花十分钟把一个 5 元素的冒泡排序每一步手推下来后面学插入排序、选择排序、二分查找都会轻松不少。别嫌这个例题基础它其实就是给你打地基的。把基础打稳了后面那些看着更炫的算法你才有底气去碰。