快速排序凭什么叫"快"排序?平均O(nlogn)背后,藏着一个能让它退化成O(n²)的选择

「算法与数据结构」系列 Day02

写在前面

上一篇讲的冒泡、选择、插入、希尔排序,谁也逃不开O(n²),因为它们都在做逐对比较。快速排序不一样,教科书都说它平均复杂度是O(nlogn),比前面四个快了一个数量级------但它同时又背着一个"最坏情况O(n²)"的名声,比希尔排序还容易退化。同样是O(n²),为什么快速排序能被认为是"更快"的排序?这篇就从它的核心动作------分治------出发,讲清楚它凭什么平均更快,又是怎么把自己作死到最坏情况的。


一、是什么:先分区,再对两半各自递归

快速排序解决的还是"给定一个无序数组,原地排成有序"这同一个问题,但思路和前面四个完全不同:它不是靠逐对比较慢慢把元素挪到该在的位置,而是每一轮先随便挑一个元素当"基准"(pivot),把数组分成两半------比基准小的挪到左边,比基准大的挪到右边,基准本身就落在了它排序完成后的最终位置上。然后对左右两半各自递归重复这个动作,直到子数组只剩一个元素或为空。

这个"分区再各自递归"的套路就是分治 :把一个大问题拆成两个规模更小、结构相同的子问题,子问题解决了,原问题也就解决了。分区操作本身用的是所谓Lomuto方案:固定选arr[low]作为基准,用一个指针i记录"小于基准的区间"该扩张到哪,遍历一遍数组就能把基准放到正确位置。

flowchart LR A[选基准] --> B[遍历数组分区] B --> C[比基准小的挪左边] B --> D[比基准大的挪右边] C --> E[基准归位] D --> E E --> F[对左半区间递归] E --> G[对右半区间递归]

二、为什么:平均更快在于分得均匀,最坏情况就出在分不均匀

快速排序平均更快的核心原因是:它不追求每一步都精确定位一个元素,而是靠"分区"一次性把无关的两拨元素隔开,减少后续需要比较的总次数。如果每次分区都能把数组大致切成两半,递归深度就是O(logn)层,每一层所有分区操作加起来都要遍历一遍当前层的全部元素,也就是O(n),两者相乘就是期望的O(nlogn)。这跟前面四种排序"不管怎么分,比较次数都是O(n²)量级"完全不同------快速排序的比较次数直接取决于每次分区分得均不均匀。

最坏情况恰恰出在"分不均匀"上。以固定选arr[low]做基准为例,如果输入数组本身已经有序(或者接近有序),每次选到的基准都是当前子数组里最小(或最大)的元素------分区结果就是"一边0个元素,一边n-1个元素",递归深度直接从O(logn)退化成O(n)层,每层还是要遍历O(n)个元素,总复杂度也就跟着退化成O(n²)。也就是说,快速排序的最坏情况不是随机撞上的,而是"基准选择策略"和"输入数据的有序程度"撞到一起的必然结果------已排序数组是它最怕的输入之一,这一点跟前面几种排序"越接近有序跑得越快"正好相反。

避免最坏情况的思路也很直接:既然固定选arr[low](或arr[high])在遇到有序输入时必然选到极端值,那就不要固定选。常见的做法有随机选基准、三数取中(取lowmidhigh三个位置的中位数),本质都是想办法让基准更接近数组的中位数,让分区结果尽量均衡。


三、怎么用:代码实现 + 常见坑

java 复制代码
public static void quickSort(int[] arr, int low, int high) {
    if (low < high) {
        int pivotIndex = partition(arr, low, high);
        quickSort(arr, low, pivotIndex - 1);   // 左子数组
        quickSort(arr, pivotIndex + 1, high);  // 右子数组
    }
}

// 以 arr[low] 作为基准,把小于它的元素挪到左边,返回基准最终所在的下标
private static int partition(int[] arr, int low, int high) {
    int pivot = arr[low];
    int i = low + 1; // i 之前(不含i)都是已确认小于 pivot 的区间
    for (int j = low + 1; j <= high; j++) {
        if (arr[j] < pivot) {
            swap(arr, i, j);
            i++;
        }
    }
    swap(arr, low, i - 1); // 把基准从 low 换到它该在的位置
    return i - 1;
}

private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

