【大话数据结构】第九章-排序

九、排序

1.排序的基本概念与分类

假设含有n个记录的序列为{r1,r2,······.rn},其相应的关键字分别为{k1,k2,······,kn},需确定1,2,······,n的一种排序p1,p2,······,pn,使其相应的关键字满足kp1≤kp2≤······≤kpn(非递减或非递增)关系,即使得序列成为一个按关键字有序的序列{pp1,rp2,······rpn},这样的操作就称为排序。

在排序问题中,通常将数据元素称为记录。显然我们输入的是一个记录集合,输出的也是一个记录集合,所以说,可以将排序看成是线性表的一种操作。

排序的依据是关键字之间的大小关系,那么,对同一个记录集合,针对不同的关键字进行排序,可以得到不同序列。

1.1 排序的稳定性

正是由于排序不仅是针对主关键字,那么对于次关键字,因为待排序的记录序列中可能存在两个或两个以上的关键字相等的记录,排序结果可能会存在不唯一的情况,我们给出了稳定与不稳定排序的定义。

假设ki=kj(1≤i≤n, 1≤j≤n, i≠j),且在排序前的序列中ri领先于rj(即i<j)。如果排序后ri仍领先于rj,则称所用的排序方法是稳定的;反之,若可能使得排序后的序列中rj领先ri,则称所用的排序方法是不稳定的。

1.2 内排序与外排序

根据在排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序和外排序。

内排序是在排序整个过程中,待排序的所有记录全部放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。我们这里主要就介绍内排序多种方法。

对于内排序来说,排序算法的性能主要是受3个方面影响:

  1. 时间性能

    排序是数据处理中经常执行的一种操作,往往属于系统的核心部分,因此排序算法的时间开销是衡量其好坏的最重要的标志。在内排序中,主要进行两种操作:比较和移动。比较指关键字之间的比较,这是要做排序最起码的操作。移动指记录从一个位置移动到另一个位置,事实上,移动可以通过改变记录的存储方式来予以避免(这个我们在讲解具体的算法时再谈)。总之,高效率的内排序算法应该是尽可能少的关键字比较次数和尽可能少的记录移动次数。

  2. 辅助空间

    评价排序算法的另一个主要标准是执行算法所需要的辅助存储空间。辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需的其他存储空间。

  3. 算法的复杂性

    注意这里指的是算法本身的复杂度,而不是指算法的时间复杂度。显然算法过于复杂也会影响排序的性能。

根据排序过程中借助的主要操作,我们把内排序分为:插入排序、交换排序、选择排序和归并排序

本章一共要讲解七种排序算法,按照算法的复杂度分为两大类,冒泡排序、简单选择排序和直接插入排序属于简单算法,而希尔排序、堆排序、归并排序、快速排序属于改进算法,后面我们依次讲解。

1.3 排序用到的结构与函数

为了讲清楚排序算法的代码,先提供一个用于排序用顺序表结构,此结构也将用于之后我们要讲的所有排序算法。

#define MAXSIZE 10       // 用于要排序数组个数最大值,可根据需要修改
typedef struct {
    
    
    int r[MAXSIZE+1];    // 用于存储要排序数组,r[0]用作哨兵或临时变量
    int length;          // 用于记录顺序表的长度
}SqList;

另外,由于排序最最常用到的操作是数组两个元素的交换,我们将它写成函数,在之后的讲解中会大量的用到。

/* 交换L中数组r的下标为i和j的值 */
void swap(SqList *L, int i, int j) {
    
    
    int temp = L->r[i];
    L->r[i] = L->r[j];
    L->r[j] = temp;
}

2.冒泡排序

2.1 最简单排序实现

冒泡排序(Bubble Sort)一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,知道没有反序的记录为止。冒泡的实现在细节上可以有很多种变化,我们将分别就3种不同的冒泡实现代码,来讲解冒泡排序的思想。这里,我们就先来看看比较容易理解的一段。

