大话数据结构 -- 排序

基本概念

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

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

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

这里关键字ki可以是记录r的主关键字,也可以是次关键字,甚至是若干数据项的组合。

对于组合排序的问题,当然可以先排序总分,若总分相等的情况下,再排序语数外总分,但这是比较土的办法。我们还可以应用一个技巧来实现一次排序即完成组合排序问题。例如,把总分与语数外都当成字符串首尾连接在一起(注意如果语数外总分不够三位,需要在前面补0)。

从此也可看出,多个关键字的排序最终都可以转化为单个关键字的排序。因此,我们这里主要讨论的是单个关键字的排序。

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

假设ki=kj(1≤i≤n,1≤j≤n,i≠j),且在排序前的序列中ri领先于rj(即i<j)。如果排序后ri仍领先于rj,则称所用的排序方法是稳定的;反之,若可能使得排序后的序列中rj领先ri,则称所用的排序方法是不稳定的。如图9-2-2所示,经过对总分的降序排序后,总分高的排在前列。此时对于令狐冲和张无忌而言,未排序时是令狐冲在前,那么它们总分排序后,分数相等的令狐冲依然应该在前,这样才算是稳定的排序,如果他们二者颠倒了,则此排序是不稳定的了。只要有一组关键字实例发生类似情况,就可认为此排序方法是不稳定的。排序算法是否稳定的,要通过分析后才能得出。

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

内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。

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

1、时间性能。在内排序中,主要进行两种操作:比较和移动。比较指关键字之间的比较,这是要做排序最起码的操作。移动指记录从一个位置移动到另一个位置。事实上,移动可以通过改变记录的存储方式来予以避免。总之,高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数

2、辅助空间。即执行算法所需要的辅助存储空间,它是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间

3、算法的复杂性。这里指的是算法本身的复杂度,而不是指算法的时间复杂度。

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

按照算法的复杂度又可分为两大类:冒泡排序、简单选择排序和直接插入排序属于简单算法;而希尔排序、堆排序、归并排序、快速排序属于改进算法

 

冒泡排序

冒泡排序是一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止

先看一段比较容易理解的代码:

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

这段代码严格意义上说不算是标准的冒泡排序算法,因为它不满足“两两比较相邻记录”的冒泡排序思想,它更应该是最最简单的交换排序而已。它的思路就是让每一个关键字都和它后面的每一个关键字比较,如果大则交换,这样第一位置的关键字在一次循环后一定变成最小值。

这个简单易懂的代码是有缺陷的。观察后发现,在排序好1和2的位置后,对其余关键字的排序没有什么帮助(数字3反而还被换到了最后一位)。也就是说,这个算法的效率是非常低的

来看看正宗的冒泡算法,并考虑它能否优化?

// 对顺序表L作冒泡排序
void BubbleSort(SqList *L){
    int i,j;
    for(i=1;i<L->length;i++){
        for(j=L->length;j>=i;j--){
            // 若前者大于后者(注意这里与上一算法差异)
            if(L->r[j]>L->r[j+1]){
                // 交换L->r[j]与L->r[j+1]的值
                swap(L,j,j+1);
            }
        }
    }
}

依然假设我们待排序的关键字序列是{9,1,5,8,3,7,4,6,2},当i=1时,变量j由8反向循环到1,逐个比较,将较小值交换到前面,直到最后找到最小值放置在了第1的位置。如图9-3-3所示,当i=1、j=8时,我们发现6>2,因此交换了它们的位置,j=7时,4>2,所以交换……直到j=2时,因为1<2,所以不交换。j=1时,9>1,交换,最终得到最小值1放置第一的位置。事实上,在不断循环的过程中,除了将关键字1放到第一的位置,我们还将关键字2从第九位置提到了第三的位置,显然这一算法比前面的要有进步,在上十万条数据的排序过程中,这种差异会体现出来。图中较小的数字如同气泡般慢慢浮到上面,因此就将此算法命名为冒泡算法。

