「算法与数据结构」系列 Day02
写在前面
上一篇讲的冒泡、选择、插入、希尔排序,谁也逃不开O(n²),因为它们都在做逐对比较。快速排序不一样,教科书都说它平均复杂度是O(nlogn),比前面四个快了一个数量级------但它同时又背着一个"最坏情况O(n²)"的名声,比希尔排序还容易退化。同样是O(n²),为什么快速排序能被认为是"更快"的排序?这篇就从它的核心动作------分治------出发,讲清楚它凭什么平均更快,又是怎么把自己作死到最坏情况的。
一、是什么:先分区,再对两半各自递归
快速排序解决的还是"给定一个无序数组,原地排成有序"这同一个问题,但思路和前面四个完全不同:它不是靠逐对比较慢慢把元素挪到该在的位置,而是每一轮先随便挑一个元素当"基准"(pivot),把数组分成两半------比基准小的挪到左边,比基准大的挪到右边,基准本身就落在了它排序完成后的最终位置上。然后对左右两半各自递归重复这个动作,直到子数组只剩一个元素或为空。
这个"分区再各自递归"的套路就是分治 :把一个大问题拆成两个规模更小、结构相同的子问题,子问题解决了,原问题也就解决了。分区操作本身用的是所谓Lomuto方案:固定选arr[low]作为基准,用一个指针i记录"小于基准的区间"该扩张到哪,遍历一遍数组就能把基准放到正确位置。
二、为什么:平均更快在于分得均匀,最坏情况就出在分不均匀
快速排序平均更快的核心原因是:它不追求每一步都精确定位一个元素,而是靠"分区"一次性把无关的两拨元素隔开,减少后续需要比较的总次数。如果每次分区都能把数组大致切成两半,递归深度就是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])在遇到有序输入时必然选到极端值,那就不要固定选。常见的做法有随机选基准、三数取中(取low、mid、high三个位置的中位数),本质都是想办法让基准更接近数组的中位数,让分区结果尽量均衡。
三、怎么用:代码实现 + 常见坑
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)时还会继续递归,partition里arr[low]直接越界。
第二个坑是基准选择过于朴素导致的最坏情况 ,上一节已经讲过原理,实践中至少应该随机选基准(把arr[low]和arr[random(low, high)]先交换一下,再走原来的分区逻辑),成本几乎为零,却能把"输入恰好有序/接近有序"这种最坏情况出现的概率降到可以忽略。
第三个坑是分区不稳定 :partition过程中会发生跨区间的交换,相同值的元素相对顺序可能被打乱,所以快速排序和选择排序一样是不稳定排序,如果业务要求排序前后相同值元素的相对顺序不变(比如按更新时间排序后还要保留原来按ID排序的顺序),不能直接用快速排序。
第四个坑容易被忽略:递归本身占用调用栈空间 。最坏情况下递归深度是O(n),如果数组足够大(比如几十万元素的近似有序数组),朴素实现可能栈溢出------这也是java.util.DualPivotQuicksort(Arrays.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 归并排序与堆排序:稳定排序和原地排序到底怎么选。