/* 对顺序表L作交换排序(冒泡排序初级版)*/
void BubbleSort0(SqList *L) {
    
    
    int i,j;
    for (i=1; i<L->length; i++) {
    
    
        for (j=i+1; j<=L->length; j++) {
    
    
            if (L->r[i] > L->r[j]) {
    
    
                swap(L,i,j);
            }
        }
    }
}

这段代码严格意义上说,不算是标准的冒泡排序算法,因为它不满足“两两比较相邻记录”的冒泡排序思想,它更应该是最最简单的交换排序而已。

2.2 冒泡排序算法

下面我们来看看正宗的冒泡排序。

/* 对顺序表L作冒泡排序 */
void BubbleSort(SqList *L) {
    
    
    int i,j;
    for (i=1; i<L->length; i++) {
    
    
        for (j=L->length-1; j>=i; j--) {
    
      // 注意j是从后往前循环
            if (L->r[j] > L->r[j+1]) {
    
      // 若前者大于后者
                swap(L,j,j+1);
            }
        }
    }
}

2.3 冒泡排序优化

试想一下,如果我们待排序的序列是{2,1,3,4,5,6,7,8,9},也就是说,除了第一和第二的关键字需要交换外,别的都已经是正常顺序。当i=1时,交换了2和1,此时序列已经有序,但是算法仍然不依不饶地将i=2到9以及每个循环中的j循环都执行了一遍,尽管没有交换数据,但之后大量比较是多余的,如下图。

在这里插入图片描述

所以,我们改进如下:

/* 对顺序表L作改进冒泡算法 */
void BubbleSort2(SqList *L) {
    
    
    int i,j;
    Status flag = TRUE;     // flag用来作为标记
    for (i=1; i<L->length&&flag; i++) {
    
    
        flag = FALSE;
        for (j=L->length-1; j>=i; j--) {
    
    
            if (L->r[j] > L->r[j+1]) {
    
    
                swap(L,j,j+1);
                flag = TRUE;
            }
        }
    }
}

2.4 冒泡排序复杂度分析

当最好的情况,也就是要排序的表本身就是有序的,那么我们比较次数为n-1次,没有数据交换,时间复杂度为O(n)。当最坏的情况,即待排序表是逆序的情况,次数需要比较1+2+3+···+(n-1)=n(n-1)/2次,并作等量级的记录移动。因此,总的时间复杂度为O(n2)。

3.简单选择排序

3.1 简单选择排序算法

简单选择排序法(Simple Selection Sort)就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1≤i≤n)个记录交换

代码如下:

/* 对顺序表L作简单选择排序 */
void SelectSort(SqList *L) {
    
    
    int i,j,min;
    for (i=1; i<L->length; i++) {
    
    
        min = i;
        for (j=i+1; j<=L->length; j++) {
    
    
            if (L->r[min] > L->r[j])
                min = j;
        }
        if (i != min)
            swap(L,i,min);
    }
}

3.2 简单选择排序复杂度分析

从简单选择排序的过程来看,它最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。分析它的时间复杂度发现,无论最好最差的情况,其比较次数都是一样多的,第i趟排序需要进行n-i次关键字的比较,此时需要比较n-1+n-2+···+1=n(n-1)/2次。而对于交换次数而言,当最好的时候,交换为0次,最差的时候,也就初始降序时,交换次数为n-1次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度为O(n2)。

应该说,尽管与冒泡排序同为O(n2),但简单选择排序的性能上还是要略优于冒泡排序。

4.直接插入排序

4.1 直接插入排序算法

直接插入排序(Straight Insertion Sort)的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表

/* 对顺序表L作直接插入排序 */
void Insertion(SqList *L) {
    
    
    int i,j;
    for (i=2; i<=L->length; i++) {
    
    
        if (L->r[i] < L->r[i-1]) {
    
    
            L->r[0] = L->r[i];      // 设置哨兵
            for (j=i-1; L->r[j]>L->r[0]; j--)
                L->r[j+1] = L->r[j];
            L->r[j+1] = L->r[0];    // 插入到正确位置
        }
    }
}