当i=2时,变量j由8反向循环到2,逐个比较,在将关键字2交换到第二位置的同时,也将关键字4和3有所提升。

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

当i=2时,我们已经对9与8,8与7,……,3与2作了比较,没有任何数据交换,这就说明此序列已经有序,不需要再继续后面的循环判断工作了。为了实现这个想法,我们需要改进一下代码,增加一个标记变量flag来实现这一算法的改进。

// 对顺序表L作改进冒泡算法
void BubbleSort2(SqList *L){
    int i,j;
    // flag用来作为标记
    Status flag=TRUE;
    // 若flag为TRUE说明有过数据交换,否则停止循环
    for(i=1;i<L->length && flag ;i++){
        // 初始为flase
        flag=FALSE;
        for(j=L->length;j>=i;j--){
            if(L->r[j]>L->r[j+1]){
                // 交换L->r[j]与r[j+1]的值
                swap(L,j,j+1);
                // 如果有数据交换,则flag为true
                flag=TRUE;
            }
        }
    }
}

代码改动的关键就是在i变量的for循环中,增加了对flag是否为true的判断。经过这样的改进,冒泡排序在性能上就有了一些提升,可以避免因已经有序的情况下的无意义循环判断

复杂度分析

当最好的情况,也就是要排序的表本身就是有序的,那么我们比较次数,根据最后改进的代码,可以推断出就是n-1次的比较,没有数据交换,时间复杂度为O(n)。当最坏的情况,即待排序表是逆序的情况,此时需要比较sigma(i=2, n, i-1)=1+2+3+...+(n-1)=n(n-1)/2次,并作等数量级的记录移动。因此,总的时间复杂度为O(n^2)

 

简单选择排序

听名字就知道,这是一种选择排序。选择排序的基本思想是每一趟在n-i+1(i=1,2,...,n-1)个记录中选取关键字最小的记录作为有序序列的第i个记录

简单选择排序法(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
            min=j;
        }
        // 若min不等于i,说明找到最小值,交换
        if(i!=min){
            // 交换L->r[i]与L->r[min]的值
            swap(L,i,min);
        }
    }
}

代码应该说不难理解,针对待排序的关键字序列是{9,1,5,8,3,7,4,6,2},对i从1循环到8。当i=1时,L.r[i]=9,min开始是1,然后与j=2到9比较L.r[min]与L.r[j]的大小,因为j=2时最小,所以min=2。最终交换了L.r[2]与L.r[1]的值。如图9-4-1所示,注意,这里比较了8次,却只交换数据操作一次。

复杂度分析

分析它的时间复杂度发现,无论最好最差的情况,其比较次数都是一样的多,第i趟排序需要进行n-i次关键字的比较,此时需要比较sigma(i=1, n-1, n-i)=(n-1)+(n-2)+...+1=n(n-1)/2次。而对于交换次数而言,当最好的时候,交换为0次,最差的时候,也就初始降序时,交换次数为n-1次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度依然为O(n^2)

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

 

直接插入排序

听名字就吉道,这是一种插入排序

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

来看代码:

// 对顺序表L作直接插入排序
void InsertSort(SqList *L){
    int i,j;
    for(i=2;i<L->length;i++){
        // 需将L->r[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];
        }
    }
}

程序开始运行,此时我们传入的SqList参数的值为length=6,r[6]={0,5,3,4,6,2},其中r[0]=0将用于后面起到哨兵的作用。

i从2开始的意思是我们假设r[1]=5已经放好位置,后面的牌其实就是插入到它的左侧还是右侧的问题

第6行,此时i=2,L.r[i]=3比L.r[i-1]=5要小,因此执行第8~11行的操作。第8行,我们将L.r[0]赋值为L.r[i]=3的目的是为了起到第9~10行的循环终止的判断依据。如图9-5-2所示。图中下方的虚线箭头,就是第10行,L.r[j+1]=L.r[j]的过程,将5右移一位。

此时,第10行就是在移动完成后,空出了空位,然后第11行L.r[j+1]=L.r[0],将哨兵的3赋值给j=0时的L.r[j+1],也就是说,将扑克牌3放置到L.r[1]的位置,如图9-5-3所示。

