当前目录
← Back

Quick Sort 快速排序


Quick Sort 快速排序

核心结论

快速排序是一种基于分治法的比较排序算法。它先通过 PARTITION 将数组围绕一个枢轴(pivot)原地划分,再递归排序左右两部分。

  • 平均与随机化期望时间:Θ(nlog⁡n)\Theta(n\log n)
  • 最坏时间:Θ(n2)\Theta(n^2)
  • 原地分区的数组辅助空间:Θ(1)\Theta(1)
  • 递归栈:期望 Θ(log⁡n)\Theta(\log n),最坏 Θ(n)\Theta(n)
  • 标准实现通常不稳定

1. 问题定义

给定一个包含 nn 个可比较元素的数组:

A[0…n−1]A[0\dots n-1]

快速排序要将其重排为:

A[0]≤A[1]≤⋯≤A[n−1]A[0] \le A[1] \le \cdots \le A[n-1]

排序只改变元素的位置,不改变元素集合。

2. 基本思想

快速排序使用“分治”策略:

  1. Divide(划分):选择一个枢轴 xx,重新排列当前子数组,使枢轴左侧元素不大于它,右侧元素大于它。
  2. Conquer(解决):递归排序枢轴左侧和右侧两个子数组。
  3. Combine(合并):不需要额外合并;左右子数组有序后,整个子数组自然有序。
graph TD
    A[待排序子数组 A low...high] --> B[选择 pivot]
    B --> C[PARTITION 原地划分]
    C --> D[左侧元素小于等于 pivot]
    C --> E[pivot 到达最终位置 q]
    C --> F[右侧元素大于 pivot]
    D --> G[递归排序 low...q-1]
    F --> H[递归排序 q+1...high]
    G --> I[当前子数组有序]
    H --> I

快速排序的关键

快速排序真正的核心不是递归,而是能够在线性时间内完成的 PARTITION。一次分区结束后,枢轴已经位于其最终排序位置,后续不再移动。

3. 具体过程

本文采用 Lomuto partition scheme,并统一使用 0-based 下标。

设当前需要排序的闭区间为:

A[low…high]A[low\dots high]

选择末尾元素作为枢轴:

pivot=A[high]pivot=A[high]

维护变量 ii,使 A[low…i]A[low\dots i] 始终是不大于枢轴的元素区域。

3.1 分区过程

初始化:

i=low−1i=low-1

依次扫描:

j=low,low+1,…,high−1j=low,low+1,\dots,high-1
  • 若 A[j]≤pivotA[j]\le pivot:令 i←i+1i\leftarrow i+1,交换 A[i]A[i] 和 A[j]A[j]。
  • 若 A[j]>pivotA[j]>pivot:不交换,继续扫描。
  • 扫描结束后,交换 A[i+1]A[i+1] 和 A[high]A[high],将枢轴放到最终位置。

最终返回:

q=i+1q=i+1

此时满足:

A[low…q−1]≤A[q],A[q+1…high]>A[q].\begin{aligned} A[low\dots q-1] &\le A[q],\\ A[q+1\dots high] &> A[q]. \end{aligned}

3.2 递归过程

得到枢轴位置 qq 后,只需递归处理:

A[low…q−1]A[low\dots q-1]

和:

A[q+1…high]A[q+1\dots high]

当子数组长度不超过 11,即 low≥highlow\ge high 时,递归结束。

4. 分区循环不变式

在每次处理 A[j]A[j] 之前,数组满足以下不变式:

区域内容
A[low…i]A[low\dots i]所有元素均满足 A[k]≤pivotA[k]\le pivot
A[i+1…j−1]A[i+1\dots j-1]所有元素均满足 A[k]>pivotA[k]>pivot
A[j…high−1]A[j\dots high-1]尚未检查
A[high]A[high]枢轴 pivotpivot

4.1 初始化

开始时 i=low−1i=low-1、j=lowj=low,前两个区域均为空,因此不变式成立。

4.2 保持

  • 若 A[j]≤pivotA[j]\le pivot,将它交换到“小于等于区”的末尾。
  • 若 A[j]>pivotA[j]>pivot,它自然进入“大于区”。

因此每轮结束后不变式仍成立。

4.3 终止

