Upload
elsa
View
86
Download
0
Embed Size (px)
DESCRIPTION
第 8 章 排 序. 8.1 排序技术概述. 8.2 插入排序. 8.3 选择排序. 8.4 交换排序. 8.5 归并排序. 8.6 基数排序. 8.7 外部排序概述. 8.8 本章小结. 8.1 排序技术概述. 从操作角度看,排序是线性结构的一种操作。 为了提高排序效率,人们已对排序进行了许多研究,提出了许多方法。. 排序就是按照某种规则,为一组给定的对象排列次序。 排序的 主要目的 是:在排好序的集合中能够快速查找(检索)一个元素。. - PowerPoint PPT Presentation
Citation preview
第 8 章 排 序 8.1 排序技术概述 8.2 插入排序8.3 选择排序8.4 交换排序8.5 归并排序8.6 基数排序8.7 外部排序概述 8.8 本章小结
8.1 排序技术概述
从操作角度看,排序是线性结构的一种操作。
为了提高排序效率,人们已对排序进行了许多研究,提出了许多方法。
排序就是按照某种规则,为一组给定的对象排列次序。
排序的主要目的是:在排好序的集合中能够快速查找(检索)一个元素。
所谓“内部”排序,就是指整个排序过程都是在内存中进行的。
如果排序的数据项很多,内存不足以存放得下全部数据项时,排序过程就需要对外存进行存取访问,也就是“外部”排序。
本章的内容以内部排序为主,对外部排序只进行简单地介绍。
我们把查找时关注或使用的数据叫做关键字( key ),它可以是数据信息当中的一个属性,也可以是几个属性的组合。
关键字可以代表其所在的那项数据信息。在这项数据信息当中,关键字所取的值叫做键值。
在本章中,为了突出排序算法本身的内容,我们简化各项数据的属性个数。 假设待排序的数据都只有一个属性,这个属性就是关键字,并且关键字的类型是整型。
我们可以把排序看成是一种定义在某种数据集合上的操作。
本章所讲的各种内部排序,都可以认为是在一维数组这种线性数据结构上定义的操作。其功能是将一组任一排列的数据元素重新排列成一个按键值有序的序列。
对排序更为确切的定义 : 假设 {D1,D2,…,DN} 为含有 N 项数据的序列,其中 Di ( 1≤i≤N )表示序列中第 i 项数据, N 项数据对应的键值序列为 {K1,K2,…,KN} 。 排序操作是将这些数据重新排列成一个按键值有序的新序列 {Rp1,Rp2,…,RpN},使得相应的键值满足条件 p1≤p2≤…≤pN (此时新序列成“升序”)或 p1≥p2≥…≥pN (此时新序列成“降序”)。
注意:在上面定义叙述中所用到的≤或≥这两个比较符号,是通用意义上的关系比较符。 对于数值型信息,它是表示关系的小或大;对于字符串来说,它是指在字典中所排列位置的前或后。
对于不同类型的数据,我们可以规定不同的比较规则来反映≤或≥的含义。
如果在数据序列中,有多个具有相同键值的数据,在排序前后,这些数据之间的相对次序保持不变,这样的排序方法被称作是稳定的,否则被称为是不稳定的。
分析算法效率:
1. 从空间角度进行:主要是看 执行算法时所需附加空间的数量;
2. 从时间角度进行:从两种操作入手进行分析。
键值的比较次数
数据移动的次数
8.2 插入排序
插入排序是指在待排序的序列中,分成两部分:前面为已经排好序的部分,后面为仍未排好序的部分。 每一轮都从没有排好序部分取出首元素按照大小将其插到前面已经排好序部分中的合适位置上。 这样前面已排序部分就多了一个元素,后面未排序部分同时少了一个元素。该排序过程不断进行,直到未排序部分的元素数目为零。
插入排序 将待排序的序列分成两部分:前面
为已经排好序的部分,后面为仍未排好序的部分。
每一轮都从没有排好序部分取出首元素按照大小将其插到前面已经排好序部分中的合适位置上。
这样前面已排序部分就多了一个元素,后面未排序部分同时少了一个元素。该排序过程不断进行,直到未排序部分的元素数目为零。
排序过程如图 8.1 所示。
图( a )为该轮排序前的状态,图( b )为该轮排序后的状态,
阴影表示本轮待排序的元素,在图( b )中它已经插入到已排好序部分的合适位置上。
本轮待插入元素
未排好序部分已排好序部分
插入到合适位置
(a)本轮插入前序列状态
未排好序部分已排好序部分(b)本轮插入后序列状态
注意: 在排序过程开始时,直接把序列中第一个元素认为是已排好序部分。另外,用整型数组代表排序的数据元素序列,数组下标为零的元素我们当作辅助空间,所以从数组下标是 1 的空间开始存储元素序列。
在把待插入元素从未排序部分移入已排好序部分时,有多种方法,下面逐一介绍。
8.2.1 直接插入排序8.2.2 折半插入排序
8.2.3 2_ 路插入排序
8.2.4 表插入排序
8.2.5 希尔排序
8.2.1 直接插入排序
直接插入排序 又称简单插入排序。排序过程中,待插入元素先放在临时空间 hold 中,依次让它和前面的元素作比较,直到发现其应插入的位置,将其插入。
// 不带哨兵的直接插入排序。//数组 rec 为待排序的序列,//count 为排序元素的个数void insertSortSimple(int rec[], int count) { int curr , hold; for(int inx=2; inx<=count; inx++) // 从第二个元素起依次做插入排序 { hold=rec[inx]; curr=inx-1;
// 从已排好序部分的尾元素开始向前 //查找待插入位置 while(curr>=1 && hold<rec[curr]) { rec[curr+1]=rec[curr]; curr--; } rec[curr+1]=hold; // 将待插入元素插到合适的位置 }}
算法中, while语句的条件表达式由两部分组成, curr>=1 保证数组下标不会越界( curr 不会被减到低于零 ,rec[curr] 保证有意义),但这样做就会增加一次条件判断。
为了提高效率,我们把待插入元素先放在临时空间 rec[0] 中,依次让它和前面的元素作比较,直到发现其应插入的位置,将其插入。
注意 rec[0]起到了“哨兵”的作用。因为 while语句的条件表达式使得 curr 不会被减到低于零。
//改进后的直接插入排序
//数组 rec 为待排序的序列, count 为排序元//素的个数insertSortSimple(int rec[], int count) { int curr; for(int inx=2; inx<=count; inx++) // 从第二个元素起依次做插入排序 { rec[0]=rec[inx]; curr=inx-1;
// 从已排好序部分的尾元素开始向前 //查找待插入位置 while(rec[0]<rec[curr]) { rec[curr+1]=rec[curr]; curr--; } rec[curr+1]=rec[0]; // 将待插入元素插到合适的位置 }} 从 while语句的条件表达式得知直接插入排序是稳定的排序算法 。
分析算法中元素比较与移动的次数
如果每轮待插入的元素都是在已排好序部分中的最大元素,那么每轮比较次数只有一次(只和已排好序部分的尾元比较一次),而且不用移动元素。 N 个元素的序列只做了N-1 轮的操作,所以标准操作次数 Tmin=(N-
1)*1+(N-1)*0=N-1=O(N) ,显然这种方式对应的情况是原序列已经是升序。
如果原序列是降序,而最终结果应该是升序,故每一轮中待插入元素要和所有已排好序的元素进行比较(包括哨兵在内),而且这些元素都要依次后移(哨兵不后移),以便将首元位置空出,让待插元素插到最前面的位置。这样, 比较次数为: Tcompare=2+3+…+N=(N+2)*(N-1)/2 移动次数为: Tshift=1+2+…+(N-1)=N*(N-1)/2 所以标准操作次数: Tmax=Tcompare+Tshift=O(N
2) 。
注意: 上面计算移动记录次数的过程中,并没有考虑以下两个赋值操作:给哨兵赋值的操作和将待插元素插到正确位置的赋值操作。 如果将这两项计算在内,那么移动次数都应再加上 2(N-1) 的值。考虑到排序序列中各元素值是随机取值的,所以它们之间大小排列也是随机的,因此各种排列概率相同,我们取最差时间效率与最好时间效率的平均值做为直接插入排序的平均时间复杂度,为 O(N2) 。在算法中我们多开了一个辅助空间,故空间复杂度为 O(1) 。
8.2.2 折半插入排序
在向有序表中插入元素的过程中,直接插入排序采用的方法是:从后向前依次与有序表中元素作比较。没有充分利用“前部分已经有序”的特性,这种方法效率较低。
考虑改进的方法:
设定有序表长度为 L ,那么让待插元素 x和处于有序表中间位置的元素 y 作比较: 如果待插元素 x 大于或等于 y ,则说明插入的位置一定在有序表的后半部分; 如果待插元素 x 较小,则说明插入的位置一定在有序表的前半部分。 无论哪种情况,我们都可以将可能插入的位置所在的空间长度降至原来长度的一半,故称“折半”插入排序。
对于长度为 L 的有序表,大约需要 log2L 次的比较就可以确定出待插元素的位置。因此对于 N 个元素的序列来说,进行 N-1 轮的所有比较次数为 O(N*log2
N) ,显然优于直接插入法中平均情况下的比较次数 O(N2) 。但是在确定了待插位置之后,元素移动的次数并没有降下来,所以时间复杂度仍为有 O(N2) 。
算法中仍旧多开了一个辅助空间,故空间复杂度为 O(1) ,保持不变。
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)
{ 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; // 将待插入元素插到合适的位置 }}
8.2.3 2_路插入排序
在折半插入排序的基础上,再针对数据元素的移动进行算法的改进,就得到 2_路插入排序算法。
我们开设一个辅助数组 temp ,其大小为待排序元素的个数,把它当作是循环数组。 将第一个元素放在数组 temp[0] 中,并把它当作是有序表中处于中间位置的元素。 从第二个元素开始,每个元素在做插入的时候,都先与 temp[0] 进行比较。 若是小于 temp[0] ,则往它前面的有序表中插;否则,往其后面的有序表中插。 有序表首元素和尾元素的下标分别用 first与 last 表示。显然,开始状态下,有序表中仅有 temp[0] ,所以 first=last=0 。 由于多开了辅助数组,所以该算法的空间复杂度为 O(N) 。
- 1242 94125544 57
44
rec数组
temp数组
fi rst l ast
44 55
fi rst l ast
44 1255
fi rstl ast
44 421255
fi rstl ast
44 42129455
fi rstl ast
44 4212 129455
fi rstl ast
44 4212 12945755
fi rstl ast
rec[1] temp[0]将 直接拷入
rec[2] l ast处理 ,修改
rec[3] fi rst处理 ,修改
rec[4] fi rst处理 ,修改
rec[5] l ast处理 ,修改
rec[6] fi rst处理 ,修改
rec[7] l ast处理 ,修改
8. 2 2_图 示例 路插入排序的过程
//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;
}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; //释放空间}
在上面的算法中,确实在一定程度上减少了数据移动的次数,但仍旧没有完全解决数据移动的问题。由第二章线性表中的知识,我们知道:要想彻底提高数据移动的效率,就应该使用链表结构的存储方式。下面就介绍用数组实现的静态链表完成的插入排序。
8.2.4 表插入排序 当希望在排序过程中不移动记录时,我们可以借助链表增删节点时的思想,用数组来实现链表的特征。在链表中,每个节点都由数据域和地址域两部分组成。类似的,让数组元素也由数据域和地址域组成。一个元素地址域中记录的是逻辑上该元素直接后继的下标值。也就说,数组中物理地址相邻的两个元素在逻辑上不一定前后相接。
72 1578 8192712
15 7227 1219788
0 64 5321
-
0 1 2 3 4 5 6 7:下标
-
7
0 1 2 3 4 5 6 7:下标
15 7227 1219788
∧:头指针
:数据域
:地址域
(a)用数组存储
(b)用单链表存储
(c)用静态链表存储
8. 3 图 存储线性表L=(72, 12, 27, 19, 78, 8, 15)的三种方法
地址域中表示的先后关系
// 静态链表节点(数组元素)的类型声明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; // 地址域为零表示该元素为尾元
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;}
上面的算法中,由于引入了静态链表这个辅助空间,故空间复杂度为 O(N) 。
移动次数就是原数组与辅助数组之间的两次互相赋值,故为 2N ; 在关键字比较的操作上,效率与前述算法一致,故时间复杂度为 O(N2) 。
8.2.5 希尔排序 在分析直接插入排序的时间复杂度时,可以看到:如果数据序列本身已是升序的,那么时间复杂度为 O(N) 。因此,如果能够保证在使用直接插入排序之前,数据序列已经基本有序,则时间复杂度就会基本接近 O(N) 。借助于这种思路,我们希望一个乱序的数据序列在每一轮操作中,较大的数据尽快地跳到序列较后的部分,而较小的数据尽快地跃到序列较前的部分。这样经过几轮之后,序列已经基本有序,在此基础上再使用一次直接插入排序即可。
为了能够使数据尽快跳到自己应该在的位置附近,就不能向以前一样:先依次同位置相近的元素作比较互换等操作。因为即使换了好几次,元素仍旧停在原位置附近。我们应让位置离得较远的元素进行比较和互换,这样才能达到前述的要求。
希尔排序( Shell Sort )又称“缩小增量排序”正是基于上述这种思想进行的排序操作。 该排序方法将序列中的元素分成小组,同组元素之间的跨度称作“增量”。对于同组的元素进行直接插入排序,每一轮对各组元素都进行一遍直接插入排序。每轮之后,都会缩小增量,减少组数。当经过几轮之后,数据各元素已经基本有序,这时候将增量缩小至 1 ,也就是说所有元素都成了一组,在最后这一轮操作中,使用直接插入排序对所有元素进行排序即可。
44 5794 12421255- 766 44
44 5794 12421255- 766 44
12 5794 44421255- 766 44
12 5794 44421255- 766 44
12 5794 4442655- 7612 44
12 5794 4442655- 7612 44
12 5776 4442655- 9412 44
初始状态
第一轮5增量为
12 5776 4442655- 9412 44
6 5744 44421255- 9412 76
6 5744 44421212- 9455 76
6 5744 44421212- 9455 76
6 5544 44421212- 9457 76
第二轮2增量为
第三轮1增量为
由上面排序的实例,可以看出希尔排序不是稳定的排序方法。
如果在 N 个元素进行排序时,增量 inc按照规则 ( inc0=N/3 , inci+1=inci/2 )取值,那么希尔算法的实现如下:
// 希尔插入排序//数组 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; // 将待插入元素插到合适的位置 } }}
关于增量的选择,有很多经验与看法,这里不再叙述。希尔排序所用的时间是一系列增量的函数,增量序列的不同,会导致时间复杂度略微不一样。有经验数据表明,希尔排序时间复杂度为O(N5/4) 。
8.3 选择排序
选择排序是指在排序过程中的每一轮,按照某种方法选择出当前未排序部分中的最小值,把它放在已排序部分的首部,随着排序过程的进行,已排序部分变大,未排序部分变小。直到比较 N-1 轮之后,所有元素都在有序部分内,排序过程结束。
8.3.1 直接选择排序 (Straight selection sort)
直接选择排序,又称简单选择排序
N 个元素进行直接选择排序的过程如下: 在第一轮,从下标为 1 的数组元素开始选出最
小的元素,把它与数组下标为 1 的元素互换,这时已排序部分长度为 1 ;
第二轮,从下标为 2 的数组元素开始在剩余的N-1 个元素中选出最小的元素,让它与数组下标为 2 的元素互换,这时已排序部分长度为 2 ;
……
从下标为 N-1 的数组元素开始,在剩下的两个元素中选出最小值,将最小值放在下标为 N-1 的数组元素中。由于 N 个元素中 N-1 个元素都已经放到了合适的位置,所以第 N 个元素也已经放到了合适的位置。 整个过程只需 N-1 轮。
// 直接选择排序//数组 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; } }}
算法中: 每一轮一般都要在最后做交换操作。一次交换涉及到 3 个赋值语句,所以有3(N-1) 次赋值操作。而比较操作执行的次数为 (N-1)+…+2+1 ,所以时间复杂度仍为 O(N2) 。
算法中在作交换操作时借用了一个辅助变量 temp ,故空间复杂度为 O(1) 。
8.3.2 堆排序 (Heap Sort)
最大堆与最小堆 如果完全二叉树各节点的关键字之间满足
以下的关系,我们就称其为最大堆。
K≥Kleft和 K≥Kright
其中 K 为某节点的关键字, Kleft 为该节点左子(如果左子存在)的关键字, Krigh
t 为该节点右子(如果右子存在)的关键字。
类似的,可以得到最小堆的定义:
如果完全二叉树各节点的关键字之间满足以下的关系,我们就称其为最小堆。
K≤Kleft 和 K≤Kright
其中 K 为某节点的关键字, Kleft 为该节点左子(如果左子存在)的关键字, Kright 为该节点右子(如果右子存在)的关键字。
由最大堆的定义能够知道 : 一个二叉树如果是最大堆,那么其根的关键字一定是该二叉树中所有节点关键字中的最大值。
72
7112219
27 12
8
78
15 19
3
625
4 1
(a)最大堆 (b)最小堆 (c)该完全二叉树不是堆
如果用一维数组(数组下标为 0 的空间不用,从下标为 1 的空间开始)来存储最大堆,那么当前节点的数组下标 R 与其左子、右子(假设左子、右子存在)对应的数组下标 Rleft、 Rrig
ht 之间的关系满足:Rleft=2R , Rright=2R+1
17
11
161514
12 13 11 1715 16141312-
1 7654320
当把一维数组中各元素看成是一个完全二叉树中各节点时 :
我们通过一定的方法,把数组调整成最大堆(堆的初始建立),这样最大的元素就位于下标为 1 的地方,之后让其与下标为 N 的元素互换,也就是让最大元素放在了它该放的地方。
排序第一轮结束。
由于数组 1 号下标的位置换进了新元素,所以原来的最大堆可能遭到了破坏,我们再按照某种方法对其进行调整(堆的调整),将数组前 N-1 个元素再次构成一个最大堆,调整后下标为 1 的位置中存储的是 N-1 个元素中的最大值,把它与数组下标为 N-1 的元素互换,这样整个序列的次大元素也放到了该放的地方 .
排序第二轮结束。
继续按照这种方法进行,直到 N-1 轮全部完成,所有数组元素就会变成有序的序列。
显然这种思想仍旧属于选择排序的方法。由于排序过程中借助了堆,所以称为堆排序。
该方法是威洛姆斯在 1964年提出的。
只要解决上面提到的两个问题:堆的初始建立与堆的调整,
我们就可以完成排序。
先来看第二个问题,在已有的最大堆上让首元(根)与尾元(最后一个叶子)互换。互换后,原来的尾元不再归入新一轮的研究范围内。在新的研究范围内,由于互换使得新首元不一定是最大值,所以要对其进行向下的调整,使得新研究范围内所涉及到的节点构成最大堆。
96
94238
83 27
(a)最大堆
9
964238
83 27
(b)准备调整
83
964238
9 27
(c)与其左子互换
83
96938
42 27
(d)与其右子互换
堆调整的算法 算法中尽量避免互换操作,而是在调整过程中只让较大的元素上浮,同时记住下降位置的下标,直到停止调整时,一次将待调整元素赋值到相应位置。 // 在考察范围内 ,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]) //若右子存在,且较左子大
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; // 将待调整元素存入到合适位置}
接下来讨论前述的第一个问题 : 如何把乱序的一维数组调成最大堆。
数组所有元素对应一棵完全二叉树。 二叉树中叶子节点不需要进行调整,所以只考虑树中那些非叶子节点。 由于堆也是递归结构,当左子树与右子树已经是堆时,再对根进行调整才是有效的,所以我们调整的次序是从最后一个非叶子节点开始处理,按照数组下标从后往前进行,直到最后再处理根节点。
因为最后一个叶子节点的父节点就是最后一个非叶子节点,所以讨论最后一个非叶子节点的下标值。 N 个元素的数组( 0 号下标空间不用),最后一个节点下标为 N ,如果它是父节点的右儿子( N 为奇数),则父节点下标为 (N-1)/2 ;如果它是父节点的左儿子( N 为偶数),则父节点下标为 N/2 。 注意: C++ 语言中整除运算只取商,故对于 N个元素的完全二叉树来说,其最后一个非叶子节点在数组中的下标对应的 C++ 语言表达式为 : ( N/2 )
堆初始建立的算法
//建堆操作 : 数组 rec 为待建堆的序列, count为元素的个数void buildHeap(int rec[], int count) { for(int inx=count/2; inx>=1; inx--) reHeap(rec,low,count);}
//堆排序//数组 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); // 在新范围内调整堆 }}
在算法中可以看到, 时间主要耗费在建堆与对堆的调整上。
可以证得,堆在最坏的情况下,时间复杂度为 O(N*log2N) ,这已经是排序的最好效率了。
空间复杂度仍为 O(1) 。
8.4 交换排序 交换排序的基本方法是: 两两比较待排序元素,并交换不满足顺序
要求的那些偶对,直到全部满足为止。 这里介绍两种交换排序,一种是最简单的冒泡排序 (Bubble Sort) ,另一种是效率很高的快速排序 (Quick Sort) 。
8.4.1 冒泡排序
8.4.2 分治法与快速排序
冒泡排序的过程很简单,首先将第 1 个数据的关键字和第 2 个数据的关键字进行比较,若为逆序(前大后小),则将两个记录交换之。再比较第 2 个和第 3 个数据的关键字,依此类推,直到第 N-1 个数据和第 N 个数据进行比较、交换为止。如此经过一轮排序,使得关键字最大的数据被安置到数组最后一个位置上。 然后,对前 N-1 个数据进行同样的操作,则具有次大关键字的数据被安置在数组中倒数第二个位置上。 重复以上过程直至没有记录需要交换为止。
8.4.1 冒泡排序(起泡排序)
20
30
10
40
20
0
20
30
10
40
20
0
20
10
30
40
20
0
20
10
30
40
20
0
20
10
30
20
40
0
20
10
30
20
0
40
20
10
30
20
0
40
10
20
30
20
0
40
10
20
30
20
0
40
10
20
20
30
0
40
10
20
20
0
30
40
(a) 1第 轮排序 (b) 2第 轮排序
10
20
20
0
30
40
10
20
20
0
30
40
10
20
0
20
30
40
10
20
0
20
30
40
10
20
0
20
30
40
10
20
0
20
30
40
10
0
20
20
30
40
10
0
20
20
30
40
0
10
20
20
30
40
(c) 3第 轮排序 (d) 4第 轮排序 (e) 5第 轮排序
// 冒泡排序//数组 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; // 本轮出现了逆序偶对 } }}
算法中,每一轮中相邻元素都要进行比较,只要逆序就进行交换。
最坏情况下,每次比较完都要交换数据,这时比较与交换的次数都是 (n-1)+(n-2)+…+1 ,所以时间复杂度为 O(N2) 。
8.4.2 分治法与快速排序 在完成一个比较复杂的任务时,如果不能直接解决,我们总会将问题分解成若干小问题,集中精力各个击破。如果每个小问题我们能够顺利解决,那么整个大问题也随之解决了。 第三章中递归问题的相关知识,我们可以看到分治法的影子。 第五章中广义表的处理以及第六章树形结构中一些操作的实现(如遍历等),都用到了类似的思想。 同样,在排序算法中,我们仍旧可以采用分治法,快速排序就是其中之一。
快速排序的思想是: 通过某种方法在待排序的数据序列中选取一个数据,并以此数据为基准值,采用一定的方法进行处理,从而将整个序列分成三部分。 基准值处于序列中间的某个位置;该位置之前,数据的关键字都小于该基准值;该位置之后,数据的关键字都大于或等于该基准值。这样就已经把基准值放到了正确的位置上。
第一轮结束之后,按照分治的思想,解决基准值之前以及基准值之后的两个子序列,只要这两个子序列完成了排序工作,整个序列的排序就全部结束了。 显然,两个子序列可以采用递归的方式进行排序。
递归的终止条件: 一种情况是子序列为空,这时不需要排序;另一种情况是子序列只有一个元素,也不需要排序。
其实,真正在对较多元素排序时,前几轮会使用快速排序的方法,而当每个子序列中元素个数较少时,就会转而用一些简单的排序方法(如冒泡排序或直接选择排序等等)
使用快速排序,基准值的选择比较关键。当基准值选得好时,其前后两个子序列长度近似;但当基准值选得不好时,在极端情况下,一个子序列为空,另一个子序列是原来序列中除去基准值的所有元素,这时前后两个子序列极端不平衡,起不到“折半”的目的,效率自然不高。 但由于排序元素大小是随机分布的,所以没有哪一种基准值的选取规则适于所有序列,因此,我们只能在一定程度上采取某些策略,但并不能够保证这些策略在任何情况下都会起作用。
在下面的算法中,为了使所取的基准值将来尽量放在数组里居中的位置上,我们先选出首元,尾元以及位置处于数组中间的元素,对这三个数据先排序,然后取出居中的元素作为基准值。
44 5794 82427655- 7686 84
44 5776 82427655- 9486 84
76 5744 82427655- 9486 84
基准值
76 5744 82427655- 9486 84
基准值 wal lcurr
76 5744 82427655- 9486 84
基准值 wal l curr
76 5744 82427655- 9486 84
基准值 wal l curr
76 5744 82764255- 9486 84
基准值 wal l curr
76 5776 82444255- 9486 84
基准值 wal l curr
76 5776 82444255- 9486 84
基准值 wal l curr
76 7657 82444255- 9486 84
基准值 wal l curr
76 7657 82444255- 9486 84
基准值 wal l curr
76 7657 82444255- 9486 84
基准值 wal l curr
76 7657 82444255- 9486 84
基准值 wal l curr
57 7676 82444255- 9486 84
wal l基准值
可以看到,快速排序是不稳定的排序。
// 快速排序//数组 rec 为待排序的序列, count 为排序元素个数void exchangeSortQuick(int rec[], int count) { quickSort(rec,1,count); //调用递归函数,完成快速排序}
//递归函数,完成快速排序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); //递归处理基准值之后的子序列 }}
// 将序列分成三部分 : 小于基准值的子序列 ,基准值本身 , 大于基准值的子序列//返回值为 : 排序后基准值在数组的最终位置void partition(int rec[],int 1ow,int high) { int mid=(low+high)/2; int temp; // 用于交换的临时变量 , 可用 rec[0] 代替
// 首元 , 位置居中的元素 ,尾元三个元素从小到大排序 if(rec[low]>rec[mid]) { temp=rec[low]; rec[low]=rec[mid]; rec[mid]=temp; } if(rec[low]>rec[high]) { temp=rec[low]; rec[low]=rec[high]; rec[high]=temp; } if(rec[mid]>rec[high]) { temp=rec[mid]; rec[mid]=rec[high]; rec[high]=temp; }
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++; } temp=rec[low]; rec[low]=rec[wall]; rec[wall]=temp; // 将基准值放在最终位置上 return wall;}
快速排序是目前各种内部排序方法中速度最快的一种,可以证明其平均时间复杂度为 O(N*log2N) 。
算法由于使用递归方法,所以其辅助空间为使用递归时栈空间的最大深度。最差情况下:每一轮过后基准值前后的子序列都极端不平衡(一边为空),这时栈的深度为 N ,故空间复杂度为 O(N) 。
8.5 归并排序
快速排序采用了分治与递归的思想:首先选出基准值,然后将序列分成三部分,基准值居中,前面都是小于它的元素,后面都是不小于它的元素,在将基准值放到最终位置后,再分步解决前后两个子序列的排序问题。这显然是从大规模问题向小规模问题转换的思路。
如果换一种角度:在解决问题时,从较小的规模向较大的规模转换,即,先有两个已经排好序的子序列前后放置,然后再集中精力将它们归并成一个大的有序序列,用这种思路也同样可以完成排序的任务。 显然,这种归并排序 (Merging Sort)仍旧是分治与递归思想的应用。
八个元素不断归并的过程 :
44 5794 82427655 86
44 5782 94784255 86
42 8657 82785544 94
42 8678 82575544 94
图中第一轮准备归并的初始状态则是将原序列不断进行分解的结果 :由8 个元素一组分成 4 个元素一组,再分成 2 个元素一组,直到分成每个元素自己一组时,停止分解。 因为此时每组元素本身已经是有序的。在此基础上,才开始进行归并。 归并过程的具体实现思想在例 2.2 中已经介绍过,这里不再重复。
// 归并排序。数组 rec 为待排序的序列, //count 为排序元素的个数
void mergeSort(int rec[], int count) { sortMerging(rec,1,count); //调用递归函数完成归并排序 , 结 //果存在 temp 数组中}
//递归函数,完成归并排序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); }}
//实现两个有序序列的归并操作,// 归并后的序列仍然保持有序//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 号下标不用 )
curr1=low; curr2=mid+1; // 指向两个序列的首元素,准备开始比较
curr=low; // 为了方便 ,temp 数组也只用一部分 ,从 //low 到 high
// 当两个序列都有元素时 , 经过比较选出小者
while(curr1<=mid && curr2<=high) if(rec[curr1]<=rec[curr2]) { temp[curr]=rec[curr1]; curr1++; curr++; }else { temp[curr]=rec[curr2]; curr2++; curr++; }
// 第二个序列先处理完后 ,处理第一个序列中// 剩余元素 while(curr1<=mid) { temp[curr]=rec[curr1]; curr1++; curr++; }// 第一个序列先处理完后 ,处理第二个序列中// 剩余元素 while(curr2<=high) { temp[curr]=rec[curr2]; curr2++; curr++; }
// 将所有元素拷回到原数组 for(curr=low; curr<=high; curr++) rec[curr]=temp[curr]; delete[] temp; //释放临时空间}
归并排序是稳定的排序。
N 个元素在排序过程中一共要做 log2N
轮。每一轮进行合并时,所有元素都要被比较,故其时间复杂度为 O(N*log2N) 。 使用的辅助空间一方面是在归并过程中使用的 N 个元素的暂存空间,另一方面是递归操作引起的栈空间开销(最大深度应为 log2N ),所以空间复杂度为 O(N) 。
8.6 基数排序
基数排序 (Radix Sort) 是我们最后讨论的一种内部排序算法,它与其他的内部排序算法思想有着很大的不同。前面几节我们介绍的各种排序方法都是以关键字的比较与数据的移动为主,但基数排序却主要借助“分配”和“收集”两种操作来完成排序任务。
利用第三章所讲的队列来做暂存数据的辅助空间。
为了简化讨论,我们假定待排序的 N 个关键字都在 0 至 999 之间。下面叙述排序过程。
第一步:先初始化十个队列,每个队列中都存放十进制整数。我们分别命名十个队列为: 0 号队列、 1 号队列、 2 号队列、…、 9 号队列。
244 457894 82642176355- 17686 984
Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7 Q8 Q9
244 457
89482
642 176355
176
86
984
第二步:依次取出数据,按照数据的个位数依次插入对应的队列。如数据 231 ,其个位数为 1 ,所以将它插入 1 号队列。所有元素都被分配到十个队列之后,完成第一次“分配”操作。
第三步 ,按照队列号的大小依次取出队列中元素存入原数组。 也就是说,先依次取出 0 号队列的所有元素,并从原数组下标为 1处开始,一个挨一个地存放数据;当 0 号队列中的数据全部被取出后,开始依次取出 1 号队列的所有元素继续放入数组;当 1 号队列的数据被全部取出后,开始依次取出 2 号队列的所有元素继续放入数组;…;直到 9号队列的所有元素全部被取出并放入数组。
Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7 Q8 Q9
244 457
89482
642 176355
176
86
984
642 17698435589424482- 45786 176
第一次“收集”操作结束。到此为止,所有数据已经按照末位数的大小,从小到大排好了顺序。
第四步,按照数据的十位数依次插入对应的队列。 如数据 231 ,其十位数为 3 ,所以将它插入 3 号队列。 所有元素都被分配到十个队列之后,完成第二次“分配”操作。 注意,到此为止,同一个队列中所有数据的十位数都是一样的,而且末位数是按照从小到大的顺序存放的。因为在入队时,个位数字小的元素先进入队列,这是第三步操作的结果决定的。
642 17698435589424482- 45786 176
Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7 Q8 Q9
642 176
984
355 894
244
82
457
86
176
第五步,按照队列号的大小依次取出队列中元素存入原数组。操作方法与第三步完全相同。
第二次“收集”操作结束。到此为止,所有数据已经按照末两位数的大小,从小到大排好了顺序。
Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7 Q8 Q9
642 176
984
355 894
244
82
457
86
176
642 82176176457355244- 894984 86
第六步,按照数据的百位数依次插入对应的队列。 如数据 231 ,其百位数为 2 ,所以将它插入 2 号队列。所有元素都被分配到十个队列之后,完成第三次“分配”操作。 注意:到此为止,同一个队列中所有数据的百位数都是一样的,而且末两位数是按照从小到大的顺序存放的。因为在入队时,末两位数字小的元素先进入队列,这是第五步操作的结果决定的。
642 82176176457355244- 894984 86
Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7 Q8 Q9
64282 176
176
457355244 894984
86
第七步,按照队列号的大小依次取出队列中元素存入原数组。操作方法与第三步完全相同。第三次“收集”操作结束。 到此为止,所有数据已经按照末三位数的大小(数据本身值的大小),从小到大排好了顺序。到此为止,排序结束。
Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7 Q8 Q9
82 45724435517617686- 984642894
64282 176
176
457355244 894984
86
在上面的操作中,开辟的辅助空间就是十个队列,由于它们最终要容纳下所有的待排序数据,所以队列所占总空间数为N 。由此得到空间复杂度为 O(N) 。 从描述过程看出,排序每一轮都要经历依次“分配”操作和一次“收集”操作,所以每一轮数据经过两次移动;而排序的轮数是由最大数据关键字的长度 R决定的(例子中最大数是三位数,故排序共做三轮),由此知时间复杂度为 O(R*N) 。
8.7 外部排序概述 对文件(第十章将专门介绍文件)进行排序,是文件上十分重要的运算,在许多地方都要用到。 通常情况下,由于文件很大,无法把整个文件的所有记录同时调入内存中进行排序,即:无法直接进行内部排序,从而需要研究外存设备上(文件)的排序技术,我们称这种排序为外部排序。
外部排序方法与各种外存设备的特征有关。 外存设备大体上可分为顺序存取(例如磁带)和直接序取(例如磁盘)两大类,所以,外部排序通常又可分为磁盘文件排序和磁带文件排序两大类。 两类排序的基本步骤相类似,主要不同之处在于初始归并段在外存储介质中的分布方式。
一般来说,在外存设备上排序的最通用方法是归并排序法,这种方法基本上由两个相对独立的阶段组成。
首先,按可用内存大小,将外存上含 n 个记录的文件分成若干长度为 L 的子文件(或段),用某种有效的内部排序方法分别将这些段读入内存进行排序,并将排序后得到的有序子文件重新写入外存,通常称这些有序子文件为“归并段”或“顺串”;
然后,把第一阶段所生成的顺串进行逐趟合并(例如通过若干趟 2路合并),使顺串(有序的子文件)逐渐由小变大,直至最终变为一个顺串为止,这时整个文件已经成为“有序文件”。
由于在排序过程中,需要读写外部存储设备当中的文件,而这样的操作显然比较费时,所以提高外部排序的效率应主要着眼于减少外存信息的读 /写次数,而不是我们在内部排序中关注的两个操作:关键字进行的比较次数和数据互换移动的次数。 针对这样的情况,可以从不同角度减少读写外存的次数:为了减少归并次数,使用多路平衡归并(可以借助于“败者树”完成);为了尽量得到较长的“归并段”,采用置换选择排序的方法,并进一步对所得长度不等的归并段构造最佳归并树等等。
8.8 本章小结 基本内容: 内部排序:
插入排序
选择排序
交换排序
归并排序
基数排序
在内部排序中我们主要关注两类操作:关键字的比较次数与数据元素位置的移动与互换次数。
对于外部排序,主要使用归并的思想,另外考虑到读取外存的时间较计算与内存访问时间来说非常耗时,所以在外部排序中尽量要注意减少外存读取的次数。
在各种排序算法中,时间效率较高的有希尔排序、堆排序和快速排序三种。
基数排序由于采用的是分配收集的操作,所以它与其他内部排序的算法在思路上有着较大的差别。
在介绍快速排序与归并排序时,我们又使用了“分治法”的思想,将复杂的问题化大为小,以便各个击破,分而治之。
•基本要求: 深刻理解排序的定义和各种排序方法的特点,并能加以灵活运用。
基于“关键字间的比较”进行排序的方法可以按排序过程所依据的不同原则分为插入排序、交换排序、选择排序、归并排序和基数排序五类。了解各种方法的排序过程及其依据的原则。
理解排序方法“稳定”和“不稳定”的含义,弄清楚在什么情况下要求应用的排序方法必须是稳定的。
掌握各种排序方法的时间复杂度的分析方法。能从“关键字间的比较次数”分析排序算法的平均情况和最坏情况的时间性能。按平均时间复杂度划分,内部排序可分为三类: O(N2) 的简单排序方法, O(N*logN) 的高效排序方法和 O(R*N ) 的基数排序方法。
希尔排序、快速排序、堆排序和归并排序等高效方法是本章的学习重点和难点。
了解外部排序的两个阶段,及外部排序的基本思想。
进一步掌握“分治法”的思想。