继续循环,第6行,因为此时i=3,L.r[i]=4比L.r[i-1]=5要小,因此执行第8~11行的操作,将5再右移一位,将4放置到当前5所在位置,如图9-5-4所示。

再次循环,此时i=4。因为L.r[i]=6比L.r[i-1]=5要大,于是第8~11行代码不执行,此时前三张牌的位置没有变化,如图9-5-5所示。

再次循环,此时i=5,因为L.r[i]=2比L.r[i-1]=6要小,因此执行第8~11行的操作。由于6、5、4、3都比2小,它们都将右移一位,将2放置到当前3所在位置。如图9-5-6所示。此时我们的排序也就完成了。

复杂度分析

从空间上看,它只需要一个记录的辅助空间(哨兵)。

对于时间复杂度,当最好的情况,也就是要排序的表本身就是有序的,

比如纸牌拿到后就是{2,3,4,5,6},那么我们比较次数,其实就是代码第6行每个L.r[i]与L.r[i-1]的比较,共比较了(n-1)sigma(i=2, n, 1)次,由于每次都是L.r[i]>L.r[i-1],因此没有移动的记录,时间复杂度为O(n)。

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

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

 

前面我们提到的三种不同的排序算法,时间复杂度都是O(n^2),似乎没法超越了,一时,计算机学术界充斥着“排序算法不可能突破O(n^2)”的声音。

直到新的算法出现打破常规。

 

希尔排序

我们之前讲的直接插入排序,应该说它的效率在某些时候是很高的。比如,我们的记录本身就是基本有序的,我们只需要少量的插入操作,就可以完成整个记录集的排序工作,此时直接插入很高效。还有就是记录数比较少时,直接插入的优势也比较明显。

可问题在于,两个条件本身就过于苛刻,现实中记录少或者基本有序属于特殊情况。

于是科学家希尔研究出了一种排序方法,对直接插入排序改进后可以增加效率,这就是希尔排序算法

如何让待排序的记录个数较少呢?很容易想到的就是将原本有大量记录树的记录进行分组。分割成若干个子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序。当整个序列都基本有序时(注意只是基本有序),再对全体记录进行一次直接插入排序。

问题在于,我们分隔待排序记录的目的是减少待排序记录的个数,并使整个序列向基本有序发展,但是如果只是简单的顺序分组后就各自排序,很有可能大的依旧在前面,小的依旧在后面,只能在每个组里做到局部有序,做不到整体的“基本有序”。

因此,我们需要采取跳跃分割的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序

代码如下:

// 对顺序表L作希尔排序
void ShellSort(SqList *L){
    int i,j;
    int increment=L->length;
    do{
        // 增量序列
        increment=increment/3+1;
        for(i=icrement+1;i<=L->length;i++){
            if(L->r[i]<L->r[i-increment]){
                // 需将L->r[i]插入有序增量子表
                // 暂存在L->r[0]
                L->r[0]=L->r[i];
                for(j=i-increment;L->r[j]>L->r[0] &&j>0;j-=increment)
                    // 记录后移,查找插入位置
                    L->r[[j+increment]=L->r[j];
                // 插入
                L->r[j+increment]=L->r[0];
            }
        }
    }while(increment>1)
}

会发现其实就是把原来直接插入排序的增量1改为increment,再加上一层循环改变increment就行了。(增量为1时停止循环)

复杂度分析

希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高

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

 

堆排序

我们前面讲到简单选择排序,它在待排序的n个记录中选择一个最小的记录需要比较n-1次。本来这也可以理解,查找第一个数据需要比较这么多次是正常的,否则如何知道它是最小的记录。

可惜的是,这样的操作并没有把每一趟的比较结果保存下来,在后一趟的比较中,有很多在前一趟已经做过了,但由于前一趟排序时未保存这些比较结果,所以后一趟排序时又重复执行了这些比较操作,因而记录的比较次数较多

如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会非常高了。而堆排序(HeapSort),就是对简单选择排序的一种改进。

“堆”结构相当于把数字符号堆成一个塔型的结构。

很明显,我们可以发现它们都是二叉树,如果观察仔细些,还能看出它们都是完全二叉树。左图中根结点是所有元素中最大的,右图的根结点是所有元素中最小的。再细看看,发现左图每个结点都比它的左右孩子要大,右图每个结点都比它的左右孩子要小。这就是我们要讲的堆结构。

是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆(例如图9-7-2左图所示);或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆(例如图9-7-2右图所示)。

这里需要注意从堆的定义可知,根结点一定是堆中所有结点最大(小)者。较大(小)的结点靠近根结点(但也不绝对,比如右图小顶堆中60、40均小于70,但它们并没有70靠近根结点)。

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

这里要结合之前“树”的讲解中二叉树的性质5,如果遗忘了可去https://blog.csdn.net/qq_36770641/article/details/82086142查看。

可以说,这个性质就是在为堆准备的。

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

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

来看代码:

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

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

我们所谓的将待排序的序列构建成为一个大顶堆,其实就是从下往上、从右到左将每个非终端结点(非叶结点)当作根结点,将其和其子树调整成大顶堆。

现在来看关键的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;
}

