数据结构:冒泡排序 (Bubble Sort)
目录
从最简单的操作开始
如何利用这个原子操作实现一个具体的小目标?
我们来手动模拟一下:
如何从一个小目标扩展到最终目标?
代码的逐步完善
第一阶段:定义函数框架和我们需要的“原子操作”
第二阶段:实现“多趟”的逻辑(外层循环)
第三阶段:实现每一趟的“比较交换”逻辑(内层循环)
思考与优化
从最简单的操作开始
📌我们的目标: 将一个无序的数组,例如 [5, 1, 4, 2, 8]
,变成有序的 [1, 2, 4, 5, 8]
。
面对这个任务,最直接、最“笨”的思考方式是什么?不是去想什么宏大的整体策略,而是思考:
我能做的最小的、能让数组变得“更”有序一点点的操作是什么?
这个最小的操作就是:只看相邻的两个元素。
比如,我们先看 [5, 1]
。在最终排好序的数组里,1
肯定在 5
的前面。而现在它们的顺序是错的。怎么办?很简单:把它们换过来!✅
[5, 1, 4, 2, 8]
-> 交换 5
和 1
-> [1, 5, 4, 2, 8]
这个 “比较相邻元素,如果顺序错了就交换” 的操作,就是冒泡排序算法最核心的原子操作。它非常简单,但通过重复这个简单的操作,我们就能完成整个排序的宏伟目标。
如何利用这个原子操作实现一个具体的小目标?
我们不能漫无目的地到处交换。我们需要一个策略。与其想着如何把整个数组排好序,不如先定一个更小、更容易实现的目标:
“我们能否通过这个原子操作,把数组中最大的那个元素,放到它最终应该在的位置上?”
答案是可以的。最大的元素最终应该在数组的最右边。我们怎么把它“弄”过去呢?
我们可以从数组的左边开始,一路向右,不停地执行我们的“原子操作”。
我们来手动模拟一下:
假设数组是 arr = [5, 1, 4, 2, 8]
,长度 n = 5
。
1. 比较 arr[0]
和 arr[1]
:
-
[ **5, 1** , 4, 2, 8]
-
5 > 1
,顺序错了,交换。 -
数组变为:
[ **1, 5** , 4, 2, 8]
2. 比较 arr[1]
和 arr[2]
:
-
[1, **5, 4** , 2, 8]
-
5 > 4
,顺序错了,交换。 -
数组变为:
[1, **4, 5** , 2, 8]
3. 比较 arr[2]
和 arr[3]
:
-
[1, 4, **5, 2** , 8]
-
5 > 2
,顺序错了,交换。 -
数组变为:
[1, 4, **2, 5** , 8]
4. 比较 arr[3]
和 arr[4]
:
-
[1, 4, 2, **5, 8** ]
-
5 < 8
,顺序是正确的,什么都不做。 -
数组保持:
[1, 4, 2, 5, 8]
经过这样从左到右的一整轮操作,我们观察结果 [1, 4, 2, 5, 8]
。我们成功了吗?
是的!最大的元素 8
已经被我们“护送”到了数组的最末端,也就是它最终应该在的位置。
这个过程就像水中的气泡,最大的那个总是最先“咕噜咕噜”地冒到水面。这就是“冒泡排序”这个名字的由来。我们把这样一整轮从头到尾的比较交换过程,称为一趟 (Pass)。
如何从一个小目标扩展到最终目标?
我们已经完成了一趟,成功地将n
个元素中最大的一个归位了。现在数组是 [1, 4, 2, 5, | 8]
(用 |
把已归位的元素隔开)。
接下来怎么办?
很简单,我们把已经归位的 8
忽略掉,把前面的 [1, 4, 2, 5]
看作是一个规模更小的新问题。
我们只需要对这个新的、长度为 n-1
的数组,重复刚才一模一样的操作。
👉 我们来模拟第二趟:
-
比较
arr[0]
和arr[1]
:[ **1, 4** , 2, 5, | 8]
->1 < 4
,不交换。 -
比较
arr[1]
和arr[2]
:[1, **4, 2** , 5, | 8]
->4 > 2
,交换 ->[1, 2, 4, 5, | 8]
-
比较
arr[2]
和arr[3]
:[1, 2, **4, 5** , | 8]
->4 < 5
,不交换。
第二趟结束后,数组变为 [1, 2, 4, | 5, 8]
。看,现在次大的元素 5
也归位了!
📈 这个逻辑可以一直重复下去。
-
第三趟,对
[1, 2, 4]
操作,会把4
归位。 -
第四趟,对
[1, 2]
操作,会把2
归位。 -
当只剩下
1
的时候,它自然就在正确的位置了。
我们需要多少趟呢?对于一个有n
个元素的数组,最多需要 n-1
趟,就能把所有元素都排好序。
这个“重复执行”的思路,在编程里天然就对应着循环✅。
代码的逐步完善
现在,我们把上面的推导翻译成代码。
第一阶段:定义函数框架和我们需要的“原子操作”
我们知道肯定需要一个排序函数,并且排序过程中必然要用到“交换”这个原子操作。
// C/C++ 中,要在函数内部修改外部变量的值,需要使用指针
void swap(int* a, int* b) {int temp = *a;*a = *b;*b = temp;
}// 冒泡排序函数的整体框架
// arr 是要排序的数组,n 是数组的长度
void bubbleSort(int arr[], int n) {// 排序逻辑将在这里实现
}
第二阶段:实现“多趟”的逻辑(外层循环)
根据我们的推导,需要重复执行 n-1
趟排序。所以我们需要一个循环来控制这个“趟数”。
void bubbleSort(int arr[], int n) {// 这个外层循环控制总共需要多少“趟” (Pass)// i 表示已经有多少个元素在数组末尾归位了for (int i = 0; i < n - 1; ++i) {// 在这里实现每一趟具体的比较和交换逻辑}
}
第三阶段:实现每一趟的“比较交换”逻辑(内层循环)
在每一趟中,我们从数组的第一个元素开始,向后两两比较。 关键点:比较到哪里为止?
-
第一趟 (i=0),
n
个元素都未排序,要比较n-1
次,检查到arr[n-2]
和arr[n-1]
为止。 -
第二趟 (i=1),最后1个元素已归位,只需要处理前面
n-1
个元素,比较n-2
次,检查到arr[n-3]
和arr[n-2]
为止。 -
第
i
趟,最后i
个元素已归位,比较范围是n-1-i
次。
这个逻辑正好可以用另一个循环,也就是内层循环来实现。
#include <iostream> // 为了方便打印结果void swap(int* a, int* b) {int temp = *a;*a = *b;*b = temp;
}void bubbleSort(int arr[], int n) {// 外层循环,控制“趟数”for (int i = 0; i < n - 1; ++i) {// 内层循环,控制每一趟中的“比较与交换”// 范围是 n - 1 - i,因为每过一趟,末尾就多一个排好的数for (int j = 0; j < n - 1 - i; ++j) {// 这就是我们的“原子操作”if (arr[j] > arr[j+1]) {swap(&arr[j], &arr[j+1]);}}// 我们可以加一句打印,来观察每一趟之后的结果std::cout << "第 " << i + 1 << " 趟排序后: ";for(int k=0; k<n; ++k) std::cout << arr[k] << " ";std::cout << std::endl;}
}
至此,一个可以正确工作的冒泡排序算法就完成了。它完美地复现了我们从第一性原理出发的推导过程。
复杂度分析
最好情况(数组有序):只需要一趟扫描,比较 n−1 次,没有交换 → 时间复杂度: O(n)
最坏情况(数组逆序):每次都需要比较并交换 → 时间复杂度: O(n^2)
空间复杂度:冒泡排序是 原地排序,只需常数个辅助空间 → O(1)
稳定性
冒泡排序是 稳定的。
-
原因:只有在
a[j] > a[j+1]
时才交换,如果相等,不动。 -
因此相同元素的前后顺序不会被破坏。
思考与优化
我们的算法已经能用了,但它是不是最聪明的?有没有什么情况下它做了“无用功”?
💡设想一个情况:arr = [1, 2, 3, 5, 4]
。
-
第一趟后,
4
和5
交换,数组变为[1, 2, 3, 4, 5]
。 -
此时数组已经完全有序了。
但是,我们上面的代码并不知道这一点。它会继续傻傻地执行第二趟、第三趟、第四趟,虽然在这些趟中一次交换都不会发生。这显然是浪费。
优化的第一性原理: 如果我们发现某一趟从头到尾走下来,一次交换都没有发生,这说明了什么?
这说明数组中每一个元素都已经不大于它的后一个元素了,也就是说,整个数组已经完全有序了!
那么,我们就可以提前结束排序,而不必执行后面多余的趟数。
最终阶段:完善代码,加入优化
我们可以在每一趟开始前,设置一个标志位 swapped = false
。如果在这一趟中发生了交换,就把它设为 true
。
一趟结束后,检查这个标志位。如果它仍然是 false
,说明没发生任何交换,我们就可以直接 break
退出外层循环。
// 优化后的冒泡排序
void bubbleSortOptimized(int arr[], int n) {// 外层循环,控制“趟数”for (int i = 0; i < n - 1; ++i) {bool swapped = false; // 设立标志位// 内层循环for (int j = 0; j < n - 1 - i; ++j) {if (arr[j] > arr[j+1]) {swap(&arr[j], &arr[j+1]);swapped = true; // 只要发生一次交换,就将标志位置为true}}// 检查标志位:如果在一整趟中都没有发生交换,说明已经有序if (swapped == false) {std::cout << "在第 " << i + 1 << " 趟后提前结束。" << std::endl;break; // 退出外层循环}std::cout << "第 " << i + 1 << " 趟排序后: ";for(int k=0; k<n; ++k) std::cout << arr[k] << " ";std::cout << std::endl;}
}
这样,我们就从最基本的“交换相邻错误元素”这个原子操作出发,通过“完成小目标 -> 重复操作 -> 发现冗余 -> 加入判断”这一系列逻辑严谨的推导,最终构建出了一个完整且经过优化的冒泡排序算法。