快速排序

快速排序按基准值把区间划分成小于、等于和大于三段,再递归处理两侧;二路划分作为经典变体补充。

一句话算法

选一个基准值,把小的放左边、相等的集中在中间、大的放右边,然后只递归中间段的两侧。

问题模型

给定一个长度为 nn 的数组,把它按从小到大排序。

快速排序要解决的核心子问题是:

如何在线性时间内,把区间 [l,r] 按基准值 key 划分成几个更容易处理的连续部分?

本文先学习三路划分。一次划分结束后,区间变成:

< key | == key | > key

中间段已经有序,不需要再次处理;递归排序左右两段即可。二路划分会把等于基准值的元素留在两侧,代码更短,但边界更难记,放在后文作为经典变体。

三路版本

三路快速排序是本文推荐的手写版本。它把等于基准值的元素一次集中起来,尤其适合含有大量重复元素的数组。

荷兰国旗模型

把小于、等于、大于 key 的元素看成三种颜色。扫描数组时,每看到一个元素,就把它送入对应的颜色区域:

< key | == key | 尚未检查 | > key

扫描完成后,“尚未检查”区域消失:

< key | == key | > key

等于 key 的整段不需要递归,这是三路划分处理重复元素的关键。

区间不变量

扫描过程中,三个指针 ltigt[l,r] 分成四段:

[l, lt-1] | [lt, i-1] | [i, gt] | [gt+1, r]
   < key   |   == key   | 未检查  |    > key

这四段的含义始终不变:

  • [l,lt-1] 中的元素都小于 key
  • [lt,i-1] 中的元素都等于 key
  • [i,gt] 中的元素还没有检查。
  • [gt+1,r] 中的元素都大于 key

只要记住“i 指向当前元素,[i,gt] 还没有检查”,三个分支的移动方式就能自然推出。

下面用格子画出当前状态。每个 +---+ 格子表示一个元素,? 表示这个元素还没有分类。

         lt       i      gt
          |       |       |
          v       v       v
+---+---+---+---+---+---+---+---+---+
| < | < | = | = | ? | ? | ? | > | > |
+---+---+---+---+---+---+---+---+---+

三种情况为什么保持不变量

情况一:a[i] < key

操作前,i 指向一个小于 key 的元素:

         lt       i      gt
          |       |       |
          v       v       v
+---+---+---+---+---+---+---+---+---+
| < | < | = | = | < | ? | ? | > | > |
+---+---+---+---+---+---+---+---+---+

执行:

cpp
        
1
2
3
swap(a[i], a[lt]); lt++; i++;

操作后:

             lt       i  gt
              |       |   |
              v       v   v
+---+---+---+---+---+---+---+---+---+
| < | < | < | = | = | ? | ? | > | > |
+---+---+---+---+---+---+---+---+---+

小元素进入左段,原来位于 ltkey 被换到等于段末尾;lti 同时右移后,四段定义恢复。

情况二:a[i] == key

操作前,i 指向一个等于 key 的元素:

         lt       i      gt
          |       |       |
          v       v       v
+---+---+---+---+---+---+---+---+---+
| < | < | = | = | = | ? | ? | > | > |
+---+---+---+---+---+---+---+---+---+

不需要交换,只执行 i++。操作后:

         lt           i  gt
          |           |   |
          v           v   v
+---+---+---+---+---+---+---+---+---+
| < | < | = | = | = | ? | ? | > | > |
+---+---+---+---+---+---+---+---+---+

数组内容没有变化,i 右移后,当前元素被并入等于段,四段定义恢复。

情况三:a[i] > key

操作前,i 指向一个大于 key 的元素:

         lt       i      gt
          |       |       |
          v       v       v
+---+---+---+---+---+---+---+---+---+
| < | < | = | = | > | ? | ? | > | > |
+---+---+---+---+---+---+---+---+---+

执行:

cpp
        
1
2
swap(a[i], a[gt]); gt--;

操作后:

         lt       i  gt
          |       |   |
          v       v   v
+---+---+---+---+---+---+---+---+---+
| < | < | = | = | ? | ? | > | > | > |
+---+---+---+---+---+---+---+---+---+

大元素进入右段,但从 gt 换到 i 的元素还没有分类,所以 i 不能移动;gt 左移后,四段定义恢复。

什么时候停止

未检查区 [i,gt] 的元素个数是:

gti+1 gt-i+1

三个分支每次都让它恰好减少 1

  • a[i]<keyi++
  • a[i]==keyi++
  • a[i]>keygt--

