vlambda博客
学习文章列表

【算法与数据结构】快速排序算法

排序可以说是算法领域中基础并有趣的课题了,有关排序的算法有很多,每一种都有它的优缺点。比如像是选择排序和插入排序,虽然运行需要耗费 的时间,但代码写起来却很容易,而且如果输入数据很小的话运行还是很快的。更快的排序算法可以达到 时间复杂度,但可能更难写,并且处理小量输入可能更慢。本篇将会分析快速排序算法和能够做的改善。

本文将涵盖:

  • 快速排序算法分析
  • 快排的分区方法
  • 改善快排的最坏时间复杂度

快速排序算法

快排应该算是最广为人知的使用分治思想的排序算法了,简单来说

  • 选择数组中一部分元素当作 pivot (分割数组的点)
  • 将数组分为三份,所有小于 pivot 的元素放左边,等于 pivot 的元素在中间,大于 pivot 的元素在右边
  • 递归的快排数组左边和右边直到区间缩小至 1

中心思想非常简单,但要写出正确并且有效率的快排需要注意两点

  • 分区算法的实现 (怎么有效率的移动元素)?
  • 如何选择 pivot?

分区问题

分区问题是快排最重要的点,分区算法的选择决定了快排是否是原地算法,是否稳定,还会影响最坏时间复杂度。

普通分区法

最容易想到也是最简单的分区方法就是直接将数组分成三份。

function NAIVE_PARTITION(array[lo..hi], pivot)
   left = empty array         // 存放小于 pivot 的元素
   pivots = empty array       // 存放等于 pivot 的元素
   right = empty array        // 存放大于 pivot 的元素
   for i = lo to hi do
      if array[i] < pivot then left.append(array[i])
      else if array[i] = pivot then pivots.append(array[i])
      else right.append(array[i])
   array[lo..hi] = left + pivots + right             // 合并三个分区
   return length(left) + ceiling(length(pivots) / 2) // 返回最中间 pivot 的位置

这种分区法的时间复杂度为 ,看起来好像还不错。但是,当元素等于 pivot 时也会被放在 pivot 左边,所以它不是稳定算法。而且,因为它需要使用额外数组储存全部 个元素,占用 空间,所以它也不是原地算法。一般来说使用额外的数组意味着算法在实际工作中会相对较慢。

霍尔分区法 (Hoare Partition Algorithm)

霍尔分区法是当时设计快排时的原始分区法。这种方法需要先将 pivot 移到数组最前面,之后对于剩下的子数组维护两个从子数组起点和终点开始的两个指针,用来交换数组内元素,直到两个指针相交。

function HOARE_PARTITION(array[lo..hi], array[p])           // array[p] 为 pivot 元素
   swap(array[lo], array[p])                                // 将 pivot 移至数组首位
   i = lo, j = hi
   while i <= j do
        while i <= j and array[i] <= p do i = i + 1         // 找到左边部分大于 pivot 的元素
        while i <= j and array[j] > array[lo] do j = j - 1  // 找到右边小于 pivot 的元素
        if i <= j then swap(array[i], array[j])             // 交换元素至正确的分区
   swap(array[lo], array[j])                                // 将 pivot 移动至正确的位置
   return j                                                 // 返回 pivot 的位置

相比普通分区法,霍尔分区法更加高效,而且也是原地算法了,但它不是稳定算法。如果范围内有很多元素都等于 pivot,那么它们都将被划分到 pivot 的左侧,这也会使算法变得非常不平衡。要使分区算法稳定,一种方法可以是给它加一个预处理步骤,扫描分区并找出等于 pivot 的元素的间距,在后续快排的递归中忽略它们。

荷兰国旗划分法 (Dutch National Flag Partition Algorithm)

另一种分区方法则是荷兰国旗划分法 (为了方便后面叫它 DNF)。DNF也会将数组分为三份 ,但只有小于和大于的子数组需要用来递归。我们可以将这个方法简化为另一个问题

给定一个含有 个元素的数组 ,含有三种颜色,红白蓝,将颜色相同的元素按照红白蓝的顺序排在一起。

这里红白蓝就代表了划分的三个区域,解决这个问题我们需要用到三个指针, ,使下面这组不变量成立

  • 包含红色元素
  • 包含白色
  • 包含目前未知元素
  • 包含蓝色

初始我们让 ,因为初始时整个数组都处在未知状态。之后我们在更新三个指针的同时迭代的将元素从未知区域移动到它们相应的区域,每一次迭代我们都移动 (未知区域第一个元素)。这涉及到三种情况:

Case 1:array[mid] 是红的

如果 是红色,我们就交换 ,这就意味着红色区域新增了一个元素,所以我们需要增加 指针。而原本的 要么是白色的,这种情况下它就已经被移动到白色区域的末尾,所以我们增加 指针;或者白色区域本来是空的,也就是说 与自己交换了位置,那我们还是增加 指针。两种情况下不变量都能得到保持,并且未知区域也缩小了一个元素。

Case 2:array[mid] 是白的

如果 是白色,那么它就已将在正确的位置上了 (就在白色部分的末尾),这时我们只需要增加 指针就好。这样也会将未知区域缩小一个元素。