循环终止时,所有非枢轴元素都已分类。将 pivotpivot 与 A[i+1]A[i+1] 交换后,枢轴位于位置 q=i+1q=i+1,且左右区域满足分区条件。

分区正确性

PARTITION 返回的位置 qq 是枢轴在最终有序数组中的正确位置,但左右区域内部此时不一定有序。

5. 伪代码

5.1 Lomuto 分区

PARTITION(A, low, high)
    pivot <- A[high]
    i <- low - 1

    for j <- low to high - 1
        if A[j] <= pivot
            i <- i + 1
            exchange A[i] <-> A[j]

    exchange A[i + 1] <-> A[high]
    return i + 1

5.2 快速排序

QUICK-SORT(A, low, high)
    if low < high
        q <- PARTITION(A, low, high)
        QUICK-SORT(A, low, q - 1)
        QUICK-SORT(A, q + 1, high)

初始调用:

QUICK-SORT(A, 0, length(A) - 1)

5.3 随机化快速排序

固定选择首元素或末元素作为枢轴时,有序输入可能持续产生极不平衡的划分。随机化版本在当前子数组中随机选择枢轴,再执行普通分区。

RANDOMIZED-PARTITION(A, low, high)
    r <- RANDOM(low, high)
    exchange A[r] <-> A[high]
    return PARTITION(A, low, high)

RANDOMIZED-QUICK-SORT(A, low, high)
    if low < high
        q <- RANDOMIZED-PARTITION(A, low, high)
        RANDOMIZED-QUICK-SORT(A, low, q - 1)
        RANDOMIZED-QUICK-SORT(A, q + 1, high)

随机化的作用

随机化不会消除 Θ(n2)\Theta(n^2) 的理论最坏情况,但使运行时间不再系统性依赖原始输入顺序。对任意固定输入,随机化快速排序的期望运行时间为 Θ(nlog⁡n)\Theta(n\log n)。

6. 正确性

6.1 归纳命题

对任意闭区间 A[low…high]A[low\dots high],调用 QUICK-SORT(A, low, high) 后,该区间按非递减顺序排列。

6.2 基础情况

当 low≥highlow\ge high 时,子数组长度为 00 或 11,天然有序。

6.3 归纳步骤

假设所有长度小于 nn 的子数组均能被正确排序。对长度为 nn 的子数组:

  1. PARTITION 将枢轴放到最终位置 qq。
  2. 左子数组 A[low…q−1]A[low\dots q-1] 的元素均不大于 A[q]A[q]。
  3. 右子数组 A[q+1…high]A[q+1\dots high] 的元素均大于 A[q]A[q]。
  4. 两个子数组长度都小于 nn,根据归纳假设,递归调用可分别将它们排序。

因此:

A[low…q−1]≤A[q]<A[q+1…high]A[low\dots q-1]\le A[q]<A[q+1\dots high]

整个区间有序。

7. 时间复杂度

设当前子数组长度为 nn。一次 PARTITION 需要扫描 n−1n-1 个非枢轴元素,因此:

P(n)=Θ(n)P(n)=\Theta(n)

若一次分区产生长度分别为 kk 和 n−k−1n-k-1 的两个子数组,则递归式为:

T(n)=T(k)+T(n−k−1)+Θ(n)T(n)=T(k)+T(n-k-1)+\Theta(n)

7.1 最好情况

每次枢轴都接近中位数,划分基本均衡:

T(n)=2T(n2)+Θ(n)T(n)=2T\left(\frac{n}{2}\right)+\Theta(n)

根据主定理:

T(n)=Θ(nlog⁡n)T(n)=\Theta(n\log n)

7.2 平均情况与随机化期望情况

虽然单次划分不一定对半,但只要划分在整体上不过度失衡,递归树高度为 Θ(log⁡n)\Theta(\log n),每层分区工作的总和为 Θ(n)\Theta(n):

E[T(n)]=Θ(nlog⁡n)\mathbb{E}[T(n)]=\Theta(n\log n)

对于元素互异的随机化快速排序,更精确地,比较次数的期望为:

E[Cn]=2(n+1)Hn−4n=2nln⁡n+O(n)\mathbb{E}[C_n] =2(n+1)H_n-4n =2n\ln n+O(n)

其中 HnH_n 是第 nn 个调和数。

