数据结构 | 第12章 快速排序

135 阅读4分钟

第12章 排序

此前各章已结合具体的数据结构,循序渐进地介绍过多种基本的排序算法:2.8节和3.5节分别针对向量和列表,统一以排序器的形式实现过起泡排序、归并排序、插入排序以及选择排序等算法;9.4.1节也曾按照散列的思路与手法,实现过桶排序算法;10.2.5节也曾完美地利用完全二叉堆的特长,实现过就地堆排序。

本章侧重于高级排序算法。与以上基本算法一样,其构思与技巧各具特色,在不同应用中的效率也各有千秋。。因此在学习过程中,唯有更多地关注不同算法之间细微且本质的差异,留意体会其优势与不足,方能做到运用自如,并结合实际问题的需要,合理取舍与并适当改造。

12.1 快速排序

12.1.1 分治策略

与归并算法一样,快速排序(quicksort)算法也是分治策略的典型应用,但二者之间有本质的区别:

  • 归并排序的计算量主要消耗于有序子向量的归并操作,而子向量的划分却几乎不费时间。
  • 快速排序恰好相反,它可以在O(1)时间内,由子问题的解直接得到原问题的解;但为了将原问题划分为两个子问题,却需要O(n)时间。

快速排序算法虽然能够确保,划分出来的子任务彼此独立,并且其规模总和保持渐进不变,却不能保证两个子任务的规模大体相当——实际上,甚至有可能极不平衡。因此,该算法并不能保证最坏情况下的O(nlogn)时间复杂度。尽管如此,它仍然受到人们的青睐,并在实际中往往成为首选的排序算法。究其原因在于:快速排序算法易于实现,代码结构紧凑简练,而且对于按通常规律随机分布的输入序列,快速排序算法实际的平均运行时间较之同类算法更少。

12.1.2 轴点

image.png

考查任一向量区间S[lo, hi)。对于任何lo < mi < hi,以元素S[mi]为界,都可分割出前后两个子向量S[lo, mi)和S(mi, hi)

若前一子向量元素均不大于S[mi],后一子向量元素均不小于S[mi],则元素S[mi]称作向量S的一个轴点(pivot)。

设向量S经排序可转化为有序向量S'。不难看出,轴点位置mi必然满足如下充要条件:

  1. S[mi] = S'[mi]
  2. S[lo, mi)和S'[lo, mi)的成员完全相同
  3. S(mi, hi)和S'(mi, hi)的成员完全相同

因此,不仅以轴点S[mi]为界,前后子向量的排序可各自独立地进行;一旦前后子向量各自完成排序,即可立即(在O(1)时间内)得到整个向量的排序结果。

采用分治策略,递归地利用轴点的以上特性,便可完成原向量的整体排序。

12.1.3 快速排序算法

quickSort_recursive

 0001 template <typename T> //向量快速排序
 0002 void Vector<T>::quickSort ( Rank lo, Rank hi ) { //0 <= lo < hi <= size
 0003    if ( hi - lo < 2 ) return; //单元素区间自然有序,否则...
 0004    Rank mi = partition ( lo, hi ); //在[lo, hi)内构造轴点
 0005    quickSort ( lo, mi ); //对前缀递归排序
 0006    quickSort ( mi + 1, hi ); //对后缀递归排序
 0007 }

算法的核心与关键在于:轴点构造算法partition()应如何实现?可以达到多高的效率?