Case 3:array[mid] 是蓝的

如果 是蓝色,那我们就得将它移动到最后一个区域,交换 交换。因为蓝色区域增加了,所以我们需要减少 指针。这种情况下我们不移动 指针,因为 这时包含着之前未知部分的最后一个元素。

伪代码如下

function DUTCH_PARTITION(array[lo..hi], pivot)
    lo = 1, mid = 1, hi = n
    while mid <= hi do
        if array[mid] < pivot then        // Case 1
            swap(array[mid], array[lo])
            lo += 1, mid += 1
        else if array[mid] = pivot then   // Case 2
            mid += 1      
        else                              // Case 3
            swap(array[mid], array[hi])
            hi -= 1
    return lo, mid

注意这里返回值为 指针的最终位置,这样我们就能很快的分辨出分区之后三个区域的位置。和霍尔分区法一样,要使它变得稳定则需要更复杂的逻辑。

快排的实现

知道了如何分区,实现快排就容易多了。假设我们现在选择 中的第一个元素为 pivot,伪代码如下

function QUICKSORT(array[lo..hi])
    if hi > lo then
        pivot = array[lo]
        mid = PARTITION(array[lo..hi], pivot)
        QUICKSORT(array[lo..mid-1])
        QUICKSORT(array[mid+1..hi])

快排的时间与空间复杂度

快排的运行时间完全由每次选择的 pivot 的质量决定。理想情况下分区的结果每次都很平衡 (每部分包含的元素数基本相等),这样就能减少递归深度。

最好情况

理想情况下,pivot 为数组的中位数。这时递归深度只有 因为数组的大小每次都会减半,每一层分区总共消耗 的时间,所以最好情况下快排总的运行时间为

最坏情况

最坏情况下 pivot 每次都是数组中的最小或最大值。如果我们选到了最小值作为 pivot,那么待解决的子问题的大小将会是 。虽然大小为 的问题不需要任何工作,但是我们只会将剩余的子问题的大小减少 ,这就会导致 层的递归,而每一层依旧需要 时间,所以最坏情况下快排总共需要

平均情况

平均情况下我们每次选择的 pivot 不会是完美的中位数或最小最大值,也就是说平均情况下分区 “还可以”,而每一次都选到最坏的 pivot 的概率还是很小的。这里的复杂度分析我会用一个不那么正式的方法证明,因为正式的数学证明还是比较复杂的。

对于任意一个含有 个元素的数组,理想情况下我们想选择一个能将数组平均划分的 pivot。我们可以将数组分为三份,定义一个 “好 pivot” 在数组的中间 50%。

假设我们运气比较好每一次都能选到好 pivot,那最坏的事就是选到了好区间的边界。这种情况下我们将会产生两个递归数组分支,大小为 ,最长的递归树分支将会由后续调用组成。 层递归时的元素数量应为 ,所以树深将为 。可以看出这棵树有着对数高度,在每一层递归我们都要做 的工作来分区,所以总共将会消耗

我们不能期待每次都选到好 pivot,但因为我们定义好 pivot 占数组的 50%,所以还是有一般的概率会选到好 pivot。那么平均来看我们期望每第二次 pivot 的选择将会是好的,换句话说我们期望翻两次硬币能得到正面。这就意味着即使其他每一次都选到不会减少太多子问题大小的坏 pivot,我们也期望在 层递归后仍能达到基础情况。也就是说平均情况下快排一个随机的数组预计的工作量仅是最好情况的两倍,仍是

空间复杂度

最坏情况下因为线性的递归调用,会使用 的空间。但这可以稍微优化一下,与其递归地排序右边的数组然后再排左边的数组,我们可以每次都先排序长度最小的那半边,然后再排序最长的那半边。这样做我们在程序栈上就只需要 层的递归,因为较长的那半部分是通过尾部递归调用 (tail-recursive call) 排序的,这不会增加额外的程序栈。如果你使用的语言不支持尾部调用,那你可以将第二个递归换成循环,一样的。下面的复杂度总结是基于这个优化的。


最好 平均 最坏
时间 O(nlog(n))
O(nlog(n)) O(n²)
辅助空间 O(log(n))
O(log(n)) O(log(n))

改善快排的最坏时间复杂度

k 阶统计量 (K-th Order Statistics) 和选择问题

最坏情况会发生的根本原因就是我们每次都随机选择 pivot,这样看来如果我们能找到一种每次都能在 最坏时间内选到中位数作为 pivot 的方法,那么最坏时间复杂度就能降到 了。解决这个问题前我们需要先了解 k 阶统计量问题。

给定一个大小为 乱序数组,找出第 最小值,

比如当 就是找出最小值, 就是找出最大值,要找到中位数就意味着 。最简单的解法应该就是先将数组排序,然后直接按照下标找到第 个元素。而使用比较类的排序算法,时间复杂度的最低限是 (不难证明,这里就先不写了)。这就导致选择找出目标值最快也需要 ,我们需要一个更快的算法。

快速选择算法 ( Quickselect Algorithm)