未检查区的长度不可能无限减小,因此循环一定停止。循环条件是 i<=gt,第一次不满足条件时必有 i=gt+1

         lt      gt   i
          |       |   |
          v       v   v
+---+---+---+---+---+---+---+---+---+
| < | < | = | = | = | > | > | > | > |
+---+---+---+---+---+---+---+---+---+

此时 [i,gt] 是空区间,四段合并成最终的三段:

  • [l,lt-1] 中的元素都小于 key
  • [lt,gt] 中的元素都等于 key
  • [gt+1,r] 中的元素都大于 key

完整流程

  1. 若区间长度小于等于 1,直接返回。
  2. 随机选择一个基准元素,把它交换到 a[l],并保存基准值 key
  3. 初始化 lt=li=l+1gt=r。此时 a[l] 单独构成等于区。
  4. i<=gt 时,按照上面的三种情况处理 a[i],不断缩短未检查区。
  5. i=gt+1 时扫描结束,递归排序 [l,lt-1][gt+1,r]

正确性

初始时,[l,lt-1][gt+1,r] 都是空区间,[lt,i-1]=[l,l] 中只有基准元素,因此四段不变量成立。上面的分类讨论已经证明,每次循环后不变量仍然成立。

循环停止时未检查区为空,所以整个区间被正确划分成“小于、等于、大于”三段。

等于段不需要递归。左右两段都比原区间短,对它们递归执行相同操作;长度为 01 时天然有序,因此整个区间最终有序。

复杂度

设当前区间长度为 mm,一次三路划分只扫描每个元素一次,时间复杂度为 O(m)O(m)

  • 随机选择基准时,期望时间复杂度为 O(nlogn)O(n\log n)
  • 基准值连续落在极端位置时,最坏时间复杂度为 O(n2)O(n^2)
  • 除递归栈外只使用常数个变量;递归栈期望为 O(logn)O(\log n),最坏为 O(n)O(n)
  • 当所有元素都相等时,只需一次 O(n)O(n) 的划分,不会继续递归。

动画

一趟三路划分动画

动画只展示一次划分。观察 ltigt 的移动,并把它们与四段不变量对应起来。

代码实现

cpp
        
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <bits/stdc++.h> using namespace std; const int maxn = 1e6+5; int a[maxn]; int n; // 三路快速排序 3-Way Quick Sort void quick_sort(int l, int r) { if (l >= r) return; // 1. 随机选一个基准数 (防止被针对卡成 O(N^2)) // 也可以简单写成 int key = a[(l+r)/2]; int rand_idx = l + rand() % (r - l + 1); swap(a[l], a[rand_idx]); int key = a[l]; // 选取 a[l] 作为 key,并以此展开 // 2. 定义指针 int lt = l; // lt (less than) : 指向 "等于区" 的第一个位置 int gt = r; // gt (greater than): 指向 "等于区" 的最后一个位置 int i = l + 1; // i : 当前扫描到的位置 (从 l+1 开始,因为 a[l] 是 key) // 3. 扫描并分类 while (i <= gt) { if (a[i] < key) { // 情况A: 发现比 key 小的,扔到左边 (lt) // 把 a[i] 和 a[lt] 交换,然后 lt 和 i 都右移 swap(a[i], a[lt]); lt++; i++; } else if (a[i] > key) { // 情况B: 发现比 key 大的,扔到右边 (gt) // 把 a[i] 和 a[gt] 交换,gt 左移 // 注意:i 不能动!因为从 gt 换回来的数还没检查过 swap(a[i], a[gt]); gt--; } else { // 情况C: 等于 key,直接跳过,i 右移 i++; } } // 此时数组状态: // [l ... lt-1] 都是 < key // [lt ... gt] 都是 == key (这一段已经排好了,不需要递归!) // [gt+1 ... r] 都是 > key // 4. 递归处理左右两边 quick_sort(l, lt - 1); quick_sort(gt + 1, r); } int main() { // 基础输入输出优化 ios::sync_with_stdio(false); cin.tie(0); cin >> n; for(int i = 1; i <= n; ++i) cin >> a[i]; // 种子随机数,防止被黑客数据卡死 srand(time(0)); quick_sort(1, n); for(int i = 1; i <= n; ++i) cout << a[i] << (i == n ? "" : " "); cout << "\n"; return 0; }

二路版本

二路快速排序把区间分成“不大于 key”和“不小于 key”两部分。等于 key 的元素可以出现在任意一边,因此左右两段都要继续递归。

双指针模型

左指针 i 从左向右找放错侧的元素,右指针 j 从右向左找放错侧的元素:

<= key | 尚未检查 | >= key
          i ... j