12.1.4 快速划分算法

  • 反例

    首先遇到的困难就是:并非每个向量都必然有轴点。

    image.png

    一般地,只要向量中所有元素都是错位的——错排序列——则任何元素都不可能是轴点。(因为轴点在S和S'中的秩应当相同)

    若保持原向量的次序不变,则不能保证总是能够找到轴点。唯有通过适当地调整向量中各元素的位置,方可“人为地”构造出一个轴点。

  • 思路

    首先需要任取某一元素m作为“培养对象”,不妨取首元素m = S[lo]作为候选,将其从向量中取出并做备份,腾出的空闲单元便于其它元素的位置调整。

    image.png

    接下来,不断试图移动lo和hi,使之相互靠拢。整个移动过程中需保证lo左侧的元素均不大于m,hi右侧的元素均不小于m。

    最后如图(c)所示,当lo和hi彼此重合时,只需将原备份的m回填至这一位置,则S[lo = hi] = m便成为一个名副其实的轴点。

    以上过程在构造出轴点的同时,也按照相对于轴点的大小关系,将原向量划分为左右两个子向量,故亦称作快速划分算法(quick partitioning)。

  • 实现

     0001 template <typename T> //轴点构造算法:通过调整元素位置构造区间[lo, hi)的轴点,并返回其秩
     0002 Rank Vector<T>::partition ( Rank lo, Rank hi ) { //DUP版:可优化处理多个关键码雷同的退化情况
     0003    swap ( _elem[lo], _elem[ lo + rand() % ( hi - lo ) ] ); //任选一个元素与首元素交换
     0004    hi--; T pivot = _elem[lo]; //以首元素为候选轴点——经以上交换,等效于随机选取
     0005    while ( lo < hi ) { //从向量的两端交替地向中间扫描
     0006       while ( lo < hi )
     0007          if ( pivot < _elem[hi] ) //在大于pivot的前提下
     0008             hi--; //向左拓展右端子向量
     0009          else //直至遇到不大于pivot者
     0010             { _elem[lo++] = _elem[hi]; break; } //将其归入左端子向量
     0011       while ( lo < hi )
     0012          if ( _elem[lo] < pivot ) //在小于pivot的前提下
     0013             lo++; //向右拓展左端子向量
     0014          else //直至遇到不小于pivot者
     0015             { _elem[hi--] = _elem[lo]; break; } //将其归入右端子向量
     0016    } //assert: lo == hi
     0017    _elem[lo] = pivot; //将备份的轴点记录置于前、后子向量之间
     0018    return lo; //返回轴点的秩
     0019 }
    
  • 过程

    算法的主体框架为循环迭代;主循环的内部,通过两轮迭代交替地移动lo和hi。 image.png 要点:

    1. hi无法移动时,将_ elem[hi]转移至_elem[lo],并归入左侧子向量。
    2. lo无法移动时,将_ elem[lo]转移至_elem[hi],并归入右侧子向量。
    3. 算法迟早终止。
  • 实例

    快速划分算法的一次完整运行过程如图12.5所示

    image.png

    由于lo和hi的移动方向相反,故原处于向量右(左)端较小(大)的元素将按颠倒的次序转移至左(右)端;特别地,重复的元素也将按颠倒的次序转移至相对的一端,因而不再保持其原有的相对次序由此可见,如此实现的快速排序算法并不稳定。 从该例中也能看出这一点。

12.1.5 复杂度

  • 最坏情况

    对partition()算法进行分析可发现,分治策略得以高效实现的两个必要条件——子问题划分的高效性及其相互之间的独立性——均可保证。然而,另一项关键的必要条件——子任务规模接近——在这里却无法保证。

    最坏情况下,T(n) = O(n2),效率低到与起泡排序相近。

  • 降低最坏情况概率

    1. 随机法:利用swap(),在区间内任选一个元素与_elem[lo]交换,效果等同于随机选择一个候选轴点。
    2. 三者取中法:从待排序向量中任取三个元素,将数值居中者作为候选轴点。如此选出的轴点在最终有序向量中秩过小或过大的概率更低——尽管还不能彻底杜绝最坏情况。
  • 平均运行时间

    以上关于最坏情况下效率仅为O(n2)的结论不免令人沮丧,难道快排名不副实?

    实际上,在大多数情况下,快速排序算法的平均效率依然可以达到O(nlogn);且较之其他排序算法,其时间复杂度中的常系数更小。

    计算可得,平均运行时间T(n) = O(1.386nlogn)

    正因为其良好的平均性能,加上其形象直观和易于实现特点,快速排序算法自诞生起就一直受到人们的青睐,并被集成到Linux和STL等环境中。

12.1.6 应对退化

  • 重复元素

    考查所有(或几乎所有)元素均重复的退化情况。对照代码12.2不难发现,partition()算法的版本A对此类输入的处理完全等效于此前所举的最坏情况。

    image.png

    如图12.6所示,如此划分的结果必然是以最左端元素为轴点,原向量被分为极不对称的两个子向量,且这一最坏情况还可能持续发生,从而使整个算法过程等效地退化为线性递归,递归深度为O(n),导致总体运行时间高达O(n2)

    当然,可以在每次深入递归之前做统一核验,若确属于退化情况,则无需继续递归而直接返回。但在重复元素不多时,如此不仅不能改进性能,反而会增加额外的计算量,总体权衡后得不偿失。

  • 改进

     0001 template <typename T> //轴点构造算法:通过调整元素位置构造区间[lo, hi)的轴点,并返回其秩
     0002 Rank Vector<T>::partition ( Rank lo, Rank hi ) { //DUP1版:与DUP版等价,类似于与LUG版等价的LUG1版
     0003    swap ( _elem[lo], _elem[ lo + rand() % ( hi - lo ) ] ); //任选一个元素与首元素交换
     0004    hi--; T pivot = _elem[lo]; //以首元素为候选轴点——经以上交换,等效于随机选取
     0005    while ( lo < hi ) { //从向量的两端交替地向中间扫描
     0006       while ( ( lo < hi ) && ( pivot < _elem[hi] ) ) //在大于pivot的前提下
     0007          hi--; //向左拓展右端子向量
     0008       if ( lo < hi ) _elem[lo++] = _elem[hi]; //不大于pivot者归入左端子向量
     0009       while ( ( lo < hi ) && ( _elem[lo] < pivot ) ) //在小于pivot的前提下
     0010          lo++; //向右拓展左端子向量
     0011       if ( lo < hi ) _elem[hi--] = _elem[lo]; //不小于pivot者归入右端子向量
     0012    } //assert: lo == hi
     0013    _elem[lo] = pivot; //将备份的轴点记录置于前、后子向量之间
     0014    return lo; //返回轴点的秩
     0015 }
    

    较之版本A,版本B主要是调整了两个内循环的终止条件。以前一内循环为例,原条件

    pivot <= _elem[hi]

    在此更改为:

    pivot < _elem[hi]

    即:一旦遇到重复元素,右端子向量随即终止拓展,并将右端重复元素转移至左端。因此,若将版本A的策略归纳为“勤于拓展、懒于交换”,版本B的策略则是“懒于拓展、勤于交换”。

  • 效果及性能

    对于由重复元素构成的输入向量,以上版本最终恰好将轴点置于正中央的位置。这就意味着,退化的输入向量能够始终被均衡的切分,如此反而转而最好情况,排序所需时间为O(nlogn)。

    当然,以上改进并非没有代价。比如,单趟partition()算法需做更多的元素交换操作。好在这并不影响该算法的线性复杂度。另外,版本B倾向于反复交换重复的元素,故它们在原输入向量中的相对次序更难保持,快速排序算法的稳定性不足更是雪上加霜。

    “开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 20 天,点击查看活动详情