虽然找最大或最小值很简单只需要线性时间就能完成,但其实更通用的k阶统计量问题也能在最坏情况下在线性时间内完成。对于这个问题最著名的算法就是快速选择算法了。

通过名字就能猜到,快选算法是基于与快排算法的。我们能发现如果使用快排来找 k 阶统计量,那么在每一层递归中,完全不需要对数组中不包含答案的那一半进行排序,可以直接跳过它们。和快排的情况一样,pivot 的选择仍是算法的关键。和快排用一样的分区算法,一个简单的快选算法的伪代码实现可以如下。

function QUICKSELECT(array[lo..hi], k)
    if hi > lo then
        pivot = array[lo]
        mid = PARTITION(array[lo..hi], pivot)
        if k < mid then
            return QUICKSELECT(array[lo..mid-1], k)
        else if k > mid then
            return QUICKSELECT(array[mid+1..hi], k)
        else
            return array[k]
    else
        return array[k]

这个算法最坏情况也需要 时间,因为我们可能选到最大会最小值作为 pivot,因此会导致 层的递归。下面我会介绍两种优化方法将复杂度降至

  • 随机 pivot 选择 (Randomised Pivot Selection)

    它的期望运行时间为 ,这也是根据经验来看最快的策略

  • 中位数的中位数 (Median of Medians)

    它能保证在最坏情况下也是线性的性能,但在实际应用中却不是那么有效率

随机 pivot 选择 (Randomised Pivot Selection)

就像它名字一样,随机选一个元素当作 pivot,听起来好像并不会将复杂度降至 。但它避免了 “病态” 的选择 pivot,是一个最简单,经验上最快的解决办法。随机选择可以给出一个期望的线性复杂度,但站在理论角度来讲,它还是充满不确定性的,所以还有提升空间。

证明方法和快排分区的平均情况的正式证明比较像,有点复杂这里就不写了,感兴趣的自己可以去查。

中位数的中位数 (Median of Medians)

尽管随机选择方法实践中绝不会减慢查找,但有一个理论上可以确定的,最坏情况也是线性的算法还是不错的。我们知道如果快选使用中位数作为 pivot,那它就能保证线性运行实践,但准确的找到中位数正是我们先前想要解决的问题。而中位数的中位数 (太绕口,下面就写作 MoM 了) 技术涉及到找到一个足够好的近似中位数来保证线性时间性能。

用 MoM 选择一个近似的中位数需要以下几步:

  • 将原始 个元素的数组分割为 组,每组最多 5 个元素
  • 找出每组的中位数 (因为最多 5 个元素,先排序再找中位数不影响性能)
  • 递归的使用快速选择找出 个中位数中真正的中位数

照着这个思想,伪代码的实现如下,这里找小列表的中位数时使用了插入排序。

function MEDIAN_OF_MEDIANS(array[lo..hi])
    if hi - lo < 5 then
        return MEDIAN_OF_FIVE(array[lo..hi])
    else
        medians = empty array
        for i = lo to hi, step 5, do
            j = min(i + 4, hi)
            median = MEDIAN_OF_FIVE(array[i..j])
            medians.append(median)
        n = length(medians)
        return QUICKSELECT(medians[1..n], floor((n + 1) / 2))

function MEDIAN_OF_FIVE(array[lo..hi])
    INSERTION_SORT(array[lo..hi])
    return array[(lo + hi) / 2]

可以发现,我们在 MoM 算法中递归调用 QUICKSELECT,而 QUICKSELECT 又必须使用 MEDIAN_OF_MEDIANS 来选择 pivot。我们将这种情况称为互递归 (Mutually Recursive),因为它们并没有在自身递归,而是在对方身上互相递归。

要证明 MoM 可以选到一个够好的 pivot 以产生最小的递归深度,并且这样做不会对性能能产生影响不算难。

首先我们能发现在 组中,有一半组的元素的中位数一定比 pivot 小,一半比 pivot 大,所以我们各有 组比 pivot 大或小。因为每一组最多只有 5 个元素,有两个元素大于中位数,两个小于中位数,那么在这 组中的每一组中至少有 3 个元素比 pivot 小,同样另一半有 3 个元素比 pivot 大,于是总共有 的元素小于或大于 pivot。

因此,中位数的中位数在 30% 到 70% 的数据之中,这样来看最坏情况我们用快选得到的分区大小也是 ,也就是说最坏情况下我们只需要额外调用 递归就能算出原数组 的中位数。加上执行分区步骤和寻找每 5 个数的中位数的步骤所需要的线性数操作,使用快选找中位数的工作量的递归关系可以表示为

其中 为常数。我们可以证明这个递归关系解为 为归纳法的常数。所以最坏情况下时间复杂度为

改善快排

经过上面的证明,使用快速选择算法就能提升快排性能。使用快选来找到中位数作为 pivot 就能得到理论上最小可能的递归深度,就能保证最坏情况 的性能。但从开发难度和经验来看,实际应用时随机选择还是好一些。

总结

  • 快速排序和它的分析。快排在最坏情况下可以做到 ,但这主要还是理论上的兴趣,实践中通常不会用到。
  • 最好使用简单的方法选择 pivot,这样简单还省时 (比如随机选择)