找到一对元素后交换它们,再继续向中间扫描。两个指针交错时,一次划分结束。

操作步骤

  1. 若区间长度小于等于 1,直接返回。
  2. 取区间中间位置的值作为 key,初始化 i=lj=r
  3. a[i]<key 时,令 i++,跳过已经在正确侧的元素。
  4. a[j]>key 时,令 j--,跳过已经在正确侧的元素。
  5. i<=j,交换 a[i]a[j],然后令 i++j--
  6. 重复扫描,直到 i>j
  7. 递归排序 [l,j][i,r]

边界与递归

二路版本难记的地方不在交换,而在下面三个边界:

  1. 内层扫描必须使用 <key>key。遇到等于 key 的元素时要停下,否则重复元素可能被直接越过。
  2. 交换后必须同时执行 i++j--。如果两端都等于 key 而指针不动,循环就不会结束。
  3. 递归区间是 [l,j][i,r]key 只是一个保存下来的值,不代表某个元素最终固定在原来的中点位置。

这也是本文把二路版本放在三路版本之后的原因:代码较短,但正确书写依赖更多边界约定。

正确性

扫描过程中保持下面的不变量:

  • [l,i-1] 中已经检查过的元素都不大于 key
  • [j+1,r] 中已经检查过的元素都不小于 key
  • [i,j] 是尚未完成划分的部分。

指针移动只会跳过已经在正确侧的元素;交换则把左侧的较大元素和右侧的较小元素同时送回正确侧。因此指针交错后,左段 [l,j] 中的元素都不大于 key,右段 [i,r] 中的元素都不小于 key

每次交换后两个指针都会向中间移动,递归区间也严格缩短。递归排序左右两段后,整个区间有序。

复杂度

一次二路划分的时间复杂度为 O(m)O(m)

  • 平均时间复杂度为 O(nlogn)O(n\log n)
  • 最坏时间复杂度为 O(n2)O(n^2)
  • 递归栈平均为 O(logn)O(\log n),最坏为 O(n)O(n)

与三路版本相比,二路版本不会一次排除所有等于 key 的元素。重复元素很多时,三路版本通常需要处理的递归区间更小。

代码实现

cpp
        
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <bits/stdc++.h> using namespace std; const int maxn = 1e6 + 5; int a[maxn]; int n; void quick_sort(int l, int r) { // 1. 递归出口:如果区间只有一个数或没有数,直接返回 if (l >= r) return; // 2. 选取基准数 (Pivot) // 建议取中间的数,防止在原本有序的数组上退化成 O(N^2) int mid = a[(l + r) / 2]; // 定义双指针 int i = l, j = r; // 3. Partition 分区操作 // 目标:让左边 [l, j] 所有的数 <= mid // 让右边 [i, r] 所有的数 >= mid while (i <= j) { // 左指针向右找,直到找到一个 >= mid 的数停下 // 注意:这里必须是 < mid,不能是 <=。遇到等于 mid 的也要停下, // 这样可以将重复的 mid 均匀分散到两边,避免树倾斜。 while (a[i] < mid) i++; // 右指针向左找,直到找到一个 <= mid 的数停下 while (a[j] > mid) j--; // 如果指针没有交错,说明找到了一对“放错位置”的数,交换它们 if (i <= j) { swap(a[i], a[j]); i++; j--; } } // 4. 递归处理子区间 // 此时指针已经“错车”了:j 在左边,i 在右边 (j < i) // 分割点变成了 j 和 i // 递归处理左半段 [l ... j] if (l < j) quick_sort(l, j); // 递归处理右半段 [i ... r] if (i < r) quick_sort(i, r); } int main() { // 读写加速 ios::sync_with_stdio(false); cin.tie(0); cin >> n; for (int i = 1; i <= n; ++i) cin >> a[i]; quick_sort(1, n); for (int i = 1; i <= n; ++i) cout << a[i] << " "; cout << endl; return 0; }

划分思想的扩展

如果只求第 kk 小,而不是把整个数组排好序,就不必递归两边。

一次划分后,第 kk 个位置只会落在左段、等于段或右段中的一个:

  • 落在等于段,答案已经找到。
  • 落在左段,只递归左段。
  • 落在右段,只递归右段。

这就是快速选择。随机选择基准时,它的期望时间复杂度为 O(n)O(n)

经典例题

  1. luogu-P1177 排序模板题。可以用本文任一版本理解完整的快速排序过程;实际竞赛中通常优先使用 std::sort

  2. luogu-P1923 求第 kk 小。利用三路划分判断答案落在哪一段,每次只保留一个递归方向。