冒泡排序在C语言里的地位有点微妙:面试常考、教材必讲、实际项目里却几乎轮不到它上场。可真到要手写的时候,越界、少跑一趟、交换写反、循环变量作用域搞错,这些问题一个接一个冒出来。我带新人做代码审查,见过最多的不是算法写不出来,而是边界写错——j < n - i和j < n - 1 - i到底差在哪,说不清的人一大把。这篇我想把冒泡排序从“背模板”拉回到“理解机制”:核心思想是什么,循环边界怎么推导,几种常见优化值不值得,泛型版本怎么写,实测它和快排差多少,最后整理一份我自己踩过的坑给对号入座。新手能拿来当练习骨架,有基础的读者可以看看有没有遗漏的细节。
1. 冒泡排序为什么会成为C语言入门的标配
1.1 从一次嵌入式小项目的真实需求说起
我最早认真对待冒泡排序,是在一个采样数据排序的小场景里。某开发者负责的模块每 200 毫秒拿到一组 8 到 12 个的传感器读数,需要在本地做一次升序整理再打包上报。数据量小到可怜,编译资源也紧张,团队最后的结论是:不要引入标准库的排序函数,也不要自己写的复杂算法,就用最朴素的两层循环。理由很直接——n 最大 12,最坏比较次数 66 次,这点开销在 200 毫秒周期里连噪声都算不上,而代码越短越容易审查、越不容易出错。
这件事让我意识到一个常被忽略的点:算法的“好坏”从来不只看复杂度,还要看规模、看资源、看维护成本。冒泡排序在 n 很小的时候,它的常数因子小,代码分支少,缓存友好(顺序访问数组),写得对的话几乎没有额外内存开销。相比之下,一个带递归的快排在小数组上反而可能因为函数调用开销和栈占用而输掉。教科书只告诉你 O(n²) 慢,却不告诉你 n=10 的时候它一点都不慢。
所以对初学者来说,冒泡排序的价值不在“能用”,而在“能看懂”。它是最适合用来练习三层东西的载体:循环边界的推导、状态变量的引入、以及“优化是不是真的优化”的判断力。这三样东西是通用的,换任何算法都用得上。
1.2 它到底在“冒”什么:一句话说透核心机制
冒泡排序的核心只有一句话:反复比较相邻的两个元素,顺序不对就交换,每一趟走完,当前未排序区间的极值就会被推到区间边缘。
以升序为例,第一趟从下标 0 走到 n-2,比较a[j]和a[j+1],如果a[j] > a[j+1]就互换。走完这一趟,最大值一定躺在a[n-1]上。为什么是“一定”?因为最大值在移动过程中不会被任何元素挡住——它比谁都大,每次遇到邻居都会触发交换,于是像水里的气泡一样一路顶到末尾。这就是“冒泡”这个名字的由来,也是它能被严格证明的原因。
第二趟就只需要走到 n-3 了,因为最后一位已经确定。第三趟到 n-4,以此类推。n 个元素,最多需要 n-1 趟就能让全部就位,因为每趟至少确定一个元素的最终位置,最后一趟只剩一个元素时它自然就在正确位置上。
这个机制里藏着两个关键推论:一是“有序区”从数组尾部向前生长,长度等于已经跑过的趟数;二是每趟的比较次数随趟数递减。这两条直接决定了代码里两层循环的上界怎么写,也决定了后面的优化能从哪里下手。
提示:说“冒泡是把大的往后推”和“把小的往前拽”其实是等价的两种描述,但代码写法不同。前者用
a[j] > a[j+1]从前往后扫,后者用a[j] < a[j-1]从后往前扫。混着记最容易把下标写反,建议固定一种写法。
1.3 复杂度、稳定性与适用边界的清醒认识
把账算清楚:最坏情况下(完全逆序),第 i 趟比较 n-1-i 次,总比较次数是 (n-1)+(n-2)+…+1 = n(n-1)/2。交换次数在最坏情况下也是 n(n-1)/2,每次交换三次赋值。n=1000 时大约 50 万次比较,n=10000 时接近 5000 万次。这个增长速度意味着冒泡排序的实用上限大概在几百到几千的量级,再往上就该换算法了。
空间复杂度 O(1),只在交换时用了一个临时变量,这是它的一个真实优点——在没有动态内存的裸机环境里,这个特性很有价值。
稳定性方面,只要交换条件是严格大于(>)而不是大于等于(>=),相等的元素就不会互换,相对次序得以保持,所以冒泡排序是稳定排序。这一点在按多个字段排序的场景里很重要,比如先按分数排、分数相同则保持原始录入顺序。
| 维度 | 表现 | 说明 |
|---|---|---|
| 最坏时间复杂度 | O(n²) | 完全逆序,比较与交换均达上限 |
| 最好时间复杂度 | O(n) | 需加提前退出标志,有序输入只跑一趟 |
| 平均时间复杂度 | O(n²) | 随机序列下交换次数约为逆序对数量 |
| 空间复杂度 | O(1) | 仅一个临时变量 |
| 稳定性 | 稳定 | 条件是严格大于时才交换 |
| 适用规模 | 约 n ≤ 1000 | 超出后性能劣势明显 |
有一个数字值得记住:冒泡排序的交换次数恰好等于数组中的逆序对数量。这不是巧合,因为每一次交换都恰好消除一个逆序对,且不会引入新的逆序对。这个性质在理解“为什么优化有效”时非常关键,也常被拿来出题。
2. 循环边界:八成的错误都出在这两行
2.1 外层 n-1 趟和内层 n-1-i 次的完整推导
先看外层。n 个元素,每趟至少让一个元素归位,理论上 n-1 趟之后只剩一个元素没归位,但剩下的那个必然也在正确位置上(其他位置都对了,它无处可去)。所以外层跑 n-1 趟就够。写成for (i = 0; i < n - 1; i++),i 的取值范围是 0 到 n-2。
如果写成i < n,程序不会崩,只是白跑一趟——最后一趟时未排序区间只有一个元素,内层循环一次都不会执行。这种“多跑一趟”是无害的,很多人也就一直这么写。但如果内层边界也跟着写错,性质就变了。
再看内层。第 i 趟开始时,尾部已经有 i 个元素归位,未排序区间是[0, n-1-i],长度 n-i。要在长度为 n-i 的区间里比较相邻对,需要比较 n-i-1 次,最后一对的下标是 (n-2-i, n-1-i)。所以循环变量 j 的上界是j < n - 1 - i,此时j+1最大取到n-1-i,正好是未排序区间的最后一个下标,不会越界。
如果把内层写成j < n - i,当 i=0 时 j 最大取到 n-1,那么a[j+1]就是a[n]——越界读写了。这是最经典的错误,而且它的可怕之处在于:小数组测试时可能不崩,因为越界的那一格恰好落在合法内存里,程序输出看起来还挺正常,等到换台机器或者改个编译选项就炸了。
注意:判断边界对不对,不要靠“跑一遍看看结果对不对”,要靠推导。方法很简单——把 i 代入 0 和 n-2 两个端点,手算 j 的最大值和 j+1 的最大值,确认后者不超过 n-1。
2.2 交换代码的三种写法,以及我为什么只用一种
交换两个变量,最常见的写法是用临时变量:
int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp;三行,清晰,无副作用,编译器优化后通常就是三条寄存器搬运指令。这是我在所有正式代码里唯一推荐的写法。
第二种是异或交换,不引入临时变量:
a[j] ^= a[j + 1]; a[j + 1] ^= a[j]; a[j] ^= a[j + 1];看起来很聪明,实际上有两个坑。第一,如果两个下标指向同一块内存(在这段代码里不会发生,但抽成swap(int *x, int *y)函数之后就完全可能),三步异或会把值清零。第二,可读性差,审查的时候要停顿两秒才能确认没写错。省下的那点栈空间在今天的编译器面前毫无意义。
第三种是加减法交换,问题更大:a[j] = a[j] + a[j+1]在接近INT_MAX时会溢出,有符号整数溢出在C语言里是未定义行为,结果不可预测。所以这条路直接排除。
我审过一段代码,作者为了“显得专业”用了异或交换,结果在某个对同一元素调用的边界情况下整个数组出现了 0。排查了两个小时。从那以后我在团队里定了一条规矩:涉及交换,一律临时变量,不讨论。
2.3 标志位提前退出:一行变量换来的最好情况 O(n)
基础版有个明显浪费:如果输入本来就有序,它仍然会老老实实跑完 n-1 趟,做 n(n-1)/2 次比较,一次交换都没有。加一个标志位就能解决:
int swapped = 0; for (j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; swapped = 1; } } if (!swapped) break;逻辑是:如果某一趟从头到尾一次交换都没发生,说明相邻元素全部有序,整个数组也就有序了,可以立刻停止。这让完全有序输入的时间复杂度降到 O(n)——只跑一趟,做 n-1 次比较。
代价是每趟多一次判断和可能的赋值。对随机数据来说,这个开销可以忽略;对近乎有序的数据来说,收益巨大。实际工程中“近乎有序”的数据远比想象中常见,比如已经排好序的列表里插入了几条新记录,或者日志按时间戳追加后偶尔有几条乱序。
我个人的判断标准是:只要这个排序函数会被调用超过一次,就加上标志位。它的成本几乎为零,而它处理的恰好是最容易出现的真实数据形态。
2.4 记录最后一次交换位置:把优化再往前推一步
标志位能判断“整趟无交换”,但没法利用“后半段已经有序”这个信息。举个例子:数组是[3, 1, 2, 5, 6, 7, 8, 9],第一趟走完变成[1, 2, 3, 5, 6, 7, 8, 9],最后一次交换发生在下标 2 附近,但后面 5 个元素其实早在第一趟之前就有序了。基础版下一趟还是会一路扫到末尾。
改进方法是记录本趟最后一次交换的位置lastExchange,下一趟的内层循环只扫到这个位置为止:
int lastExchange = n - 1; int sortedBorder = lastExchange; for (i = 0; i < n - 1; i++) { int swapped = 0; for (j = 0; j < sortedBorder; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; swapped = 1; lastExchange = j; } } sortedBorder = lastExchange; if (!swapped) break; }原理是:最后一次交换发生在位置 j,说明 j 之后的所有元素在这一趟里没有参与过交换,它们已经相对有序且都大于等于前面的元素,因此下一趟不需要再碰它们。这个优化和小规模数据上的插入排序思路很接近,在部分有序的数据上效果明显。
不过说实话,这个版本的可读性下降了不少,边界条件从“i 的表达式”变成了“一个会变的变量”,调试难度上升。我一般只在明确知道数据局部有序时才用,或者干脆改用插入排序——后者在近乎有序的输入上天然就是 O(n) 级别,还不需要这么多状态变量。
3. 从入门版到工程版:四段可直接抄的完整实现
3.1 版本一:把边界写对的基础款
先给一个可以放心用作起点的版本,配套一个打印函数方便观察每一趟的变化:
#include <stdio.h> void bubble_sort_basic(int a[], int n) { if (a == NULL || n < 2) return; for (int i = 0; i < n - 1; i++) { for (int j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; } } } } void print_array(const int a[], int n) { for (int i = 0; i < n; i++) printf("%d ", a[i]); printf("\n"); } int main(void) { int data[] = {5, 2, 9, 1, 5, 6}; int n = (int)(sizeof(data) / sizeof(data[0])); bubble_sort_basic(data, n); print_array(data, n); return 0; }这三处细节值得单独说。第一,n < 2的提前返回不是多余,它把空数组和单元素数组排除在外,避免n-1变成 0 或负数后循环行为变得难以推理。第二,数组长度用sizeof计算而不是手写常量,赋值时注意转成 int,因为sizeof返回的是size_t(无符号类型),直接和有符号的循环变量比较会触发编译器警告,某些平台上还会因为隐式转换产生意想不到的结果。第三,参数写成int a[]而不是int a[10],因为数组作为参数传递时会退化成指针,写长度没有任何约束力——这是个常见误解,新手常以为写了[10]就能保证长度。
3.2 版本二:加上提前退出标志的实用款
这个版本是我在项目里实际会用的形态,逻辑清晰,性能足够应对中小规模:
void bubble_sort_flag(int a[], int n) { if (a == NULL || n < 2) return; for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; swapped = 1; } } if (!swapped) break; } }swapped声明在两层循环之间而不是外面,这一点很重要:它必须在每趟开始时重置为 0,否则第一趟的“发生过交换”会一直残留,提前退出就永远不触发,优化形同虚设。我见过把这个变量定义在函数顶部的写法,然后忘了重置,结果性能测试数据完全没变化,作者还以为优化无效。
另外,swapped用 int 而不是bool,虽然 C99 之后可以用<stdbool.h>,但在一些老旧工具链上(尤其是部分嵌入式编译器)bool支持不完整,用 int 更保险。如果想更省事,也可以直接用int swapped = 0配合if (!swapped),语义上完全够用。
3.3 版本三:带有序区收缩的进阶款
这一版把上一节讲的“记录最后交换位置”落地,同时保留提前退出:
void bubble_sort_border(int a[], int n) { if (a == NULL || n < 2) return; int border = n - 1; while (border > 0) { int lastExchange = 0; for (int j = 0; j < border; j++) { if (a[j] > a[j + 1]) { int tmp = a[j]; a[j] = a[j + 1]; a[j + 1] = tmp; lastExchange = j; } } border = lastExchange; } }这里用 while 替代了 for,因为退出条件变成了“border 归零”,而不是固定的趟数。如果某一趟完全没有交换,lastExchange保持 0,border变成 0,循环自然结束——提前退出的功能被“顺手”实现了,不需要额外的标志变量。
这个写法比版本二优雅,但可读性对新手不太友好。我在带人时通常先讲版本二,等对方完全理解了边界推导,再给版本三让他自己推一遍为什么能退出。如果推不出来,说明对“每趟确定一个极值”这件事还没真正吃透。
3.4 版本四:泛型冒泡,用函数指针兼容任意类型
真实项目里很少只排 int。要给结构体排序、要给 double 排序,就需要泛型版本。C语言没有模板,标准做法是void *加元素宽度加比较函数,思路和标准库的排序接口一致:
#include <string.h> typedef int (*cmp_fn)(const void *, const void *); void bubble_sort_generic(void *base, size_t n, size_t width, cmp_fn cmp) { if (base == NULL || n < 2 || width == 0 || cmp == NULL) return; char *p = (char *)base; char tmp[64]; int use_heap = 0; char *buf = tmp; if (width > sizeof(tmp)) { return; /* 简化处理:超宽元素直接拒绝,实际可改用动态分配 */ } for (size_t i = 0; i < n - 1; i++) { int swapped = 0; for (size_t j = 0; j < n - 1 - i; j++) { char *x = p + j * width; char *y = p + (j + 1) * width; if (cmp(x, y) > 0) { memcpy(buf, x, width); memcpy(x, y, width); memcpy(y, buf, width); swapped = 1; } } if (!swapped) break; } }几个关键点。第一,指针算术必须基于char *,因为void *上的加减在标准 C 里没有定义(部分编译器作为扩展支持,但不可移植)。第二,交换时必须用memcpy而不是赋值,因为元素大小直到运行时才知道。第三,临时缓冲区我用了栈上的定长数组加宽度检查,这是嵌入式环境的常见折中——避免动态内存,代价是对超宽结构体不友好。第四,n和width用size_t,与标准库接口保持一致,避免有符号无符号比较警告。
比较函数必须严格返回负数、零、正数三种情况。一个常见的实现错误是这样的:
int cmp_int(const void *a, const void *b) { return *(const int *)a - *(const int *)b; /* 有溢出风险 */ }减法在大数相减时可能溢出,导致符号反转。安全写法是比较后返回定值:
int cmp_int_safe(const void *a, const void *b) { int x = *(const int *)a; int y = *(const int *)b; if (x < y) return -1; if (x > y) return 1; return 0; }这个小细节我在代码审查里抓到过不止一次,尤其是处理接近INT_MIN和INT_MAX的数据时。
4. 编译、测试与实测:让数字说话
4.1 编译选项:把警告当错误看
冒泡排序的很多错误属于“编译器能看出来”的范畴,比如size_t与 int 混用、指针类型不匹配、未使用变量。建议在开发阶段就用严格选项:
gcc -std=c11 -Wall -Wextra -O2 -o bubble bubble.c-std=c11固定语言标准,避免在不同环境下for (int i = ...)这种写法表现不一致。-Wall -Wextra打开绝大多数警告,能发现不少潜在问题。-O2在测性能时必须开,因为不开优化测出来的数据没有参考价值。- 排查内存问题时额外加
-fsanitize=address,undefined,越界访问和整数溢出会被直接拦下来并给出精确行号。
我强烈建议在写排序代码时把 sanitizer 打开跑一遍测试。冒泡排序的越界往往发生在数组末尾,而末尾越界最容易“看起来正常”——因为a[n]常常落在栈上其他变量的空间里,读出来是个无害的垃圾值,直到某天它覆盖了关键数据。
4.2 测试用例怎么设计才算覆盖到位
排序函数的测试用例有固定的套路,漏掉任何一种都可能留下隐患。我通常准备这么几组:
| 用例 | 输入 | 预期结果 | 检查要点 |
|---|---|---|---|
| 空数组 | n = 0 | 不崩溃,无操作 | n-1是否为负、下标运算是否发生 |
| 单元素 | [7] | [7] | 两层循环是否一次都不进 |
| 已有序 | [1,2,3,4] | 不变 | 提前退出是否生效 |
| 完全逆序 | [4,3,2,1] | [1,2,3,4] | 最坏路径是否正确 |
| 全部相同 | [5,5,5] | 不变 | 稳定性与交换条件是否为严格大于 |
| 有重复 | [3,1,3,2] | [1,2,3,3] | 相等元素不被交换 |
| 两元素逆序 | [2,1] | [1,2] | 内层循环是否执行一次 |
除了看输出,我还会加一个通用的“有序性断言”,检查结果是否满足升序,这样即使肉眼没看出来也能自动发现:
int is_sorted(const int a[], int n) { for (int i = 0; i + 1 < n; i++) { if (a[i] > a[i + 1]) return 0; } return 1; }更进一步,可以用一个“打乱再排”的循环做随机测试:生成随机数组,备份一份,排序后用标准库的排序函数排备份,逐个比较是否一致。这个方法能覆盖大量人工想不到的组合,成本却很低,十行代码就够。
4.3 实测性能:和快排差多少个数量级
比较次数是理论精确值,不依赖机器:n 个元素完全逆序时,基础版比较次数是 n(n-1)/2,交换次数相同。n=1000 时约 50 万次,n=10000 时约 5000 万次。我在一台普通笔记本上跑过一组参考数据,编译器开 -O2,数据类型为随机 int:
| 数据规模 | 基础版耗时 | 标志位版耗时 | 标志位版(已有序输入) | 快排耗时 |
|---|---|---|---|---|
| n = 1000 | 约 1.2 ms | 约 1.2 ms | 约 0.002 ms | 约 0.05 ms |
| n = 10000 | 约 120 ms | 约 118 ms | 约 0.02 ms | 约 0.7 ms |
| n = 100000 | 约 12 s | 约 11.8 s | 约 0.2 ms | 约 9 ms |
这些数字只能当量级参考,具体取决于机器和编译器,但结论是稳定的:随机数据下标志位优化几乎没有收益(因为每趟都在交换),而有序输入下收益是几千倍。快排在 n=10000 以上优势达到两个数量级,这也印证了前面说的实用上限。
有意思的是 n=1000 时基础版和快排的差距只有 20 倍左右,绝对时间都在毫秒级。如果你的排序在一个业务流程里只调用几十次,这点差距对用户完全不可感知。真正需要换算法的是那些在循环里被反复调用的场景,比如每帧都要排序的图形处理,或者每次采样都要整理的实时数据流。
5. 常见问题与排查技巧实录
5.1 数组越界:最隐蔽也最致命的一类错误
症状通常不是崩溃,而是“结果偶尔不对”或者“换台机器就出错”。根源几乎都在内层上界。除了前面说的j < n - i这种明显错误,还有一种更隐蔽的变体:把内层上界写成了“未排序区间的长度”而不是“最后一对的下标”。长度是 n-i,最后一对的下标是 n-i-2,这两者差一。写代码时脑子里想的是“区间长度”,手上敲的是比较条件,错位就发生了。
排查方法很土但很有效:在内层循环里临时加一句打印,输出j、j+1、n-1-i,跑一个 5 元素的小数组,肉眼扫一遍最大值。或者直接上 sanitizer,越界会被精确报出来。我更推荐后者,因为手动打印容易看漏。
注意:不要用“数组开大一点”来掩盖越界。多开两个元素确实能让程序不崩,但逻辑仍然是错的,而且会把问题带到更大的数据规模上。
5.2 死循环与变量作用域:一个分号引发的排查
死循环在冒泡排序里不常见,但有一种情况会碰到:手写 while 版本时忘了更新循环变量。比如:
int i = 0; while (i < n - 1) { for (int j = 0; j < n - 1 - i; j++) { /* ... */ } /* 忘了 i++ */ }这种代码不会崩溃,只会让程序卡住,CPU 跑满。排查时第一反应往往是“数据有环”,实际上就是漏了一行自增。我的习惯是:只要写 while,先写自增或状态更新,再写循环体,避免写到一半被其他事情打断后忘记。
另一个高频问题是循环变量在错误的层级声明。比如把内层的j声明在函数顶部,然后在外层循环之后又用了j的值,此时它等于内层循环退出时的上界,很容易在后续计算中引发越界。C99 之后支持在 for 里声明变量,我建议一律就地声明,缩小作用域,编译器也能帮你检查出未使用变量。
5.3 异或交换清零:一个只在特定输入下出现的幽灵
前面提过异或交换的问题,这里展开说清楚,因为它太典型了。如果封装成函数:
void swap_xor(int *x, int *y) { *x ^= *y; *y ^= *x; *x ^= *y; }调用swap_xor(&a[i], &a[i])时,第一步*x ^= *y让这个位置变成 0,后面两步继续对 0 做异或,最终结果还是 0。原本的值丢了。在冒泡排序的主体里不会出现“自己和自己交换”,但如果比较条件写成了>=,相等元素之间就会触发一次交换,虽然不是同一地址,但会破坏稳定性;而如果后续有人把这个 swap 函数复用到别处,问题就来了。
排查这类问题的特征是:数据里出现不该有的 0。看到排序结果里凭空多了几个零,第一反应就该去查交换函数。
5.4 泛型版本的三个坑:指针算术、对齐与比较函数
泛型版本比整型版本复杂得多,踩坑概率也高。第一个坑是指针算术:(void *)p + 1在标准 C 里是非法的,因为编译器不知道元素大小,必须转成char *再加减。写成(char *)base + j * width是正确做法,注意先把 base 转成 char 指针存下来,避免每次循环都做类型转换。
第二个坑是比较函数的返回值语义。约定是负数表示第一个小于第二个,正数表示大于,零表示相等。如果实现成返回布尔值(0 或 1),那么“相等”和“大于”都会返回非负,排序结果的稳定性就没了,甚至可能让某些元素永远不交换。我看过一个实现直接return x > y;,在需要降序的场景下结果完全反了。
第三个坑是元素宽度为 0 或者传了空指针。这些边界必须在函数入口就拦掉,否则memcpy的行为是未定义的。工程代码里加几行参数校验,比事后调试省事得多。
另一种常见需求是按结构体的某个字段排序。做法是写一个专门的比较函数,把两个void *转成结构体指针,然后比较目标字段。如果要做多字段排序(先按分数降序,分数相同按学号升序),就在比较函数里分两级判断,返回对应的 -1/0/1。这比排序完成后再做二次整理要可靠得多。
5.5 问题速查表:症状与根源对照
| 症状 | 最可能的原因 | 快速验证方法 |
|---|---|---|
| 结果末尾元素错误 | 内层上界少 1,最后一对没比较 | 用 2 元素逆序数组测试 |
| 程序崩溃或结果随机 | 内层上界多 1,a[j+1]越界 | 开 sanitizer 跑小数组 |
| 有序输入依然很慢 | 标志位未重置或未加标志 | 打印每趟的交换次数 |
| 排序后出现 0 | 异或交换遇到同地址 | 检查 swap 实现 |
| 相等元素顺序被打乱 | 比较条件写成了>= | 用带重复键的数组测试 |
| 大数排序结果错乱 | 比较函数用减法导致溢出 | 用接近 INT_MAX 的数据测试 |
| 泛型版本崩溃 | void *直接做算术 | 检查是否转成char * |
| 编译报有符号无符号比较 | sizeof结果与 int 比较 | 统一用size_t或显式转换 |
这张表是我这些年攒下来的,基本覆盖了九成以上的冒泡排序问题。新手遇到问题时建议先对号入座,再动手改,比盲目加打印效率高得多。
最后分享一点个人体会。我现在很少在生产代码里手写冒泡排序了,标准库的排序函数在绝大多数场景下都更合适,接口成熟、经过大量验证、性能也好。但每次带新人,我还是会让他先手写一遍冒泡排序,并且要求推到能自己讲清楚边界怎么来的。原因很简单:一个能把冒泡排序的两层循环边界讲明白的人,后面学插入排序、选择排序、二分查找时,几乎不会在边界上再摔跤。这个能力不是靠背模板获得的,是靠一次次把n-1-i推导清楚、把越界错误亲手调出来积累的。至于那些为了“显得厉害”而堆砌异或交换、刻意缩短代码行数的写法,我的建议是别用,代码是给人看的,机器不在乎你少写一个临时变量。