文章目录
八大排序算法
这篇文章是c语言数据结构章节的最后一个部分——排序算法
在我们的日常生活中,排序是无处不在的,比如打开一个购物网站,会发现网站可能会对其上架的商品有多种排序方式:
随便输入一个商品,平台对其都有对应的排序功能如销量、价格等。再到中高考成绩的排序决定考生排位,又或是学校内成绩的排序,最终都离不开排序。
所以今天这篇文章将重点讲解八大排序算法的思想以及实现。因为重点是思想以及功能能够成功实现,所以排序的对象选择整形数组。最重要的是学习背后的思想。其他类型的数据排序只需要稍微改动一些逻辑即可。
排序算法种类
八大排序算法正好两两对应一组:
其中有两种在之前已经接触过了,即堆排序和冒泡排序。所以对应的篇幅会少一些。
接下来,我们将对这几个算法进行深度地剖析及实现。请注意,在这篇文章中,所有的排序算法都已升序进行讲解。
选择排序类
如其名,该类排序最重要的二字就是"选择"。即每一次排序的时候都会在给定数组中选取数据,将选定好的数据放在指定的位置。下面让我们来看看是怎么样操作的。
堆排序
堆排序在之前的堆的实现文章中就已经讲过了,在这里只进行简单的回顾。
感兴趣的读者可以翻阅这篇文章:二叉树概念和堆的实现应用
算法思路
堆本质上是完全二叉树,即逻辑结构是二叉树。但是真正存储的时候,为了效率,其底层选择的是使用数组进行存储。
为了排一个升序的数组,那么应当建大堆。这是之前的文章讲过的。
对于建堆,选择使用向下调整算法建堆的时间复杂度为O(N),代码如下所示:
void AdjustDown(HPDataType* a, int Heapsize, int parent) {
//默认调整小堆
//向下调整小堆的逻辑:(但是得保证当前已经是一个小堆)
//从传入的parent位置开始 找到parent左右孩子较小的那个 (注意:可能会没有左右孩子)
//如果大于较小的那个孩子 就交换 反之退出循环
int Parent = parent;
while ((Parent * 2 + 1) < Heapsize) {
//如果不是叶子节点就向下调整
//假设法找到左右孩子较小的那个
//如果能进入循环 说明有左孩子 但不一定有右孩子
int Child = Parent * 2 + 1;
if (Child + 1 < Heapsize && a[Child] < a[Child + 1]) Child = Child + 1; //调为大堆要改第二个符号 第一个不能改
if (a[Parent] < a[Child]) {
//想要调整为大堆就改符号 找左右孩子大的
swap(&a[Parent], &a[Child]);
Parent = Child;
}
else break;
}
}
这段代码已经修改为调整大堆的版本。如果想要改为调整小堆的请按注释修改!
既然已经建立大堆了,就可以模仿出堆过程。将最大的数(堆顶数据)出堆,其实就是存储在数组的最后一个位置。但是后续会调整堆的大小减小1,所以就可以近似看成出堆操作。
最后代码的实现如下:
void HeapSort(int* a, int n) {
//向下调整建堆
/*int begin = (n - 1 - 1) / 2;
while (begin >= 0) {
AdjustDown(a, n, begin);
begin--;
}*/
//当然可以使用for循环
for (int i = (n - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(a, n, i);
}
//模仿出堆过程
while (n > 0) {
int end = n - 1;
swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
n--;
}
}
时间复杂度和空间复杂度
在上述提及的那篇文章中介绍过:
向下调整算法建堆的时间复杂度为O(N),出堆过程时间复杂度为O(N * LogN),所以最后该算法属于是N * LogN级别,所以时间复杂度为O(N * LogN)。
由于该算法没有开辟额外的空间堆数组进行处理,所以空间复杂度为O(1)。
由于已经讲过,便不再赘述。
选择排序
算法思路
这个排序十分简单,即给定一个数组,每次都找到最小值位置,然后与第一个位置的元素交换位置。然后让后面部分的数组执行上述操作。
如下图例子所示:
对于上面这个思路的代码实现:
void SelectSort(int* a, int n) {
int begin = 0;
for(int i = 0; i < n - 1; i++){
int min = i;
for(int j = i + 1; j < n; j++){
if(a[j] < a[min])
min = j;
}
swap(&a[min],&a[i]);//交换操作
}
}
很容易看见,选择排序算法的本质就是暴力查找数据。但是为了排序的效率,能不能再对这个部分的算法进行一定程度的优化呢?
答案是可以的
算法优化
既然是暴力查找,那就可以每一次暴力查找的时候同时找到当前数组中最大和最小的,然后放在数组的头尾,然后不断地查找下去直到无法查找即可。
这样子相比于上面的暴力查找,虽然方法类似,但是效率高了很多,所需要的时间可以近似看成上面那个算法的一半。
代码实现:
void SelectSort(int* a, int n) {
//begin从0往后走 end从n-1往前走
//每一次都要找到最大和最小的,最小的放begin位置,最大的放end位置
//然后begin++ end--
//直到begin>=end就出这个循环
int begin = 0, end = n - 1;
while (begin < end) {
int maxi = begin, mini = begin;//最大数和最小数的下标
for (int i = begin + 1; i <= end; i++) {
if (a[i] > a[maxi]) {
maxi = i;
}
if (a[i] < a[mini]) {
mini = i;
}
}
//交换操作
if (begin == maxi && end == mini) swap(&a[begin], &a[end]);
else if (begin == maxi) {
swap(&a[end], &a[maxi]);
swap(&a[begin], &a[mini]);
}
else {
swap(&a[begin], &a[mini]);
swap(&a[end], &a[maxi]);
}
begin++;
end--;
}
}
但是为什么后续的交换操作如此麻烦呢?
这是因为当一次性找两个数据的时候,有如下几种可能:
1.最大的数字的坐标与寻找数组段的第一个位置元素重合,最小的数字坐标与数组段最后一个位置元素重合
如果此时先swap(&a[begin], &a[mini]);
,再swap(&a[end], &a[maxi]);
,就会导致数组实际上是没有交换。所以要特殊处理
2.最大的数字的坐标与寻找数组段的第一个位置元素重合,最小的数字坐标与数组段最后一个位置元素不重合
这一种情况下,如果先swap(&a[begin], &a[mini]);
,会导致maxi的位置的值发生改变,指向的是被换后的最小值。那么再执行swap(&a[end], &a[maxi]);
的时候,就无法将最大的数放在数组的尾部了,所以也要特殊处理。
3.最大的数字的坐标与寻找数组段的第一个位置元素不重合,最小的数字坐标与数组段最后一个位置元素重合及其余情况
第三种情况的前面半个部分和第2条正好相反,那就与上述操作相反即可。对于其余情况,无论先执行哪一条语句都是正确的,所以不用另外再进行判断。
当然,可以再特殊情况下通过调整maxi和mini的位置。但是在这里为了确保正确,选择逻辑判断。感兴趣的读者可以尝试使用调整坐标方法。
时间复杂度和空间复杂度
无论是算法优化前又或是优化后,这个算法总是会出现两层循环嵌套的逻辑。且每一层循环的次数都与数组长度N有关。所以时间复杂度是很明显的O(N^2),由于比较简单,不进行计算。
没有开辟新的数组对原数组进行处理,所以该算法的时间复杂度是O(1)。
插入排序类
本部分算法的思想就在于”插入“二字。
插入排序
算法思路
一个数组可以看作成已排序区和未排序区。然后从未排序区的第一个数据(记作a)开始,从已排序区的最后一个元素开始向前比较。直到找到第一个小于等于a的数b的时候,就将a插入在b后面的位置。(如果已排序区的数字全部都比a小,那就将a放在第一个位置)
直到已排序区为整个数组的时候,就不用再执行插入算法了。
如图所示:
代码实现非常简单:
void InsertSort(int* a, int n) {
//思路:把前面的部分当作已排序区(升序)
//然后让已排序区的下一个位置的元素跟已排序区内比较
//直到找到一个小于这个元素的位置,在这个位置后面插入该元素
for (int i = 0; i < n - 1; i++) {
int end = i;
int tmp = a[end + 1];
while (end >= 0) {
if (a[end] > tmp) {
a[end + 1] = a[end];
end--;
}
else break;
}
a[end + 1] = tmp;
}
}
比tmp小的数会往后挪,直到找到插入位置放入tmp。
注意:这里插入tmp数据尽量不要写在第二层循环内。因为插入排序算法的核心思想就是与前一个位置的数据比较。但是当tmp走到数组第一个位置比较的时候,前面是没有数据的。会退出循环。如果在第二层循环内写插入tmp操作,那么对于刚刚讲到的这个情况需要进行特殊判断。但是写在当前代码显示位置就可以将所有插入情况都满足。故建议写在当前展示的位置。
时间复杂度和空间复杂度
假设最坏的情况下:每一个数字往前插入都要将已排序区的数字全部比较一遍,那么语句执行的次数是一个很明显的等差数列,时间复杂度为O(N^2)。
空间复杂度为O(1),因为没有额外开辟新的空间。
希尔排序
对于刚刚写的插入排序,我们发现,当数组为倒序的时候,每个数据往前插入的时候,都要与已排序区中的所有元素进行排序。那么效率会退化。当数组中元素较多的时候,那么排序的效率并不高。而当算法是升序的时候,每个元素只要比较一遍就可以不用再执行该算法。
也就是说:对于插入排序来讲,数组越接近升序,那排序效率就越高。
针对这个特点,就提出了希尔排序这个算法。这个算法本质上就是对插入排序算法的优化。
算法思路
希尔排序是这样操作的,既然数组越接近升序插入排序效率越高,那就可以先对数组进行若干次预排序,使其较为接近升序。然后再执行上面实现的插入排序操作,这样效率就高得多。
希尔排序会有一些抽象:
它先规定了一个范围叫gap,需要将这个数组分为gap组。
为了方便讲述,我们先假定gap = 3,数组中有十个元素进行讲解:
如图所示操作,我们会发现所有的数据都被分到了分配的gap组上,且没有重复。
现在这个数组被分为了红、绿、紫三组。希尔排序就是对这红、绿、紫三组指向的元素分别进行预排序。使用的算法就是上面的插入排序算法。只不过此时相邻的数据举例变为了gap = 3,而不是之前的1。
连续对三组预排序的代码编写很困难,不妨先写对红色的预排序,然后再来推广:
//对红色排
for (int end = 0; end < n - gap; end += gap) {
int tmp = a[end + gap];
//插入排序
while (end >= 0) {
if (tmp < a[end]) {
a[end +