第一个坑是递归出口写错if (low < high)不能漏,否则子数组只剩一个元素(low == high)或为空(low > high)时还会继续递归,partitionarr[low]直接越界。

第二个坑是基准选择过于朴素导致的最坏情况 ,上一节已经讲过原理,实践中至少应该随机选基准(把arr[low]arr[random(low, high)]先交换一下,再走原来的分区逻辑),成本几乎为零,却能把"输入恰好有序/接近有序"这种最坏情况出现的概率降到可以忽略。

第三个坑是分区不稳定partition过程中会发生跨区间的交换,相同值的元素相对顺序可能被打乱,所以快速排序和选择排序一样是不稳定排序,如果业务要求排序前后相同值元素的相对顺序不变(比如按更新时间排序后还要保留原来按ID排序的顺序),不能直接用快速排序。

第四个坑容易被忽略:递归本身占用调用栈空间 。最坏情况下递归深度是O(n),如果数组足够大(比如几十万元素的近似有序数组),朴素实现可能栈溢出------这也是java.util.DualPivotQuicksortArrays.sort对基本类型数组的实现)要做双轴优化、对长度小于47的子数组直接切换成插入排序的原因之一:既降低了小数组上分治的开销,也间接减少了递归深度。

基准选择策略 最坏情况触发条件 额外开销
固定选arr[low]/arr[high] 输入已排序或接近有序
随机选基准 极小概率仍可能命中最坏情况,但和输入无关 一次随机数生成+交换
三数取中 需要精心构造对抗样本才能触发 三次比较

四、面试追问

Q1:快速排序的平均复杂度和最坏复杂度分别是多少?为什么会有这个差距?

平均O(nlogn),最坏O(n²)。差距的根源在于分区是否均衡:分得越均匀,递归树越接近平衡二叉树,深度是O(logn);分得越不均衡(极端情况一边0个、一边n-1个),递归深度退化到O(n),而每一层分区操作都要遍历O(n)个元素,两者相乘就得到O(n²)。

Q2:为什么说已经排好序的数组是快速排序最怕的输入之一?

因为朴素实现固定选arr[low](或arr[high])作为基准,面对已排序数组时,这个基准永远是当前子数组里的最小值(或最大值),分区结果永远是一边为空、一边n-1个元素,退化成前面提到的最坏情况。这也是为什么实际工程实现都不会用这种朴素选基准方式,而要引入随机化或三数取中。

Q3:快速排序和归并排序都是分治,为什么快速排序不稳定,归并排序却是稳定的?

区别在于合并(或分区)阶段有没有发生跨区间的元素交换。快速排序的分区是原地交换实现的,相同值的元素可能因为一次交换被换到了对方原来的位置,破坏相对顺序;归并排序的合并阶段是逐个比较后顺序拷贝到辅助数组,只要合并时遇到相等元素优先取左边(前)子数组的元素,两个相同值的相对顺序就能被保留下来,这也是归并排序天生适合稳定排序场景的原因。


下一篇预告

Day03 归并排序与堆排序:稳定排序和原地排序到底怎么选。

相关推荐
6Hzlia1 小时前
【Classic 150 刷题计划】 LeetCode 242. 有效的字母异位词 | C++ 哈希计数与严密防线
c++·算法·leetcode
wabs6661 小时前
关于二叉树【力扣101.对称二叉树的思考】
数据结构·c++·算法·leetcode·二叉树
6Hzlia1 小时前
【Classic 150 刷题计划】 LeetCode 228. 汇总区间 | C++ 双游标区间扫描与 to_string 规范
c++·算法·leetcode
玖玥拾2 小时前
LeetCode 383 赎金信
算法·leetcode
维克兜率天2 小时前
5.3 宏观因子:抬头看天
笔记·python·深度学习·算法·量化
LuminousCPP2 小时前
数据结构-排序(五):计数排序与排序专题阶段总结|从外部归并到线性排序,再看算法边界与选型
c语言·数据结构·笔记·算法·排序算法
流云枫2 小时前
003-上下文窗口:物理限制、KV Cache 与工程应对策略
算法
盟道科技2 小时前
小程序订阅消息大规模投递实践:频控令牌池、Redis限流与失败补偿设计
分布式·算法·小程序
工具派2 小时前
视频场景检测是怎么找到切镜头位置的?三种思路与实测
算法·计算机视觉·视频