7.3 最坏情况

若每次枢轴都是当前子数组的最小值或最大值,则划分为 00 和 n−1n-1:

T(n)=T(n−1)+Θ(n)T(n)=T(n-1)+\Theta(n)

展开得:

T(n)=Θ(n+(n−1)+⋯+1)=Θ(n2)T(n)=\Theta(n+(n-1)+\cdots+1)=\Theta(n^2)

典型触发条件:

  • 输入已经有序或逆序;
  • 始终固定选择首元素或末元素为枢轴;
  • 大量重复元素与不合适的二路分区组合使用。

7.4 汇总

情况划分特征时间复杂度
最好情况每次近似均分Θ(nlog⁡n)\Theta(n\log n)
平均情况整体划分较均衡Θ(nlog⁡n)\Theta(n\log n)
随机化期望枢轴均匀随机选择Θ(nlog⁡n)\Theta(n\log n)
最坏情况每次产生 0:(n−1)0:(n-1) 划分Θ(n2)\Theta(n^2)

8. 空间复杂度

8.1 分区辅助空间

Lomuto 分区仅使用常数个变量:

Θ(1)\Theta(1)

因此快速排序通常被称为原地排序(in-place sorting)。

8.2 递归栈空间

真正的额外空间取决于递归深度:

情况递归深度栈空间
最好情况Θ(log⁡n)\Theta(\log n)Θ(log⁡n)\Theta(\log n)
随机化期望Θ(log⁡n)\Theta(\log n)Θ(log⁡n)\Theta(\log n)
最坏情况Θ(n)\Theta(n)Θ(n)\Theta(n)

“原地”不等于总空间恒为

O(1)O(1) 原地通常指元素划分不需要与输入同规模的辅助数组。递归实现仍需计算调用栈空间,因此标准快速排序的总辅助空间不能一概写成 O(1)O(1)。

8.3 栈空间优化

每次只递归处理较短的一侧,再用循环处理较长的一侧,可以把最坏递归栈深度限制为:

O(log⁡n)O(\log n)
QUICK-SORT-BOUNDED-STACK(A, low, high)
    while low < high
        q <- PARTITION(A, low, high)

        if q - low < high - q
            QUICK-SORT-BOUNDED-STACK(A, low, q - 1)
            low <- q + 1
        else
            QUICK-SORT-BOUNDED-STACK(A, q + 1, high)
            high <- q - 1

9. 问题需要具有的性质

快速排序适用于满足以下条件的问题。

9.1 元素具有可比较关系

必须能通过比较器判断两个元素的相对次序。通常要求比较关系形成全序或至少全预序,例如满足:

  • 自反性;
  • 传递性;
  • 任意两个元素可比较;
  • 比较结果在算法执行期间保持一致。

9.2 可围绕枢轴进行划分

元素必须能够根据与枢轴的比较结果,被划分到“不大于枢轴”和“大于枢轴”的区域。

9.3 子问题相互独立

分区后,左、右子数组占据互不重叠的区间,可以独立递归排序。

9.4 合并操作必须简单

左右子数组排序完成后,不需要像归并排序那样再执行线性合并;枢轴的最终位置保证三部分可以直接连接。

9.5 原地实现需要可修改且可随机访问的序列

原地快速排序需要频繁交换元素,因此数组或动态数组最合适。链表虽可实现类似思想,但难以发挥快速排序的原地交换和缓存局部性优势。

9.6 不要求稳定性

标准快速排序会交换远距离元素,因此通常不稳定。若任务要求保持相等键元素的原始相对顺序,应优先考虑稳定排序,或额外记录原始下标作为次关键字。

10. 典型例子

对数组进行升序排序:

A=[9,4,8,3,1,2,5]A=[9,4,8,3,1,2,5]

选择末尾元素 55 为枢轴。

10.1 第一次分区

扫描元素操作后的数组ii
初始[9, 4, 8, 3, 1, 2, 5]−1-1
9>59>5[9, 4, 8, 3, 1, 2, 5]−1-1
4≤54\le5[4, 9, 8, 3, 1, 2, 5]00
8>58>5[4, 9, 8, 3, 1, 2, 5]00
3≤53\le5[4, 3, 8, 9, 1, 2, 5]11
1≤51\le5[4, 3, 1, 9, 8, 2, 5]22
2≤52\le5[4, 3, 1, 2, 8, 9, 5]33
放置枢轴[4, 3, 1, 2, 5, 9, 8]q=4q=4