4.2 直接插入排序复杂度分析

从空间上来看,它只需要一个记录的辅助空间,因此关键是看它的时间复杂度。

当最好的情况,也就是要排序的表本身就是有序的,那么我们比较的次数,其实就是代码第5行每个L.r[i]与L.r[i-1]的比较,共比较了n-1次,由于没有移动的记录,时间复杂度O(n)。

当最坏的情况,即待排序是逆序的情况,此时需要比较2+3+···+n=(n+2)(n-1)/2次,而记录的移动次数也达到最大值3+4+···+n+1=(n+4)(n-1)/2次。

如果排序记录是随机的,那么根据概率相同的原则,平均比较和移动次数约为n2/4次。因此,我们得出直接插入排序法的时间复杂度为O(n2)。从这里也看出,同样的O(n2)时间复杂度,直接插入排序法比冒泡和简单选择排序的性能要好一些。

5.希尔排序

5.1 希尔排序原理

前面一节讲了直接插入排序,它在记录数比较少或基本有序的情况下,效率是比较高的,但现实中记录少或者基本有序都属于特殊情况。

希尔排序是对直接插入排序的改进,它将原本有大量记录数的记录进行分组。分割成若干个子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序,当整个序列都基本有序时,注意只是基本有序时,再对全体记录进行一次直接插入排序。

这里需要强调一下,所谓的基本有序,就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间,所以这里我们还需要采取跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序

5.2 希尔排序算法

/* 对顺序表L作希尔排序 */
void ShellSort(SqList *L) {
    
    
    int i,j;
    int increment = L->length;
    do {
    
    
        increment = increment/3+1;    // 增量序列
        for (i=increment+1; i<=L->length; i++) {
    
    
            if (L->r[i] < L->r[i-increment]) {
    
    
                L->r[0] = L->r[i];
                for (j=i-increment; j>0&&L->r[0]<L->r[j]; j-=increment)
                    L->r[j+increment] = L->r[0];
            }
        }
    } while (increment > 1);
}

5.3 希尔排序复杂度分析

通过这段代码,我们可以看出,希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。

这里“增量”的选取就非常关键了。我们在代码中第6行,是用increment=increment/3+1的方式选取增量的,可究竟应该选取什么样的增量才是最好的,目前还是一个数学难题,迄今为止还没有人找到一种最好的增量序列。不过大量的研究表明,当增量序列为dlta[k]=2t-k+1-1(0≤k≤t≤[log2(n+1)])时,可以获得不错的效率,其时间复杂度为O(n3/2),要好于直接排序的O(n2)。需要注意的是,增量序列的最后一个增量值必须等于1才行。另外由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。

6.堆排序

前面我们讲了简单选择排序,它在待排序的n个记录中选择一个最小的记录需要比较n-1次,可惜这样的操作并没有把每一趟的比较结果保存下来,在后一趟的比较中,有许多比较在前一趟已经做过了,但由于前一趟排序时为保存这些比较结果,所以后一趟排序时又重复执行了这些比较操作,因而记录的比较次数较多。

如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会非常高了。而堆排序(Heap Sort),就是对简单选择排序进行的一种改进,这种改进的效果是非常明显的。堆排序算法是Floyd和Williams在1964年共同发明的,同时,他们发明了“堆”这样的数据结构。

在这里插入图片描述

观察上图,我们发现它们都是完全二叉树,左图中根结点是所有元素中最大的,右图的根结点是所有元素中最小的,而且左图每个结点都比它的左右孩子要大,右图每个结点都比它的左右孩子要小。这就是我们要讲的堆结构。

堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。

如果按照层序遍历的方式给结点从1开始编号,则结点之间满足如下关系:

在这里插入图片描述

如果将上图的大顶堆和小顶堆用层序遍历存入数组,则一定满足上面的关系表达,如下图。

在这里插入图片描述

我们现在将这个堆结构,其目的就是为了堆排序用的。

6.1 堆排序算法

