排序之交换排序:冒泡排序与快速排序
交换排序,说白了就是通过比较和交换元素位置来达到有序。这类算法里最经典的两个代表,一个是冒泡排序,另一个是快速排序。一个简单到让人想笑,一个高效到让人想哭——嗯,我当年学的时候就是这个感觉。
冒泡排序:基础与优化
冒泡排序的思路很简单:从头到尾,两两比较,把大的往后挪。一轮下来,最大的数就沉到了最后。重复这个过程,直到所有数都排好。
但这里有个问题——如果某轮遍历下来,一次交换都没发生,说明数组已经有序了。这时候再继续遍历就是浪费。我在项目中遇到过这种情况:一个几乎有序的数组,用原始冒泡排序跑了整整n²次比较,白白浪费CPU周期。
优化点:设置一个标志位 swapped,每轮开始时置为0。如果发生了交换,置为1。一轮结束后检查 swapped,如果为0,直接跳出循环。
// 优化后的冒泡排序
void bubbleSort(int arr[], int n) {
int i, j, temp;
int swapped;
for (i = 0; i < n - 1; i++) {
swapped = 0; // 每轮开始重置标志
for (j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = 1;
}
}
// 如果没发生交换,提前结束
if (swapped == 0) {
break;
}
}
}
个人习惯:我一般会在排序前先判断一下数组长度,如果小于2就直接返回。这种小优化在嵌入式系统里特别有用,能省掉一次不必要的函数调用开销。
快速排序:枢轴选择与Partition过程
快速排序的核心思想是分治。选一个枢轴(pivot),把比它小的放左边,比它大的放右边。然后递归处理左右两部分。
这里最关键的就是两个问题:枢轴怎么选?Partition怎么做?
枢轴选择策略
枢轴选得好,快排效率高。选得不好,退化成冒泡排序。我见过有人直接取第一个元素做枢轴,结果遇到一个逆序数组,直接崩成O(n²)。
| 策略 | 做法 | 优缺点 |
|---|---|---|
| 固定位置 | 取第一个或最后一个 | 简单,但容易退化 |
| 随机选择 | 随机选一个元素 | 避免最坏情况,但有随机开销 |
| 三数取中 | 取首、中、尾三个数的中位数 | 工程中最常用,平衡性好 |
我曾经踩过的坑:在嵌入式平台上用随机数选枢轴,结果随机数生成函数本身就很慢,反而拖累了整体性能。后来我改用三数取中,效果稳定多了。
Partition过程
Partition是快排的核心操作。我习惯用挖坑填数法来实现,思路清晰,不容易出错。
// 挖坑填数法 Partition
int partition(int arr[], int low, int high) {
// 三数取中选枢轴
int mid = low + (high - low) / 2;
if (arr[low] > arr[mid]) swap(&arr[low], &arr[mid]);
if (arr[low] > arr[high]) swap(&arr[low], &arr[high]);
if (arr[mid] > arr[high]) swap(&arr[mid], &arr[high]);
// 把枢轴放到最右边
swap(&arr[mid], &arr[high]);
int pivot = arr[high];
int i = low - 1; // i指向小于枢轴的最后一个元素
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
i++;
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[high]);
return i + 1;
}
你想想看,这个Partition的过程其实就是在做一件事:把数组分成两堆,一堆小于枢轴,一堆大于枢轴。i指针始终指向小于枢轴的那一堆的末尾,j指针负责扫描整个数组。
快速排序的递归实现
递归实现最直观,也最容易理解。但要注意递归深度的问题——极端情况下可能栈溢出。
void quickSortRecursive(int arr[], int low, int high) {
if (low < high) {
int pivotIndex = partition(arr, low, high);
quickSortRecursive(arr, low, pivotIndex - 1);
quickSortRecursive(arr, pivotIndex + 1, high);
}
}
我的建议:递归实现里,可以先处理短的那一半,再处理长的那一半。这样能控制递归深度不超过log₂n。这个小技巧在嵌入式开发里特别实用。
快速排序的非递归实现
非递归实现需要手动维护一个栈。为什么要学这个?因为有些嵌入式环境不支持递归,或者递归深度受限。我在一个RTOS项目里就遇到过这种情况——任务栈只有4KB,递归快排直接崩了。
void quickSortIterative(int arr[], int low, int high) {
// 用数组模拟栈
int stack[high - low + 1];
int top = -1;
stack[++top] = low;
stack[++top] = high;
while (top >= 0) {
high = stack[top--];
low = stack[top--];
int pivotIndex = partition(arr, low, high);
// 先压入右半部分,再压入左半部分
// 这样左半部分会先被处理
if (pivotIndex + 1 < high) {
stack[++top] = pivotIndex + 1;
stack[++top] = high;
}
if (low < pivotIndex - 1) {
stack[++top] = low;
stack[++top] = pivotIndex - 1;
}
}
}
非递归版本的核心思想就是:用栈保存待处理的区间边界。每次从栈里取出一对边界,做Partition,然后把新的边界压入栈中。说白了,就是把递归调用栈显式地模拟出来。
注意:栈的大小要足够。最坏情况下,栈深度可能达到n。如果你在内存受限的环境下使用,建议先估算一下最大深度,或者用动态分配的方式。
知识体系总览
下面这张图展示了交换排序的整体脉络,以及我们今天讲的核心内容。
这张图把今天的内容串起来了。冒泡排序和快速排序虽然都属于交换排序,但思路完全不同。冒泡是稳扎稳打,快速是分而治之。在实际项目中,我90%的场景都用快速排序,只有在数据量极小(比如少于10个元素)或者几乎有序的情况下,才会考虑冒泡排序。
核心总结:
- 冒泡排序优化点:标志位提前退出,避免无效遍历
- 快速排序关键:枢轴选三数取中,Partition用挖坑填数法
- 非递归实现:用数组模拟栈,注意栈空间大小
- 工程建议:小数据用冒泡,大数据用快排,递归深度受限时用非递归