前言
排序算法有很多,因其思想及依赖的数据结构不同造成对应排序方法性能的不同。本文将介绍常用的八大排序算法。让大家对各类排序有所了解。
注意:本文介绍的排序均为排升序
排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
- 稳定性介绍:
特性总结
为方便大家更好理解下面实现的各类排序,先对各类排序算法特性进行总结
排序方式 | 平均情况 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | O(N^2) | O(N) | O(N^2) | O(1) | 稳定 |
选择排序 | O(N^2) | O(N^2) | O(N^2) | O(1) | 不稳定 |
冒泡排序 | O(N^2) | O(N^2) | O(N^2) | O(1) | 稳定 |
希尔排序 | O(N^1.3) | O(N^1.25) | O(N^1.6) | O(1) | 不稳定 |
堆排序 | O(N*logN) | O(N*logN) | O(N*logN) | O(1) | 不稳定 |
快速排序 | O(N*logN) | O(N*logN) | O(N^2) | O(logN~N) | 不稳定 |
归并排序 | O(N*logN) | O(N*logN) | O(N*logN) | O(N) | 稳定 |
计数排序 | O(max(range,N)) | O(max(range,N)) | O(max(range,N)) | O(range) | 稳定 |
- 不稳定的原因在下图:
冒泡排序
开始先上盘开胃小菜:想必冒泡排序大家都不陌生了
思想:
冒泡就是将当前位置的元素与后一个位置的元素进行比较,符合排序要求就交换两个位置的元素。
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void BubbleSort(int* a, int n)
{
//每一趟
for (int i = 0; i < n; i++)
{
int flag = 1;//优化
//每一轮
for (int j = 0; j < n - i - 1; j++)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
flag = 0;
}
}
//如果一轮下来都不需要交换元素,则说明数组元素已经有序,则可直接跳出循环
if (flag == 1)
{
break;
}
}
}
实现:
冒牌排序需要嵌套两层循环,外层循环控制趟数,内层循环控制排序元素的位置。每一趟排序完成会将当前轮的最值排到最后一个位置。需要注意的是内外循环结束的条件
- 外层循环控制的是趟数:每一趟只将当前趟数的最值排到最后的位置,一趟只排好一个位置,也就是需要排n-1趟。
- 内层循环控制排序位置:每一趟会将元素排好在最后面,所以每一轮就不需要排到已经排好元素的位置,所以停止条件为j < n - i - 1。
冒泡排序的时间复杂度是经典的O(N^2),这是因为嵌套两层循环,而且每轮只能排好一个元素,效率是非常低下的,即便我们增加了flag来优化:判断当前轮是否已经排完序,是则无需再排。但效率依旧很低。
选择排序
一般的选择排序一轮只选最大或者最小的元素,效率低下。我们这里直接升级一下,同时选最大和最小,一轮排一大一小两个元素。
思想:
每一次从待排序的数据元素中选出最小和最大的两个元素,存放在序列的起始,结束位置,直到全部待排序的数据元素排完 。(下图演示的是只选一个的选择排序)
//优化版,同时选最大的和最小的,将大的往后放,小的往前放,不断缩小区间
void SelectSort(int* a, int n)
{
int begin = 0;
int end = n - 1;
while (begin < end)//相遇则说明已经排好
{
//都从开始位置选
int maxi = begin;
int mini = begin;
for (int i = begin; i <= end; i++)//遍历选出当前区间的最值
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[begin], &a[mini]);//一轮选出了两个最值,交换
if (maxi == begin)//要注意前一轮交换是否会影响下一轮
{
maxi = mini;//若最大值在第一个位置,则需要更新一下交换后最大值的下标位置
}
Swap(&a[end], &a[maxi]);
begin++;//控制区间
end--;
}
}
实现:
选择排序也由两层循环一起完成:外层用来检测元素是否相遇,相遇则说明已经排完序了。内层循环用来控制待排序区间:先选出最小和最大值,再将其与当前区间首尾位置元素进行交换。最后不断缩小当前区间,直到排好序。
- 需要注意的是在每轮第一次交换是否会影响到下一一次交换。
即便我们推出优化版本,但选择排序的思想注定了它的时间复杂度只能是
直接插入排序
思想:
我们将待排序元素与其前面位置的元素进行比较(前面元素已经有序),直到找到目标位置将其插入
实际中我们玩扑克牌时,就用了类似的思想
//类似插排,将要插入的数与前面的数(已有序)一一比较,将元素后移,直到找到要插入的位置
void InsertSort(int* a, int n)
{
for (int i = 1; i < n; i++)
{
int tmp = a[i];//要插入的值
int prev = i - 1;//插入值的前一位
while (prev >= 0)
{
if (a[prev] > tmp)
{
a[prev+1] = a[prev];//将元素后移
prev--;//向前继续比对
}
else//找到插入位置
{
break;
}
}
a[prev + 1] = tmp;//插入,prev位置之后
}
}
实现:
由于待排序元素前面已经是有序数组,所以我们从第二个位置开始排序。先用临时变量tmp记录待排序元素,然后将其与前面位置的元素一一比较,如果前一个大于tmp(此时排升序),则将前面的元素往后覆盖,直至找到位置break出循环。这时候再将tmp插入目标位置(目标位置为前一元素之后)
- 需要注意的是排序位置之前已经是有序数组,要记住这一点,这也是在循环里为什么不是往后覆盖数据就是break出循环的原因。
- 还有一点就是待插入元素的位置:以升序为例,如果前一元素已经小于待插入元素,则主动跳出循环,再将其插到前一元素的后面,也就是prev + 1的位置。如果待插入元素是最小值,那么只能等prev越界,此时prev + 1便为首元素位置。
直接插入排序的时间复杂度也为O(N^2) ,但与前面两个O(N^2) 的相比,直接插入排序是O(N^2)家族里的唯一牌面了,虽说是同一级别的,但在实际中也还能拿的出手
以上单位为毫秒,对100000个数据进行排序(测试方法后续再讲解)。
该测试可以看出冒泡是真的没得救了,完全上不了桌,选择排序优化后好像也还能用,插入排序与他们相比给人一种错觉:好像他们不是这个量级的。
- 他们的时间复杂度都为O(N^2) 没错,这是由于时间复杂度表示方法的原因,将他们都归为了一类,但在实际中直接插入排序最好情况下是可以达到O(N)的
- 结合动图及代码可以看到,当直接插入排序要排的是接近有序数组时,一趟下来就能将数组排好,做到O(N)的级别。
希尔排序
我们的大佬Shell,对上面的直接插入排序很看好,能够有O(N)级别的潜力,觉得很有潜力,是个潜力股,决心好好挖掘一下。
思想:
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数gap作为距离,把待排序数据中所有个数记录分成n个组,所有距离为gap的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达gap=1时,所有记录在统一组内排好序。
//1 当gap不为1时为预排序
//2 当gap为1时是插入排序
void ShellSort(int* a, int n