54、排序算法:冒泡、选择、插入、快速排序的实现与比较

排序算法,可以说是嵌入式开发里的基本功了。说实话,我入行头三年,一直觉得排序嘛,能用就行。直到有一次在一个实时控制项目里,因为排序慢了那么几毫秒,导致整个控制周期超时,电机直接抖了起来……嗯,从那以后,我对排序算法的选择就再也不敢马虎了。

今天咱们就把冒泡、选择、插入、快速这四种排序算法掰开揉碎了讲。我会结合代码实现和实际场景,帮你搞清楚:什么时候该用谁,为什么

一、先看一张图:四种排序的核心逻辑

我习惯先把整体框架画出来,这样你心里有个谱。下面这张图展示了四种排序的核心思路:

四种排序算法核心逻辑对比 冒泡排序 相邻元素两两比较 大的往后“冒泡” 每轮确定一个最大值 稳定 | O(n²) 选择排序 每轮选出最小元素 放到已排序末尾 交换次数少 不稳定 | O(n²) 插入排序 将元素插入已排序区 类似打牌理牌 数据基本有序时极快 稳定 | O(n²) 快速排序 选基准,分左右 递归排序左右子序列 分治思想 不稳定 | O(n log n) 选型建议 • 数据量小(<50):插入排序最实用,代码简单且对基本有序数据极快 • 数据量大且不要求稳定:快速排序,平均性能最优 • 嵌入式内存受限:选择排序(交换次数最少)或插入排序(原地排序) • 要求稳定排序:冒泡或插入,但冒泡效率最低,一般用插入替代

二、冒泡排序——最直观,但效率最低

冒泡排序的思路很简单:从头到尾,相邻两个元素比较,如果顺序不对就交换。每一轮下来,最大的元素就像气泡一样“浮”到最后面。

void bubble_sort(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) break;  // 没交换,提前结束
    }
}
我的经验:冒泡排序的“提前退出”优化一定要加。我在一个数据采集项目里,传感器数据大部分时候已经基本有序,加了提前退出后,平均排序时间从2ms降到了0.3ms。别小看这点优化,在循环采样里差距很大。

冒泡排序是稳定的,因为相等元素不会交换位置。但它的时间复杂度永远是O(n²),数据量一大就扛不住了。

三、选择排序——交换次数最少

选择排序的思路是:每一轮找到未排序部分的最小元素,放到已排序部分的末尾。它最大的特点是交换次数少——最多只交换n-1次。

void selection_sort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        int min_idx = i;
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[min_idx]) {
                min_idx = j;
            }
        }
        if (min_idx != i) {
            int tmp = arr[i];
            arr[i] = arr[min_idx];
            arr[min_idx] = tmp;
        }
    }
}
注意:选择排序是不稳定的。举个例子:数组 [5a, 5b, 3],第一轮会把3和5a交换,结果变成 [3, 5b, 5a],两个5的相对顺序变了。如果你需要稳定排序,别用选择排序。

我曾经在一个EEPROM写入项目里用过选择排序。因为EEPROM写入次数有限,每次写入都很宝贵。选择排序的交换次数最少,能最大程度减少写入操作。说白了,选它不是因为快,而是因为“省”。

四、插入排序——小数据量的王者

插入排序的思路,就像你打牌时理牌:把新摸到的牌插入到手中已经排好序的牌里。它对于基本有序的数据,效率极高,甚至能达到O(n)。

void insertion_sort(int arr[], int n) {
    for (int i = 1; i < n; i++) {
        int key = arr[i];
        int j = i - 1;
        // 把比key大的元素往后移
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
}

为什么插入排序在小数据量时比快速排序还快?

快速排序有递归调用和基准选择的开销。当n小于20-30时,插入排序的常数因子更小,实际跑起来反而更快。很多工业级的排序库(比如glibc的qsort)在数据量小时会切换到插入排序。

嗯,这里要注意:插入排序是稳定的。而且它是原地排序,不需要额外内存。在嵌入式环境里,这两个特性非常讨喜。

五、快速排序——大数据的首选

快速排序用的是分治思想:选一个基准值,把小于基准的放左边,大于基准的放右边,然后递归处理左右两边。平均时间复杂度O(n log n),是目前应用最广的通用排序算法。

void quick_sort(int arr[], int left, int right) {
    if (left >= right) return;
    
    int i = left, j = right;
    int pivot = arr[(left + right) / 2];  // 取中间值做基准
    
    while (i <= j) {
        while (arr[i] < pivot) i++;
        while (arr[j] > pivot) j--;
        if (i <= j) {
            int tmp = arr[i];
            arr[i] = arr[j];
            arr[j] = tmp;
            i++;
            j--;
        }
    }
    
    quick_sort(arr, left, j);
    quick_sort(arr, i, right);
}
避坑指南:我曾经在递归深度上吃过亏。快速排序在最坏情况下(比如数组已经有序,且基准选得不好)会退化成O(n²),递归深度达到n。在嵌入式系统里,栈空间通常只有几KB,递归太深直接栈溢出。我的建议是:如果数据可能接近有序,要么随机选基准,要么改用三数取中法选基准

快速排序是不稳定的。而且它是递归实现,需要额外的栈空间(平均O(log n),最坏O(n))。

六、四种排序对比总结

算法 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性 适用场景
冒泡排序 O(n²) O(n²) O(1) 稳定 教学演示,几乎不用
选择排序 O(n²) O(n²) O(1) 不稳定 交换代价高的场景(如EEPROM)
插入排序 O(n²) O(n²) O(1) 稳定 小数据量(<50)或基本有序的数据
快速排序 O(n log n) O(n²) O(log n)~O(n) 不稳定 大数据量通用排序
我的个人习惯:在嵌入式项目里,如果数据量不确定,我通常会这样写:当n < 20时用插入排序,否则用快速排序。这样既保证了小数据量的高效,又兼顾了大数据量的性能。你想想看,这其实就是很多标准库的做法。

好了,四种排序算法就讲到这里。每种算法都有它的脾气,没有绝对的好坏,只有合不合适的场景。下次写排序代码前,先问问自己:数据有多大?内存够不够?要不要稳定?想清楚了再动手,比上来就写要靠谱得多。


公众号:蓝海资料掘金营,微信deep3321