==堆排序(Heap Sort)==就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次小值。如此反复执行,便能得到一个有序序列了

如下图,图①是一个大顶堆,90为最大值,将90与20(末尾元素)互换,如图②所示,此时90就成了整个堆序列的最后一个元素,将20经过调整,使得除90以外的结点继续满足大顶堆定义(所有结点都大于等于其孩子),见图③,然后再考虑将30与80互换······

在这里插入图片描述

在这里插入图片描述

大家应该有些明白堆排序的基本思想了,不过要实现它还需要解决两个问题:

  1. 如何有一个无序序列构建成一个堆
  2. 如何在输出堆顶元素后,调整剩余元素成为一个新的堆

要解释清楚它们,让我们来看代码:

/* 对顺序表L进行堆排序 */
void HeapSort(SqList *L) {
    
    
    int i;
    for (i=L->length/2; i>0; i--)   // 把L中的r构建成一个大顶堆
        HeapAdjust(L,i,L->length);
    for (i=L->length; i>1; i--) {
    
    
        swap(L,1,i);  // 将堆顶记录和当前未经排序子序列的最后一个记录交换
        HeapAdjust(L,1,i-1);  // 将L->r[1..i-1]重新调整为最大堆
    }
}

从代码中可以看出,整个排序过程分为两个for循环。第一个循环要完成的就是将现在的待排序序列构建成一个大顶堆。第二个循环要完成的就是逐步将每个最大值的根结点与末尾元素交换,并且再调整其成为大顶堆。

假设我们要排序的序列是{50,10,90,30,70,40,80,60,20},那么L.length=9,第一个for循环,代码第4行,i是从9/2=4开始,4->3->2->1的变量变化。为什么不是从1到9或者从9到1呢?看下图我们发现,它们都是有孩子的结点,注意灰色结点的下标编号就是1、2、3、4。

在这里插入图片描述

我们所谓的将待排序的序列构建成为一个大顶堆,其实就是从下往上、从右到左,将每个非终端结点(非叶子结点)当做根结点,将其和其子树调整成大顶堆。i的4->3->2->1的变量变化,其实也就是30、90、10、50的结点调整过程。

现在我们来看关键的HeapAdjust(堆调整)函数是如何实现的。

/* 已知L->r[s..m]中记录的关键字除L->r[s]之外均满足堆的定义 */
/* 本函数调整L->r[s]的关键字,使L->r[s..m]成为一个大顶堆 */
void HeapAdjust(SqList *L,int s,int m) {
    
    
    int temp,j;
    temp = L->r[s];
    for (j=2*s; j<=m; j*=2) {
    
      // 沿关键字较大的孩子结点向下筛选
        if (j<m && L->r[j]<L->r[j+1])
            j++;  // j为关键字中较大的记录下标
        if (temp >= L->r[j])
            break;
        L->r[s] = L->r[j];
        s = j;
    }
    L->r[s] = temp;
}

6.2 堆排序复杂度分析

堆排序的运行时间主要是消耗在初始构建堆和重建堆时的反复筛选上。

在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)。

在正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间(完全二叉树的某个结点到根结点的距离为[log2i]+1),并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。

所以总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。

空间复杂度上,它只有一个用来交换的暂存单元,也是非常不错的。不过由于记录的比较与交换是跳跃式进行的,因此堆排序也是一种不稳定的排序方法。

另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。

7.归并排序

先来看下图,相信从下图中你已经大概能猜到归并排序的意思了。

在这里插入图片描述

7.1 归并排序算法

“归并”一词的中文含义就是合并、并入的意思,而在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。

归并排序(Merging Sort)就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2]个长度为2或1的有序子序列;再两两归并,······,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。

代码如下:

/* 对顺序表L作归并排序 */
void MergeSort(SqList *L) {
    
    
    MSort(L->r,L->r,1,L->length);
}

由于我们要讲解的归并排序实现需要用到递归调用,因此我们外封装了一个函数。假设现在要对数组{50,10,90,30,70,40,80,60,20}进行排序,L.length=9,下面来看MSort的实现。