复杂度分析

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

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

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

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

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

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

 

归并排序

前面我们讲了堆排序,因为它用到了完全二叉树,充分利用了完全二叉树的深度是|log2n|+1的特性,所以效率比较高。不过堆结构的设计本身是比较复杂的,老实说,能想出这样的结构就挺不容易,有没有更直接简单的办法利用完全二叉树来排序呢?当然有。

为了更清晰地说清楚这里的思想,大家来看图9-8-1所示,我们将本是无序的数组序列{16,7,13,10,9,15,3,2,5,8,12,1,11,4,6,14},通过两两合并排序后再合并,最终获得了一个有序的数组。注意仔细观察它的形状,你会发现,它像极了一棵倒置的完全二叉树通常涉及到完全二叉树结构的排序算法,效率一般都不低的——这就是我们要讲的归并排序法。

 

“归并”在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。

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

来看代码:

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

一句代码,别奇怪,它只是调用了另一个函数而已。为了与前面的排序算法统一,我们用了同样的参数定义SqList *L,由于我们要讲解的归并排序实现需要用到递归调用,因此我们外封装了一个函数。假设现在要对数组{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{
        // 将SR[s..t]平分为SR[s..m]和SR[m+1..t]
        m=(s+t)/2;
        // 递归将SR[s..m]归并为有序的TR2[s..m]
        Msort(SR,TR2,s,m);
        // 递归将SR[m+1..t]归并为有序TR2[m+1..t]
        Msort(SR,TR2,m+1,t);
        // 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]
        Merge(TR2,TR1,s,m,t);
    }
}

 

接下来再看Merge函数的代码是如何实现的:

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

1.假设我们此时调用的Merge就是将{10,30,50,70,90}与{20,40,60,80}归并为最终有序的序列,因此数组SR为{10,30,50,70,90,20,40,60,80},i=1,m=5,n=9。

2.第4行,for循环,j由m+1=6开始到9,i由1开始到5,k由1开始每次加1,k值用于目标数组TR的下标。

3.第6行,SR[i]=SR[1]=10,SR[j]=SR[6]=20,SR[i]<SR[j],执行第7行,TR[k]=TR[1]=10,并且i++。如图9-8-7所示。

4.再次循环,k++得到k=2,SR[i]=SR[2]=30,SR[j]=SR[6]=20,SR[i]>SR[j],执行第9行,TR[k]=TR[2]=20,并且j++,如图9-8-8所示。

 

5.再次循环,k++得到k=3,SR[i]=SR[2]=30,SR[j]=SR[7]=40,SR[i]<SR[j],执行第7行,TR[k]=TR[3]=30,并且i++,如图9-8-9所示。

6.接下来完全相同的操作,一直到j++后,j=10,大于9退出循环,如图9-8-10所示。

