cv.cm

编码 · 算法

快速排序详解:原理、代码与时间复杂度

阅读约 7 分钟

简短回答

快速排序是一种分治排序算法。它选一个元素作为基准,重新排列数组,使较小的元素位于基准之前、较大的位于其后(即分区),然后递归地对两侧排序。它的平均时间是 O(n log n),原地排序,栈空间为 O(log n),但基准持续选得不好时会退化为 O(n²)。它不稳定。使用随机基准或三数取中基准时,它是实践中最快的通用排序之一。

打开行排序

通俗地说,思路是什么

  1. 选一个基准元素,比如最后一个元素,或者随机的一个。
  2. 分区:把所有比基准小的元素移到它的左边,所有比它大的移到右边。此时基准就处在它最终的有序位置。
  3. 递归地对基准左边和右边的部分排序。之后不需要再合并任何东西。

归并排序是拆分简单、合并困难;快速排序正相反:由分区完成主要工作,而拼合两半则是零成本,因为它们本来就在同一个数组里的正确位置上。

示例:一次 Lomuto 分区

以最后一个元素 70 为基准,对 [10, 80, 30, 90, 40, 50, 70] 分区。下标 i 标记比基准小的元素区域的末尾;j 从左到右扫描。

jitems[j]比 70 小吗?操作之后的数组
010是i = 0,交换 0↔0[10, 80, 30, 90, 40, 50, 70]
180否无[10, 80, 30, 90, 40, 50, 70]
230是i = 1,交换 1↔2[10, 30, 80, 90, 40, 50, 70]
390否无[10, 30, 80, 90, 40, 50, 70]
440是i = 2,交换 2↔4[10, 30, 40, 90, 80, 50, 70]
550是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)。

全部编码教程