/* 将SR[s..t]归并排序为TR1[s..t] */
void MSort(int SR[],int TR1[],int s,int t) {
    
    
    int m;
    int TR2[MAXSIZE+1];
    if (s==t)
        TR1[s]=SR[s];
    else {
    
    
        m = (s+t)/2;  // 将SR[s..t]平分为SR[s..m]和SR[m+1..t]
        MSort(SR,TR2,s,m);  // 递归将SR[s..m]归并为有序的TR2[s..m]
        MSort(SR,TR2,m+1,t);  // 递归将SR[m+1..t]归并为有序的TR2[m+1..t]
        Merge(TR2,TR1,s,m,t);  // 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]
    }
}

整体流程图如下:

在这里插入图片描述

现在我们来看看Merge函数的代码实现。

void Merge(int SR[],int TR[],int i,int m,int n) {
    
    
    int j,k,l;
    for (j=m+1,k=i; i<=m&&j<=n; k++) {
    
      // 将SR中记录有小到大归并入TR
        if (SR[i] < SR[j])
            TR[k] = SR[i++];
        else
            TR[k] = SR[j++];
    }
    if (i<=m) {
    
    
        for (l=0; l<=m-i; l++)
            TR[k+1] = SR[i+1];  // 将剩余的SR[i..m]复制到TR
    }
    if (j<=n) {
    
    
        for (l=0; l<=n-j; l++)
            TR[k+1] = SR[j+1];  // 将剩余的SR[j..n]复制到TR
    }
}

7.2 归并排序复杂度分析

一趟归并需要将SR[1]~SR[n]中相邻的长度为h的有序序列进行两两归并,并将结果放到TR1[1]~TR1[n]中,这需要将待排序序列中的所有记录扫描一遍,因此耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行[log2n]次,因此,总的时间复杂度为O(nlogn),而且这是归并排序算法中最好、最坏、平均的时间性能。

由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果及递归时深度为log2n的栈空间,因此空间复杂度为O(n+logn)

另外,Merge函数中有if(SR[i]<SR[j])语句,这就说明它需要两两比较,不存在跳跃,因此归并排序是一种稳定的排序算法。

总的来说,归并排序是一种比较占用内存,但效率高且稳定的算法。

7.3 非递归实现归并排序

归并排序大量引用了递归,尽管在代码上比较清晰,容易理解,但这会造成时间和空间上的性能损耗。我们排序追求的就是效率,这里还可以对之前的算法进行改动,使性能进一步提高,代码如下。

/* 对顺序表L作归并非递归排序 */
void MergeSort2(SqList *L) {
    
    
    int* TR = (int*)malloc(L->length*sizeof(int)); // 申请额外空间
    int k = 1;
    while (k<L->length) {
    
    
        MergePass(L->r,TR,k,L->length);
        k = 2*k;
        MergePass(TR,L->r,k,L->length);
        k = 2*k;
    }
}

从代码中我们可以看出,非递归的迭代做法更加直截了当,从最小的序列开始归并直至完成。下面我们来看MergePass代码。

/* 将SR[]中相邻长度为s的子序列两两归并到TR[] */
void MergePass(int SR[],int TR[],int s,int n) {
    
    
    int i = 1;
    int j;
    while (i <= n-2*s+1) {
    
    
        Merge(SR,TR,i,i+s-1,i+2*s-1);  // 两两归并
        i = i+2*s;
    }
    if (i < n-s+1)  // 归并最后两个序列
        Merge(SR,TR,i,i+s-1,n);
    else
        for (j=i; j<=n; j++)
            TR[j] = SR[j];
}

非递归的迭代方法,避免了递归时深度为log2n的栈空间,空间只是用到申请归并临时用的TR数组,因此空间复杂度为O(n),并且也在时间性能上有一定的提升,应该说,使用归并排序时,尽量考虑非递归方法。

8.快速排序