7.第11~20行的代码,其实就将归并剩下的数组数据,移动到TR的后面。当前k=9,i=m=5,执行第13~20行代码,for循环l=0,TR[k+l]=SR[i+l]=90,大功告成。

复杂度分析

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

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

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

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

非递归实现归并排序

递归尽管在代码上比较清晰、容易理解,但这会造成时间和空间上的性能损耗。我们排序追求的就是效率,有没有可能将递归转化成迭代呢?当然是可以的,而且改动之后,性能上进一步提高了,来看代码:

// 对顺序表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;
    }
}    

1.程序开始执行,数组L为{50,10,90,30,70,40,80,60,20},L.length=9。

2.第3行,我们事先申请了额外的数组内存空间,用来存放归并结果。

3.第5~11行,是一个while循环,目的是不断地归并有序序列。注意k值的变化,第8行与第10行,在不断循环中,它将由1→2→4→8→16,跳出循环。

4.第7行,此时k=1,MergePass函数将原来的无序数组两两归并入TR(此函数代码稍后再讲),如图9-8-11所示。

5.第8行,k=2。

6.第9行,MergePass函数将TR中已经两两归并的有序序列再次归并回数组L.r中,如图9-8-12所示。

7.第10行,k=4,因为k<9,所以继续循环,再次归并,最终执行完第7~10行,k=16,结束循环,完成排序工作,如图9-8-13所示。

从代码中可以发现,非递归的迭代做法更加直截了当,从最小的序列开始归并直至完成。不需要像归并的递归算法一样,需要先拆分递归,再归并退出递归。

现在来看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];
}

 1.程序执行。我们第一次调用“MergePass(L.r,TR,k,L.length);”,此时L.r是初始无序状态,TR为新申请的空数组,k=1,L.length=9。

2.第5~9行,循环的目的就两两归并,因s=1,n-2×s+1=8,为什么循环i从1到8,而不是9呢?就是因为两两归并,最终9条记录定会剩下来,无法归并。

3.第7行,Merge函数我们前面已经详细讲过,此时i=1,i+s-1=1,i+2×s-1=2。也就是说,我们将SR(即L.r)中的第一个和第二个记录归并到TR中,然后第8行,i=i+2×s=3,再循环,我们就是将第三个和第四个记录归并到TR中,一直到第七和第八个记录完成归并,如图9-8-14所示。

4.第10~14行,主要是处理最后的尾数,第11行是说将最后剩下的多个记录归并到TR中。不过由于i=9,n-s+1=9,因此执行第13~14行,将20放入到TR数组的最后,如下图所示。

5.再次调用MergePass时,s=2,第5~9行的循环,由第8行的i=i+2×s可知,此时i就是以4为增量进行循环了,也就是说,是将两个有两个记录的有序序列进行归并为四个记录的有序序列。最终再将最后剩下的第九条记录“20”插入TR,如图9-8-16所示。

6.后面的类似,略。

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

 

快速排序

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

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

从字面上感觉不出它的好处来。假设现在要对数组{50,10,90,30,70,40,80,60,20}进行排序。我们通过代码的讲解来学习快速排序的精妙。

// 对顺序表L作快速排序
void QuickSort(SqList *L){
    Qsort(L,1,L->length);
}

又是一句代码,和归并排序一样,由于需要递归调用,因此我们外封装了一个函数。现在我们来看QSort的实现。

// 对顺序表L中的子序列L->r[low..high]作快速排序
void Qsort(SqList *L, int low, int high){
    int pivot;
    if(low<high){
        // 将L->r[low..high]一分为二
        // 算出枢轴值pivot
        pivot=Partition(L,low,high);
        // 对低子表递归排序
        QSort(L,low,pivot-1);
        // 对高子表递归排序
        Qsort(L,pivot+1,high);
    }
}

这一段代码的核心是“pivot=Parti-tion(L,low,high);”在执行它之前,L.r的数组值为{50,10,90,30,70,40,80,60,20}。Partition函数要做的,就是先选取当中的一个关键字,比如选择第一个关键字50,然后想尽办法将它放到一个位置,使得它左边的值都比它小,右边的值比它大,我们将这样的关键字称为枢轴(pivot)

