编码 · 算法
快速排序详解:原理、代码与时间复杂度
简短回答
快速排序是一种分治排序算法。它选一个元素作为基准,重新排列数组,使较小的元素位于基准之前、较大的位于其后(即分区),然后递归地对两侧排序。它的平均时间是 O(n log n),原地排序,栈空间为 O(log n),但基准持续选得不好时会退化为 O(n²)。它不稳定。使用随机基准或三数取中基准时,它是实践中最快的通用排序之一。
打开行排序通俗地说,思路是什么
- 选一个基准元素,比如最后一个元素,或者随机的一个。
- 分区:把所有比基准小的元素移到它的左边,所有比它大的移到右边。此时基准就处在它最终的有序位置。
- 递归地对基准左边和右边的部分排序。之后不需要再合并任何东西。
归并排序是拆分简单、合并困难;快速排序正相反:由分区完成主要工作,而拼合两半则是零成本,因为它们本来就在同一个数组里的正确位置上。
示例:一次 Lomuto 分区
以最后一个元素 70 为基准,对 [10, 80, 30, 90, 40, 50, 70] 分区。下标 i 标记比基准小的元素区域的末尾;j 从左到右扫描。
| j | items[j] | 比 70 小吗? | 操作 | 之后的数组 |
|---|---|---|---|---|
| 0 | 10 | 是 | i = 0,交换 0↔0 | [10, 80, 30, 90, 40, 50, 70] |
| 1 | 80 | 否 | 无 | [10, 80, 30, 90, 40, 50, 70] |
| 2 | 30 | 是 | i = 1,交换 1↔2 | [10, 30, 80, 90, 40, 50, 70] |
| 3 | 90 | 否 | 无 | [10, 30, 80, 90, 40, 50, 70] |
| 4 | 40 | 是 | i = 2,交换 2↔4 | [10, 30, 40, 90, 80, 50, 70] |
| 5 | 50 | 是 | i = 3,交换 3↔5 | [10, 30, 40, 50, 80, 90, 70] |
| 结束 | 把基准放到 i + 1 = 4 | [10, 30, 40, 50, 70, 90, 80] |
70 现在位于下标 4,即它的最终位置。接着快速排序分别对 [10, 30, 40, 50] 和 [90, 80] 排序。
Python 实现
import random
def partition(a, lo, hi):
"""Lomuto partition of a[lo..hi] around a random pivot."""
r = random.randint(lo, hi)
a[r], a[hi] = a[hi], a[r] # move the pivot to the end
pivot = a[hi]
i = lo - 1
for j in range(lo, hi):
if a[j] < pivot:
i += 1
a[i], a[j] = a[j], a[i]
a[i + 1], a[hi] = a[hi], a[i + 1]
return i + 1
def quicksort(a, lo=0, hi=None):
"""Sort list a in place."""
if hi is None:
hi = len(a) - 1
while lo < hi:
p = partition(a, lo, hi)
if p - lo < hi - p: # recurse on the smaller side
quicksort(a, lo, p - 1)
lo = p + 1
else:
quicksort(a, p + 1, hi)
hi = p - 1
return a
print(quicksort([10, 80, 30, 90, 40, 50, 70])) # [10, 30, 40, 50, 70, 80, 90]
print(quicksort([3, 3, 3, 1])) # [1, 3, 3, 3]递归进入较小的一侧、对较大的一侧用循环处理,可以让栈深度即使在最坏情况下也保持 O(log n)。为了讲解思路,一个简短的非原地版本很流行,但它在每一层都会分配新列表:
def quicksort_simple(items):
if len(items) <= 1:
return items
pivot = items[len(items) // 2]
less = [x for x in items if x < pivot]
equal = [x for x in items if x == pivot]
greater = [x for x in items if x > pivot]
return quicksort_simple(less) + equal + quicksort_simple(greater)
print(quicksort_simple([5, 2, 9, 2, 7])) # [2, 2, 5, 7, 9]时间与空间复杂度
| 情况 | 时间 | 原因 |
|---|---|---|
| 最好 | O(n log n) | 每个基准都把范围对半分 |
| 平均 | O(n log n) | 随机基准能带来足够均衡的划分,约 1.39 n log₂ n 次比较 |
| 最坏 | O(n²) | 每个基准都是最小或最大的元素 |
| 额外空间 | O(log n) | 优先递归较小一侧时的递归栈 |
经典的最坏情况是:固定选首元素或末元素为基准,输入又恰好已有序或逆序,此时每次分区只去掉基准本身,形成 n 层、每层 O(n) 的工作。随机基准让任何输入都几乎不可能出现这种情况。
快速排序与其他排序算法的比较
| 算法 | 最好 | 平均 | 最坏 | 额外空间 | 稳定 |
|---|---|---|---|---|---|
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 否 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 是 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 否 |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 是 |
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 是 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 否 |
Timsort(Python 的 sorted) | O(n) | O(n log n) | O(n log n) | O(n) | 是 |
快速排序不稳定:分区时的交换可能让元素越过与它相等的元素。在内存中的数组上,它通常胜过归并排序和堆排序,因为它的内层循环简单、原地工作,并且大多按顺序访问内存。Introsort 被许多 C++ 标准库的 std::sort 实现所采用,它从快速排序开始,递归过深时切换到堆排序,从而保证 O(n log n)。
基准选择、变体与常见错误
- 基准的选择:首元素或末元素(简单,但在有序输入上是 O(n²))、随机元素,或三数取中(首、中、尾三者的中位数)。
- Hoare 分区:两个下标相向移动,交换处于错误一侧的元素对。平均而言它的交换次数比 Lomuto 少。
- 三路分区(荷兰国旗问题):划分为小于、等于和大于三部分。它让含大量重复键的数组也很快;而普通的 Lomuto 在所有键都相等时会退化为 O(n²)。
- 双基准快速排序使用两个基准、分成三部分;Java 用它来对基本类型的数组排序。
- 快速选择(Quickselect)只做一次分区,并只递归进入其中一侧,平均 O(n) 时间内找到第 k 小的元素。
何时使用快速排序
在不需要稳定性时,快速排序是对内存中的数组排序的良好默认选择。在 Python 中请改用 sorted() 或 list.sort();它们使用 Timsort,既稳定,又对部分有序的数据很快。实现快速排序是非常常见的面试题,后面往往追问最坏情况、基准选择、稳定性,或用快速选择求第 k 大的元素。
常见问题
快速排序稳定吗?
不稳定。分区会在远距离上交换元素,所以相等元素的相对顺序可能改变。稳定的版本存在,但需要额外内存。
快速排序的最坏情况是什么?
O(n²),即每个基准都是其范围内的最小值或最大值,例如在已排序的输入上选末元素为基准。实践中,随机基准或三数取中基准可以避免这种情况。
快速排序在实践中为什么快?
它原地排序,内层循环只是简单的比较与交换,并且按顺序扫描内存,能很好地利用 CPU 缓存。它平均的常数因子比堆排序小,也不像归并排序那样需要缓冲区。
快速排序和归并排序有什么区别?
快速排序先分区,不需要合并;归并排序先拆分,主要工作在合并中完成。快速排序是原地的,但最坏情况为 O(n²),且不稳定;归并排序是稳定的,始终 O(n log n),需要 O(n) 额外空间。
快速排序用多少内存?
它原地排序。递归栈平均为 O(log n);如果总是先递归较小的一侧,最坏情况下也是 O(log n)。