希尔排序相当于直接插入排序的升级,他们同属于插入排序类,堆排序相当于简单选择排序的升级,它们同属于选择排序类。而快速排序其实就是我们前面任务最慢的冒泡排序的升级,它们同属于交换排序类。即它也是通过不断比较和移动交换来实现排序的,只不过它的实现,增大了记录的比较和移动的距离,将关键字较大的记录从前面直接移动到后面,关键字较小的记录从后面直接移动到前面,从而减少了总的比较次数和移动交换次数。

8.1 快速排序算法

快速排序(Quick Sort)的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

代码如下:

/* 对顺序表L作快速排序 */
void QuickSort(SqList *L) {
    
    
    QSort(L,1,L->length);
}
/* 对顺序表L中的子序列L->r[low..high]作快速排序 */
void QSort(SqList *L,int low,int high) {
    
    
    int pivot;
    if (low < high) {
    
    
        pivot = Partition(L,low,high);  // 将L->r[low..high]一分为二,算出枢轴值pivot
        QSort(L,low,pivot-1);  // 对低子表递归排序
        QSort(L,pivot+1,high);  // 对高子表递归排序
    }
}
/* 交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置 */
/* 此时在它之前(后)的记录均不大(小)于它。 */
int Partition(SqList *L,int low,int high) {
    
    
    int pivotkey;
    pivotkey = L->r[low];  // 用子表的第一个记录作枢轴记录
    while (low < high) {
    
    
        while (low<high && L->r[high]>=pivotkey)
            high--;
        swap(L,low,high);  // 将比枢轴记录小的记录交换到低端
        while (low<high && L->r[low]<=pivotkey)
            low++;
        swap(L,low,high);  // 将比枢轴记录大的记录交换到高端
    }
    return low;  // 返回枢轴所在位置
}

8.2 快速排序复杂度分析

快速排序的时间性能取决于快速排序递归的深度,可以用递归树来描述递归算法的执行情况。如下图,它是{50,10,90,30,70,40,80,60,20}在快速排序过程中的递归过程。由于我们的第一个关键字是50,正好是待排序的序列中间值,因此递归树是平衡的,此时性能也比较好。

在这里插入图片描述

在最优情况下,Partition每次都划分得很均匀,如果排序n个关键字,其递归树的深度就为[log2n]+1,即仅需递归log2n次,需要时间为T(n)的话,第一次Partition应该是需要对整个数组扫描一遍,做n次比较。然后,获得的枢轴将数组一分为二,那么各自还需要T(n/2)的时间(注意是最好情况,所以是平方两半)。于是不断地划分下去,我们就有了下面的不等式推断。

在这里插入图片描述

也就是说,在最优的情况下,快速排序算法的时间复杂度为O(nlogn)。

在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行n-1次递归调用,且第i次划分需要经过n-i次关键字的比较才能找到第i个记录,也就是枢轴的位置,因此比较次数为n-1+n-2+···+1=n(n-1)/2,最终其时间复杂度为O(m2)。

平均的情况,设枢轴的关键字应该在第k的位置(1≤k≤n),那么

在这里插入图片描述

由数学归纳法可证明,其数量级为O(nlogn)。

就空间复杂度来说,主要是递归造成的栈空间的使用,最好情况,递归树的深度为log2n,其空间复杂度也就为O(logn),最坏情况,需要进行n-1递归调用其空间复杂度为O(n),平均情况,空间复杂度也为O(logn)。

另外,由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。

8.3 快速排序优化

8.3.1 优化选取枢轴

之前我们用pivotkey=L->r[low]来取第一个枢轴,如果这个关键字太大或太小都会影响性能,所以总是固定选取第一个关键字作为首个枢轴就变成了极为不合理的做法。

我们可以通过三数取中法,即取三个关键字先进行排序,将中间树作为枢轴,一般是取左端、右端和中间三个数,也可以随机选取。这样至少这个中间数一定不会是最小或者最大的数,从概率来说,去三个数均为最小会最大树的可能性是微乎其微的,因此中间数位于较为中间的值的可能性就大大提高了。

