写在前面:
- 本系列笔记主要以《数据结构(C语言版)》为参考(本章部分图片来源于王道),结合下方视频教程对数据结构的相关知识点进行梳理。所有代码块使用的都是C语言,如有错误欢迎指出。
- 视频链接:第01周a--前言_哔哩哔哩_bilibili
- 基数排序部分的代码参考了一位小伙伴分享的代码,特此说明一下,其它C代码均由笔者根据书上的类C代码进行编写。
一、排序的基本概念和方法概述
1、排序的基本概念
(1)排序是按关键字的非递减或非递增顺序对一组记录重新进行排列的操作,如果参加排序的数据结点包含多个数据域,那么排序往往是针对其中某个域而言。
(2)当待排序的序列中存在两个或两个以上关键字相等的记录时,则排序所得的结果不唯一。假设(1≤i≤n,1≤j≤n,i≠j),且在排序前的序列中领先于(即i<j),若在排序后的序列中仍领先于,则称所用的排序方法是稳定的,反之,若可能使排序后的序列中领先于,则称所用的排序方法是不稳定的。
(3)排序的分类:
①按数据存储介质可分为:
[1]内部排序:待排序记录全部存放在计算机内存中进行排序的过程。
[2]外部排序:待排序记录的数量很大,以致内存一次不能容纳全部记录,在排序过程中尚需对外存进行访问的排序过程。
②按自然性可分为:
[1]自然排序:输入数据越有序,排序的速度越快的排序方法。
[2]非自然排序:不是自然排序的排序方法。
③按使用存储空间的大小可分为:
[1]原地排序:辅助空间用量为O(1)的排序方法。
[2]非原地排序:辅助空间用量超过O(1)的排序方法。
2、内部排序方法的分类
(1)插入类:将无序子序列中的一个或几个记录插入有序序列,从而增加记录的有序子序列的长度。主要包括直接插入排序、折半插入排序和希尔排序。
(2)交换类:通过交换无序序列中的记录从而得到其中关键字最小或最大的记录,并将它加入有序子序列中,以此方法增加记录的有序子序列的长度。主要包括冒泡排序和快速排序。
(3)选择类:从记录的无序子序列中选择关键字最小或最大的记录,并将它加入有序子序列中,以此方法增加记录的有序子序列的长度。主要包括简单选择排序和堆排序。
(4)归并类:通过归并两个或两个以上的记录有序子序列,逐步增加记录有序序列的长度。2-路归并排序是最为常见的归并排序方法。
(5)分配类:是唯一一类不需要进行关键字比较的排序方法,排序时主要利用分配和收集两种基本操作来完成。基数排序是主要的分配排序方法。
3、待排序记录的存储方式
(1)顺序表:记录之间的次序关系由其存储位置决定,实现排序需要移动记录。(除基数排序外,下面介绍的算法均使用顺序表进行介绍)
cpp
#define MAXSIZE 20
typedef int ElemType;
typedef int KeyType;
typedef struct
{
KeyType key; //关键字项
ElemType otherinfo; //其它数据项
}RedType;
typedef struct
{
RedType r[MAXSIZE + 1]; //0号元素闲置或用于做哨兵
int length;
}SqList;
(2)链表:记录之间的次序关系由指针指示,实现排序不需要移动记录,仅需修改指针,这种排序方式称为链表排序。
(3)待排序记录本身存储在一组地址连续的存储单元内,同时另设一个指示各个记录存储位置的地址向量,在排序过程中不移动记录本身,而移动地址向量中这些记录的"地址",在排序结束之后按照地址向量中的值调整记录的存储位置,这种排序方式称为地址排序。
4、排序算法效率的评价指标
(1)执行时间:对于排序操作,时间主要消耗在关键字之间的比较和记录的移动上(这里只考虑以顺序表方式存储待排序记录),排序算法的时间复杂度由这两个指标决定,因此可以认为高效的排序算法的比较次数和移动次数都应该尽可能的少。
(2)辅助空间:空间复杂度由排序算法所需的辅助空间决定,辅助空间是除了存放待排序记录占用的空间之外,执行算法所需要的其它存储空间。理想的空间复杂度为O(1),即算法执行期间所需要的辅助空间与待排序的数据量无关。
二、插入排序
1、概述
(1)插入排序的基本思想:每一趟将一个待排序的记录,按其关键字的大小插入已经排好序的一组记录的适当位置,直到所有待排序记录全部插入为止。
(2)可以选择不同的方法在已排好序的记录中寻找插入位置。根据查找方法的不同,有多种插入排序方法,这里仅介绍3种方法:直接插入排序、折半插入排序和希尔排序。
2、直接插入排序
(1)采用顺序查找法查找插入位置,然后插入即可。
(2)算法实现:
cpp
void InsertSort(SqList* L) //直接插入排序
{
for (int i = 2; i <= L->length; i++)
{
if (L->r[i].key < L->r[i - 1].key) //新加入元素小于先前加入的最大元素,说明需要插入到前部
{
L->r[0] = L->r[i]; //将待插入的记录暂存到监视哨中
L->r[i] = L->r[i - 1]; //r[i-1]后移
int j;
for (j = i - 2; L->r[0].key < L->r[j].key; j--) //从后向前寻找插入位置
L->r[j + 1] = L->r[j]; //记录逐个后移,直到找到插入位置
L->r[j + 1] = L->r[0]; //将r[0](即原r[i])插入正确位置
}
}
}
(3)该算法的时间复杂度为O(),空间复杂度为O(1)。
(4)该算法能实现稳定排序,比较简便,易于实现,且适用于链式存储结构,当初始记录基本有序(正序)的时候该算法的效率较高。
3、折半插入排序
(1)采用折半查找法查找插入位置,然后插入即可。
(2)算法实现:
cpp
void BInsertSort(SqList* L) //折半插入排序
{
for (int i = 2; i <= L->length; i++)
{
L->r[0] = L->r[i]; //将待插入的记录暂存到监视哨中
int low = 1, high = i - 1; //置查找区间初值
while (low <= high) //在查找区间中折半查找插入的位置
{
int m = (low + high) / 2; //折半
if (L->r[0].key < L->r[m].key)
high = m - 1;
else
low = m + 1;
}
for (int 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])插入正确位置
}
}
(3)该算法的时间复杂度为O(),空间复杂度为O(1)。
(4)该算法能实现稳定排序,比较简便,易于实现,但不适用于链式存储结构,适合初始记录无序、记录量较大的情况。
4、希尔排序
(1)希尔排序实质上是采用分组插入的方法,先将整个待排序记录序列分割成几组(将相隔某个"增量"的记录分成一组),从而减少参与直接插入排序的数据量,对每组分别进行直接插入排序,然后增加每组的数据量,重新分组,这样当经过几次分组排序后,整个序列中的记录"基本有序"时,再对全体记录进行一次直接插入排序。(增量序列可以有各种取法,但应该使增量序列中的值没有除1之外的公因子,并且最后一个增量值必须等于1)
(2)算法实现:
cpp
void ShellInsert(SqList* L, int dk) //一趟希尔排序
{
for (int i = dk + 1; i <= L->length; i++)
{
if (L->r[i].key < L->r[i - dk].key) //将L->r[i]插入有序增量子表
{
L->r[0] = L->r[i]; //暂存在r[0]中
int j;
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]; //将r[0](即原r[i])插入正确位置
}
}
}
void ShellSort(SqList* L, int dt[], int t) //希尔排序
{
for (int k = 0; k < t; k++)
ShellInsert(L, dt[k]); //一趟增量为dt[t]的希尔插入排序
}
(3)该算法的平均时间复杂度为O(),空间复杂度为O(1)。
(4)该算法不能实现稳定排序,只能用于顺序结构,不能用于链式结构。该算法记录总的比较次数和移动次数都比直接插入排序的要少,n越大时效果越明显,所以适合初始记录无序、n较大时的情况。
三、交换排序
1、概述
交换排序的基本思想是:两两比较待排序记录的关键字,一旦发现两个记录不满足次序要求时则进行交换,直到整个序列全部满足要求为止。
2、冒泡排序
(1)冒泡排序是一种最简单的交换排序方法,它通过两两比较相邻记录的关键字,如果为逆序,则进行交换,从而使关键字小的记录如气泡一般逐渐往上"漂浮"(左移),或者使关键字大的记录如石块一样逐渐向下"坠落"(右移),对待排序记录经过若干轮比较后,排序结束。
每趟结束时,不仅能挤出一个最大值到最后面,还能同时部分理顺其它元素,下一趟不必比较上一趟得出的最大值。
(2)算法实现:
cpp
void BubbleSort(SqList* L) //冒泡排序
{
int m = L->length - 1;
int flag = 1; //flag用来标记某一趟排序是否发生交换
while ((m > 0) && (flag == 1)) //最坏情况下需要m-1趟排序
{
flag = 0; //flag置为0,如果本趟排序没有发生交换,则不必执行下一趟排序
for (int j = 1; j < m; j++)
if (L->r[j].key > L->r[j + 1].key)
{
flag = 1; //flag置为1,表示本趟排序发生了交换
RedType tmp = L->r[j]; //交换前后两个记录
L->r[j] = L->r[j + 1];
L->r[j + 1] = tmp;
}
m--;
}
}
(3)该算法的时间复杂度为O(),空间复杂度为O(1)。
(4)该算法能实现稳定排序,比较简便,易于实现,且适用于链式存储结构,但是移动记录的次数较多,算法的平均时间性能比直接插入排序差,当初始记录无序且记录量较大时,此算法不宜采用。
3、快速排序
(1)快速排序是由冒泡排序改进而得的,在冒泡排序过程中,只对相邻的两个记录进行比较,因此每次交换两个相邻记录时只能消除一个逆序排列,如果能通过两个(不相邻)记录的一次交换消除多个逆序排列,则会大大加快排序的速度。
(2)算法具体步骤:在待排序的n个记录中任取一个记录(通常取第一个记录)作为枢轴,设其关键字为pivotkey,经过一趟排序后,把所有关键字小于pivotkey的记录交换到前面,把所有关键字大于pivotkey的记录交换到后面,结果将待排序记录分成两个子表,最后将枢轴放置在分界处的位置,然后分别对左、右子表重复上述过程,直至每一子表只有一个记录时,排序完成。(若每一次选中的"枢轴"都将待排序序列划分为均匀的两个部分,则算法效率最高)
(3)算法实现:
cpp
int Partition(SqList* L, int low, int high)
{
L->r[0] = L->r[low]; //用子表的第一个记录作为枢轴记录
KeyType pivotkey = L->r[low].key; //枢轴记录关键字保存在pivotkey中
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; //返回枢轴位置
}
void QSort(SqList* L, int low, int high) //快速排序
{
//调用时low初值为1,high初值为L->length
if (low < high) //长度大于1
{
int pivotloc = Partition(L, low, high); //一分为二
QSort(L, low, pivotloc - 1); //对左子表递归排序
QSort(L, pivotloc + 1, high); //对右子表递归排序
}
}
(4)该算法的时间复杂度在最好情况下为O(),在最坏情况下为O();空间复杂度在最好情况下为O(),在最坏情况下为O(n)。
(5)该算法不能实现稳定排序,不适用于链式存储结构,适合初始记录无序、n较大的情况,不适合初始记录有序或基本有序的情况。
四、选择排序
1、概述
选择排序的基本思想是:每一趟从待排序的记录中选出关键字最小的记录,按顺序将其放在已排好序的记录序列的最后,直到全部排完为止。
2、简单选择排序
(1)算法的具体步骤:每一趟在待排序元素中选取关键字最小(或最大)的元素加⼊有序子序列。
(2)算法实现:
cpp
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->r[j].key < L->r[k].key) //k指向此趟排序中关键字最小的记录
k = j;
if (k != i)
{
RedType tmp = L->r[i];
L->r[i] = L->r[k];
L->r[k] = tmp;
}
}
}
(3)该算法的时间复杂度为O(),空间复杂度为O(1)。
(4)上述算法实现采用"交换记录"的策略,这导致了该算法不能实现稳定排序(实际上该算法可以实现稳定排序,不过需要改变"交换记录"的策略)。该算法可用于链式存储结构。该算法的移动记录次数较少,当每一记录占用的空间较多时,此方法比直接插入排序快。
3、堆排序
(1)若n个关键字序列L[1...n] 满足下面某一条性质,则称为堆(Heap):
①若满足L(i)≥L(2i)且L(i)≥L(2i+1)(1≤i≤n/2),则为大根堆。
②若满足L(i)≤L(2i)且L(i)≤L(2i+1)(1≤i≤n/2),则为小根堆。
(2)若在输出堆顶的最小值(最大值)后,使得剩余n-1个元素的序列又建成一个堆,则得到n个元素的次小值(次大值)......如此反复,便能得到一个有序序列,这个过程称为堆排序。实现堆排序需要解决建初堆和调整堆两个问题,也就是如何将一个无序序列建成一个堆,以及去掉堆顶元素(堆顶元素改变)之后如何调整剩余元素成为一个新的堆。
(3)调整堆(以大根堆为例):
①算法步骤:
[1]输出堆顶元素之后,以堆中最后一个元素替代之。
[2]将根结点与左、右子树的根结点值进行比较,并与其中的大者进行交换(小根堆则是与小者交换)。
[3]重复上述操作,直至叶子结点,将得到新的堆,称这个从堆顶至叶子的调整过程为"筛选"。(对一个无序序列反复"筛选",就可以得到一个堆)
②算法实现:
cpp
void HeapAdjust(SqList* L, int s, int m) //筛选法调整堆
{ //假设r[s+1..m]已经是堆,将r[s..m]调整为以r[s]为根的大根堆
RedType rc = L->r[s];
for (int j = 2 * s; j <= m; j *= 2) //沿key较大的孩子结点向下筛选
{
if (j < m&&L->r[j].key < L->r[j + 1].key) //j为key较大的记录的下标
j++;
if (rc.key >= L->r[j].key) //rc应插入在位置s上
break;
L->r[s] = L->r[j];
s = j;
}
L->r[s] = rc; //插入
}
(4)建初堆(以大根堆为例):
①由于堆实质上是一个完全二叉树,那么可以顺序存储一个堆。
②要将一个无序序列调整为堆,就必须将其所对应的完全二叉树中以每一结点为根的子树都调整为堆。显然,只有一个结点的树必是堆,而在完全二叉树中,所有序号大于的结点都是叶子,因此以这些结点为根的子树均已是堆,这样,只需利用筛选法,从最后一个分支结点开始,依次将序号为、、...、1的结点作为根的子树都调整为堆即可。
③算法实现:
cpp
void CreatHeap(SqList* L) //建初堆
{
int n = L->length;
for (int i = n / 2; i > 0; i--)
HeapAdjust(L, i, n);
}
(5)堆排序算法的实现:堆排序就是将无序序列建成初堆以后,反复进行交换和堆调整。
cpp
void HeapSort(SqList* L) //堆排序
{
CreatHeap(L); //把无序序列建成大根堆
for (int i = (*L).length; i > 1; i--)
{
RedType x = L->r[1]; //将堆顶记录和当前未经排序子序列中最后一个记录互换
L->r[1] = L->r[i];
L->r[i] = x;
HeapAdjust(L, 1, i - 1); //将序列L->r重新调整成大根堆
}
}
(6)该算法的时间复杂度为O(),空间复杂度为O(1)。
(7)该算法不能实现稳定排序,且不能用于链式结构,记录数较少时该算法不宜采用。