第八章 排序
基本概念和排序方法概述
排序的基本概念
- 排序
排序(Sorting)是按关键字的非递减或非递增顺序对一组记录重新进行排列的操作。 - 排序的稳定性
若在排序后的序列中Ri仍领先于Rj,则称所用的排序方法是稳定的;反之,若可能使排序后的序列中Rj领先于Ri,则称所用的排序方法是不稳定的。 - 内部排序和外部排序
由于待排序记录的数量不同,使得排序过程中数据所占用的存储设备会有所不同。根据在排序过程中记录所占用的存储设备,可将排序方法分为两大类:一类是内部排序,指的是待排序记录全部存放在计算机内存中进行排序的过程;另一类是外部排序,指的是待排序记录的数量很大,以致内存一次不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程。
内部排序方法的分类
- 插入类
将无序子序列中的一个或几个记录插入有序序列,从而增加记录的有序子序列的长度。主要包括直接插入排序、折半插入排序和希尔排序。 - 交换类
通过交换无序序列中的记录从而得到其中关键字最小或最大的记录,并将它加入有序子序列中,以此方法增加记录的有序子序列的长度。主要包括冒泡排序和快速排序。 - 选择类
从记录的无序子序列中选择关键字最小或最大的记录,并将它加入有序子序列中,以此方法增加记录的有序子序列的长度。主要包括简单选择排序、树形选择排序和堆排序。 - 归并类
通过归并两个或两个以上的记录有序子序列,逐步增加记录有序序列的长度。2-路归并排序是最为常见的归并排序方法。 - 分配类
是唯一一类不需要进行关键字比较的排序方法,排序时主要利用分配和收集两种基本操作来完成。基数排序是主要的分配排序方法。
待排序记录的存储方式
(1)顺序表:记录之间的次序关系由其存储位置决定,实现排序需要移动记录。
(2)链表:记录之间的次序关系由指针指示,实现排序不需要移动记录,仅需修改指针。这种排序方式称为链表排序。
(3)待排序记录本身存储在一组地址连续的存储单元内,同时另设一个指示各个记录存储位置的地址向量,在排序过程中不移动记录本身,而移动地址向量中这些记录的“地址”,在排序结束之后按照地址向量中的值调整记录的存储位置。这种排序方式称为地址排序。
//待排序记录的数据类型定义为:
#define MAXSIZE 20
typedef struct {
int *data; //data[0]闲置或用做哨兵单元
int length;
}SqList;
//初始化顺序表
bool InitSqList(SqList &L) {
L.data = new int[MAXSIZE + 1];
if (!L.data)
{
exit(-2);
return false;
}
L.length = 0;
return true;
}
//输出顺序表
void PrintSqList(SqList L) {
for (int i = 1; i <= L.length; i++)
{
printf("%d ", L.data[i]);
}
printf("\n");
}
排序算法效率的评价指标
- 执行时间
对于排序操作,时间主要消耗在关键字之间的比较和记录的移动上(这里,只考虑以顺序表方式存储待排序记录),排序算法的时间复杂度由这两个指标决定。因此可以认为,高效的排序算法的比较次数和移动次数都应该尽可能的少。 - 辅助空间
空间复杂度由排序算法所需的辅助空间决定。辅助空间是除了存放待排序记录占用的空间之外,执行算法所需要的其他存储空间。理想的空间复杂度为O(1),即算法执行期间所需要的辅助空间与待排序的数据量无关。
插入排序
插入排序的基本思想是:每一趟将一个待排序的记录,按其关键字的大小插入已经排好序的一组记录的适当位置,直到所有待排序记录全部插入为止。
直接插入排序
直接插入排序(Straight Insertion Sort)是一种最简单的排序方法,其基本操作是将一条记录插入已排好序的表,从而得到一个新的、记录数量增1的有序表。
【算法步骤】
① 设待排序的记录存放在数组r[1…n]中,r[1]是一个有序序列。
② 循环n−1次,每次使用顺序查找法,查找r[i](i = 2,…,n)在已排好序的序列r[1…i−1]中的插入位置,然后将r[i]插入表长为i−1的有序序列r[1…i−1],直到将r[n]插入表长为n−1的有序序列r[1…n−1],最后得到一个表长为n的有序序列。
void InsertSort(SqList& L) {
int j;
for (int i = 2; i <= L.length; i++)
{
if (L.data[i] < L.data[i-1])
{
L.data[0] = L.data[i];
L.data[i] = L.data[i - 1];
for (j = i - 2; L.data[0] < L.data[j]; j--)
{
L.data[j + 1] = L.data[j];
}
L.data[j+1] = L.data[0];
}
}
}
测试代码
int main() {
SqList L;
InitSqList(L);
L.data[1] = 49;
L.data[2] = 38;
L.data[3] = 65;
L.data[4] = 97;
L.data[5] = 76;
L.data[6] = 13;
L.data[7] = 27;
L.data[8] = 49;
L.length = 8;
printf("待排序记录的关键字序列为:\n");
PrintSqList(L);
printf("已排序记录的关键字序列为:\n");
InsertSort(L);
PrintSqList(L);
}
【算法分析】
(1)时间复杂度
直接插入排序的时间复杂度为O(n2)
(2)空间复杂度
直接插入排序只需要一个记录的辅助空间r[0],所以空间复杂度为O(1)。
【算法特点】
(1)稳定排序。
(2)算法简便,且容易实现。
(3)也适用于链式存储结构,只是在单链表上无须移动记录,只需修改相应的指针。
(4)更适合于初始记录基本有序(正序)的情况,当初始记录无序、n较大时,此算法时间复杂度较高,不宜采用。
折半插入排序
直接插入排序采用顺序查找法查找当前记录在已排好序的序列中的插入位置,这个“查找”操作可利用“折半查找”来实现,由此进行的插入排序称之为折半插入排序(Binary Insertion Sort)。
【算法步骤】
① 设待排序的记录存放在数组r[1…n]中,r[1]是一个有序序列。
② 循环n−1次,每次使用折半查找法,查找r[i](i = 2, …, n)在已排好序的序列r[1…i−1]中的插入位置,然后将r[i]插入表长为i−1的有序序列r[1…i−1],直到将r[n]插入表长为n−1的有序序列r[1…n−1],最后得到一个表长为n的有序序列。
void BInsertSort(SqList& L) {
for (int i = 2; i <= L.length; i++)
{
L.data[0] = L.data[i];
int low = 1, high = i - 1;
while (low <= high)
{
int m = (low + high) / 2;
if (L.data[0] < L.data[m])
{
high = m - 1;
}
else
{
low = m + 1;
}
}
for (int j = i - 1; j >= high + 1; j--)
{
L.data[j + 1] = L.data[j];
}
L.data[high + 1] = L.data[0];
}
}
【算法分析】
(1)时间复杂度
折半插入排序的时间复杂度仍为O(n2)。
(2)空间复杂度
折半插入排序所需附加存储空间和直接插入排序相同,只需要一个记录的辅助空间data[0],所以空间复杂度为O(1)。
【算法特点】
(1)稳定排序。
(2)因为要进行折半查找,所以只能用于顺序结构,不能用于链式结构。
(3)适合初始记录无序、n较大的情况。
希尔排序
希尔排序(Shell’s Sort)又称“缩小增量排序”(Diminishing Increment Sort),是插入排
序的一种,因D.L.希尔(D.L.Shell)于1959年提出而得名。
当待排序的记录个数较少且待排序序列的关键字基本有序时,直接插入排序效率较高。希尔排序基于以上两点,从“减少记录个数”和“序列基本有序”两个方面对直接插入排序进行了改进。
【算法步骤】
希尔排序实质上是采用分组插入的方法,先将整个待排序记录序列分割成几组,从而减少参与直接插入排序的数据量,对每组分别进行直接插入排序,然后增加每组的数据量,重新分组。这样当经过几次分组排序后,整个序列中的记录“基本有序”时,再对全体记录进行一次直接插入排序。
希尔对记录的分组,不是简单地“逐段分割”,而是将相隔某个“增量”的记录分成一组。
① 第一趟取增量d1(d1<n)把全部记录分成d1个组,所有间隔为d1的记录分在同一组,在各个组中进行直接插入排序。
② 第二趟取增量d2(d2<d1),重复上述的分组和排序。
③ 依次类推,直到所取的增量dt = 1(dt < dt−1 <…< d2 < d1),所有记录在同一组中进行直接插入排序为止。
void ShellInsert(SqList& L, int dk) {
int j;
for (int i = dk+1; i <= L.length; i++)
{
if (L.data[i] < L.data[i-dk])
{
L.data[0] = L.data[i];
for (j = i - dk ; j > 0 && L.data[0] < L.data[j]; j-=dk)
{
L.data[j + dk] = L.data[j];
}
L.data[j + dk] = L.data[0];
}
}
}
void ShellSort(SqList& L, int dt[], int t) {
for (int i = 0; i < t; i++)
{
ShellInsert(L, dt[i]);
}
}
【算法分析】
(1)时间复杂度
当增量序列为dt[k]=2t−k+1−1时,希尔排序的时间复杂度为O(n3/2),其中t为排序趟数1≤k≤t≤⌊log2(n+1)⌋。
还有人在大量的实验基础上推出:当n在某个特定范围内,希尔排序所需的比较和移动次数约为n1.3,当n→∞时,比较和移动次数可减少到n(log2n)2。
(2)空间复杂度
空间复杂度为O(1)。
【算法特点】
(1)记录跳跃式地移动导致排序方法是不稳定的。
(2)只能用于顺序结构,不能用于链式结构。
(3)增量序列可以有各种取法,但应该使增量序列中的值没有除1之外的公因子,并且最后一个增量值必须等于1。
(4)记录总的比较次数和移动次数都比直接插入排序的要少,n越大时,效果越明显。所以适合初始记录无序、n较大时的情况。
交换排序
交换排序的基本思想是:两两比较待排序记录的关键字,一旦发现两个记录不满足次序要求时则进行交换,直到整个序列全部满足要求为止。
冒泡排序
冒泡排序(Bubble Sort)是一种最简单的交换排序方法,它通过两两比较相邻记录的关键字,如果为逆序,则进行交换,从而使关键字小的记录如气泡一般逐渐往上“漂浮”(左移),或者使关键字大的记录如石块一样逐渐向下“坠落”(右移)。
void BubbleSort(SqList& L) {
for (int i = 1; i < L.length-1; i++)
{
for (int j = 1; j <= L.length-i; j++)
{
if (L.data[j] > L.data[j+1])
{
int temp = L.data[j];
L.data[j] = L.data[j + 1];
L.data[j + 1] = temp;
}
}
}
}
【算法分析】
(1)时间复杂度
时间复杂度为O(n2)。
(2)空间复杂度
冒泡排序只有在两个记录交换位置时需要一个辅助空间用于暂存记录,所以空间复杂度为O(1)。
【算法特点】
(1)稳定排序。
(2)可用于链式存储结构。
(3)移动记录次数较多,算法的平均时间性能比直接插入排序的差。当初始记录无序、n较大时,此算法不宜采用。
快速排序
快速排序(Quick Sort)是由冒泡排序改进而得的。在冒泡排序过程中,只对相邻的两个记录进行比较,因此每次交换两个相邻记录时只能消除一个逆序排列。如果能通过两个(不相邻)记录的一次交换,消除多个逆序排列,则会大大加快排序的速度。快速排序方法中的一次交换可能消除多个逆序排列。
int Partition(SqList& L, int low, int high) {
//对顺序表L中的子表data[low..high]进行一趟排序,返回枢轴位置
L.data[0] = L.data[low];//用子表的第一个记录作为枢轴记录
int pivotkey = L.data[low]; //枢轴记录关键字保存在pivotkey中
while (low < high) //从表的两端交替地向中间查找
{
while (low < high && L.data[high] >= pivotkey) --high;
L.data[low] = L.data[high]; //将比枢轴记录小的记录移到低端
while (low < high && L.data[low] <= pivotkey) ++low;
L.data[high] = L.data[low]; //将比枢轴记录大的记录移到高端
} //while
L.data[low] = L.data[0]; //枢轴记录到位
return low; //返回枢轴位置
}
void QSort(SqList& L, int low, int high)
{//调用前置初值 :low=1; high=L.length;
//对顺序表L中的子表L.data[low..high]进行快速排序
if (low < high) { //长度大于1
int pivotloc = Partition(L, low, high); //将L.data[low..high]一分为二,pivotloc是枢轴位置
QSort(L, low, pivotloc - 1); //对左子表递归排序
QSort(L, pivotloc + 1, high);//对右子表递归排序
}
}
【算法分析】
(1)时间复杂度
快速排序的时间复杂度为O(nlog2n)
(2)空间复杂度
快速排序是递归的,执行时需要有一个栈来存放相应的数据。最大递归调用次数与递归树的深度一致,所以最好情况下的空间复杂度为O(log2n),最坏情况下为O(n)。
【算法特点】
(1)记录非顺次的移动导致排序方法是不稳定的。
(2)排序过程中需要定位表的下界和上界,所以适合用于顺序结构,很难用于链式结构。
(3)当n较大时,在平均情况下快速排序是所有内部排序方法中速度最快的一种,所以其适合初始记录无序、n较大的情况。
选择排序
选择排序的基本思想是:每一趟从待排序的记录中选出关键字最小的记录,按顺序将其放在已排好序的记录序列的最后,直到全部排完为止。
简单选择排序
简单选择排序(Simple Selection Sort)也称作直接选择排序。
【算法步骤】
① 设待排序的记录存放在数组r[1…n]中。第一趟从r[1]开始,通过n−1次比较,从n个记录中选出关键字最小的记录,记为r[k],交换r[1]和r[k]。
② 第二趟从r[2]开始,通过n−2次比较,从n−1个记录中选出关键字最小的记录,记为r[k],交换r[2]和r[k]。
③ 依次类推,第i趟从r[i]开始,通过n−i次比较,从n−i+1个记录中选出关键字最小的记录,记为r[k],交换r[i]和r[k]。
④ 经过n−1趟,排序完成。
void SelectSort(SqList& L) {
for (int i = 1; i < L.length; i++)
{
int k = i;
for (int j = i+1; j <= L.length; j++)
{
if (L.data[j] < L.data[k])
{
k = j;
}
}
if (k != i)
{
int temp = L.data[i];
L.data[i] = L.data[k];
L.data[k] = temp;
}
}
}
【算法分析】
(1)时间复杂度
简单选择排序的时间复杂度也是O(n2)。
(2)空间复杂度
空间复杂度为O(1)
【算法特点】
(1)就选择排序方法本身来讲,它是一种稳定的排序方法,但图8.6所表现出来的现象是不稳定的,这是因为上述实现选择排序的算法采用“交换记录”的策略所造成的,改变这个策略,可以写出不产生“不稳定现象”的选择排序算法。
(2)可用于链式存储结构。
(3)移动记录次数较少,当每一记录占用的空间较多时,此方法比直接插入排序快。
树形选择排序
树形选择排序(Tree Selection Sort),又称锦标赛排序(Tournament Sort),是一种按照锦标赛的思想进行选择排序的方法。
堆排序
堆排序(Heap Sort)是一种树形选择排序,在排序过程中,将待排序的记录r[1…n]看成一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲节点和孩子节点之间的内在关系,在当前无序的序列中选择关键字最大(或最小)的记录。
- 调整堆
【算法步骤】
从r[2s]和r[2s+1]中选出关键字较大者,假设r[2s]的关键字较大,比较r[s]和r[2s]的关键字。
① 若r[s].key> = r[2s].key,说明以r[s]为根的子树已经是堆,不必进行任何调整。
② 若r[s].key<r[2s].key,交换r[s]和r[2s]。交换后,以r[2s+1]为根的子树仍是堆,如果以r[2s]为根的子树不是堆,则重复上述过程,将以r[2s]为根的子树调整为堆,直至进行到叶子节点为止。
void HeapAdjust(SqList &L,int s,int m)
{//假设r[s+1..m]已经是堆,将r[s..m]调整为以r[s]为根的大根堆
rc=L.r[s];
for(j=2*s;j<=m;j*=2) //沿key较大的孩子节点向下筛选
{
if(j<m&&L.r[j].key<L.r[j+1].key) ++j; //j为key较大的记录的下标
if(rc.key>=L.r[j].key) break; //rc应插入在位置s上
L.r[s]=L.r[j];s=j;
} //for
L.r[s]=rc; //插入
}
- 建初堆
void CreatHeap(SqList &L)
{//把无序序列L.r[1..n]建成大根堆
n=L.length;
for(i=n/2;i>0; --i) //反复调用HeapAdjust
HeapAdjust(L,i,n);
}
- 堆排序算法的实现
void HeapSort(SqList &L)
{//对顺序表L进行堆排序
CreatHeap(L); //把无序序列L.r[1..L.length]建成大根堆
for(i=L.length;i>1;--i)
{
x=L.r[1]; //将堆顶记录和当前未经排序子序列L.r[1..i]中最后一个记录互换
L.r[1]=L.r[i];
L.r[i]=x;
HeapAdjust(L,1,i-1); //将L.r[1..i-1]重新调整为大根堆
} //for
}
【算法分析】
(1)时间复杂度
时间复杂度也为O(nlog2n)。实验研究表明,堆排序的平均性能接近于最坏性能。
(2)空间复杂度
空间复杂度为O(1)。
【算法特点】
(1)是不稳定排序。
(2)只能用于顺序结构,不能用于链式结构。
(3)初始建堆所需的比较次数较多,因此记录数较少时不宜采用。堆排序在最坏情况下时间复杂度为O(nlog2n),相对于快速排序最坏情况下的O(n2)而言更有优势,当记录较多时较为高效。
归并排序
归并排序(Merging Sort)就是将两个或两个以上的有序表合并成一个有序表的过程。将两个有序表合并成一个有序表的过程称为2-路归并,2-路归并最为简单和常用。
- 相邻两个有序子序列的归并
【算法步骤】
设两个有序表存放在同一数组中相邻的位置(R[low…mid]和R[mid + 1…high])上,每次分别从两个表中取出一个记录进行关键字的比较,将较小者放入T[1ow…high]中。重复此过程,直至其中一个表为空,最后将另一非空表中余下的部分直接复制到T中。
void Merge(RedType R[],RedType T[],int low,int mid,int high)
{//将有序表R[low..mid]和R[mid+1..high]归并为有序表T[low..high]
i=low;j=mid+1;k=low;
while(i<=mid&&j<=high) //将R中的记录由小到大地并入T中
{
if(R[i].key<=R[j].key) T[k++]=R[i++];
else T[k++]=R[j++];
} //while
while(i<=mid) T[k++]=R[i++]; //将剩余的R[i..mid]复制到T中
while(j<=high) T[k++]=R[j++]; //将剩余的R[j..high]复制到T中
}
- 归并排序
void MSort(RedType R[],RedType T[],int low,int high)
{//R[low..high]归并排序后放入T[low..high]中
if(low==high) T[low]=R[low];
else
{
mid=(low+high)/2; //将当前序列一分为二,求出分裂点mid
MSort(R,S,low,mid); //对子序列R[low..mid]递归进行归并排序,结果放入S[low..mid]
MSort(R,S,mid+1,high);//对子序列R[mid+1..high]递归进行归并排序,结果放入S[mid+1..
high]
Merge(S,T,low,mid,high);//将S[low..mid]和S[mid+1..high]归并到T[low..high]
} //else
}
void MergeSort(SqList &L)
{//对顺序表L进行归并排序
MSort(L.r,L.r,1,L.length);
}
【算法分析】
(1)时间复杂度
归并排序的时间复杂度为O(nlog2n)。
(2)空间复杂度
用顺序表实现归并排序时,需要和待排序记录个数相等的辅助存储空间,所以空间复杂度为O(n)。
【算法特点】
(1)是稳定排序。
(2)可用于链式结构,且不需要附加存储空间,但递归实现时仍需要开辟相应的递归工作栈。
基数排序
前述各类排序方法都建立在关键字比较的基础上,而分配类排序不需要比较关键字的大小,它是根据关键字中各位的值,通过对待排序记录进行若干趟“分配”与“收集”来实现排序的,是一种借助于多关键字排序的思想对单关键字进行排序的方法。基数排序(Radix Sorting)是典型的分配类排序。
多关键字的排序
- 最高位优先法
先按不同花色分成有次序的4堆牌,每堆牌均具有相同的花色,然后分别对每堆牌按面值大小整理排序。 - 最低位优先法
这是一种分配与收集交替进行的方法。先按不同面值将牌分成13堆,然后将这13堆牌自小至大叠在一起(“3”在“2”之上,“4”在“3”之上,……,最上面的是4张“A”),再将每堆按照面值的次序收集到一起。接着重新对这些牌按不同花色分成4堆,最后将这4堆牌按花色的次序再收集到一起(♣在最下面,♠在最上面),此时同样得到一副满足如上次序关系的牌,如图8.14所示。
链式基数排序
基数排序的思想类似于上述最低位优先法的洗牌过程,是借助分配和收集两种操作对单逻辑关键字进行排序的一种内部排序方法。有的逻辑关键字可以看成由若干个关键字复合而成。
假设记录的逻辑关键字由d个关键字组成,每个关键字可能取rd个值。只要从最低数位关键字起,按关键字的不同值将序列中记录分配到rd个队列中后再收集,如此重复d次完成排序。按这种方法实现排序称之为基数排序,其中“基”指的是rd的取值范围,在上述两种关键字的情况下,rd分别为10和26。
算法实现时采用静态链表,以便于更有效地存储和重排记录。相关数据类型的定义如下:
#define MAXNUM_KEY 8 //关键字项数的最大值
#define RADIX 10 //关键字基数,此时基数是十进制整数
#define MAX_SPACE 10000
typedef struct
{
KeyType keys[MAXNUM_KEY]; //关键字
InfoType otheritems; //其他数据项
int next ;
}SLCell ; //静态链表的节点类型
typedef struct
{
SLCell r[MAX_SPACE]; //静态链表的可利用空间,r[0]为头节点
int keynum; //记录的当前关键字个数
int recnum; //静态链表的当前长度
}SLList; //静态链表类型
typedef int ArrType[RADIX] //数组类型
- 基数排序
void Distribute(SLCell &r,int i,ArrType &f,ArrType &e)
{//静态链表L的r域中记录已按(keys[0], …, keys[i-1])有序
// 本算法按第 i 个关键字 keys[i] 建 立 RADIX 个 子 表,使 同 一 子 表 中 记 录 的
keys[i]相同
//f[0..RADIX-1]和e[0..RADIX-1]分别指向各子表中第一个和最后一个记录
for(j=0;j<RADIX;++j) f[j]=0;//各子表初始化为空表
for(p=r[0].next;p;p=r[p].next)
{
j=ord(r[p].keys[i]); //ord将记录中第i个关键字映射到[0..RADIX-1]
if(!f[j]) f[j]=p;
else r[e[j]].next=p;
e[j]=p; //将p所指的节点插入第j个子表中
} //for
}
void Collect(SLCell &r,int i,ArrType f,ArrType e)
{//本算法按keys[i]自小至大地将f[0..RADIX-1]所指各子表依次链接成一个链表
//e[0..RADIX-1]为各子表的尾指针
for(j=0;!f[j]; j=succ(j)); //找第一个非空子表,succ()为求后继函数
r[0].next=f[j]; t=e[j]; //r[0].next指向第一个非空子表中第一个节点
while(j<RADIX)
{
for(j=succ(j);j<RADIX-1&&!f[j];j=succ(j)); //找下一个非空子表
if(f[j]) {r[t].next=f[j]; t=e[j];} //链接两个非空子表
} //while
r[t].next=0; //t指向最后一个非空子表中的最后一个节点
}
void RadixSort(SLList &L)
{//L是采用静态链表表示的顺序表
//对L进行基数排序,使得L成为按关键字自小到大的有序静态链表,L.r[0]为头节点
for(i=0;i<L.recnum;++i) L.r[i].next=i+1;
L.r[L.recnum].next = 0; //将L改造为静态链表
for(i=0;i<L.keynum;++i) { //按最低位优先依次对各关键字进行分配和收集
Distribute(L.r,i,f,e); //第i趟分配
Collect(L.r,i,f,e); //第i趟收集
} //for
}
【算法分析】
(1)时间复杂度
时间复杂度为O(d(n + rd))。
(2)空间复杂度
空间复杂度为O(n + rd)。
【算法特点】
(1)是稳定排序。
(2)可用于链式结构,也可用于顺序结构。
(3)时间复杂度可以突破基于关键字比较一类方法的下界O(nlog2n),达到O(n)。
(4)基数排序使用条件有严格的要求:需要知道各级关键字的主次关系和各级关键字的取值范围。
小结