在之前第4行和第5代码行之间增加这样一段代码:

int pivotkey;   // 之前第4行
int m = low + (high-low)/2;  // 计算数组中间的元素的下标
if (L->r[low] > L->r[high])
    swap(L,low,high);
if (L->r[m] > L->r[high])
    swap(L,high,m);
if (L->r[m] > L->r[low])
    swap(L,m,low);

三数取中对小数组来说有很大的概率选择到一个比较好的pivotkey,但是对于非常大的待排序的序列来说还是不足以保证能够选择出一个好的pivotkey,因此还有个办法是所谓的九树取中,它先从数组中分三次取样,每次取三个数,三个样品各取出中数,然后从这三个中数中再取出一个中数作为枢轴。

8.3.2 优化不必要的交换

我们之前举的例子,仔细研究会发现,50这个关键字,其位置变化是1->9->3->6->5,可其实它的最终目标就是5,当中的交换其实是不需要的。因此我们对Partition函数的代码再进行优化。

/* 快速排序优化算法 */
int Partition1(SqList *L,int low,int high) {
    
    
    int pivotkey;
    // 这里省略三数取中代码
    pivotkey = L->r[low];
    L->r[0] = pivotkey;  // 将枢轴关键字备份到L->r[0]
    while (low < high) {
    
    
        while (low<high && L->r[high]>=pivotkey)
            high--;
        L->r[low] = L->r[high];  // 采用替换而不是交换的方式进行操作
        while (low<high && L->r[low]<=pivotkey)
            low++;
        L->r[high] = L->r[low];  // 采用替换而不是交换的方式进行操作
    }
    L->r[low] = L->r[0];  // 将枢轴数值替换回L.r[low]
    return low;  // 返回枢轴所在位置
}

8.3.3 优化小数组时的排序方案

刚刚一直在讨论对于非常大的数组的解决办法,那么对于数组非常小的情况,其实快速排序反而不如直接插入排序来得更好。其原因在于快速排序用到了递归操作,在大量数据排序时,这点性能影响相对于它的整体算法优势而言是可以忽略的,但如果数组只有几个记录需要排序时,这就成了大炮打蚊子的问题了。因此我们需要改进一下QSort函数。

#define MAX_LENGTH_INSERT_SORT 7  // 数组长度阀值
void QSort(SqList *L,int low,int high) {
    
    
    int pivot;
    if (high-low > MAX_LENGTH_INSERT_SORT) {
    
      // 当high-low大于常数时用快速排序
        pivot = Patition(L,low,high);
        QSort(L,low,pivot-1);
        SQort(L,pivot+1,high);
    } else {
    
      // 当high-low小于等于常数时用直接插入排序
        InsertSort(L);
    }
}

8.3.4 优化递归操作

大家知道,递归对性能是有一定影响的,QSort函数在其尾部有两次递归操作。如果待排序的序列划分极端不平衡,递归深度将趋近于n,而不是平衡时的log2n,这就不仅仅是速度快慢的问题了。栈的大小是有限的,每次递归调用都会消耗一定的栈空间,函数的参数越多,每次递归耗费的空间也越多。因此如果能够减少递归,将会大大提高性能。

于是我们对QSort实施尾递归优化,代码如下:

void QSort1(SqList *L,int low,int high) {
    
    
    int pivot;
    if (high-low > MAX_LENGTH_INSERT_SORT) {
    
    
        while (low < high) {
    
    
            pivot = Partition1(L,low,high);
            QSort1(L,low,pivot-1);
            low = pivot+1;  // 尾递归
        }
    } else 
        InsertSort(L);
}

当我们将if改成while后,因为第一次递归以后,遍历low就没有用处了,所以可以将pivot+1赋值给low,再循环后,来一次Partition(L,low,high),其效果等同于“QSort(L,pivot+1,high)”。结果相同,但因采用迭代而不是递归的方法可以缩减堆栈深度,从而提高了整体性能。

猜你喜欢

转载自blog.csdn.net/m0_50833438/article/details/114600522