Download presentation
Presentation is loading. Please wait.
Published byΛωΐς Αλεξόπουλος Modified 6年之前
1
第8章 排 序 8.1 排序技术概述 8.2 插入排序 8.3 选择排序 8.4 交换排序 8.5 归并排序 8.6 基数排序
第8章 排 序 8.1 排序技术概述 8.2 插入排序 8.3 选择排序 8.4 交换排序 8.5 归并排序 8.6 基数排序 8.7 外部排序概述 8.8 本章小结
2
8.1 排序技术概述 从操作角度看,排序是线性结构的一种操作。 为了提高排序效率,人们已对排序进行了许多研究,提出了许多方法。
3
排序就是按照某种规则,为一组给定的对象排列次序。
排序的主要目的是:在排好序的集合中能够快速查找(检索)一个元素。
4
所谓“内部”排序,就是指整个排序过程都是在内存中进行的。
如果排序的数据项很多,内存不足以存放得下全部数据项时,排序过程就需要对外存进行存取访问,也就是“外部”排序。 本章的内容以内部排序为主,对外部排序只进行简单地介绍。
5
我们把查找时关注或使用的数据叫做关键字(key),它可以是数据信息当中的一个属性,也可以是几个属性的组合。
关键字可以代表其所在的那项数据信息。在这项数据信息当中,关键字所取的值叫做键值。
6
假设待排序的数据都只有一个属性,这个属性就是关键字,并且关键字的类型是整型。
在本章中,为了突出排序算法本身的内容,我们简化各项数据的属性个数。 假设待排序的数据都只有一个属性,这个属性就是关键字,并且关键字的类型是整型。
7
我们可以把排序看成是一种定义在某种数据集合上的操作。
本章所讲的各种内部排序,都可以认为是在一维数组这种线性数据结构上定义的操作。其功能是将一组任一排列的数据元素重新排列成一个按键值有序的序列。
8
对排序更为确切的定义: 假设{D1,D2,…,DN}为含有N项数据的序列,其中Di(1≤i≤N)表示序列中第i项数据,N项数据对应的键值序列为{K1,K2,…,KN}。 排序操作是将这些数据重新排列成一个按键值有序的新序列{Rp1,Rp2,…,RpN},使得相应的键值满足条件p1≤p2≤…≤pN(此时新序列成“升序”)或p1≥p2≥…≥pN(此时新序列成“降序”)。
9
注意:在上面定义叙述中所用到的≤或≥这两个比较符号,是通用意义上的关系比较符。
对于数值型信息,它是表示关系的小或大;对于字符串来说,它是指在字典中所排列位置的前或后。 对于不同类型的数据,我们可以规定不同的比较规则来反映≤或≥的含义。
10
如果在数据序列中,有多个具有相同键值的数据,在排序前后,这些数据之间的相对次序保持不变,这样的排序方法被称作是稳定的,否则被称为是不稳定的。
11
分析算法效率: 1. 从空间角度进行:主要是看 执行算法时所需附加空间的数量; 2. 从时间角度进行:从两种操作入手进行分析。 键值的比较次数 数据移动的次数
12
8.2 插入排序
13
插入排序是指在待排序的序列中,分成两部分:前面为已经排好序的部分,后面为仍未排好序的部分。
每一轮都从没有排好序部分取出首元素按照大小将其插到前面已经排好序部分中的合适位置上。 这样前面已排序部分就多了一个元素,后面未排序部分同时少了一个元素。该排序过程不断进行,直到未排序部分的元素数目为零。
14
插入排序 将待排序的序列分成两部分:前面为已经排好序的部分,后面为仍未排好序的部分。
每一轮都从没有排好序部分取出首元素按照大小将其插到前面已经排好序部分中的合适位置上。 这样前面已排序部分就多了一个元素,后面未排序部分同时少了一个元素。该排序过程不断进行,直到未排序部分的元素数目为零。
15
排序过程如图8.1所示。 图(a)为该轮排序前的状态, 图(b)为该轮排序后的状态, 阴影表示本轮待排序的元素,在图(b)中它已经插入到已排好序部分的合适位置上。
17
注意: 在排序过程开始时,直接把序列中第一个元素认为是已排好序部分。另外,用整型数组代表排序的数据元素序列,数组下标为零的元素我们当作辅助空间,所以从数组下标是1的空间开始存储元素序列。
18
在把待插入元素从未排序部分移入已排好序部分时,有多种方法,下面逐一介绍。
直接插入排序 折半插入排序 _路插入排序 表插入排序 希尔排序
19
直接插入排序 直接插入排序 又称简单插入排序。排序过程中,待插入元素先放在临时空间hold中,依次让它和前面的元素作比较,直到发现其应插入的位置,将其插入。
20
//不带哨兵的直接插入排序。 //数组rec为待排序的序列, //count为排序元素的个数 void insertSortSimple(int rec[], int count) { int curr , hold; for(int inx=2; inx<=count; inx++) //从第二个元素起依次做插入排序 { hold=rec[inx]; curr=inx-1;
21
//从已排好序部分的尾元素开始向前 //查找待插入位置 while(curr>=1 && hold<rec[curr]) { rec[curr+1]=rec[curr]; curr--; } rec[curr+1]=hold; //将待插入元素插到合适的位置
22
算法中,while语句的条件表达式由两部分组成,curr>=1保证数组下标不会越界(curr不会被减到低于零,rec[curr]保证有意义),但这样做就会增加一次条件判断。
注意rec[0]起到了“哨兵”的作用。因为while语句的条件表达式使得curr不会被减到低于零。
23
//改进后的直接插入排序 //数组rec为待排序的序列,count为排序元 //素的个数
insertSortSimple(int rec[], int count) { int curr; for(int inx=2; inx<=count; inx++) //从第二个元素起依次做插入排序 { rec[0]=rec[inx]; curr=inx-1;
24
//从已排好序部分的尾元素开始向前 //查找待插入位置 while(rec[0]<rec[curr]) { rec[curr+1]=rec[curr]; curr--; } rec[curr+1]=rec[0]; //将待插入元素插到合适的位置 从while语句的条件表达式得知直接插入排序是稳定的排序算法 。
25
分析算法中元素比较与移动的次数 如果每轮待插入的元素都是在已排好序部分中的最大元素,那么每轮比较次数只有一次(只和已排好序部分的尾元比较一次),而且不用移动元素。N个元素的序列只做了N-1轮的操作,所以标准操作次数Tmin=(N-1)*1+(N-1)*0=N-1=O(N),显然这种方式对应的情况是原序列已经是升序。
26
如果原序列是降序,而最终结果应该是升序,故每一轮中待插入元素要和所有已排好序的元素进行比较(包括哨兵在内),而且这些元素都要依次后移(哨兵不后移),以便将首元位置空出,让待插元素插到最前面的位置。这样, 比较次数为: Tcompare=2+3+…+N=(N+2)*(N-1)/2 移动次数为: Tshift=1+2+…+(N-1)=N*(N-1)/2 所以标准操作次数: Tmax=Tcompare+Tshift=O(N2)。
27
注意: 上面计算移动记录次数的过程中,并没有考虑以下两个赋值操作:给哨兵赋值的操作和将待插元素插到正确位置的赋值操作。
如果将这两项计算在内,那么移动次数都应再加上2(N-1)的值。考虑到排序序列中各元素值是随机取值的,所以它们之间大小排列也是随机的,因此各种排列概率相同,我们取最差时间效率与最好时间效率的平均值做为直接插入排序的平均时间复杂度,为O(N2)。在算法中我们多开了一个辅助空间,故空间复杂度为O(1)。
28
折半插入排序 在向有序表中插入元素的过程中,直接插入排序采用的方法是:从后向前依次与有序表中元素作比较。没有充分利用“前部分已经有序”的特性,这种方法效率较低。
29
考虑改进的方法: 设定有序表长度为L,那么让待插元素x和处于有序表中间位置的元素y作比较: 如果待插元素x大于或等于y,则说明插入的位置一定在有序表的后半部分; 如果待插元素x较小,则说明插入的位置一定在有序表的前半部分。 无论哪种情况,我们都可以将可能插入的位置所在的空间长度降至原来长度的一半,故称“折半”插入排序。
30
对于长度为L的有序表,大约需要log2L次的比较就可以确定出待插元素的位置。因此对于N个元素的序列来说,进行N-1轮的所有比较次数为O(N
对于长度为L的有序表,大约需要log2L次的比较就可以确定出待插元素的位置。因此对于N个元素的序列来说,进行N-1轮的所有比较次数为O(N*log2N),显然优于直接插入法中平均情况下的比较次数O(N2)。但是在确定了待插位置之后,元素移动的次数并没有降下来,所以时间复杂度仍为有O(N2)。 算法中仍旧多开了一个辅助空间,故空间复杂度为O(1),保持不变。
31
void insertSortBinary(int rec[], int count)
{ int hold; //当前正处理的元素 int btm,top,mid; for(int inx=2; inx<=count; inx++) //从第二个元素起依次做插入排序 { hold=rec[inx]; btm=1;top=inx-1; //从已排好序部分的尾元素开始向前 //查找待插入位置 while(btm<=top)
32
{ mid=(btm+top)/2; if(rec[mid]<hold) btm=mid+1; else top=mid-1; } for(int i=inx-1;i>=btm;i--) rec[i+1]=rec[i]; rec[btm]=hold; //将待插入元素插到合适的位置
33
_路插入排序 在折半插入排序的基础上,再针对数据元素的移动进行算法的改进,就得到2_路插入排序算法。
34
我们开设一个辅助数组temp,其大小为待排序元素的个数,把它当作是循环数组。
有序表首元素和尾元素的下标分别用first与last表示。显然,开始状态下,有序表中仅有temp[0],所以first=last=0。 由于多开了辅助数组,所以该算法的空间复杂度为O(N)。
36
//2_路插入排序 //数组rec为待排序的序列,count为排序元素的个数 void insertSortBinary(int rec[], int count) { int *temp=new int[count]; //分配辅助空间 temp[0]=rec[1]; //第一个元素直接放入辅助数组,形成初始的有序表 int first=last=0; //有序表首元和尾元的下标 for(int inx=2; inx<=count; inx++) //从第二个元素起依次做插入排序 { if(rec[inx]<temp[0]) { //新插元素比有序表中间元素还小 for(int i=first; temp[i]<=rec[inx]; i=(i+1)%count ) temp[(i-1+count)%count]=temp[i]; temp[(i-1+count)%count]=rec[inx]; //插入待插元素 first=(first-1+count)%count;
37
}else { //新插元素比有序表表中间元素还大
for(int i=last;temp[i]>rec[inx];i--) temp[i+1]=temp[i]; temp[i+1]=rec[inx]; //插入待插元素 last++; } for(int inx=0; inx<count; inx++) rec[inx+1]=temp[inx]; //复制回原数组 delete[] temp; //释放空间
38
在上面的算法中,确实在一定程度上减少了数据移动的次数,但仍旧没有完全解决数据移动的问题。由第二章线性表中的知识,我们知道:要想彻底提高数据移动的效率,就应该使用链表结构的存储方式。下面就介绍用数组实现的静态链表完成的插入排序。
39
表插入排序 当希望在排序过程中不移动记录时,我们可以借助链表增删节点时的思想,用数组来实现链表的特征。在链表中,每个节点都由数据域和地址域两部分组成。类似的,让数组元素也由数据域和地址域组成。一个元素地址域中记录的是逻辑上该元素直接后继的下标值。也就说,数组中物理地址相邻的两个元素在逻辑上不一定前后相接。
41
//静态链表节点(数组元素)的类型声明 struct SLNode { int value; //假定数据为整型 int next; //记录下一元素的地址下标 }; //静态链表插入排序。 //数组rec为待排序的序列,count为排序元素的个数 void insertSortStaticTable(int rec[], int count) { SLNode tmp=new SLNode[count+1]; //数据元素从1号下标开始存储 int inx; for(inx=1; inx<=count; inx++) tmp[inx].value=rec[inx]; tmp[0].next=1; //有序表中仅有一个元素,该元素下标为1, //tmp[0].next为头指针 tmp[1].next =0; //地址域为零表示该元素为尾元
42
int curr,pre;//存数组下标的变量,起指针变量的作用
for(inx=2; inx<=count; inx++) { pre=0; curr=tmp[0].next; //curr指向首元,pre为首元的前驱,用0表示空 while(curr!=0&& tmp[curr].value<=tmp[inx]) //有序表中找待插位置 { pre=curr; curr=tmp[curr].next; } tmp[inx].next=curr; tmp[pre].next=inx; //将当前元素插到静态链表中的合适位置 } for(inx=1,curr=tmp[0].next;inx<=count;inx++) { //遍历链表依次将元素移回 rec[inx]=tmp[curr].value; curr=tmp[curr].next; delete[] tmp;
43
上面的算法中,由于引入了静态链表这个辅助空间,故空间复杂度为O(N)。
44
希尔排序 在分析直接插入排序的时间复杂度时,可以看到:如果数据序列本身已是升序的,那么时间复杂度为O(N)。因此,如果能够保证在使用直接插入排序之前,数据序列已经基本有序,则时间复杂度就会基本接近O(N)。借助于这种思路,我们希望一个乱序的数据序列在每一轮操作中,较大的数据尽快地跳到序列较后的部分,而较小的数据尽快地跃到序列较前的部分。这样经过几轮之后,序列已经基本有序,在此基础上再使用一次直接插入排序即可。
45
为了能够使数据尽快跳到自己应该在的位置附近,就不能向以前一样:先依次同位置相近的元素作比较互换等操作。因为即使换了好几次,元素仍旧停在原位置附近。我们应让位置离得较远的元素进行比较和互换,这样才能达到前述的要求。
46
希尔排序(Shell Sort)又称“缩小增量排序”正是基于上述这种思想进行的排序操作。
该排序方法将序列中的元素分成小组,同组元素之间的跨度称作“增量”。对于同组的元素进行直接插入排序,每一轮对各组元素都进行一遍直接插入排序。每轮之后,都会缩小增量,减少组数。当经过几轮之后,数据各元素已经基本有序,这时候将增量缩小至1,也就是说所有元素都成了一组,在最后这一轮操作中,使用直接插入排序对所有元素进行排序即可。
49
由上面排序的实例,可以看出希尔排序不是稳定的排序方法。
如果在N个元素进行排序时,增量inc按照规则 (inc0=N/3,inci+1=inci/2) 取值,那么希尔算法的实现如下:
50
//希尔插入排序 //数组rec为待排序的序列,count为排序元素的个数 void insertSortShell(int rec[], int count) { int hold,curr; //可以在下面的算法中用rec[0]作临时变量,代替hold for(int inc=count/3; inc>=1; inc/=2) //当增量为1时,作最后一次插入操作 for(int inx=inc+1; inx<=count; inx++) { hold=rec[inx]; curr=inx-inc; while(curr>=1 && hold<rec[curr]) { rec[curr+inc]=rec[curr]; curr-=inc; } rec[curr+inc]=hold; //将待插入元素插到合适的位置
51
关于增量的选择,有很多经验与看法,这里不再叙述。希尔排序所用的时间是一系列增量的函数,增量序列的不同,会导致时间复杂度略微不一样。有经验数据表明,希尔排序时间复杂度为O(N5/4)。
52
8.3 选择排序 选择排序是指在排序过程中的每一轮,按照某种方法选择出当前未排序部分中的最小值,把它放在已排序部分的首部,随着排序过程的进行,已排序部分变大,未排序部分变小。直到比较N-1轮之后,所有元素都在有序部分内,排序过程结束。
53
8.3.1 直接选择排序 (Straight selection sort)
直接选择排序,又称简单选择排序 N个元素进行直接选择排序的过程如下: 在第一轮,从下标为1的数组元素开始选出最小的元素,把它与数组下标为1的元素互换,这时已排序部分长度为1; 第二轮,从下标为2的数组元素开始在剩余的N-1个元素中选出最小的元素,让它与数组下标为2的元素互换,这时已排序部分长度为2; ……
54
从下标为N-1的数组元素开始,在剩下的两个元素中选出最小值,将最小值放在下标为N-1的数组元素中。由于N个元素中N-1个元素都已经放到了合适的位置,所以第N个元素也已经放到了合适的位置。
55
//直接选择排序 //数组rec为待排序的序列,count为排序元素的个数 void selectSortSimple(int rec[], int count) { int min,temp; //min存放当前轮的最小元素下标; //temp是用于交换的临时变量 for(int inx=1; inx<count; inx++) { min=inx; //设该轮中未排序部分的首元为最小值 for(int curr=inx+1; curr<=count; curr++) //找出该轮中未排序部分真正的最小值 if(rec[curr]<rec[min]) min=curr; if(min!=inx) //如果未排序部分的首元不是最小值,做交换操作 { temp=rec[inx]; rec[inx]=rec[min]; rec[min]=temp; }
56
算法中: 每一轮一般都要在最后做交换操作。一次交换涉及到3个赋值语句,所以有3(N-1)次赋值操作。而比较操作执行的次数为(N-1)+…+2+1,所以时间复杂度仍为O(N2)。 算法中在作交换操作时借用了一个辅助变量temp,故空间复杂度为O(1)。
57
8.3.2 堆排序 (Heap Sort) 最大堆与最小堆 如果完全二叉树各节点的关键字之间满足以下的关系,我们就称其为最大堆。
K≥Kleft和K≥Kright 其中K为某节点的关键字,Kleft为该节点左子(如果左子存在)的关键字,Kright为该节点右子(如果右子存在)的关键字。
58
类似的,可以得到最小堆的定义: 如果完全二叉树各节点的关键字之间满足以下的关系,我们就称其为最小堆。 K≤Kleft和K≤Kright 其中K为某节点的关键字,Kleft为该节点左子(如果左子存在)的关键字,Kright为该节点右子(如果右子存在)的关键字。
59
由最大堆的定义能够知道: 一个二叉树如果是最大堆,那么其根的关键字一定是该二叉树中所有节点关键字中的最大值。
60
如果用一维数组(数组下标为0的空间不用,从下标为1的空间开始)来存储最大堆,那么当前节点的数组下标R与其左子、右子(假设左子、右子存在)对应的数组下标Rleft、Rright之间的关系满足:
Rleft=2R,Rright=2R+1
61
当把一维数组中各元素看成是一个完全二叉树中各节点时:
我们通过一定的方法,把数组调整成最大堆(堆的初始建立),这样最大的元素就位于下标为1的地方,之后让其与下标为N的元素互换,也就是让最大元素放在了它该放的地方。 排序第一轮结束。
62
由于数组1号下标的位置换进了新元素,所以原来的最大堆可能遭到了破坏,我们再按照某种方法对其进行调整(堆的调整),将数组前N-1个元素再次构成一个最大堆,调整后下标为1的位置中存储的是N-1个元素中的最大值,把它与数组下标为N-1的元素互换,这样整个序列的次大元素也放到了该放的地方. 排序第二轮结束。
63
继续按照这种方法进行,直到N-1轮全部完成,所有数组元素就会变成有序的序列。
显然这种思想仍旧属于选择排序的方法。由于排序过程中借助了堆,所以称为堆排序。 该方法是威洛姆斯在1964年提出的。
64
只要解决上面提到的两个问题: 堆的初始建立与堆的调整, 我们就可以完成排序。
先来看第二个问题,在已有的最大堆上让首元(根)与尾元(最后一个叶子)互换。互换后,原来的尾元不再归入新一轮的研究范围内。在新的研究范围内,由于互换使得新首元不一定是最大值,所以要对其进行向下的调整,使得新研究范围内所涉及到的节点构成最大堆。
66
算法中尽量避免互换操作,而是在调整过程中只让较大的元素上浮,同时记住下降位置的下标,直到停止调整时,一次将待调整元素赋值到相应位置。
堆调整的算法 算法中尽量避免互换操作,而是在调整过程中只让较大的元素上浮,同时记住下降位置的下标,直到停止调整时,一次将待调整元素赋值到相应位置。 //在考察范围内,low为待调整元素的下标(考察范围的起始下//标),high为考察范围的终止下标 void reHeap(int rec[], int low, int high) { int curr=rec[low]; //将待调整元素存入临时变量curr中 int bigger=2*low; //先设定待调整元素左子为较大者 while(bigger<=high) //若仍在考虑范围内 { if(bigger<high && rec[bigger]<rec[bigger+1]) //若右子存在,且较左子大
67
while(bigger<=high) //若仍在考虑范围内
{ if(bigger<high && rec[bigger]<rec[bigger+1]) //若右子存在,且较左子大 bigger++; //bigger为左子右子中较大者下标 if(curr>=rec[bigger]) break; //带调整元素已经找到最后位置,不用继续下降 else //上浮较大者,为下一次迭代作准备 { rec[low]=rec[bigger]; low=bigger; bigger=2*bigger; } rec[low]=curr; //将待调整元素存入到合适位置
68
接下来讨论前述的第一个问题: 如何把乱序的一维数组调成最大堆。 数组所有元素对应一棵完全二叉树。 二叉树中叶子节点不需要进行调整,所以只考虑树中那些非叶子节点。 由于堆也是递归结构,当左子树与右子树已经是堆时,再对根进行调整才是有效的,所以我们调整的次序是从最后一个非叶子节点开始处理,按照数组下标从后往前进行,直到最后再处理根节点。
69
因为最后一个叶子节点的父节点就是最后一个非叶子节点,所以讨论最后一个非叶子节点的下标值。
N个元素的数组(0号下标空间不用),最后一个节点下标为N,如果它是父节点的右儿子(N为奇数),则父节点下标为(N-1)/2;如果它是父节点的左儿子(N为偶数),则父节点下标为N/2。 注意:C++语言中整除运算只取商,故对于N个元素的完全二叉树来说,其最后一个非叶子节点在数组中的下标对应的C++语言表达式为:(N/2)
70
堆初始建立的算法 //建堆操作:数组rec为待建堆的序列,count为元素的个数
void buildHeap(int rec[], int count) { for(int inx=count/2; inx>=1; inx--) reHeap(rec,low,count); }
71
//堆排序 //数组rec为待排序的序列,count为排序元素的个数 void selectSortHeap(int rec[], int count) { int temp; //用于变量互换,可以用rec[0]代替 buildHeap(rec,count); //堆的初始建立,选出最大元 for(int round=count; round>1; round--) //做count-1轮 { temp=rec[round]; rec[round]=rec[1]; rec[1]=temp; //本轮最大元放入正确位置 reHeap(rec,1,round-1); //在新范围内调整堆 }
72
在算法中可以看到, 时间主要耗费在建堆与对堆的调整上。 可以证得,堆在最坏的情况下,时间复杂度为O(N*log2N),这已经是排序的最好效率了。 空间复杂度仍为O(1)。
73
8.4 交换排序 8.4.1 冒泡排序 8.4.2 分治法与快速排序 交换排序的基本方法是:
8.4 交换排序 交换排序的基本方法是: 两两比较待排序元素,并交换不满足顺序要求的那些偶对,直到全部满足为止。 这里介绍两种交换排序,一种是最简单的冒泡排序(Bubble Sort),另一种是效率很高的快速排序(Quick Sort)。 冒泡排序 分治法与快速排序
74
8.4.1 冒泡排序(起泡排序) 冒泡排序的过程很简单,首先将第1个数据的关键字和第2个数据的关键字进行比较,若为逆序(前大后小),则将两个记录交换之。再比较第2个和第3个数据的关键字,依此类推,直到第N-1个数据和第N个数据进行比较、交换为止。如此经过一轮排序,使得关键字最大的数据被安置到数组最后一个位置上。 然后,对前 N-1个数据进行同样的操作,则具有次大关键字的数据被安置在数组中倒数第二个位置上。 重复以上过程直至没有记录需要交换为止。
77
//冒泡排序 //数组rec为待排序的序列,count为排序元素的个数 void exchangeSortBubble(int rec[], int count) { int temp; //用于交换的临时变量,可以用rec[0]代替 bool isChanged=true; //设为true,进入循环 for(int round=1; isChanged && round<count; round++) { isChanged=false; //假定本轮没有逆序的偶对 for(int inx=1; inx<=count-round; inx++) if(rec[inx]<rec[inx+1]) //逆序的偶对 { temp=rec[inx]; rec[inx]=rec[inx+1]; rec[inx+1]=temp; isChanged=true; //本轮出现了逆序偶对 }
78
算法中,每一轮中相邻元素都要进行比较,只要逆序就进行交换。
最坏情况下,每次比较完都要交换数据,这时比较与交换的次数都是(n-1)+(n-2)+…+1,所以时间复杂度为O(N2)。
79
分治法与快速排序 在完成一个比较复杂的任务时,如果不能直接解决,我们总会将问题分解成若干小问题,集中精力各个击破。如果每个小问题我们能够顺利解决,那么整个大问题也随之解决了。 第三章中递归问题的相关知识,我们可以看到分治法的影子。 第五章中广义表的处理以及第六章树形结构中一些操作的实现(如遍历等),都用到了类似的思想。 同样,在排序算法中,我们仍旧可以采用分治法,快速排序就是其中之一。
80
快速排序的思想是: 通过某种方法在待排序的数据序列中选取一个数据,并以此数据为基准值,采用一定的方法进行处理,从而将整个序列分成三部分。 基准值处于序列中间的某个位置;该位置之前,数据的关键字都小于该基准值;该位置之后,数据的关键字都大于或等于该基准值。这样就已经把基准值放到了正确的位置上。
81
第一轮结束之后,按照分治的思想,解决基准值之前以及基准值之后的两个子序列,只要这两个子序列完成了排序工作,整个序列的排序就全部结束了。
显然,两个子序列可以采用递归的方式进行排序。 递归的终止条件: 一种情况是子序列为空,这时不需要排序;另一种情况是子序列只有一个元素,也不需要排序。
82
其实,真正在对较多元素排序时,前几轮会使用快速排序的方法,而当每个子序列中元素个数较少时,就会转而用一些简单的排序方法(如冒泡排序或直接选择排序等等)
83
使用快速排序,基准值的选择比较关键。当基准值选得好时,其前后两个子序列长度近似;但当基准值选得不好时,在极端情况下,一个子序列为空,另一个子序列是原来序列中除去基准值的所有元素,这时前后两个子序列极端不平衡,起不到“折半”的目的,效率自然不高。 但由于排序元素大小是随机分布的,所以没有哪一种基准值的选取规则适于所有序列,因此,我们只能在一定程度上采取某些策略,但并不能够保证这些策略在任何情况下都会起作用。
84
在下面的算法中,为了使所取的基准值将来尽量放在数组里居中的位置上,我们先选出首元,尾元以及位置处于数组中间的元素,对这三个数据先排序,然后取出居中的元素作为基准值。
88
可以看到,快速排序是不稳定的排序。 //快速排序 //数组rec为待排序的序列,count为排序元素个数
void exchangeSortQuick(int rec[], int count) { quickSort(rec,1,count); //调用递归函数,完成快速排序 }
89
//递归函数,完成快速排序 void quickSort(int rec[],int 1ow,int high) { int pivot; //基准值的位置 if(low<high) { pivot=partition(rec,low,high); //将序列分成三部分 quickSort(rec,1,pivot-1); //递归处理基准值之前的子序列 quickSort(rec,pivot,high); //递归处理基准值之后的子序列 }
90
//将序列分成三部分:小于基准值的子序列,基准值本身,大于基准值的子序列
//返回值为:排序后基准值在数组的最终位置 void partition(int rec[],int 1ow,int high) { int mid=(low+high)/2; int temp; //用于交换的临时变量,可用rec[0]代替
91
//首元,位置居中的元素,尾元三个元素从小到大排序
if(rec[low]>rec[mid]) { temp=rec[low]; rec[low]=rec[mid]; rec[mid]=temp; } if(rec[low]>rec[high]) { rec[low]=rec[high]; rec[high]=temp; if(rec[mid]>rec[high]) { temp=rec[mid]; rec[mid]=rec[high]; rec[high]=temp;
92
temp=rec[low]; rec[low]=rec[high]; rec[high]=temp; //基准值放在首元位置 int curr; //curr指向当前处理元素 int wall=1ow; //wall指向已排序部分中小于基准值的最后元素, //初始值为首元位置 for(curr=low+1; curr<=high; curr++) if(rec[curr]<rec[low]) { temp=rec[wall+1]; rec[wall+1]=rec[curr]; rec[curr]=temp; wall++; } rec[low]=rec[wall]; rec[wall]=temp; //将基准值放在最终位置上 return wall;
93
快速排序是目前各种内部排序方法中速度最快的一种,可以证明其平均时间复杂度为O(N*log2N)。
算法由于使用递归方法,所以其辅助空间为使用递归时栈空间的最大深度。最差情况下:每一轮过后基准值前后的子序列都极端不平衡(一边为空),这时栈的深度为N,故空间复杂度为O(N)。
94
8.5 归并排序
95
快速排序采用了分治与递归的思想:首先选出基准值,然后将序列分成三部分,基准值居中,前面都是小于它的元素,后面都是不小于它的元素,在将基准值放到最终位置后,再分步解决前后两个子序列的排序问题。这显然是从大规模问题向小规模问题转换的思路。
96
如果换一种角度:在解决问题时,从较小的规模向较大的规模转换,即,先有两个已经排好序的子序列前后放置,然后再集中精力将它们归并成一个大的有序序列,用这种思路也同样可以完成排序的任务。
显然,这种归并排序(Merging Sort)仍旧是分治与递归思想的应用。
97
八个元素不断归并的过程:
98
图中第一轮准备归并的初始状态则是将原序列不断进行分解的结果:由8个元素一组分成4个元素一组,再分成2个元素一组,直到分成每个元素自己一组时,停止分解。
因为此时每组元素本身已经是有序的。在此基础上,才开始进行归并。 归并过程的具体实现思想在例2.2中已经介绍过,这里不再重复。
99
//归并排序。数组rec为待排序的序列,//count为排序元素的个数
void mergeSort(int rec[], int count) { sortMerging(rec,1,count); //调用递归函数完成归并排序,结 //果存在temp数组中 }
100
//递归函数,完成归并排序 void sortMerging(int rec[], int 1ow , int high) { if(low<high) { int mid=(low+high)/2; sortMerging(rec,low,mid); sortMerging(rec,mid+1,high); merge(rec,low,mid,high); }
101
//实现两个有序序列的归并操作, //归并后的序列仍然保持有序 //rec中第一个序列的下标是从low到mid, //第二个序列的下标从mid+1到high void merge(int rec[], int low, int mid, int high) { int curr1,curr2,curr; //分别指向第一个序列、第二个序列、目 //标序列的当前元素 int *temp=new int[count+1]; //开辟临时数组,暂存数据(0号下标不用)
102
curr1=low; curr2=mid+1; //指向两个序列的首元素,准备开始比较 curr=low; //为了方便,temp数组也只用一部分,从 //low到high
103
//当两个序列都有元素时,经过比较选出小者
while(curr1<=mid && curr2<=high) if(rec[curr1]<=rec[curr2]) { temp[curr]=rec[curr1]; curr1++; curr++; }else { temp[curr]=rec[curr2]; curr2++; curr++; }
104
//第二个序列先处理完后,处理第一个序列中
//剩余元素 while(curr1<=mid) { temp[curr]=rec[curr1]; curr1++; curr++; } //第一个序列先处理完后,处理第二个序列中 while(curr2<=high) { temp[curr]=rec[curr2]; curr2++; curr++;
105
//将所有元素拷回到原数组 for(curr=low; curr<=high; curr++) rec[curr]=temp[curr]; delete[] temp; //释放临时空间 }
106
归并排序是稳定的排序。 N个元素在排序过程中一共要做log2N轮。每一轮进行合并时,所有元素都要被比较,故其时间复杂度为O(N*log2N)。 使用的辅助空间一方面是在归并过程中使用的N个元素的暂存空间,另一方面是递归操作引起的栈空间开销(最大深度应为log2N),所以空间复杂度为O(N)。
107
8.6 基数排序 基数排序(Radix Sort)是我们最后讨论的一种内部排序算法,它与其他的内部排序算法思想有着很大的不同。前面几节我们介绍的各种排序方法都是以关键字的比较与数据的移动为主,但基数排序却主要借助“分配”和“收集”两种操作来完成排序任务。
108
利用第三章所讲的队列来做暂存数据的辅助空间。
为了简化讨论,我们假定待排序的N个关键字都在0至999之间。下面叙述排序过程。
109
第一步:先初始化十个队列,每个队列中都存放十进制整数。我们分别命名十个队列为:0号队列、1号队列、2号队列、…、9号队列。
110
第二步:依次取出数据,按照数据的个位数依次插入对应的队列。如数据231,其个位数为1,所以将它插入1号队列。所有元素都被分配到十个队列之后,完成第一次“分配”操作。
111
第三步,按照队列号的大小依次取出队列中元素存入原数组。
也就是说,先依次取出0号队列的所有元素,并从原数组下标为1处开始,一个挨一个地存放数据;当0号队列中的数据全部被取出后,开始依次取出1号队列的所有元素继续放入数组;当1号队列的数据被全部取出后,开始依次取出2号队列的所有元素继续放入数组;…;直到9号队列的所有元素全部被取出并放入数组。
112
第一次“收集”操作结束。到此为止,所有数据已经按照末位数的大小,从小到大排好了顺序。
113
第四步,按照数据的十位数依次插入对应的队列。
如数据231,其十位数为3,所以将它插入3号队列。 所有元素都被分配到十个队列之后,完成第二次“分配”操作。 注意,到此为止,同一个队列中所有数据的十位数都是一样的,而且末位数是按照从小到大的顺序存放的。因为在入队时,个位数字小的元素先进入队列,这是第三步操作的结果决定的。
115
第二次“收集”操作结束。到此为止,所有数据已经按照末两位数的大小,从小到大排好了顺序。
第五步,按照队列号的大小依次取出队列中元素存入原数组。操作方法与第三步完全相同。 第二次“收集”操作结束。到此为止,所有数据已经按照末两位数的大小,从小到大排好了顺序。
117
第六步,按照数据的百位数依次插入对应的队列。
如数据231,其百位数为2,所以将它插入2号队列。所有元素都被分配到十个队列之后,完成第三次“分配”操作。 注意:到此为止,同一个队列中所有数据的百位数都是一样的,而且末两位数是按照从小到大的顺序存放的。因为在入队时,末两位数字小的元素先进入队列,这是第五步操作的结果决定的。
119
第七步,按照队列号的大小依次取出队列中元素存入原数组。操作方法与第三步完全相同。第三次“收集”操作结束。
到此为止,所有数据已经按照末三位数的大小(数据本身值的大小),从小到大排好了顺序。到此为止,排序结束。
121
在上面的操作中,开辟的辅助空间就是十个队列,由于它们最终要容纳下所有的待排序数据,所以队列所占总空间数为N。由此得到空间复杂度为O(N)。
从描述过程看出,排序每一轮都要经历依次“分配”操作和一次“收集”操作,所以每一轮数据经过两次移动;而排序的轮数是由最大数据关键字的长度R决定的(例子中最大数是三位数,故排序共做三轮),由此知时间复杂度为O(R*N)。
122
8.7 外部排序概述 对文件(第十章将专门介绍文件)进行排序,是文件上十分重要的运算,在许多地方都要用到。
8.7 外部排序概述 对文件(第十章将专门介绍文件)进行排序,是文件上十分重要的运算,在许多地方都要用到。 通常情况下,由于文件很大,无法把整个文件的所有记录同时调入内存中进行排序,即:无法直接进行内部排序,从而需要研究外存设备上(文件)的排序技术,我们称这种排序为外部排序。
123
外部排序方法与各种外存设备的特征有关。 外存设备大体上可分为顺序存取(例如磁带)和直接序取(例如磁盘)两大类,所以,外部排序通常又可分为磁盘文件排序和磁带文件排序两大类。 两类排序的基本步骤相类似,主要不同之处在于初始归并段在外存储介质中的分布方式。
124
一般来说,在外存设备上排序的最通用方法是归并排序法,这种方法基本上由两个相对独立的阶段组成。
125
首先,按可用内存大小,将外存上含n个记录的文件分成若干长度为L的子文件(或段),用某种有效的内部排序方法分别将这些段读入内存进行排序,并将排序后得到的有序子文件重新写入外存,通常称这些有序子文件为“归并段”或“顺串”; 然后,把第一阶段所生成的顺串进行逐趟合并(例如通过若干趟2路合并),使顺串(有序的子文件)逐渐由小变大,直至最终变为一个顺串为止,这时整个文件已经成为“有序文件”。
126
由于在排序过程中,需要读写外部存储设备当中的文件,而这样的操作显然比较费时,所以提高外部排序的效率应主要着眼于减少外存信息的读/写次数,而不是我们在内部排序中关注的两个操作:关键字进行的比较次数和数据互换移动的次数。 针对这样的情况,可以从不同角度减少读写外存的次数:为了减少归并次数,使用多路平衡归并(可以借助于“败者树”完成);为了尽量得到较长的“归并段”,采用置换选择排序的方法,并进一步对所得长度不等的归并段构造最佳归并树等等。
127
8.8 本章小结 基本内容: 内部排序: 插入排序 选择排序 交换排序 归并排序 基数排序
128
在内部排序中我们主要关注两类操作:关键字的比较次数与数据元素位置的移动与互换次数。
对于外部排序,主要使用归并的思想,另外考虑到读取外存的时间较计算与内存访问时间来说非常耗时,所以在外部排序中尽量要注意减少外存读取的次数。
129
在各种排序算法中,时间效率较高的有希尔排序、堆排序和快速排序三种。
基数排序由于采用的是分配收集的操作,所以它与其他内部排序的算法在思路上有着较大的差别。 在介绍快速排序与归并排序时,我们又使用了“分治法”的思想,将复杂的问题化大为小,以便各个击破,分而治之。
130
基本要求: 深刻理解排序的定义和各种排序方法的特点,并能加以灵活运用。
基于“关键字间的比较”进行排序的方法可以按排序过程所依据的不同原则分为插入排序、交换排序、选择排序、归并排序和基数排序五类。了解各种方法的排序过程及其依据的原则。
131
理解排序方法“稳定”和“不稳定”的含义,弄清楚在什么情况下要求应用的排序方法必须是稳定的。
掌握各种排序方法的时间复杂度的分析方法。能从“关键字间的比较次数”分析排序算法的平均情况和最坏情况的时间性能。按平均时间复杂度划分,内部排序可分为三类:O(N2)的简单排序方法,O(N*logN)的高效排序方法和O(R*N )的基数排序方法。
132
希尔排序、快速排序、堆排序和归并排序等高效方法是本章的学习重点和难点。
了解外部排序的两个阶段,及外部排序的基本思想。 进一步掌握“分治法”的思想。
Similar presentations