第23讲:交换排序——冒泡排序与快速排序

交换排序,说白了就是通过不断交换元素位置来达到有序。这类算法里最经典的两个代表,就是冒泡排序和快速排序。一个简单到让人一眼看穿,一个高效到成为工业级标配。今天我们就来好好聊聊它们。

23.1 冒泡排序:最朴素的交换

冒泡排序的思路,我估计你闭着眼睛都能说出来:从头到尾,两两比较,大的往后冒。每一轮下来,最大的那个数就像气泡一样浮到末尾。

代码实现也很直白:

void bubble_sort(int arr[], int n) {
    for (int i = 0; i < n - 1; i++) {
        // 每一轮把最大的冒到最后
        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,每轮开始时置为0,发生交换就置为1。如果一轮结束 swapped 还是0,直接 break。

冒泡排序的时间复杂度是 O(n²),空间复杂度 O(1)。稳定排序。说实话,除了教学价值,实际项目中很少用它。但它的思想——两两比较交换——是快速排序的基石。

23.2 快速排序:分而治之的典范

快速排序,简称快排。它的核心思想是:选一个枢轴(pivot),把比它小的放左边,比它大的放右边,然后递归处理左右两边。

我刚开始学快排时,总觉得它和冒泡没什么关系。后来才意识到,快排其实就是冒泡的升级版——冒泡每次只能消除一个逆序对,而快排一次 partition 能消除大量逆序对。

标准快排代码:

int partition(int arr[], int low, int high) {
    int pivot = arr[low];  // 选第一个元素做枢轴
    int i = low, j = high;
    while (i < j) {
        while (i < j && arr[j] >= pivot) j--;
        if (i < j) arr[i++] = arr[j];
        while (i < j && arr[i] <= pivot) i++;
        if (i < j) arr[j--] = arr[i];
    }
    arr[i] = pivot;
    return i;
}

void quick_sort(int arr[], int low, int high) {
    if (low < high) {
        int pi = partition(arr, low, high);
        quick_sort(arr, low, pi - 1);
        quick_sort(arr, pi + 1, high);
    }
}

快排的平均时间复杂度是 O(n log n),最坏情况 O(n²)。为什么会退化到 O(n²)?说白了就是每次选的枢轴都恰好是最大或最小值,导致 partition 后一边倒。

注意:如果数组已经有序,而你每次都选第一个元素做枢轴,快排就退化成了冒泡排序。我见过有人线上用快排处理一个几乎有序的配置表,结果性能惨不忍睹。

23.3 枢轴选择优化:三数取中法

怎么避免最坏情况?核心就是让枢轴尽量接近中位数。三数取中法就是最常用的手段:取左端、中间、右端三个元素,选它们的中位数做枢轴。

我个人习惯这样实现:

int median_of_three(int arr[], int low, int high) {
    int mid = low + (high - low) / 2;
    // 让 arr[low] <= arr[mid] <= arr[high]
    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]);
    // 把中位数放到 low+1 位置,作为枢轴
    swap(&arr[mid], &arr[low + 1]);
    return arr[low + 1];
}

然后在 partition 里用这个中位数做枢轴。这样即使数组已经有序,枢轴也不会是极端值。我在项目中处理过几十万条用户行为数据,用了三数取中后,快排的性能非常稳定,再也没有出现过 O(n²) 的情况。

核心结论:三数取中法几乎消除了最坏情况,让快排在实际应用中始终保持在 O(n log n) 级别。

23.4 知识体系图

下面这张图帮你理清交换排序的脉络:

交换排序 冒泡排序 快速排序 两两比较 标志位优化 O(n²) 分治思想 枢轴选择 O(n log n) 三数取中法(优化枢轴) 避免最坏情况 性能稳定

23.5 两种排序对比

特性 冒泡排序 快速排序
平均时间复杂度 O(n²) O(n log n)
最坏时间复杂度 O(n²) O(n²)(可优化)
空间复杂度 O(1) O(log n)(递归栈)
稳定性 稳定 不稳定
适用场景 小规模数据、教学 大规模数据、工业级
我的建议:如果你在写一个工具函数,数据量不确定,直接用快排+三数取中。如果数据量很小(比如几十个),冒泡排序反而因为常数小,可能更快。你想想看,快排的递归开销在小数据面前并不划算。

好了,交换排序就聊到这里。冒泡排序让你理解交换的本质,快速排序让你见识分治的威力。三数取中法这个小优化,在实际项目中能帮你省下不少排查性能问题的时间。我曾经在一个线上服务里,就因为没加这个优化,导致高峰期排序耗时暴涨,排查了半天才发现是枢轴选择的问题。从那以后,我写快排必加三数取中。

专注资料整理