在经过Partition(L,1,9)的执行之后,数组变成{20,10,40,30,50,70,80,60,90},并返回值5给pivot,数字5表明50放置在数组下标为5的位置。此时,计算机把原来的数组变成了两个位于50左和右小数组{20,10,40,30}和{70,80,60,90},而后的递归调用“QSort(L,1,5-1);”和“QSort(L,5+1,9);”语句,其实就是在对{20,10,40,30}和{70,80,60,90}分别进行同样的Partition操作,直到顺序全部正确为止。

下面来看快速排序最关键的Partition函数实现。

// 交换顺序表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;
}

可以发现,Partition函数其实就是将选取的pivotkey不断交换,将比它小的换到它的左边,比它大的换到它的右边,它也在交换中不断更改自己的位置,直到完全满足这个要求为止。

复杂度分析

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

 

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

T(n) ≤ 2T(n / 2) + n, T(1) = 0
T(n) ≤ 2(2T(n / 4) + n / 2) + n = 4T(n / 4)+2n
T(n) ≤ 4(2T(n / 8) + n / 4) + 2n = 8T(n / 8)+3n
……
T(n) ≤ nT(1) + (log2n) × n = O(nlogn)

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

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

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

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

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

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

快速排序优化

1、优化选取枢轴

我们发现,在之前的代码中,排序速度的快慢取决于L.r[1]的关键字处在整个序列的位置,L.r[1]太小或者太大,都会影响性能(比如第一例子中的50就是一个中间数,而第二例子的9就是一个相对整个序列过大的数)。因为在现实中,待排序的系列极有可能是基本有序的,此时,总是固定选取第一个关键字(其实无论是固定选取哪一个位置的关键字)作为首个枢轴就变成了极为不合理的作法。

改进方法:

1). 有人提出,应该随机获得一个low与high之间的数rnd,让它的关键字L.r[rnd]与L.r[low]交换,此时就不容易出现这样的情况,这被称为随机选取枢轴法。应该说,这在某种程度上,解决了对于基本有序的序列快速排序时的性能瓶颈。不过,随机就有些撞大运的感觉,万一没撞成功,随机到了依然是很小或很大的关键字怎么办呢?

2). 再改进,于是就有了三数取中(median-of-three)法。即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数,也可以随机选取。这样至少这个中间数一定不会是最小或者最大的数,从概率来说,取三个数均为最小或最大数的可能性是微乎其微的,因此中间数位于较为中间的值的可能性就大大提高了。由于整个序列是无序状态,随机选取三个数和从左中右端取三个数其实是一回事,而且随机数生成器本身还会带来时间上的开销,因此随机生成不予考虑

来看取左端、右端和中间三个数的实现代码(在Partition函数代码的第3行与第4行之间增加这样一段代码。):

int pivotkey;
//计算数组中间的元素的下标
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);
// 此时L.r[low]已将为整个序列左中右三个关键字的中间值
// 用子表的第一个记录作枢轴记录
pivotkey=L->r[low];

三数取中对小数组来说有很大的概率选择到一个比较好的pivotkey,但是对于非常大的待排序的序列来说还是不足以能够选择出一个好的pivotkey,因此还有个办法是九数取中(median-of-nine),它先从数组中分三次取样,每次取三个数,三个样品各取出中数,然后从三个中数当中再取出一个中数作为枢轴。显然这就更加保证了取到的pivotkey是比较接近中间值的关键字。

2、优化不必要的交换

在把数移到正确的位置前,多余的交换是不必要的。

对Partition函数的代码再进行优化:

// 快速排序优化算法
int Partition1(SqList *L, int low, int high){
    int pivotkey;
    // 这里省略三数取中代码
    // 用子表的第一个记录作枢轴记录
    pivotkey=L->r[low];
    // 将枢轴关键字备份到L->r[0];
    L->r[0]=L->r[low];
    // 从表的两端交替向中间扫描
    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->[low];
    }
    // 将枢轴数值替换回L.r[low]
    L->r[low]=L->r[0];
    // 返回枢轴所在位置
    return low;
}

 注意代码中加粗部分的改变。我们事实将piv-otkey备份到L.r[0]中,然后在之前是swap时,只作替换的工作,最终当low与high会合,即找到了枢轴的位置时,再将L.r[0]的数值赋值回L.r[low]。因为这当中少了多次交换数据的操作,在性能上又得到了部分的提高。如下图所示。

3、优化小数组时的排序方案

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

#define MAX_LENGTH_INSERT_SORT     //数组长度阈值
// 对顺序表L中的子序列L..r[low..high]作快速排序
void Qsort(SqList &L, int low, int high){
    int pivot;
    if((high-low)>MAX_LENGTH_INSERT_SORT){
        // 当high-low大于常数时用快速排序
        // 将L.r[low..high]一分为二
        // 并算出枢轴值pivot
        pivot =Partition(L,low,high);
        // 对低子表递归排序
        Qsort(L,low,pivot-1);
        Qsort(L,pivot+1,high);
    }
    else
        // 当high-low小于等于常数时用直接插入排序
        InsertSort(L);
}

4、优化递归操作

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

于是我们对Qsort实施尾递归优化

// 对顺序表L中的子序列L..r[low..high]作快速排序
void Qsort1(SqList *L, int low, int high){
    int pivot;
    if(high-low>MAX_LENGTH_SORT){
        while(low<high){
            // L..r[low..high]一分为二
            // 算出枢轴值pivot
            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);”。结果相同,但因采用迭代而不是递归的方法可以缩减堆栈深度,从而提高了整体性能。

 

总结

排序稳定对于某些特殊需求来说是至关重要的,因此在排序算法中,我们需要关注此算法的稳定性如何。

事实上,目前还没有十全十美的排序算法,有优点就会有缺点。即使是快速排序法,也只是在整体性能上优越,它也存在排序不稳定、需要大量辅助空间、对少量数据排序无优势等不足。

我们将7种算法的各种指标进行对比,如表9-10-1所示。

从算法的简单性来看,我们将7种算法分为两类:

简单算法:冒泡、简单选择、直接插入。
改进算法:希尔、堆、归并、快速。


平均情况来看,显然最后3种改进算法要胜过希尔排序,并远远胜过前3种简单算法。

最好情况看,反而冒泡和直接插入排序要更胜一筹,也就是说,如果你的待排序序列总是基本有序,反而不应该考虑4种复杂的改进算法。

最坏情况看,堆排序与归并排序又强过快速排序以及其他简单排序。

空间复杂度来说,归并排序与快速排序都有相应的空间要求,反而堆排序等对空间要求是O(1).

稳定性来看,归并排序独占鳌头,故对于非常在乎排序稳定性的应用中,归并排序是个好算法。

待排序记录的个数上来说,待排序的个数n越小,采用简单排序方法越合适。反之,n越大,采用改进排序方法越合适。这也就是我们为什么对快速排序优化时,增加了一个阀值,低于阀值时换作直接插入排序的原因。

从表9-10-1的数据中,似乎简单选择排序在3种简单排序中性能最差,其实也不完全是,比如,如果记录的关键字本身信息量比较大(例如,关键字都是数十位的数字),此时表明其占用存储空间很大,这样移动记录所花费的时间也就越多,我们给出3种简单排序算法的移动次数比较,如表9-10-2所示。

你会发现,此时简单选择排序就变得非常有优势,原因也就在于,它是通过大量比较后选择明确记录进行移动,有的放矢。因此对于数据量不是很大而记录的关键字信息量较大的排序要求,简单排序算法是占优的。另外,记录的关键字信息量大小对那四个改进算法影响不大。

总之,从综合各项指标来说,经过优化的快速排序是性能最好的排序算法,但是不同的场合我们也应该考虑使用不同的算法来应对它。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值