第一次分区后:

[4,3,1,2]5[9,8][4,3,1,2]\quad 5\quad [9,8]

其中 55 已在最终位置。

10.2 递归处理

左侧:

[4,3,1,2]→[1,2,4,3]→[1,2,3,4][4,3,1,2]\rightarrow[1,2,4,3]\rightarrow[1,2,3,4]

右侧:

[9,8]→[8,9][9,8]\rightarrow[8,9]

最终结果:

[1,2,3,4,5,8,9][1,2,3,4,5,8,9]

11. Python 实现

11.1 随机化原地快速排序

from __future__ import annotations

import random
from collections.abc import MutableSequence
from typing import TypeVar

T = TypeVar("T")


def partition(a: MutableSequence[T], low: int, high: int) -> int:
    """使用 Lomuto 方法划分 a[low:high + 1],返回枢轴最终下标。"""
    pivot = a[high]
    i = low - 1

    for j in range(low, high):
        if a[j] <= pivot:
            i += 1
            a[i], a[j] = a[j], a[i]

    pivot_index = i + 1
    a[pivot_index], a[high] = a[high], a[pivot_index]
    return pivot_index


def randomized_partition(
    a: MutableSequence[T],
    low: int,
    high: int,
    rng: random.Random,
) -> int:
    """随机选择枢轴,再调用普通分区。"""
    pivot_index = rng.randint(low, high)
    a[pivot_index], a[high] = a[high], a[pivot_index]
    return partition(a, low, high)


def quick_sort(a: MutableSequence[T], seed: int = 42) -> None:
    """原地随机化快速排序;相同 seed 可复现实验。"""
    rng = random.Random(seed)

    def sort(low: int, high: int) -> None:
        while low < high:
            q = randomized_partition(a, low, high, rng)

            # 递归较短一侧,循环处理较长一侧,限制调用栈深度。
            if q - low < high - q:
                sort(low, q - 1)
                low = q + 1
            else:
                sort(q + 1, high)
                high = q - 1

    sort(0, len(a) - 1)


if __name__ == "__main__":
    data = [9, 4, 8, 3, 1, 2, 5]
    expected = sorted(data)

    quick_sort(data, seed=42)

    assert data == expected
    print(data)

预期输出:

[1, 2, 3, 4, 5, 8, 9]

11.2 验证方法

对随机输入验证结果与 Python 内置排序一致:

import random

rng = random.Random(42)

for n in range(100):
    data = [rng.randint(-20, 20) for _ in range(n)]
    expected = sorted(data)

    quick_sort(data, seed=42)
    assert data == expected

print("all tests passed")

12. 大量重复元素:三路快速排序

当数组包含大量与枢轴相等的元素时,二路分区可能反复处理这些重复值。可使用 3-way partition,一次划分为:

A[low…lt−1]<pivotA[low\dots lt-1] < pivot A[lt…gt]=pivotA[lt\dots gt] = pivot A[gt+1…high]>pivotA[gt+1\dots high] > pivot

随后只递归处理“小于区”和“大于区”。若所有元素都相等,三路快速排序可在线性时间内完成,而普通二路实现可能退化到平方时间。

13. 常见错误

错误 1:递归区间包含枢轴

分区后 qq 已经位于最终位置,正确递归区间是 [low, q - 1] 和 [q + 1, high]。若再次包含 qq,可能导致无限递归。

错误 2:混用闭区间和半开区间

本文伪代码使用闭区间 [low, high]。若 Python 实现改用半开区间 [low, high),循环边界、枢轴位置和初始调用都必须同步修改。

错误 3:只写数组辅助空间为

O(1)O(1) 完整空间分析必须同时给出递归栈:期望 O(log⁡n)O(\log n),最坏 O(n)O(n);采用“递归较短侧”优化后可保证栈空间 O(log⁡n)O(\log n)。

错误 4:把随机化理解为平均输入假设

随机化快速排序不要求输入本身随机。随机性来自算法内部的枢轴选择。

14. 与其他排序算法对比

