← 目录 / 算法文档 · 模块九 分治排序 / 9.1 快速排序

9.1 快速排序

分治的另一种玩法:不先拆后合,而是先想办法"站好队",站好队之后两边根本不用合并。

本页目录
① 分治的核心思想

分治(Divide and Conquer)由三个步骤组成:

分治三步骤
① 分(Divide)
把原问题拆成若干规模更小、结构相同的子问题。
② 治(Conquer)
递归地解决每个子问题。当子问题小到可以直接处理时(如只剩 0 或 1 个元素),直接返回。
③ 合(Combine)
把子问题的解合并成原问题的解。

分治的威力在于:把一个 O(n²) 的问题,通过"拆成两半、各自解决"变成 O(n log n)——每次对半拆,只需要 log₂n 层,每层的总工作量是 O(n),整体就是 O(n log n)。同样是排序问题,"分""治""合"这三步可以有不同的分工方式:上一节(9.2)的归并排序,"分"的时候什么都不做(只是简单地对半切),真正的工作量都在"合"这一步;本节要讲的快速排序正好相反——"分"这一步就已经把活干完了,"合"这一步反而几乎不需要做任何事。

② 快速排序:先站队,再递归

快速排序的"分"是这样做的:从数组里选一个数当作基准值(pivot),然后把整个数组"重新排队"——比 pivot 小的都站到左边,比 pivot 大的都站到右边。这个"重新排队"的操作叫 partition(划分)

partition 做完之后,会发现一件很方便的事:左边一整块都比右边一整块小,所以只需要分别对左边和右边递归排序,两边各自排好之后,根本不需要再做任何"合并"的动作——数组本来就已经是"左边一块、右边一块"排好的样子了,"合"这一步几乎是免费的。这正好和归并排序"分的时候什么都不做,合的时候要认真做"反过来。

③ partition 图解

用数组 [5, 2, 8, 1, 9, 3]、选 5 作为 pivot,看一次 partition 具体做了什么:

一次 partition 的效果(pivot = 5)
操作前
5
pivot
2
8
1
9
3
partition 后
2
< 5
1
< 5
3
< 5
5
pivot
8
> 5
9
> 5
左边都比 5 小,右边都比 5 大!
经过一次 partition,5(粉色)左边全是比它小的数,右边全是比它大的数——但注意:这一次 partition 不保证 pivot 本身已经站到了排序完成后的最终位置(下一节代码里会具体解释为什么)。接下来只需要对左右两段分别递归调用同样的 partition,不需要额外的合并步骤。
④ 完整代码
C++ · 快速排序
1void QuickSort(int a[], int l, int r)
2{
3 if (l >= r) return; // 递归终止:区间只剩 0 或 1 个元素
4
5 int pivot = a[(l + r) / 2]; // 取区间中间的元素作基准值
6 int i = l - 1, j = r + 1;
7 while (i < j)
8 {
9 do { i++; } while (a[i] < pivot); // i 从左往右,找第一个 ≥ pivot 的位置
10 do { j--; } while (a[j] > pivot); // j 从右往左,找第一个 ≤ pivot 的位置
11 if (i < j) { swap(a[i], a[j]); } // 两个位置都找到了,交换,让左边变小、右边变大
12 }
13
14 QuickSort(a, l, j); // 递归排左边这一段
15 QuickSort(a, j + 1, r); // 递归排右边这一段
16}
17
18// 调用方式:QuickSort(a, 0, n - 1);
📖
为什么用 do...while 而不是普通 while因为需要 ij 在比较之前先移动一步——如果 a[i] 恰好等于 pivot,用 do...while 能保证 i 至少往前挪一位,避免两个指针停在原地不动、死循环卡住。
💡
为什么递归写的是 QuickSort(l, j)QuickSort(j+1, r),而不是按 pivot 的下标切?这种写法(叫 Hoare 划分)的 partition 结束后,只保证下标 j 左边都 ≤ pivot、右边都 ≥ pivot,但 pivot 本身具体停在哪个下标是不确定的——所以正确的切分点是 j,而不是 pivot 最初所在的下标。这是本节最容易写错的地方,下一节陷阱里会再具体展开。
⑤ 复杂度分析
情况时间复杂度什么时候发生
最好 / 平均O(n log n)每次 partition 都能把数组大致分成两半
最坏O(n²)每次 partition 都极不均衡(比如一边只有 1 个元素)
⚠️
为什么会退化到 O(n²)?如果每次选的 pivot 都恰好是这一段里最小或最大的数,partition 之后一边会是空的,另一边几乎是原封不动的一整段——递归树就从"均衡的两叉树"变成了"一条链",原本 log n 层的递归深度变成了 n 层。上面代码选择区间中间的元素作为 pivot(而不是固定选第一个或最后一个),已经能避开"数组本身就有序"这种最常见的构造方式导致的最坏情况,但仍然可能被专门构造的数据卡住——更稳妥的做法是随机选择 pivot,或者"三数取中"(取首、中、尾三个数的中位数),这里先了解思路即可。
⑥ 常见陷阱
误以为 partition 之后 pivot 已经"归位":和一些教材里"选最后一个元素作 pivot,partition 后 pivot 精确落在最终排序位置"的写法不同,本节采用的 Hoare 划分只保证"以下标 j 为界,左边都小、右边都大",pivot 本身的下标可能已经变了。递归时必须按 j 切分,如果习惯性地写成按 pivot 原来的下标切分,会漏掉或重复处理某些元素。
递归终止条件写成 l == r 而不是 l >= r如果某次递归传入的区间是空的(l > r),用 l == r 判断会漏掉这种情况,导致继续访问不属于当前区间的元素,甚至无限递归。
数据本身接近有序时容易触发最坏情况:虽然本节选了区间中间元素作 pivot,一定程度上缓解了"完全有序/完全逆序"这种最简单粗暴的构造方式,但面对专门设计过的数据,仍然可能被卡到 O(n²)。如果题目明确会用这类数据卡效率,记得考虑随机化 pivot 或者直接使用语言自带的 sort(背后通常已经做了这类优化)。
🏆
和 9.2 归并排序对比:两者都是分治,但分工完全相反——归并排序"分"不费力气、"合"是关键;快速排序"分(partition)"就是关键、"合"几乎不费力气。归并排序稳定、复杂度稳定在 O(n log n),但需要额外的 O(n) 空间;快速排序原地排序、常数更小,但最坏情况会退化到 O(n²)。竞赛里两者都值得掌握,但日常排序更多直接调用语言自带的 sort