1、第10章 排序,10.1 排序的基本概念10.2 排序方法 10.3 各种排序方法的比较10.4 排序应用举例 10.5 小结习题10,10.1 排序的基本概念,所谓排序,就是把一组杂乱无章的记录按照某种次序排列起来,使其具有一定的顺序。一般,设有一个由记录R(1), R(2), , R(n)组成的文件,其相应的关键字值为K(1), K(2), , K(n),按关键字值的某种次序,寻求一种排列P(1),P(2), P(n),使其相应的关键字满足非减关系K(P(i)K(P(i+1) 1in-1或满足非增关系K(P(i)K(P(i+1) 1in-1从而得到文件中各记录的一种线性有序序列R(P(1)
2、, R(P(2), , R(P(n)这个过程叫做排序。,简而言之,排序就是根据关键字值的非减或非增次序,把文件中的记录依次排列起来,使一个无序的文件变成一个有序的文件。若文件中有多个记录的关键字值相等,则上面定义的排列将不是惟一的。在这种情况下,我们约定:在未排序的文件中,如果有i, j且K(i)=K(j),则在经过排序的文件中,R(i)仍处在R(j)的前面,即具有相同关键字值的记录在排序过程中其相对位置不变。能产生这种排序的方法称为稳定的,反之称为不稳定的。,排序的方法可根据记录的存放位置不同,分为内部排序和外部排序两大类。内部排序是指在排序过程中,全部记录都存放在内存中的排序方法,即待排序
3、的文件小到使整个排序过程可在内存中进行的排序方法。内部排序速度较快,一般用于小型文件。外部排序是用于大型文件的排序方法,因为文件较大,全部数据不能全部放入内存之中,在排序过程中,首先取一部分记录,在内存中用内部排序方法进行排序,把排序的结果写回外存,再取另一部分记录到内存中用内部排序方法进行排序,再把排序的结果写回到外存从而在外存得到一系列有序子文件,再用归并的方法得到最终的有序文件。在排序过程中,记录要在内、外存之间来回调动。在这一章,主要讨论几种基本的内部排序方法,对外部排序不作介绍。,内部排序的方法很多,对这些方法我们不可能全部介绍,下面只介绍其中主要的几种。在学习这些排序方法时,除了掌
4、握算法本身之外,更重要的是了解该算法在进行排序时所依据的原则和算法的基本思想,以利于了解和创造更新的算法。每一种排序方法均可在不同的存储结构上实现。通常,文件可有下列三种存储结构。(1) 文件中相邻的两个记录R(i)和R(i+1)在存储器中的物理位置也是相邻的,这类似于线性表,以向量作存储结构,此时需移动记录来实现排序。(2) 文件中记录的物理位置是任意的,但相邻的两个记录之间用指针相连,即以链表作为存储结构,排序时无需移动记录,仅修改地址向量中相应分量的值即可。此类排序又可作为表排序。,(3) 记录的物理位置可以不受约束,同时另设一个地址向量依次指示文件中每个记录的物理位置,排序过程也无需移
5、动记录,仅需修改地址向量中相应分量的值,最后再按照地址向量调整记录之间的相互位置。此类排序称为地址排序。本章在讨论时仅就第一种存储结构讨论各种排序方法的实现,因此,对算法的分析有两个方面:记录比较次数和记录移动次数。,10.2 排序方法,10.2.1 直接插入排序1插入排序的过程插入排序的基本思想是把一个记录插入到一个有序的文件中,在插入后使该文件仍然是有序的。设有一个包含n个记录R(1), R(2), , R(n)的源文件。假设有一个子文件,它是由源文件的第一个记录R(1)构成的,显然,这个只有一个记录的源文件是有序的。然后,把源文件的第二个记录R(2)按记录关键值的有序性插入到只包含一个记
6、录R(1)的子文件中。,为了使插入后得到的新子文件也是有序的,R(2)必须在子文件中的适当位置上。接着再把R(3)插入到包含R(1)、R(2)两个记录的有序子文件中的适当位置上。同样,务必使插入后得到的新子文件仍是有序的,照此继续,把第i个记录插入到包含i?1个记录的有序子文件中的适当位置上,使插入后的子文件仍保持有序性。经过n-1次插入后,一个具有n个记录的无序文件就变成了有序文件,从而完成了排序。这个过程如图10-1所示。,图10-1 插入排序示例,2直接插入排序的算法为了把第i个记录插入到包含i-1个记录的有序子文件的适当位置,我们虚设一个记录R(0),其关键字为?,这个记录的作用相当于
7、顺序查找中的边界标志,从j=i-1个记录开始向前比较,将第j个记录赋值给第j+1个记录,直至关键字不大于待插入记录的关键字为止,此时第j个位置即为第i个记录插入的适当位置。算法如下:INSORT(R)/* R0:n为记录的一维数组,其中R1至Rn存储待排序文件的记录,R0作为边界标志,ki为第i个记录的关键字,排完序的文件仍存放在R1至Rn,max为计算机允许的最大值/ k0=-max;,/* 虚设一个记录R0的关键值k(0),其关键字为负无穷 */ for(i=2; i=n;i+) /* 从第二个记录起进行插入 */ t=ki; /* 保存待插入的记录 */j=I-1;while(kikj)
8、 kj+1= kj; j-;,/将第j个记录赋值给第j+1个记录,直至关键字值不大于待插入记录的关键字为止*/ kj+1=t;/* 将第i个记录插入 */ 2) 比较次数为了寻找第1个记录的插入位置,最多要比较n-1次,最少为1次。因此,对于有n个记录的文件进行插入排序的最大、最小和平均比较次数如下:,最小比较次数:若待排序文件已经有序,每个记录都只比较一次就找到了其应插入的位置,则从第2到第n-1个记录总共只需比较n-1次。 最大比较次数:若待排序文件与要求的顺序逆序,由于第2个记录找到其应插入的位置最多需要比较两次,第3个记录最多需要比较三次,第i个记录最多需要比较i次,第n个记录最多比较
9、n次,因此,从第2到第n个记录共n-1个记录,需要比较的最多次数为则执行量级为O(n2)。,平均比较次数为则执行量级为O(n2)。3) 移动次数显然,直接插入排序所需移动记录的次数的量级为O(n2)。4) 稳定性顺序插入排序是稳定的。,10.2.2 简单选择排序选择排序的基本思想是:第一趟在n-i+1(i=1,2,n-1)个记录中选择关键字进行n-i次比较,从n-i+1个记录中选出关键字最小的记录和第i个记录进行交换。具体步骤是:(1) 在未排序的文件中找出关键字值最小的记录,然后把这个记录与第一个位置上的记录对换,使得关键字值最小的记录定位;(2) 在余下的记录中找出关键字值最小的记录,并把
10、它与第二个位置上的记录进行对调,使关键字值次小的记录在已排序的序列中定位;,(3) 依次类推,一直到所有的记录逐个在排序的序列中定位。按以上的步骤就能得到按关键字值从小到大的次序排序的文件。图10-2是选择排序的一个实例。,图10-2 简单选择排序示例,按以上的选择排序的算法思想,该算法描述如下:SELECTSORT(r)/* 用选择排序对文件R1,R2,Rn进行排序,k是记录的关键字值 */ for(i=1;i=n-1; i+) /* 共进行n-1趟排序 min=i; /* min是在未排序的记录中,指示关键字值最小记录的序号 */ for(j=i+1;j=n;j+) if(kjkmin)
11、min=j;,if(min!=j) t=ki; ki= kmin; kmin=t; /* 选出第i个最小关键字的记录 */,简单选择排序的算法分析如下:1比较次数无论文件中记录排列的初始状态如何,第一次找出关键字最小的记录需要进行n-1次比较;找出第二小的关键字记录需进行n-2次比较;若找出第i小的记录的关键字需进行n-i次比较。因此该算法总的比较次数为(n-1)+(n-2)+(n-i)+2+1=具有n个记录的文件,选择排序所需时间的量级为O(n2)。从整个算法分析,外层for语句和内层for语句的循环,形成排序所需时间的量级为O(n2)。,2移动次数在算法的外循环for语句中,每次循环选择关
12、键值最小的记录的指针min,若min与i不等,也就是说,最小关键值记录指针min的位置不是在未排序记录中的第一个,即第i位序号,则就要进行一次两个记录的对换,而两个记录的位置交换需要三次移动记录。由于n个记录的选择排序要进行n-1次最小关键字值选择,因此记录的移动次数最多为3(n-1)。,10.2.3 快速排序快速排序(Quick Sort)是目前排序中速度较快的方法之一,其实质是在分区内交换未排的记录而完成排序。该文件的存储结构是一个向量,即线性表。 插入排序是把记录插到全部文件中已经排好序的子文件的恰当位置上,使得在插入后,子文件仍然保持其有序性。但是,以后的记录再一次插入时,为了保证在插
13、入后得到的新的子文件仍然是有序的,则原来的子文件中的一些记录可能需要再次移动。也就是说,先前插入的记录所占据的位置,对于扩大了的子文件来说,并不一定是原来的位置。,因此,在插入排序过程中,一个记录为了取得最终在已排序的有序文件中的位置,可能需要不断地移动,这将使整个排序的速度下降。快速排序就克服了这个需要不断移动位置的不足,在排序过程中,一次定位就确定了该记录在已排序文件中的位置。快速排序的基本思想是:通过一趟排序,将文件分成两个部分,然后分别对这两部分进行排序,以达到最后整个文件有序的目的。,1快速排序的过程先取待排序文件中第一个记录的关键字作为控制关键字,也称作“划分元”,首先确定“划分元
14、”最终占据的正确位置,当把该记录放到最终应占据的位置之后,文件被分割成两部分,关键字值小于或等于该记录关键字值的所有记录都处于它的左侧,构成一个子文件;而关键字值大于该记录关键字值的所有记录处于它的右侧,构成另一个子文件。对于每一个子文件,又可按照同样的方法进行处理,进而分成更小的部分,直到每部分只剩下一个记录为止。待排序文件中的所有记录都被放到其最终应占据的位置上,整个排序完成。,这个方法的关键在于确定“划分元”最终应占据的正确位置。其实现的方法是:取待排序文件的第一个记录的关键字值为关键字值比较工作单元。设文件有n个记录,每趟排序均取文件的最后一个记录。在第一趟排序中,取最后一个记录的关键
15、字值K(n)与第一个记录的关键字值K(1)进行比较。若K(n)K(1),则说明R(1)与R(n)的相对位置是正确的,然后用K(n-1)与K(1)比较,直到K(j)K(1),则交换记录K(j)与K(1)的位置。然后,取K(2)与K(j)继续比较,若K(2)K(j),再取下一个K(3)与K(j)比较,直到K(i)K(j)时,则交换记录K(i)与K(j)的位置。,继续以上过程,交替地从两侧向中间搜索,直到i=j,文件的第一个记录便取得了最终的正确位置。这是第一趟排序的过程。通过第一趟排序,可确定当前待排序文件中第一个记录在排序完成后的有序文件中的正确位置,同时把文件分为两个部分。子文件第一个记录又是
16、划分元。不断对这部分未排序的子文件进行以上描述的一趟排序,直至整个排序完成。2快速排序的非递归算法 例10.1 待排序文件有10个记录,其关键字值分别为:415,032,984,746,518,081,946,314,205,827。图10-3给出了快速排序的一趟搜索示例,其中W为划分元。指针LW和RW从两端向中间搜索。初始时,LW指向第一个记录,RW指向最后一个记录。图中415是划分元,最后定位在第5号位置。,图10-3 快速排序一趟搜索示例,我们用PARTITION来完成每一趟的排序和定位。PARTITION(l,r,lw,k)/* 完成一趟程序,确定划分元位置,k是文件记录关键字值,l,
17、 r分别是当前排序子文件(区间)的第一个和最末记录的位置,lw是划分元定位的序号 */ lw=l; rw=r; w=klw; /* w比较工作单元即划分元 */while(lww)&(rw!=lw) rw=rw-1; /* 当前排序区间的右指针向中间搜索 */,if(rw!=lw) klw=krw; lw=lw+1; /* 若划分元不定位,左右指针所指的记录交换,左指针向中间搜索 */ while(klw=w)&(lw!=rw) lw=lw+1; if(lw!=rw) krw=klw; rw=rw-1; ,k(lw)=w;/* 划分元最后定位 */在完成了一趟PARTITION排序后,以划分元
18、的定位位置LW为界,把当前未排序的子文件分成两个子文件,先处理左侧子文件,再处理右侧子文件。为了在左侧子文件处理结束后能够取得右侧子文件,应设置一个栈。在处理左侧子文件之前,把当前文件的结束位置(即右侧子文件的结束位置)保存在栈中,而当左侧子文件处理结束后,从栈中取出前一次处理的子文件的结束位置(即右侧子文件的结束位置),而右侧子文件的开始位置为划分元定位序号增加1,这样即可处理右侧子文件。,根据快速排序的算法思想和一趟排序划分元定位的结果,快速排序算法描述如下:QSORT(m,n,k)/* 关键字值为k的文件m至n的快速排序,stack为栈,w是划分元,l、r是当前排序区间(子文件)的下界和
19、上界 */ stack1=m;stack2=n;/* 文件的第一个和最末记录的序号进栈 */ top=2;/* 栈指针的初态 */ while(top!=0), r=stacktop; top=top-1; l=stacktop; top=top-1; /* 从栈中取当前排序区间(子文件)的上界和下界 */ if(lr) PARTITION(l,r,lw,k);,/* 子文件中有2个或2个以上的记录,调用一趟排序划分元定位的子程序 */ if(lw1) top=top+1; stacktop=1; top=top+1; stacktop=lw-1; /* 划分元定位后,若有左侧子文件,则左侧子
20、文件的第一个和最末一个记录指针进栈 */按以上的算法,上例中快速排序的全过程如图10-4所示。,图10-4 快速排序结果,从快速排序的算法和排序的全过程可以看出,当划分元把当前排序的子文件分成左、右两侧子文件时,总是右侧文件先进栈,左侧文件后进栈。因此,从整体上看是文件的左侧先排完序后再处理右侧的文件。排序全过程中,每一行表示当前排序子文件的一趟排序定位;当右侧或左侧文件中只有一个记录时,就不需要调用PARTITION。从图10-5中可以看出在排序全过程中栈指针的变化、栈STACK内容的变化、调用PARTITION进行划分元定位的次数及划分元的定位情况。,图10-5 快速排序中栈和定位的示意图
21、,从图10-5中可清楚地看到快速排序的全过程和当前排序子文件,即子区间的状态,栈内容的变化和每一趟划分元的定位情况。实际上,只有当子文件的长度大于等于两个记录时,才调用一趟PARTITION排序,从而划分元才产生定位。当子文件只有一个记录时,算法就认为该子文件已完成定位。3快速排序的递归算法快速排序也可以用递归程序实现。递归算法的描述如下:QSORT(m,n),/* 根据关键字值为k数组m至n的非递减次序进行快速排序,其中m,n是文件中记录的起始和最末记录的序号 */ if(mn) i=m; j=n;/* i, j分别指向文件的第m和第n个记录 */t=km;/* 把划分元保存到中间变量t中
22、*/while(i!=j) while(t=k(j) /* 从文件的末端向中间搜索 */,if(ij) ki=kj;i=i+1;while(ki=t) /* 从文件的前端向中间搜索 */if(ij) kj=ki; j=j-1; ,/* 找到划分元的定位序号i */ki=t;/* 划分元定位 */i=i+1;/* 重新形成右侧子文件的第一个记录位置 */j=j-1;/* 重新形成左侧子文件的最末一个记录的位置 */QSORT(m,j);/* 处理左侧子文件 */QSORT(i,n);/* 处理右侧子文件 */,4. 快速排序算法的C程序为了便于上机实现快速排序的算法,以下给出算法的C程序。PAR
23、TITION(int x,int y)/* 假设栈和文件的大小为100,实际应用时可根据需要而定 */ int w; lw=x;rw=y;w=klw; while(lww) while(klw=w)&(lw!=rw) lw=lw+1; if(lw!=rw), krw=klw; lw=lw-1; klw= w;main( ) top=0 puts(m,n=); scanf(%d%d,for(i=m;i=n;i+) scanf(%d, /* 读入文件的关键字值,也可以用数据文件形式读入 */ top=top+1;stacktop=m; top=top+1;stacktop=n; while(top
24、!=0) r=stacktop;top=top-1; l=stacktop;top=top-1;,if(lr) PARTITION(l,r); if(lwl) top=top+1; stacktop=1; top=top+1; stacktop=lw-1; /* 排序结束 */ for(i=m; ikj) ki=kj;/* 小的记录上浮 */ i=j;/* 从j开始继续筛选 */ j=2*i; /* j为i的新左孩子 */ else j=n+1;,/* 以l为根的二叉树已建成堆,筛选完成,退出循环 */ ki=t;/* 把原来二叉树的根放入其正确位置 */,3) 堆排序的算法有了筛选的算法,就
25、可以进一步讨论堆排序的算法。(1) 建立初始堆。对于具有n个记录的文件建立初始堆的过程就是一个反复“筛选”的过程。我们把文件看作一棵二叉树,各终端结点没有孩子,不需要下沉,它们自然符合堆的定义,即所有in/2的记录R(i)不需要下沉。由此,只需要从第n/2个记录开始,直到第一个记录,反复调用SIFT筛选算法,建立一个初始堆。,图10-8(a)所示为二叉树结构的一个由8个记录组成的无序序列。初始序列为49, 38,65, 97, 76, 13, 27, 50,按筛选的算法从第4个记录开始(i=n/2=8/2=4)。由于9750,则交换之;同理,在第3个记录筛选后序列的状态如图10-8(d)所示,
26、而由于第2个记录不大于其左、右孩子的值,则在筛选过程中记录之间的位置不变。那么,图10-8(e)就是最终得到的堆。,图10-8 建立初始堆的过程,(2) 重建堆。现在,讨论堆排序中第二个问题,当堆顶元素和堆底元素交换后,重建堆的过程。在初始堆里,关键字值最小的记录占据了堆顶,这就是说找出了n个记录中的最小者。然后,交换堆顶和堆底,对剩下的n-1个记录再重新建堆。此时,由于二叉树根结点的左、右子树都已满足堆的条件,因此,对于根结点只要调用一次SIFT子程序,就可完成堆的重建。根据堆的特性,现在占据堆顶的是n-1个记录中的最小者,也就是说第一和第二个记录已排序。照此重复这种堆顶、堆底交换和重建堆的
27、过程,就可实现对给定文件的排序。图10-9给出了文件中以关键字值46,55,13,42,94,17,5,70为序列的记录堆排序的全过程。,在图10-9中首先建立堆,然后堆顶元素5与堆底元素70交换,再重建堆;第二步13和46交换,再重建堆由此可以得知:经过堆顶元素和堆底元素的交换,文件中最末一个记录的关键字值最小,倒数第二个记录的关键字值次小,依次类推。所以本例中堆排序的结果是:94,70,55,46,42,17,13,5。,图10-9 堆排序的全过程,图10-9 堆排序的全过程,图10-9 堆排序的全过程,(3) 根据堆排序的算法思想,给出以下算法描述:HEAPSORT(k,n)/* 对具有
28、n个记录的文件R,记录的关键字值以k表示,用堆排序方法排序 */ for(l=n/2; l=1; l-) /* 对以向量表示的文件调用“筛选”算法,建立初始堆 */ SIFT(l,n); for(l=n; l=2; l-), t=kl; kl=kl; kl=t;/* 堆顶元素与堆底元素交换 */ SIFT(l,l-1) /* 重建堆 */,3. 算法分析1) 比较次数堆排序算法对记录个数较少的文件并不值得提倡,但对记录个数较多的文件是十分有效的。由于第一个循环,即n个记录建立初始堆所需要的比较次数不超过2n次,第二个循环要执行n-1次,而对于有n个结点的堆,其最大深度为lb n+1,因此,该循
29、环的最大执行时间为O(n lb n)。从表示算法的量级可取算法中语句最大执行时间量级的部分,所以堆排序的执行时间为O(n lb n)。2) 空间堆排序算法对存储空间的要求,除了一个与文件长度相同的向量外,只是在第二个for循环里为进行堆顶与堆底换位,需要附加一个记录的工作单元。,10.2.5 归并排序1归并排序的过程 归并排序的过程为:把源文件中的n个记录看成是n个子文件,每个子文件只有一个记录。因此,这n个子文件是有序的。这样可利用归并办法把这n个有序的子文件两两归并。经一趟归并后的每个子文件包含两个记录,若n是奇数时,尚有一个只包含一个记录的子文件,然后,再继续两两归并下去,最后便得到一个
30、包含全部n个记录的有序文件,这个过程叫做2路归并排序。例10.3 图10-10为利用归并排序方法对具有10个记录的文件进行排序的过程。,图10-10 2路归并排序过程示例,2归并排序的算法1) 基本归并算法归并排序就是用归并的方法对文件进行排序。因此,在讨论归并排序之前,首先是如何把两个有序文件X1, X2, , Xm和Xm+1, Xm+2, , Xn归并成一个有序文件Z1, Z2, , Zn。可以设立三个指针i、j、p,分别指向三个文件的表头。把指针i所指记录和j所指记录进行比较,取其中较小者作为新子文件的第p个记录。在取出记录的那个子文件中移动指针(即指针加1),从而指向下一个记录,再与另
31、一个文件中指针所指的记录进行比较。依次进行下去,当某个子文件中的记录全部取完后,就将另一个子文件中的剩余记录按顺序复抄到新子文件中,从而完成了两个子文件的基本归并过程。,基本归并算法如下:MERGE(x,l,m,n,z)/* 把两个首尾相接的各自有序的文件x1, , m和xm+1, , n归并成一个有序文件z1,n */ i=1; j=m+1; p=1; /* 三个子文件指针初始化 */ while(i=m)&(j=n) /* 当两个文件都没有结束 */, if(kim) z(p), , z(n)=k(j), , k(n) else z(p), , z(n)=k(i), , k(m)/* 把剩
32、余子文件的记录复制到新文件z中 */,2) 一趟归并算法从图10-10所示的归并排序的过程中可以看出,归并排序是通过对待排序文件的记录进行若干趟归并后完成排序的。下面我们先给出一趟归并排序算法,并作为2路归并排序的调用函数。MERGEPASS(n,l,x,y)/* 把n个记录的文件x,分成长度为l的有序文件,执行一趟归并,结果放在y文件中 */ i=1; while(i=n-2*l+1) /* 剩余部分不少于2*l个记录 */, MERGE(x, i, i+l-1, i+2*l-1,y);/* 归并两个长度为2*l的文件 */ i=i+2*l;/* 移动指针,归并下一个长度为2*l的两个子文件
33、 */ if(i+l-1)n) MERGE(x, i, i+l-1, n, y);/* 剩余部分有一部分长度为l,另一部分长度不够l,归并这两部分 */ else y(i), , y(n)=x(i), , x(n); /* 剩余部分长度不够l,复抄余下的记录 */,3) 2路归并排序算法利用一趟归并排序过程就可以很方便地写出2路归并排序算法。由于文件X排序后仍放在X中,因此,在循环体中调用后已得到有序文件,但为了把排序结果放在X中,仍需再次调用才能完成排序。以下是2路归并排序算法。MERGESORT(n,x)/* 归并有n个记录的有序文件x,排序后结果仍放在x中 */ l=1;/* 待归并的有
34、序子文件的初始长度 */ while(ln),/* 若还没有得到长度为n的有序文件 */ MERGESORT(n, l, x, y);/* 把有n个记录的文件x分成长度为l的有序子文件,执行一趟归并,结果放在y中*/l=2*l;/* 改变待归并有序子文件的长度 */ MERGESORT(n, l, y, x);/* 把有n个记录的文件y分成长度为l的有序子文件,执行一趟归并,结果放在x中*/l=2*l;/* 改变待归并有序子文件的长度 */ ,3算法分析1) 比较次数在2路归并排序算法中,第一趟归并,大小为1的子文件得到归并;在第二趟中,归并的子文件长度为2;在第i趟中,归并文件的长度为2i。因此,对包含有n个记录的文件,完成归并排序总共需要作lb n趟归并。而完成一趟归并所需要的时间为O(n),因此对有n个记录的文件进行归并排序所需要的时间为O(n lb n)。2) 空间完成归并排序除了待排序文件长度为n的存储空间外,还需要附加产生与新文件同样长度的存储空间。因此,附加存储量是比较大的。,