算法最好时间平均时间最坏时间额外空间稳定性
Quick SortΘ(nlog⁡n)\Theta(n\log n)Θ(nlog⁡n)\Theta(n\log n)Θ(n2)\Theta(n^2)期望 Θ(log⁡n)\Theta(\log n) 栈否
Merge Sort 归并排序Θ(nlog⁡n)\Theta(n\log n)Θ(nlog⁡n)\Theta(n\log n)Θ(nlog⁡n)\Theta(n\log n)数组实现通常 Θ(n)\Theta(n)是
Heap Sort 堆排序Θ(nlog⁡n)\Theta(n\log n)Θ(nlog⁡n)\Theta(n\log n)Θ(nlog⁡n)\Theta(n\log n)Θ(1)\Theta(1)否
Insertion Sort 插入排序Θ(n)\Theta(n)Θ(n2)\Theta(n^2)Θ(n2)\Theta(n^2)Θ(1)\Theta(1)是

实践建议

  • 一般数组排序:使用随机枢轴或高质量枢轴选择策略。
  • 小子数组:常切换为插入排序以降低常数开销。
  • 重复值很多:使用三路分区。
  • 必须保证最坏 O(nlog⁡n)O(n\log n):考虑归并排序、堆排序或 introsort。
  • 必须稳定:不要直接使用标准原地快速排序。

15. 复习要点

  • 能解释快速排序的 Divide、Conquer、Combine。
  • 能手算一次 Lomuto 分区过程。
  • 能写出 PARTITION 和 QUICK-SORT 的 0-based 伪代码。
  • 能用循环不变式证明分区正确。
  • 能分别推导最好、期望和最坏时间复杂度。
  • 能区分数组辅助空间与递归栈空间。
  • 能解释随机化为什么降低输入顺序造成的退化风险。
  • 能说明大量重复元素时为何应使用三路分区。

相关笔记

参考资料

  1. Cormen, Thomas H.; Leiserson, Charles E.; Rivest, Ronald L.; Stein, Clifford. Introduction to Algorithms, 3rd ed., Chapter 7: Quicksort. MIT Press, 2009.
  2. Demaine, Erik D.; Leiserson, Charles E. “Lecture 4: Quicksort, Randomized Algorithms.” MIT 6.046J, Fall 2005. MIT OpenCourseWare 课程页面.
  3. Devadas, Srinivas. “Lecture 6: Randomization: Matrix Multiply, Quicksort.” MIT 6.046J, Spring 2015. PDF.
  4. kepano. “Obsidian Flavored Markdown Skill.” obsidian-skills.
文章大纲
  1. Quick Sort 快速排序
  2. 1. 问题定义
  3. 2. 基本思想
  4. 3. 具体过程
  5. 3.1 分区过程
  6. 3.2 递归过程
  7. 4. 分区循环不变式
  8. 4.1 初始化
  9. 4.2 保持
  10. 4.3 终止
  11. 5. 伪代码
  12. 5.1 Lomuto 分区
  13. 5.2 快速排序
  14. 5.3 随机化快速排序
  15. 6. 正确性
  16. 6.1 归纳命题
  17. 6.2 基础情况
  18. 6.3 归纳步骤
  19. 7. 时间复杂度
  20. 7.1 最好情况
  21. 7.2 平均情况与随机化期望情况
  22. 7.3 最坏情况
  23. 7.4 汇总
  24. 8. 空间复杂度
  25. 8.1 分区辅助空间
  26. 8.2 递归栈空间
  27. 8.3 栈空间优化
  28. 9. 问题需要具有的性质
  29. 9.1 元素具有可比较关系
  30. 9.2 可围绕枢轴进行划分
  31. 9.3 子问题相互独立
  32. 9.4 合并操作必须简单
  33. 9.5 原地实现需要可修改且可随机访问的序列
  34. 9.6 不要求稳定性
  35. 10. 典型例子
  36. 10.1 第一次分区
  37. 10.2 递归处理
  38. 11. Python 实现
  39. 11.1 随机化原地快速排序
  40. 11.2 验证方法
  41. 12. 大量重复元素:三路快速排序
  42. 13. 常见错误
  43. 14. 与其他排序算法对比
  44. 15. 复习要点
  45. 相关笔记
  46. 参考资料