排序 (Sorting) 是按关键字的非递减或非递增顺序对一组记录重新进行排列的操作
假设 = (1 ≤ i ≤ n,1 ≤ j ≤ n, i ≠ j ),且在排序前的序列中 领先于 (即i
排序算法的稳定性是针对所有记录而言的
根据在排序过程中记录所占用的存储设备,可将排序方法分为两大类:
- 一类是内部排序,指的是待排序记录全部存放在计算机内存中进行排序的过程
- 另一类是外部排序,指的是待排序记录的数量很大,以致内存一次不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程
内部排序的过程是一个逐步扩大记录的有序序列长度的过程
在排序的过程中, 可以将排序记录区分为两个区域:有序序列区和无序序列区
使有序区中记录的数目增加一个或几个的操作称为一趟排序
根据逐步扩大记录有序序列长度的原则不同, 可以将内部排序分为以下几类:
顺序表:记录之间的次序关系由其存储位置决定,实现排序需要移动记录
链表:记录之间的次序关系由指针指示,实现排序不需要移动记录,仅需修改指针即可。这种排序方式称为链表排序
待排序记录本身存储在一组地址连续的存储单元内,同时另设一个指示各个记录存储位置的地址向量,在排序过程中不移动记录本身,而移动地址向撒中这些记录的 “地址” ,在排序结束之后再按照地址向量中的值调整记录的存储位置。这种排序方式称为地址排序
待排序记录的数据类型定义为:
#define MAXSIZE 20 // 顺序表的最大长度
typedef int KeyType; // 定义关键字类型为整型
typedef struct{
KeyType key; // 关键字项
InfoType otherinfo; // 其他数据项
}RedType; // 记录类型
typedef struct{
RedType r[MAXSIZE+1]; // r[0]闲置或用做哨兵单元
int length; // 顺序表长度
}SqList; // 顺序表类型
对于排序操作,时间主要消耗在关键字之间的比较和记录的移动上(这里,只考虑以顺序表方式存储待排序记录),排序算法的时间复杂度由这两个指标决定。因此可以认为,高效的排序算法的比较次数和移动次数都应该尽可能的少
空间复杂度由排序算法所需的辅助空间决定。辅助空间是除了存放待排序记录占用的空间之外,执行算法所需要的其他存储空间。理想的空间复杂度为 O(1), 即算法执行期间所需要的辅助空间与待排序的数据量无关
插入排序的基本思想是:每一趟将一个待排序的记录,按其关键字的大小插入到已经排好序的一组记录的适当位置上,直到所有待排序记录全部插入为止
直接插入排序 (Straight Insertion Sort) 是一种最简单的排序方法,其基本操作是将一条记录插入到已排好序的有序表中,从而得到一个新的、记录数量增 1 的有序表
void InsertSort(SqList &L){
// 对顺序表L做直接插入排序
for(i = 2;i <= L.length;++i){
if(L.r[i].key < L.r[i - 1].key){ // "<",需将 r[i]插人有序子表
L.r[0] = L.r[i]; // 将待插人的记录暂存到监视哨中
L.r[i] = L.r[i-1]; // r[i-1]后移
for(j = i -2; L.r[0].key
直接插入排序的时间复杂度为
直接插入排序只需要一个记录的辅助空间 r[0],所以空间复杂度为 O(1)
特点:
- 稳定排序
- 算法简便,且容易实现
- 也适用于链式存储结构,只是在单链表上无需移动记录,只需修改相应的指针
- 更适合于初始记录基本有序的情况,当初始记录无序,n较大时,此算法时间复杂度较
- 高,不宜采用
void BInsertSort(SqList &L){
// 对顺序表L做折半插入排序
for(i = 2;i <= L.length;++i){
L.r[0] = L.r[i]; // 将待插人的记录暂存到监视哨中
low = 1;
high = i - 1; // 置查找区间初值
while(low <= high){ // 在r[low..high]中折半查找插入的位置
m = (low + high) / 2; // 折半
if(L.r[0].key < L.r[m].key)
high = m - 1; // 插入点在前一子表
else
low = m + 1; // 插入点在后一子表
}
for(j = i-1; j>=high+1; --j)
L.r[j+1] = L.r[j]; // 记录后移
L.r[high+1] = L.r[0]; // 将r[0]即原r[i], 插入到正确位置
}
}
折半插入排序的时间复杂度为
折半插入排序只需要一个记录的辅助空间 r[0],所以空间复杂度为 O(1)
特点:
- 稳定排序
- 因为要进行折半查找,所以只能用于顺序结构,不能用于链式结构
- 适合初始记录无序、n较大时的情况
希尔排序(Shell's Sort)又称 “缩小增量排序”(Diminishing Increment Sort), 是插入排序的一种
希尔排序实质上是采用分组插入的方法
先将整个待排序记录序列分割成几组,从而减少参与直接插入排序的数据量,对每组分别进行直接插入排序,然后增加每组的数据量,重新分组。 这样当经过几次分组排序后,整个序列中的记录“基本有序” 时,再对全体记录进行一次直接插入排序
希尔对记录的分组,不是简单地“逐段分割”,而是将相隔某个“增量”的记录分成一组
void ShellInsert(SqList &L,int dk){
// 对顺序表 L 做一趟增量是 dk 的希尔插入排序
for(i = dk+1; i <= L.length; ++i){
if(L.r[i].key < L.r[i-dk].key){
L.r[0] = L.r[i];
for(j = i-dk; j>0 && L.r[0].key < L.r[j].key; j -= dk){
L.r[j+dk] = L.r[j];
}
L.r[j+dk] = L.r[0];
}
}
}
void ShellSort(SqList &l,int td[],int t){
// 按增批序列 dt[0 .. t-1] 对顺序表 L作 t 趟希尔排序
for(k=0; k
希尔排序的平均时间复杂度为
希尔排序只需要一个辅助空间r[0], 空间复杂度为 O(1)
特点:
- 记录跳跃式地移动导致排序方法是不稳定的
- 只能用于顺序结构,不能用于链式结构
- 增量序列可以有各种取法,但应该使增量序列中的值没有除1之外的公因子,并且最后一个增量值必须等于1
- 记录总的比较次数和移动次数都比直接插入排序要少,n越大时,效果越明显。所以适合初始记录无序、n较大时的情况
交换排序的基本思想是:两两比较待排序记录的关键字,一旦发现两个记录不满足次序要求时则进行交换,直到整个序列全部满足要求为止
冒泡排序(Bubble Sort)是一种最简单的交换排序方法,它通过两两比较相邻记录的关键字, 如果发生逆序,则进行交换,从而使关键字小的记录如气泡一般逐渐往上 "漂浮"(左移),或者使关键字大的记录如石块一样逐渐向下 "坠落”(右移)
void BubbleSort(SqList &L){
// 对顺序表L做冒泡排序
m = L.length - 1;
flag = 1; // flag用来标记某一趟排序是否发生交换
while((m>0) & (flag==1)){
flag = 0; // flag置为0, 如果本趟排序没有发生交换,则不会执行下一趟排序
for(j = 1;i <= m;j++){
if(L.r[j].key > L.r[j+1].key){
flag = 1; // flag置为1, 表示本趟排序发生了交换
t = L.r[j];
L.r[j] = L.r[j+1];
L.r[j+1] = t; // 交换前后两个记录
}
--m;
}
}
}
冒泡排序的时间复杂度为
冒泡排序只有在两个记录交换位置时需要一个辅助空间用做暂存记录,所以空间复杂度为 O(1)
特点:
- 稳定排序
- 可用于链式存储结构
- 移动举例次数较多,算法平均时间性能比直接插入排序差。当初始记录无序,n 较大时,此算法不宜采用
快速排序 (Quick Sort) 是由冒泡排序改进而得的。在冒泡排序过程中,只对相邻的两个记录进行比较,因此每次交换两个相邻记录时只能消除一个逆序。如果能通过两个(不相邻)记录的一次交换,消除多个逆序,则会大大加快排序的速度。快速排序方法中的一次交换可能消除多个逆序
步骤:在待排序的 n 个记录中任取一个记录(通常取第一个记录)作为枢轴(或支点),设其关键字为 pivotkey。经过一趟排序后,把所有关键字小于 pivotkey 的记录交换到前面,把所有关键字大于 pivotkey 的记录交换到后面,结果将待排序记录分成两个子表,最后将枢轴放置在分界处的位置。然后,分别对左、右子表重复上述过程,直至每一子表只有一个记录时,排序完成
int Partition(SqList &L,int low,int high){
// 对顺序表L中的子表r[low..high]进行一趟排序,返回枢轴位置
L.r[0] = L.r[low];
pivotkey = L.r[low].key;
while(low < high){
while(low=pivotkey)
--high;
L.r[low] = L.r[high];
while(low
快速排序的平均时间复杂度为
快速排序是递归的,执行时需要有一个栈来存放相应的数据。最大递归调用次数与递归树
的深度一致,所以最好情况下的空间复杂度为 ,最坏情况下为 O(n)
特点:
- 记录非顺次的移动导致排序方法是不稳定的
- 排序过程中需要定位表的下界和上界,所以适合用于顺序结构,很难用于链式结构
- 当 n 较大时,在平均情况下快速排序是所有内部排序方法中速度最快的一种,所以其适合初始记录无序、n较大时的情况
选择排序的基本思想是:每一趟从待排序的记录中选出关键字最小的记录,按顺序放在已
排序的记录序列的最后,直到全部排完为止
简单选择排序 (SimpleSelection Sort) 也称作直接选择排序
void SelectSort(SqList &L){
// 对顺序表L做简单选择排序
for(i=1; i
简单选择排序的时间复杂度也是
简单选择排序只有在两个记录交换时需要一个辅助空间,所以空间复杂度为 O(1)
特点:
- 稳定排序
- 可用于链式存储结构
- 移动记录次数较少,当每一记录占用的空间较多时,此方法比直接插入排序快
树形选择排序(Tree Selection Sort), 又称锦标赛排序(Tournament Sort), 是一种按照锦标赛的思想进行选择排序的方法
对 n 个记录的关键字进行两两比较,然后在其中 个较小者之间再进行两两比较,如此重复,直至选出最小关键字的记录为止
树形选择排序的平均时间复杂度为
这种排序方法尚有辅助存储空间较多、和“最大值”进行多余的比较等缺点
堆排序 (Heap Sort) 是一种树形选择排序,在排序过程中,将待排序的记录 r[1..n] 看成是一棵完全二叉树的顺序存储结构,利用完全二叉树中双亲结点和孩子结点之间的内在关系,在当前无序的序列中选择关键字最大(或最小)的记录
堆的定义:n 个元素的序列称之为堆
可以通过构造小根堆得到一个非递增的有序序列
void HeapAdjust(SqList &L,int s,int m){
// 假设r[s+l. .m]已经是堆,将r[s..m]调整为以r[s]为根的大根堆
rc = L.r[s];
for(j = 2*s;j <= m;j *= 2){ // 沿key较大的孩子结点向下筛选
if(j= L.r[j].key)
break; // rc应插入在位置s上
L.r[s] = rc;
}
}
要将一个无序序列调整为堆,就必须将其所对应的完全二叉树中以每一结点为根的子树都调整为堆
显然,只有一个结点的树必是堆,而在完全二叉树中,所有序号大于 的结点都是叶子,因此以这些结点为根的子树均已是堆。这样,只需利用筛选法,从最后一个分支结点 开始,依次将序号为 ,,… ,1 的结点作为根的子树都调整为堆即可
void CreatHeap(SqList &L){
// 把无序序列L.r[l..n]建成大根堆
n = L.length;
for(i=n/2; i>0; --i)
HeapAdjust(L,i,n); // 反复调用HeapAdjust
}
堆排序算法的实现
根据前面堆排序算法步骤的描述,可知堆排序就是将无序序列建成初堆以后,反复进行交换和堆调整
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] = L.r[i];
L.r[i] = x; // 将堆顶记录和当前未经排序子序列L.r[l..i]中最后一个记录互换
HeapAdjust(L,1,i-1); // 将L.r[l..i-l]重新调整为大根堆
}
}
堆排序的运行时间主要耗费在建初堆和调整堆时进行的反复 “筛选” 上
堆排序在最坏的情况下,其时间复杂度也为
仅需一个记录大小供交换用的辅助存储空间,所以空间复杂度为O(1)
特点:
- 不稳定排序
- 只能用于顺序结构,不能用于链式结构
- 初始建堆所需的比较次数较多,因此记录数较少时不宜采用。堆排序在最坏情况下时间复杂度为 , 相对于快速排序最坏情况下的 而言是一个优点,当记录较多时较为高效
归并排序(Merging Sort)就是将两个或两个以上的有序表合并成一个有序表的过程
将两个有序表合并成一个有序表的过程称为 2-路归并
归并排序算法的思想是:假设初始序列含有 n 个记录,则可看成是 n 个有序的子序列,每个子序列的长度为 1,两两归并,得到 个长度为 2 或 1 的有序子序列;再两两归并,……, 如此重复,直至得到一个长度为 n 的有序序列为止
相邻两个有序子序列的归并
void Merge(RedType R[],RedType &T[],int low,int mid,int high){
// 将有序表 R[low..mid]和R[mid+l..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+1];
else
T[k] = R[j++];
}
while(i <= mid)
T[k++] = R[i++]; // 将剩余的 R[low..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
// 对子序列R[low..mid]递归归并排序,结果放入S[low..mid]
MSort(R,S,low,mid);
// 对子序列 R[mid+l..high]递归归并排序,结果放人S[mid+1..high]
MSort(R,S,mid+1,high);
// 将S[low..mid]和S[mid+l..high]归并到T[low..high]
Merge(S,T,low,mid,high);
}
}
void MergeSort(SqList &L){
// 对顺序表 L 做归并排序
MSort(L.r,L.r,1,L.length);
}
归并排序的时间复杂度为
用顺序表实现归并排序时,需要和待排序记录个数相等的辅助存储空间,所以空间复杂度为O(n)
特点:
- 稳定排序
- 可用于链式结构, 且不需要附加存储空间,但递归实现时仍需要开辟相应的递归工作栈
基数排序是根据关键字中各位的值,通过对待排序记录进行若干趟“ 分配 ”与“ 收集”来实现排序的,是一种借助于多关键字排序的思想对单关键字排序的方法
基数排序(Raddd Sorting)是典型的分配类排序
例子:整理扑克牌(每一张牌有两个 “ 关键字”:花色和面值,且“ 花色 ” 的地位高于“ 面值" , 在比较任意两张牌面的大小时, 必须先比较 “ 花色"' 若 “ 花色 ” 相同, 则再比较面值 )
先按不同 “ 花色 ” 分成有次序的4堆,每一堆的牌均具有相同的 “ 花色 ” ,然后分别对每一堆按 “ 面值 ” 大小整理有序
这是一种 “ 分配 ” 与 “ 收集” 交替进行的方法
先按不同“ 面值 ” 分成13堆,然后将这13堆牌自小至大叠在一起,然后将每堆按照面值的次序收集到一起。再重新对这些牌按不同“ 花色 ” 分成4堆, 最后将这 4堆牌按花色的次序再收集到一起,此时同样得到一副满足如上次序关系的牌
假设记录的逻辑关键字由 d 个“关键字”组成,每个关键字可能取 rd 个值。只要从最低数位关键字起,按关键字的不同值将序列中记录“分配”到 rd 个队列中后再“收集”之,如此重复 d 次完成排序。按这种方法实现排序称之为基数排序,其中“基”指的是 rd 的取值范围
相关数据类型的定义如下:
#define MAXNUM_KEY 8 // 关键字项数的最大值
#define RADIX 10 // 关键字基数,此时是十进制整数的基数
#define MAX_SPACE 100
typedef struct{
KeysType keys[MAXNUM_KEY]; // 关键字
InfoType otheritems; // 其他数据项
int next;
}SLCell; // 静态链表的结点类型
typedef struct{
SLCell r[MAX_SPACE]; // 静态链表的可利用空间, r[O]为头结点
int keynum; // 记录的当前关键字个数
int recnum; // 静态链表的当前长度
} SLList; // 静态链表类型
typedef int ArrType[RADIX]; // 指针数组类型
基数排序算法实现
void Distribute(SLCell &r,int i,ArrType &f,ArrType &e){
// 静态链表 L 的 r 域中记录已按 (keys[O],…,keys [i-1]) 有序
// 本算法按第i个关键字keys[i]建立RADIX个子表,使同一子表中记录的keys[i]相同
// f[0..RADIX-1和 e[0..RADIX-1]分别指向各子表中第一个和最后一个记录
for(j=0; j
链式基数排序的时间复杂度为 O(d(n+ rd))
链式基数排序的空间复杂度为 O(n + rd)
特点:
- 稳定排序
- 可用于链式结构,也可用于顺序结构
- 时间复杂度可以突破基于关键字比较一类方法的下界 ,达到 O(n)
- 基数排序使用条件有严格的要求:需要知道各级关键字的主次关系和各级关键字的取值范围
内部排序的整个排序过程全部是在内存中完成的,并不涉及数据的内外存交换问题。但如果待排序的记录数目很大,无法一次性调入内存,整个排序过程就必须借用外存分批调入内存才能完成
外部排序基本上由两个相对独立的阶段组成
首先,按可用内存大小,将外存上含 n 个记录 的文件分成若干长度为 l 的子文件或段(segment), 依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写入外存,通常称这些有序子文件为归并段或顺串(run); 然后,对这些归并段进行逐趟归并,使归并段(有序的子文件)逐渐由小至大,直至得到整个有序文件为止
2-路平衡归并
外部排序所需总时间 = 内部排序所需的时间 + 外部信息读写的时间 + 内部归并所需的时间
对同一文件而言,进行外排时所需读/写外存的次数和归并的趟数 s 成正比
在一般情况下,对 m 个初始归并段进行 k-路平衡归并时,归并的趟数
为了减少归并趟数s, 可以从以下两个方面进行改进:
- 增加归并段的个数 k
- 减少初始归并段的个数 m
归并的趟数 s 不仅和 K 成反比,也和 m 成反比,因此,减少 m 是减少 s 的另一条途径
m 是外部文件经过内部排序之后得到的初始归并段的个数
n 为外部文件中的记录数,l 为初始归并段中的记录数
置换选择排序 (Replacement-Selection Sorting) 是在树形选择排序的基础上得来的,它的特点是:在整个排序(得到所有初始归并段)的过程中,选择最小(或最大)关键字和输入、输出交叉或平行进行
置换-选择排序所得初始归并段的长度不等
若对长度不等的 m 个初始归并段,构造一棵哈夫曼树作为归并树,便可使在进行外部归并时所需对外存进行读\写次数达最少,这棵归并树便称做最佳归并树
从时间复杂度的平均情况来看,直接插入排序、折半插入排序、冒泡排序和简单选择排序的速度较慢,而其他排序方法的速度较快。从算法实现的角度来看,速度较慢的算法实现过程比较简单,称之为简单的排序方法;而速度较快的算法可以看作是对某一排序算法的改进,称之为先进的排序方法, 但这些算法实现过程比较复杂。总的来看,各种排序算法各有优缺点,没有哪一种是绝对最优的
在使用时需根据不同情况适当选用,甚至可将多种方法结合起来使用。一般综合考虑以下因素:
结论:
一 叶 知 秋,奥 妙 玄 心