数据结构(C语言版)严蔚敏 吴伟民 编著 第10章 内部排序
10.1 概述
排序是将一个数据元素(或记录)的任意序列,重新排列成一个按关键字有序的序列。从第9章讨论中可以看出,通常希望计算机中的表是按关键字有序的。因为有序的顺序表可以采用效率更高的折半查找法,其平均查找长度为log2(n+1)-1,而无序的顺序表只能进行顺序查找,其平均查找长度为(n+1)/2。又如建造树表(无论是二叉排序树或B-树)的过程本身就是一个排序的过程。
先给排序下一个确切的定义:
假设含n个记录的序列为
{R1,R2,…,Rn}
其相应的关键字序列为
{K1,K2,…,Kn}
需确定1,2,…,n的一种排列p1,p2,…,pn,使其相应的关键字满足如下的非递减(或非递增)关系:
Kp1≤Kp2≤…≤Kpn
这样的一种操作叫做排序。
若Ki是主关键字,则任何一个记录的无序序列经排序后得到的结果是唯一的,若Ki是次关键字,则排序的结果不唯一,因为待排序的记录序列中可能存在两个或两个以上关键字相等的记录。假定Ki=Kj(1≤i≤n,1≤j≤n,i≠j),且在排序前的序列中Ri领先于Rj,即i<j。若在排序后的序列中Ri仍领先于Rj,则称所用的排序方法是稳定的,反之若可能使排序后的序列中Rj领先于Ri,则称所用的排序方法是不稳定的。
由于待排序的记录数量不同,使得排序过程中涉及的存储器不同,可将排序方法分为两大类:一类是内部排序,指的是待排序记录存放在计算机随机存储器中进行的排序过程;另一类是外部排序,指的是待排序的数量很大,以致内存依次不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程。
如果按排序过程中依据的不同原则对内部排序方法进行分类,则大致可以分为插入排序、交换排序、选择排序、归并排序和计数排序等五类;
如果按内部排序过程中所需的工作量来区分,则可分为3类:
(1)简单的排序方法,其时间复杂度为O(2)
(2)先进的排序方法,其时间复杂度为O(nlogn)
(3) 基数排序,其时间复杂度为O(dn)。
待排序的记录序列可有下列3种存储方式:
(1)待排序的一组记录存放在地址连续的一组存储单元上。它类似于线性表的顺序存储结构,在序列中相邻的两个记录Rj和Rj+1,它们的存储位置也相邻。在这种存储方式中,记录之间的次序关系由其存储位置决定,则实现排序必须借助移动记录。
(2)一组待排序记录存放在静态链表中,记录之间的次序关系由指针指示,则实现排序不需要移动记录,仅需修改指针即可。
(3)带排序记录本身存储在一组地址连续的存储单元内,同时另设一个指示各个记录存储位置的地址向量,在排序结束之后再按照地址向量中的值调整记录的存储位置。
在第二种存储方式下实现的排序又称为(链)表排序,在第三种存储方式下实现的排序又称地址排序。在本章讨论的,设待排序的一组记录以上述第一种方式存储,且为了讨论方便起见,设记录的关键字均为整数。即在以后讨论的大部分算法中,待排记录的数据类型设为:
#define MAXSIZE 20 // 一个用作示例的小顺序表的最大长度
typedef int KeyType; // 定义关键字类型为整数类型
typedef struct{
KeyType key; // 关键字项
InfoType otherinfo; // 其他数据项
}RedType;
typedef struct {
RedType r[MAXSIZE+1]; // r[0]闲置或用作哨兵单元
int length; // 顺序表长度
}Sqlist; // 顺序表类型
10.2 插入排序
10.2.1 直接插入排序
直接插入排序是一种最简单的排序方法,它的基本操作是将一个记录插入到已排好序的有序表中,从而得到一个新的、记录树增1的有序表。其算法如下:
void InsertSort(SqList &L){
// 对顺序表L作直接插入排序
for(i =2; i<= L.length;++i)
if(LT(L.r[i].key,L.r[i-1].key)) { // "<",需将L.r[i]插入到有序子表
L.r[0] = L.r[i]; // 复制为哨兵
L.r[i] = L.r[i-1];
for(j = i-2; LT(L.r[0].key,L.r[j].key); --j)
L.r[j+1] = L.r[j]; // 记录后移
L.r[j+1] = L.r[0]; // 插入到正确位置
}
}// InsertSort;
从时间上看,它只需要一个记录的辅助空间,从时间来看,排序的基本操作为:比较两个关键字的大小和移动记录。其时间复杂度为O(n2)。
10.2.2 其他插入排序
在直接插入排序的基础上,从减少“比较”和“移动”这两种操作的次数着眼,可得下列各种插入排序的方法。
- 折半插入排序
由于插入排序的基本操作是在一个有序表中进行查找和插入。这个“查找”操作可利用“折半查找”来实现,由此进行的插入排序称之为折半插入排序,其算法如下:
void BInsertSort(SqList &L){
// 对顺序表L作折半插入排序
for(i =2; i <= L.length; ++i){
L.r[0] = L.r[i]; // 将L.r[i]暂存到L.r[0]
low = 1; high = i -1;
while(low <= high){ // 在r[low..high]中折半查找有序插入的位置
m = (low + high)/2; // 折半
if(LT(L.r[0].key,L.r[m].key)) high = m-1; // 插入点在低半区
else low = m+1; // 插入点在高半区
}// while
for(j = i-1; j>= high +1; --j) L.r[j+1] = L.r[j]; // 记录后移
L.r[high+1] = L.r[0]; // 插入
}// for
}// BInsertSort
折半插入排序所需排序所需附加存储空间和直接插入排序相同,从时间上比较,折半插入排序仅减少了关键字间的比较次数,而记录的移动次数不变。因此,折半插入排序的时间复杂度仍为O(n2)。
2. 2-路插入排序
2-路插入排序是在折半插入排序的基础上再改进之,其目的是减少排序过程中移动记录的次数,但为此需要n个记录的辅助空间。若希望在排序过程中不移动记录,只有改变存储结构,进行表插入排序。
3. 表插入排序
#define SIZE 100 // 静态链表容量
typedef struct{
RcdType rc; // 记录项
int next; // 指针项
}SLNode
typedef struct{
SLNode r[size]; // 0号单元为表头结点
int length; // 链表当前长度
}SLinkListType;
从表插入排序的过程可见,表插入排序的基本操作仍是将一个记录插入到已排好序的有序表中。和直接插入排序相比,不同之处仅是以修改2n次指针值代替移动记录,排序过程中所需进行的关键字间的比较次数相同。因此,表插入排序的时间复杂度仍是0(n2)。
另一方面,表插入排序的结果只是求得一个有序链表,则只能对它进行顺序查找,不能进行随机查找,为了能实现有序表的折半查找,尚需对记录进行重新排列。
10.2.3 希尔排序
希尔排序的基本思想是先将整个待排记录序列分割成为若干个子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。希尔排序的一个特点是:子序列的构成不是简单地“逐段分割”,而是将相隔某个“增量”的记录组成一个子序列。
10.3 快速排序
冒泡排序总的时间复杂度为O(n2)。
快速排序是对冒泡排序的一种改进。它的基本思想是,通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
一趟快速排序的做法是:附设两个指针low和high,他们的初值分别为low和high,设枢轴记录的关键字为pivotkey,则首先从high所指位置起先前搜索找到第一个关键字小于pivotkey的记录和枢轴记录互相交换,然后从low所指位置起位置向后搜索,找到第一个关键字大于pivotkey的记录和枢轴记录互相交换,重复这两步直至low= high为止。其算法如下:
int Partition(SqList &L, int low, int high){
// 交换顺序表L中子表L.r[low..high]的记录,使枢轴记录到位,并返回其所在位置,此时在它之前(后)的记录均不大(小)于它
pivotkey = L.r[low].key; // 用子表的第一个记录作枢轴记录
while(low < high){ // 从表的两端交替地向中间扫描
while(low < high && L.r[high].key <= pivotkey) --high;
L.r[low]<-->L.r[high]; // 将比枢轴记录小的记录交换到低端
while(low < high && L.r[low].key >= pivotkey) ++low;
L.r[low]<-->L.r[high]; // 将比枢轴记录大的记录交换到高端
}
return low;
}// Partition
具体实现上述算法时,每交换一次记录需进行3次记录移动(赋值)的操作。而实际上,在排序过程中对枢轴记录的赋值是多余的,因为只有在一趟排序结束时,即low = high的位置才是枢轴记录的最后位置。由此可改写上述算法,先将枢轴记录暂存在r[0]的位置上,排序过程中只作r[low]和r[high]的单向移动,直至一趟排序结束后再将枢轴记录移至正确位置上,其算法如下:
int Partition (SqList &L, int low, int high){
// 交换顺序表L中子表L.r[low..high]的记录,使枢轴记录到位,并返回其所在位置,此时在它之前(后)的记录均不大(小)于它
L.r[0] - L.r[low]; // 用子表的第一个记录作枢轴记录
pivotkey = L.r[low].key; // 枢轴记录关键字
while(low < high){ // 从表的两端交替地向中间扫描
while(low < high && L.r[high].key <= pivotkey) --high;
L.r[low]<-->L.r[high]; // 将比枢轴记录小的记录交换到低端
while(low < high && L.r[low].key >= pivotkey) ++low;
L.r[high]<-->L.r[low]; // 将比枢轴记录大的记录交换到高端
}
L.r[low] = L.r[0]; // 枢轴记录到位
return low; // 返回枢轴位置
}// Partition
递归形式的快速排序算法如下述两个算法所示:
void Qsort(SqList &L, int low, int high){
// 对顺序表L中的子序列L.r[low..high]作快速排序
if(low <high){ // 长度大于1
pivotkey = Partition(L,low,high); // 将L.r[low..high]一分为二
QSort(L,low,pivotloc-1); // 对低子表递归排序,pivotkey是枢轴位置
QSort(L,pivotkey+1,high); // 对高子表递归排序
}
}// QSort
void QuickSort(SqList &L){
// 对顺序表L作快速排序
QSort(L,1,L.length);
}// QuickSort
快速排序的平均时间为Tavg(n) = knlnn,其中n为待排序序列中记录的个数,k为某个常数,经验表明,在所有同数量级的此类(先进的)排序方法中,快速排序的常数因子k最小。因此,就平均时间而言,快速排序是目前被认为是最好的一种内部排序方法。
10.4 选择排序
选择排序的基本思想是:每一趟在n-i+1(i=1,2,…,n-1)个记录中选取关键字最小的记录作为有序序列中的第i个记录。其中最简单且为读者最熟悉的是简单选择排序。
10.4.1 简单选择排序
一趟简单选择排序的操作为:通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1≤i≤n)个记录交换之。显然,对L.r[1…n]中记录进行简单选择排序的算法为:令i从1至n-1,进行n-1趟选择操作。总的时间复杂度为O(n2)。其算法如下:
void SelectSort(SqList &L){
// 对顺序表L作简单选择排序
for(i = 1; i<L.length; ++i){ // 选择第i小的记录,并交换到位
j = SelectMinKey(L,i); // 在L.r[i..L.length]中选择key最小的记录
if(i!=j) L.r[i]<-->L.r[j]; // 与第i个记录交换
}
}// SelectSort
10.4.2 树形选择排序
树形选择排序,又称锦标赛排序,是一种按照竞标赛的思想进行选择排序的方法。首先对n个记录的关键字进行两两比较,然后在⌈n/2⌉个较小者之间再进行两两比较,如此重复,直至选出最小关键字的记录为止。
由于含有n个叶子结点的完全二叉树的深度为⌈log2n⌉ +1,则在树形选择排序中,除了最小关键字之外,每选择一个次小关键字仅需进行⌈log2n⌉次比较,因此它的时间复杂度为O(nlogn)。但是这种排序方法尚有存储空间较多,和“最大值”进行多于的比较等缺点。为了弥补,J.willioms提出了另一种形式的选择排序——堆排序。
10.4.3 堆排序
堆排序仅需一个记录大小的辅助空间,每个待排序的记录仅占有一个存储空间。
堆的定义如下:n个元素的序列{k1,k2,…,kn}当且仅当满足下列关系时,称之为堆:
ki≤k2i,ki≤k2i+1 或ki≥k2i+1,ki≥k2i+1
若将和此序列对应的一维数组,即以一维数组作此序列的存储结构看成是一个完全二叉树,则堆的含义表明,完全二叉树中所有非终端结点的值均不大于(或不小于)其左、右孩子结点的值。由此,若序列{k1,k2,…,kn}是堆,则堆顶元素或完全二叉树的根必为序列中n个元素的最小值(或最大值)。
若在输出堆顶的最小值之后,使得剩余n-1个元素的序列重又建成一个堆,则得到n个元素中的最小值。如此反复执行,便能得到一个有序序列,这个过程称之为堆排序。
由此,实现堆排序需要解决两个问题:
(1)如何由一个无序序列建成一个堆?
(2)如何在输出堆顶元素之后,调整剩余元素成为一个新的堆?
在输出堆顶元素之后,调整剩余元素成为一个新的堆的过程称为“筛选”。
堆排序的算法如下:
typedef SqList HeapType; // 堆采用顺序表存储表示
void HeapAdjust(HeapType &H, int s, int m){
// 已知H.r[s..m]中记录的关键字除H.r[s].key之外均满足堆的定义,本函数调整为H.r[s]的关键字
// 使H.r[s..m]成为一个大顶堆(对其中记录的关键字而言)
rc = H.r[s];
for(j = 2*s;j<=m; j*=2){ // 沿key较大的孩子结点向下筛选
if(j<m && LT(H.r[j].key,H.r[j+1].key)) ++j; // j为key较大的记录的下标
if(!LT(rc.key,H.r[j].key)) break; // rc应插入到位置s上
H.r[s]= H.r[j]; s= j;
}
H.r[s] = rc; // 插入
}// HeapAdjust
堆排序算法如下:
void HeapSort(HeapType &H){
// 对顺序表H进行堆排序
for(i= H.length/2; i>0; --i) // 把H.r[1..H.length]建成大顶堆
HeapAdjust(H,i,H.length);
for(i = H.length;i>1; --i){
H.r[1]<-->H.r[i]; // 将堆顶记录和当前未经排序子序列Hr[1..i]中最后一个记录相互交换
}
}// HeapSort
堆排序方法对记录数较少的文件并不值得提倡,但对n较大的文件还是很有效的。因为其运行时间主要耗费在建初始堆和调整建新堆时进行的反复“筛选”上。
堆排序在最坏的情况下,其时间复杂度也为O(nlogn),相对于快速排序来说,这是堆排序的最大优点。由此,堆排序仅需一个记录大小供交换用的辅助存储空间。
10.5 归并排序
归并排序中“归并”的含义是将两个或两个以上的有序表组合成一个新的有序表。二-路归并排序中的核心操作是将一维数组中前后相邻的两个有序序列归并为一个有序序列,其算法如下:
void Merge(RcType SR[], RcdType &TR[], int i, int m, int n){
// 将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n]
for(j=m+1; k=i; i<=m && j<=n; ++k){ // 将SR中记录由小到达地并入TR
if(LQ(SR[i].key, SR[j].key)) TR[k] = SR[i++];
else TR[k] = SR[j++];
}
if(j<=m) TR[k..n] = SR[i..m]; // 将剩余的SR[i..m]复制到TR
if(j<=n) TR[k..n] = SR[j..n]; // 将剩余的SR[j..n]复制到TR
}// Merge
一趟归并排序的操作是,调用 ⌈n/2h⌉次算法将SR[1…n]中前后相邻且长度为h的有序段进行两两归并,得到前后相邻、长度为2h的有序段,并存放在TR[1…n]中,整个归并排序需进行⌈log2n⌉趟。可见,实现归并排序需和待排记录等数量的辅助空间,其时间复杂度为O(nlogn)。
与快速排序和堆排序相比,归并排序的最大特点是,它是一种稳定的排序方法。但在一般情况下,很少利用2-路归并排序法进行内部排序。
递归形式的2-路归并排序的算法如下述两个算法所示,递归形式的算法在形式上较简洁,但实用性很差。
void MSort(RcdType SR[], RcdType &TR1[], int s, int t){
// 将SR[s..t]归并排序为TR1[s..t]
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]
}
}// MSort
void MergeSort(SqList &L){
// 对顺序表L作归并排序
MSort(L,r,L,r,1,L.length);
}// MergeSort
10.6 基数排序
从前几节的讨论可见,实现排序主要是通过关键字间的比较和移动记录这两种操作,而实现基数排序不需要进行记录关键字间的比较。基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法。
一般情况下,假设有n个记录的序列
{R1,R2,…,Rn}
且每个记录Ri中含有d个关键字。为了实现多关键字排序,有最高位优先法(Most Significant Digit first,MSD),还有最低位优先法(Least Significant Digit first,LSD)。
10.7 各种内部排序方法的比较讨论
排序方法 | 平均时间 | 最坏情况 | 辅助存储 |
---|---|---|---|
简单排序 | O(n2) | O(n2) | O(1) |
快速排序 | O(nlogn) | O(n2) | O(logn) |
堆排序 | O(nlogn) | O(nlogn) | O(1) |
归并排序 | O(nlogn) | O(nlogn) | O(n) |
基数排序 | O(d(n+rd)) | O(d(n+rd)) | O(rd) |