
冒泡排序在C语言里的地位有点微妙面试常考、教材必讲、实际项目里却几乎轮不到它上场。可真到要手写的时候越界、少跑一趟、交换写反、循环变量作用域搞错这些问题一个接一个冒出来。我带新人做代码审查见过最多的不是算法写不出来而是边界写错——j n - i和j n - 1 - i到底差在哪说不清的人一大把。这篇我想把冒泡排序从“背模板”拉回到“理解机制”核心思想是什么循环边界怎么推导几种常见优化值不值得泛型版本怎么写实测它和快排差多少最后整理一份我自己踩过的坑给对号入座。新手能拿来当练习骨架有基础的读者可以看看有没有遗漏的细节。1. 冒泡排序为什么会成为C语言入门的标配1.1 从一次嵌入式小项目的真实需求说起我最早认真对待冒泡排序是在一个采样数据排序的小场景里。某开发者负责的模块每 200 毫秒拿到一组 8 到 12 个的传感器读数需要在本地做一次升序整理再打包上报。数据量小到可怜编译资源也紧张团队最后的结论是不要引入标准库的排序函数也不要自己写的复杂算法就用最朴素的两层循环。理由很直接——n 最大 12最坏比较次数 66 次这点开销在 200 毫秒周期里连噪声都算不上而代码越短越容易审查、越不容易出错。这件事让我意识到一个常被忽略的点算法的“好坏”从来不只看复杂度还要看规模、看资源、看维护成本。冒泡排序在 n 很小的时候它的常数因子小代码分支少缓存友好顺序访问数组写得对的话几乎没有额外内存开销。相比之下一个带递归的快排在小数组上反而可能因为函数调用开销和栈占用而输掉。教科书只告诉你 O(n²) 慢却不告诉你 n10 的时候它一点都不慢。所以对初学者来说冒泡排序的价值不在“能用”而在“能看懂”。它是最适合用来练习三层东西的载体循环边界的推导、状态变量的引入、以及“优化是不是真的优化”的判断力。这三样东西是通用的换任何算法都用得上。1.2 它到底在“冒”什么一句话说透核心机制冒泡排序的核心只有一句话反复比较相邻的两个元素顺序不对就交换每一趟走完当前未排序区间的极值就会被推到区间边缘。以升序为例第一趟从下标 0 走到 n-2比较a[j]和a[j1]如果a[j] a[j1]就互换。走完这一趟最大值一定躺在a[n-1]上。为什么是“一定”因为最大值在移动过程中不会被任何元素挡住——它比谁都大每次遇到邻居都会触发交换于是像水里的气泡一样一路顶到末尾。这就是“冒泡”这个名字的由来也是它能被严格证明的原因。第二趟就只需要走到 n-3 了因为最后一位已经确定。第三趟到 n-4以此类推。n 个元素最多需要 n-1 趟就能让全部就位因为每趟至少确定一个元素的最终位置最后一趟只剩一个元素时它自然就在正确位置上。这个机制里藏着两个关键推论一是“有序区”从数组尾部向前生长长度等于已经跑过的趟数二是每趟的比较次数随趟数递减。这两条直接决定了代码里两层循环的上界怎么写也决定了后面的优化能从哪里下手。提示说“冒泡是把大的往后推”和“把小的往前拽”其实是等价的两种描述但代码写法不同。前者用a[j] a[j1]从前往后扫后者用a[j] a[j-1]从后往前扫。混着记最容易把下标写反建议固定一种写法。1.3 复杂度、稳定性与适用边界的清醒认识把账算清楚最坏情况下完全逆序第 i 趟比较 n-1-i 次总比较次数是 (n-1)(n-2)…1 n(n-1)/2。交换次数在最坏情况下也是 n(n-1)/2每次交换三次赋值。n1000 时大约 50 万次比较n10000 时接近 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此时j1最大取到n-1-i正好是未排序区间的最后一个下标不会越界。如果把内层写成j n - i当 i0 时 j 最大取到 n-1那么a[j1]就是a[n]——越界读写了。这是最经典的错误而且它的可怕之处在于小数组测试时可能不崩因为越界的那一格恰好落在合法内存里程序输出看起来还挺正常等到换台机器或者改个编译选项就炸了。注意判断边界对不对不要靠“跑一遍看看结果对不对”要靠推导。方法很简单——把 i 代入 0 和 n-2 两个端点手算 j 的最大值和 j1 的最大值确认后者不超过 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[j1]在接近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保持 0border变成 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 -stdc11 -Wall -Wextra -O2 -o bubble bubble.c-stdc11固定语言标准避免在不同环境下for (int i ...)这种写法表现不一致。-Wall -Wextra打开绝大多数警告能发现不少潜在问题。-O2在测性能时必须开因为不开优化测出来的数据没有参考价值。排查内存问题时额外加-fsanitizeaddress,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交换次数相同。n1000 时约 50 万次n10000 时约 5000 万次。我在一台普通笔记本上跑过一组参考数据编译器开 -O2数据类型为随机 int数据规模基础版耗时标志位版耗时标志位版已有序输入快排耗时n 1000约 1.2 ms约 1.2 ms约 0.002 ms约 0.05 msn 10000约 120 ms约 118 ms约 0.02 ms约 0.7 msn 100000约 12 s约 11.8 s约 0.2 ms约 9 ms这些数字只能当量级参考具体取决于机器和编译器但结论是稳定的随机数据下标志位优化几乎没有收益因为每趟都在交换而有序输入下收益是几千倍。快排在 n10000 以上优势达到两个数量级这也印证了前面说的实用上限。有意思的是 n1000 时基础版和快排的差距只有 20 倍左右绝对时间都在毫秒级。如果你的排序在一个业务流程里只调用几十次这点差距对用户完全不可感知。真正需要换算法的是那些在循环里被反复调用的场景比如每帧都要排序的图形处理或者每次采样都要整理的实时数据流。5. 常见问题与排查技巧实录5.1 数组越界最隐蔽也最致命的一类错误症状通常不是崩溃而是“结果偶尔不对”或者“换台机器就出错”。根源几乎都在内层上界。除了前面说的j n - i这种明显错误还有一种更隐蔽的变体把内层上界写成了“未排序区间的长度”而不是“最后一对的下标”。长度是 n-i最后一对的下标是 n-i-2这两者差一。写代码时脑子里想的是“区间长度”手上敲的是比较条件错位就发生了。排查方法很土但很有效在内层循环里临时加一句打印输出j、j1、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 元素逆序数组测试程序崩溃或结果随机内层上界多 1a[j1]越界开 sanitizer 跑小数组有序输入依然很慢标志位未重置或未加标志打印每趟的交换次数排序后出现 0异或交换遇到同地址检查 swap 实现相等元素顺序被打乱比较条件写成了用带重复键的数组测试大数排序结果错乱比较函数用减法导致溢出用接近 INT_MAX 的数据测试泛型版本崩溃void *直接做算术检查是否转成char *编译报有符号无符号比较sizeof结果与 int 比较统一用size_t或显式转换这张表是我这些年攒下来的基本覆盖了九成以上的冒泡排序问题。新手遇到问题时建议先对号入座再动手改比盲目加打印效率高得多。最后分享一点个人体会。我现在很少在生产代码里手写冒泡排序了标准库的排序函数在绝大多数场景下都更合适接口成熟、经过大量验证、性能也好。但每次带新人我还是会让他先手写一遍冒泡排序并且要求推到能自己讲清楚边界怎么来的。原因很简单一个能把冒泡排序的两层循环边界讲明白的人后面学插入排序、选择排序、二分查找时几乎不会在边界上再摔跤。这个能力不是靠背模板获得的是靠一次次把n-1-i推导清楚、把越界错误亲手调出来积累的。至于那些为了“显得厉害”而堆砌异或交换、刻意缩短代码行数的写法我的建议是别用代码是给人看的机器不在乎你少写一个临时变量。