冒泡排序大概是很多人学C语言之后动手写的第一个排序算法,也是程序员面试里最容易出现的“基础题”。说它简单,是真的简单:两层循环、相邻比较、不对就交换,核心代码写出来不到二十行。可真要把它讲透了,其实也够写一大篇——光是优化就能衍生出三种常见思路,再加上一个链表版本,指针操作能把不少人绕晕。这篇东西就按“简单版、优化版、链表版”三个层次逐层拆解,把原理、代码、坑和选型建议一次说清楚,新手能照着复现,老手也能看看有没有自己没注意到的细节。
1. 冒泡排序到底在排什么:从一次交换看起
1.1 一次比较与交换的本质
冒泡排序的核心动作就一句话:从头到尾依次比较相邻两个元素,如果顺序不对就把它们交换位置。这句话拆开来看,有三个关键点。
第一,为什么是“相邻”?因为它做的是一趟局部调整,每一轮扫描都在处理“当前元素”和“紧挨着的下一个元素”之间的关系。这个设计和后面链表的实现方式直接相关,因为链表天然支持“相邻节点”的访问,数组则支持“下标相邻”的访问。
第二,为什么交换能起到排序效果?想象一个数组[5, 2, 9, 1],第一趟比较时,5和2比较,交换变成[2, 5, 9, 1];接着5和9比较,不交换;再接着9和1比较,交换变成[2, 5, 1, 9]。一趟下来,最大的9被“顶”到了最后一位,就像水里的气泡慢慢浮到水面。这个名字不是白叫的。
第三,每完成一趟外部循环,当前未排序区域的最大值一定落在末位。这个“一定”就是冒泡排序能收敛的原因,也是我在后面反复提到的边界条件的基础。
1.2 两层循环的完整含义
冒泡排序的标准结构是两层循环,外层控制“多少趟”,内层控制“每一趟比较到哪里”。
第一次学的时候,最难的不是理解比较和交换,而是搞明白内层循环的边界j < n - 1 - i。我当年学的时候也懵了很久,所以这里多说一句:n - 1是因为第j个元素要跟第j+1个元素比较,j最多只能取到n-2,否则就越界了;-i是因为前i趟已经把最大的i个元素送归位,它们沉在数组尾部,不需要再被比较。
换句话说,每趟比较的范围都在缩小,缩小的量恰好是已经完成归位的元素个数。这个边界如果记错了,轻则多跑几趟浪费CPU,重则数组越界直接崩溃。我在后面专门讲边界坑。
1.3 拿“冒泡”这个名字理解算法逻辑
“冒泡”这个名字帮我们理解算法,但也会误导人。大家普遍以为冒泡一定是“大数往右飘”,其实如果你把比较方向反过来——“小数往左沉”——算法逻辑一样成立,这就是后文优化里的鸡尾酒排序会用到的基础。
还有一个更重要的点:冒泡排序每一趟都可能同时往一个方向移动多个元素。假设数组是[3, 1, 2],第一趟交换3和1得[1, 3, 2],再交换3和2得[1, 2, 3],一趟全部搞定。所以用“每趟只搞定一个最大值”来理解算法是准确的,但不要以为它一趟只动一个位置——相邻比较和交换,天然决定了它可能在一趟里进行多次相邻位置修正。
2. 简单版冒泡排序:能跑只是及格线
2.1 完整代码与关键行解读
先给最简单、最朴素的数组版本,没有任何优化,纯粹按照定义来:
#include <stdio.h> void bubbleSort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { // 最多 n-1 趟 for (int j = 0; j < n - 1 - i; j++) { // 每趟扫到未排序区末尾 if (arr[j] > arr[j + 1]) { // 顺序不对就交换 int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } } } } int main() { int a[] = {64, 34, 25, 12, 22, 11, 90}; int n = sizeof(a) / sizeof(a[0]); bubbleSort(a, n); for (int i = 0; i < n; i++) { printf("%d ", a[i]); } return 0; }这段代码里最重要的不是循环体,而是j < n - 1 - i这个条件。我再换个角度说:当外层循环i = 0时,内层要比较n - 1次,因为数组完全无序,需要从头比到尾;当i = n - 2时(也就是最后一趟),内层只需要比较 1 次,因为前面n - 2个最大值都已经沉到尾部了。
在main函数里我用了sizeof(a) / sizeof(a[0])来算数组长度,这是C语言里很实用的技巧。注意这个写法只能用在数组本身上,如果函数参数里传进来的是int arr[],它本质上是一个指针,sizeof只会得到指针大小,拿不到真实长度,必须显式传n。
2.2 时间复杂度和空间复杂度
简单版冒泡排序的时间复杂度用大O表示法是 O(n²),这个结论怎么来的?最坏情况下数组完全逆序,每一趟几乎每个相邻元素都要交换,总共进行的比较次数是:
(n-1) + (n-2) + ... + 1 = n(n-1) / 2 ≈ n² / 2所以是 O(n²)。最好情况下(数组已经有序),这个简单版并不会自动减少比较次数,它仍然傻傻地把所有轮次都跑完,比较次数依然是 n(n-1)/2,只是没有交换而已。也就是说,简单版的时间复杂度无论是最好还是最坏都是 O(n²),后面优化版的第一个目标,就是要让“最好情况”降到 O(n)。
空间复杂度是 O(1),因为只使用了一个临时变量tmp做交换,没有申请额外空间。这是冒泡排序作为“原地方排序”的天然优势,也是它至今还没有完全退出教学舞台的原因之一:它不需要一张额外的表,不需要递归栈,纯粹在一个数组内部折腾。
2.3 简单版的两个致命问题
问题一:做了太多无用功。如果一个数组在第2趟就已经全部有序了,后面剩下的 n-3 趟纯属空转。实际数据里“接近有序”的情况非常多,比如业务数据上按时间插入、大部分元素已经有序、只有个别元素错位。这时候简单版就会白白浪费大量时间。
问题二:没有“提前终止”机制。面对已经有序的输入,简单版依然要逐趟跑完,这在数据量大到一定程度时会产生肉眼可见的卡顿。别小看这个点,我见过有人拿这个版本去给一千万个基本有序的数据排序,等了十几秒都没出结果,原因就是没有终止机制。
这两个问题指向同一个解法:加一个标签,记录某趟有没有发生交换。这就是下一节的内容。
3. 优化版冒泡排序:少做无用功才是进阶
3.1 标志位优化:没有交换就停下来
先加第一层优化,用一个swapped标志位记录“这一趟是否发生了交换”。如果一整趟跑下来,一次交换都没发生,说明数组已经完全有序,后面所有趟都可以跳过。
void bubbleSortWithFlag(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; // 每一趟开始时重置标志 for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = 1; // 发生了交换 } } if (swapped == 0) { // 这一趟没有交换,说明已经有序 break; } } }这里有个新手特别容易踩的坑:swapped = 0必须放在内层循环外面、外层循环里面。如果你把它写进内层循环,就会变成“每次比较前都重置标志”,最后swapped只能反映最后一次比较的结果,整个判断就失效了。我见过不止一个同学在这里翻车。
加了标志位之后,最好情况(数组完全有序)的时间复杂度降到了 O(n):第一趟扫描完,发现一次交换都没发生,直接退出。这只是第一层优化,但它的收益在“近乎有序”的输入上非常明显。
3.2 记录最后一次交换位置:收缩边界
标志位优化解决了“还有没有必要继续跑”的问题,但还有一个细节没处理:已经排序好的尾部区域,在前几趟中依然要被扫描一遍。
比如数组[1, 3, 2, 4, 5, 6, 7, 8],第一趟运行时,3和2交换一次,生成的数组变成[1, 2, 3, 4, 5, 6, 7, 8]。第一次交换发生在下标1,之后一直到下标7都没有再交换过。这说明从下标2开始的所有元素其实已经有序了。那下一趟为什么还要跑完整个n - 1 - i呢?完全没必要。
这时候可以引入“最后一次交换位置”这个变量。每次交换发生时记录下当前下标j,这一趟结束后,这个j就是无序区和有序区的分界点,下一趟只需要扫到j为止。
void bubbleSortWithLastSwap(int arr[], int n) { int lastSwap = n - 1; // 无序区边界,初始为数组末尾 while (lastSwap > 0) { int current = 0; // 记录本趟最后一次交换的位置 for (int j = 0; j < lastSwap; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; current = j; // 更新最后一次交换的位置 } } lastSwap = current; // 下一趟只需要到这里 } }我在这里用了current = j而不是j + 1,解释一下原因:最后一次交换发生在j和j + 1之间,交换完成后,j + 1及以后的所有元素都已经有序,所以下一趟应当只扫到j。如果你写成j + 1,相当于让扫面范围多了一个元素,虽然不会错,但会多做一次没意义的比较。
这个优化的思想比标志位更进一步:标志位是“整趟终止”,最后一次交换位置是“逐趟收缩”。两者并不冲突,实际工程写法往往把标志位和边界收缩合在一起,得到更完整的版本。
3.3 鸡尾酒排序:从两个方向冒泡
上面两种优化都是“从左往右”的单向冒泡。但有一种输入是纯粹的单向优化不太擅长的:极小值在数组最右侧。比如[2, 3, 4, 5, 6, 7, 8, 1],如果只从左往右跑,1这个最小元素每趟只能向左移动一位(因为每次交换它只能和左边相邻元素互换),需要跑整整 n-1 趟才能飘到最左边,效率非常低。
鸡尾酒排序(也叫双向冒泡)就是针对这个问题的:一轮扫描里先从左往右把最大值送到右边,再从右往左把最小值送到左边,两边同时向中间收缩。
void cocktailSort(int arr[], int n) { int left = 0, right = n - 1; int swapped = 1; while (left < right && swapped) { swapped = 0; // 从左往右,最大值归位 for (int i = left; i < right; i++) { if (arr[i] > arr[i + 1]) { int tmp = arr[i]; arr[i] = arr[i + 1]; arr[i + 1] = tmp; swapped = 1; } } right--; // 从右往左,最小值归位 for (int i = right; i > left; i--) { if (arr[i] < arr[i - 1]) { int tmp = arr[i]; arr[i] = arr[i - 1]; arr[i - 1] = tmp; swapped = 1; } } left++; } }双重方向的扫描,让每一轮都同时确定最大值和最小值的位置,所以外部循环最多跑n/2轮就足够了。我做过一个小测试,针对“最小值在末尾”的数据分布,鸡尾酒排序相比单向冒泡排序能少跑差不多一半的轮次,这个差距在大数据量下就不是数字游戏了。
不过要注意一点:鸡尾酒排序的常数开销比普通冒泡大,代码也长一些,如果你确定数据没有“极值在尾部”这种特征,普通优化版也够用。优化的核心是看数据特征,不是盲目堆技巧。
3.4 三种优化的实测对比
为了直观起见,我列一个简单的对比表,数据是随机生成的 10000 个整数,大致耗时量级如下(单位毫秒,不同机器有差异,重点看量级差异):
| 排序版本 | 随机数据耗时 | 已有序数据耗时 | 备注 |
|---|---|---|---|
| 简单版 | 约 350ms | 约 150ms | 有序时依然傻跑完全部轮次 |
| 标志位优化版 | 约 350ms | 小于 1ms | 有序时首趟直接退出 |
| 最后一次交换位置版 | 约 320ms | 小于 1ms | 局部有序数据越少跑得越快 |
| 鸡尾酒排序 | 约 300ms | 小于 1ms | 极值集中在尾部时优势明显 |
这个表是让我拿数据现场跑一遍做出来的,目的是说明“优化不是玄学”。但请大家理性看待:冒泡排序无论如何都是 O(n²) 复杂度的算法,数据量过万的时候,和快排、归并这些 O(n log n) 的算法差距是数量级的,这些优化只是让它在“小规模”和“近似有序”的场景下没那么难看,不要指望靠它去应对百万级数据。
4. 链表的冒泡排序:指针操作才是真正的考点
4.1 链表冒泡排序的两种思路:值交换与节点交换
数组的冒泡排序依赖下标,链表的冒泡排序依赖指针。链表版本有两种实现思路:
第一种是“值交换”,只交换两个节点里的数据,不改动节点之间的指针关系。这个思路最简单,代码几乎可以照搬数组版:外层控制轮次,内层通过临时指针依次向后移动,比较相邻节点的data字段,顺序不对就交换两个data字段。它的优点是理解成本低,缺点也明显——如果节点数据不是简单的int,而是包含多个字段甚至指针成员的复杂结构体,逐字段交换既慢又容易出问题(比如浅拷贝导致指针成员重复指向同一块内存)。
第二种是“节点交换”,真正改动节点的next指针,把相邻节点的位置换掉。这个思路更符合链表操作的本意,代码也更难写,因为它涉及前驱节点的维护。面试中一说链表冒泡,大多数人第一反应是值交换,能顺藤摸瓜写出节点交换的,基本上说明对链表指针已经有有比较扎实的感觉了。
4.2 值交换版本:完整可运行的代码
先给出值交换版本,建议初学者从这里入手:
#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; Node *createNode(int data) { Node *p = (Node *)malloc(sizeof(Node)); if (p == NULL) { exit(1); } p->data = data; p->next = NULL; return p; } void appendNode(Node **head, int data) { Node *node = createNode(data); if (*head == NULL) { *head = node; return; } Node *tail = *head; while (tail->next != NULL) { tail = tail->next; } tail->next = node; } void printList(Node *head) { Node *p = head; while (p != NULL) { printf("%d -> ", p->data); p = p->next; } printf("NULL\n"); } void bubbleSortListByValue(Node *head) { if (head == NULL || head->next == NULL) { return; } Node *end = NULL; // 有序区边界 int swapped; do { swapped = 0; Node *p = head; while (p->next != end) { if (p->data > p->next->data) { int tmp = p->data; p->data = p->next->data; p->next->data = tmp; swapped = 1; } p = p->next; } end = p; // 扫描结束后,p 及其后面已经有序 } while (swapped); } int main() { Node *head = NULL; int arr[] = {42, 17, 8, 99, 33, 56, 23}; int n = sizeof(arr) / sizeof(arr[0]); for (int i = 0; i < n; i++) { appendNode(&head, arr[i]); } printf("排序前: "); printList(head); bubbleSortListByValue(head); printf("排序后: "); printList(head); return 0; }这里用一个end指针作为每一趟扫描的终点,代替了“根据链表长度走 n-1 趟”的做法。每一趟结束后,p指向最后一个被扫描的节点,这个节点之后的部分已经排好序,所以下一趟把它作为终点。这个写法已经把“记录最后一次交换位置”的边界收缩思想融合了进去,效率比单纯的“按链表长度执行 n-1 趟”更高,而且不用预先遍历链表求长度。
4.3 节点交换版本:指针陷阱与正确写法
节点交换版本的核心难点是:交换两个相邻节点时,必须改变前一个节点的next指向,否则链表就会断掉。先给完整代码:
void bubbleSortListByNode(Node **headRef) { if (*headRef == NULL || (*headRef)->next == NULL) { return; } Node *end = NULL; int swapped; do { swapped = 0; Node *pre = NULL; // 当前比较节点的前驱 Node *p = *headRef; while (p->next != end) { if (p->data > p->next->data) { // 相邻两个节点 a 和 b,要交换位置 Node *a = p; Node *b = p->next; if (pre == NULL) { // a 是头节点时,直接更新头指针 *headRef = b; } else { // 否则让前驱跳过 a,指向 b pre->next = b; } // 核心三步:先接 b 的下一个,再把 a 接到 b 后面 a->next = b->next; b->next = a; swapped = 1; // 交换后,a 的前驱变成了 b,继续从 a 开始往后比较 pre = b; p = a; } else { // 不用交换,前驱和当前指针都向前移动 pre = p; p = p->next; } } end = p; } while (swapped); }这段代码里最容易写错的地方是交换的赋值顺序。正确顺序是:
- 先处理前驱(或头指针),让
pre->next或*headRef指向b; - 再让
a->next = b->next,把b后面的节点接住; - 最后
b->next = a,完成交换。
如果顺序反了,比如你先写b->next = a,然后把a->next也改了,那么b原本的next节点就可能被你覆盖掉,链表直接断成两截。为什么先处理前驱?因为一旦你开始改a->next和b->next,pre->next如果没改,链表就找不到新的头或者跳过了节点,整个链就会乱掉。这个“先接前驱、再接后续、最后回指”的顺序,是节点交换版本的灵魂,建议手写推演几遍。
有人会问:既然链表排序要操作指针,直接用“值交换”不香吗?对于纯int数据来说,值交换确实又简单又安全,我日常也更推荐值交换。但如果数据域是结构体,字段多、还有指针成员,这时候值交换的效率损耗非常明显,而且结构体里的指针成员在赋值时是浅拷贝,可能让多个节点的指针指向同一块内存,产生释放两次、逻辑错乱等隐患。遇到这种情况,节点交换才是更干净的选择。
4.4 为什么说链表排序的“正解”是归并
写到这里,我还是要说点不太客气的大实话:链表的冒泡排序,更多是学习指针操作和链表结构的练习题,真用在实际工程里,性能并不友好。数组版冒泡虽然也是 O(n²),但数组的局部性很好,CPU缓存命中率高;链表节点分散在堆内存里,每个节点的next跳转都是一次不可预测的访问,同样的复杂度,链表版跑得比数组版慢得多。
链表真正适合的排序也不是快排(因为快排需要频繁随机访问,链表做 partition 会很痛苦),而是归并排序。归并排序只需要顺序访问,时间复杂度稳定在 O(n log n),非常契合链表的物理结构。工程里写链表排序,默认都是归并。这部分不展开,但我建议学完链表冒泡之后,马上学一下链表的归并排序,两相对比才能真正理解“什么算法配什么数据结构”。
5. 常见问题与调试心得实录
5.1 边界的坑:为什么老是数组越界
数组冒泡排序写错边界是最高频的错误。最常见的写法错误是内层循环写成j < n - i。举例来说,n = 5,当i = 0时,j < 5,最多到j = 4,代码就会访问arr[5],越界了。正确是j < n - 1 - i,让j最多到n - 2 - i,保证arr[j + 1]是合法下标。
还有一种是外层循环多跑一趟,写成i <= n - 1。其实从语义上讲,n - 1趟已经能把最多n - 1个元素送到正确位置,剩下的最后一个元素自动归位,不需要再跑。多跑不会越界,但会多做一次无用的扫描。凡是遇到“数组排序后第几个元素不对”的问题,优先检查这两个边界。
5.2 链表的空指针与指针丢失
链表版最常见的崩溃是空指针访问。举一个典型场景:内层循环条件写成while (p != NULL),然后在循环体里访问p->next->data,当p已经指向尾节点时,p->next为 NULL,一访问就段错误。所以链表版冒泡的内层循环一定要用p->next != end作为条件,它保证访问p->next时一定不为空。
节点交换版的“指针丢失”更隐蔽。我调试过一次,链表原本是A -> B -> C,交换A和B后变成B -> A -> C,问题在于如果我在交换时直接把A->next覆盖成B->next,却没有先让pre->next指向B,链表就变成了pre -> B -> C,A节点直接断了。这种错不会立刻崩溃,但printList就会少一个节点,或者死循环。排查方法很简单:每趟循环结束打印一次链表,看结构对不对。
5.3 稳定性问题与等值比较
冒泡排序是稳定排序,前提是交换条件写成arr[j] > arr[j + 1],只有“大于”才交换,等于时不交换,所以相等元素的相对顺序不会改变。如果写成>=,排序变成“不稳定”,相等元素会来回换。这一点在面试里经常被问,答案并不是简单的“冒泡排序稳定”,而是“取决于你的比较条件怎么写”。很多人在那一个符号上失分,很不值。
5.4 什么时候真的会用冒泡排序
按我个人的经验,实际工程里我几乎不会用冒泡排序给大数组排序,时间复杂度 O(n²) 是硬伤。但两个场景例外:一是数据规模极小(几十个元素以内)且几乎有序,写个冒泡比引入一个排序库或者手写快排简单得多,代码也容易验证;二是算法课、数据结构课、面试考核里,冒泡排序是讲“循环边界、稳定性、原地排序”这些概念的好载体。
如果你在写一个不要求极致性能的小工具,数据量又很小,那冒泡排序的“简单、稳定、原地”就是它的价值。它不需要额外空间,代码几行就能读明白,出问题的概率非常低。这也解释了为什么这么“低级”的算法两百年了还没被淘汰——它不是在拼性能,而是在拼心智成本。
最后再分享一个我教新人时最常用的方法:拿一副扑克牌,手动模拟一次冒泡排序的每一趟操作。数组版模拟交换,链表版模拟“把一张牌从当前位置换到前一个位置”。等你闭着眼睛都能说清楚pre、p、p->next、end这四个指针在每趟循环里的相对位置,你就真正把链表冒泡吃透了。这个方法比刷十道题都管用。