文章目录
- 前言
- 一、排序的概念
- 二、常见的排序算法
- 排序实现的接口
- (1)插入排序
- 基本思想
- 1.直接插入排序
- 1.1代码实现
- 1.2直接插入排序特性总结
- 2.希尔排序(缩小增量排序)
- 2.1代码实现
- 2.2希尔排序的特性总结
- (2)选择排序
- 基本思想
- 1.直接选择排序
- 1.1代码实现
- 1.2直接选择排序的特性总结
- 2.堆排序
- (3)交换排序
- 基本思想
- 1.冒泡排序
- 1.1代码实现
- 1.2冒泡排序的特性总结
- 2.快速排序
- 2.1hoare版本
- 2.2hoare版本代码实现
- 2.3挖坑法
- 2.4挖坑法代码实现
- 2.5前后指针版本
- 2.6前后指针版本代码实现
- 2.7快速排序优化
- 2.7.1三数取中法选key
- 2.7.2递归到小的子区间时,可以考虑使用插入排序
- 2.8非递归实现快速排序
- 2.9快速排序的特性总结
- (4)归并排序
- 4.1基本思想
- 4.2代码实现
- 4.2.1递归实现
- 4.2.2非递归实现
- 正确代码实现1
- 正确代码实现2
- 两种方法的对比
- 4.3归并排序的特性总结
- (5)非比较排序(计数排序)
- 5.1基本思想
- 5.2代码实现
- 5.3计数排序的特性总结
- 三、排序算法复杂度及稳定性分析
- 四、补充知识(了解)
- 总结
前言
排序在我们的生活中发挥着非常重要的作用,美团上各种美食的排名,择校时各高校的排名以及我们上学时期在班中的排名,都需要进行排序,这个时候的排序算法就非常重要了,不同的排序算法的效率是不同的,下面就让我们一起看看那些热门的排序算法吧。
注: 下面的排序均为排升序
一、排序的概念
排序: 所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性: 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序: 数据元素全部放在内存中的排序。
外部排序: 数据元素太多不能同时放在内存中,根据排序过程的要求能在内外存之间移动数据的排序。
二、常见的排序算法
排序实现的接口
// 插入排序
void InsertSort(int* a, int n);
// 希尔排序
void ShellSort(int* a, int n);
//选择排序
void SelectSort(int* a, int n);
//堆排序
void HeapSort(int* a, int n);
//冒泡排序
void BubbleSort(int* a, int n);
//快速排序递归实现
//快速排序hoare版本
int PartSort1(int* a, int begin, int end);
//快速排序挖坑法
int PartSort2(int* a, int begin, int end);
//快速排序前后双指针法
int PartSort3(int* a, int begin, int end);
//快速排序调用函数
void QuickSort(int* a, int begin, int end);
//非递归实现快速排序
void QuickSortNonR(int* a, int begin, int end);
//归并排序递归实现
void MergeSort(int* a, int n);
//归并排序非递归实现
void MergeSortNonR(int* a, int n);
void MergeSortNonR1(int* a, int n);
//计数排序
void CountSort(int* a, int n);
因为我们后面讲解排序的过程中,需要不断的交换数据,所以我们在这里先写一个交换函数。
void Swap(int* left, int* right)
{
int tmp = *left;
*left = *right;
*right = tmp;
}
(1)插入排序
基本思想
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序
的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。
1.直接插入排序
当插入第i个元素时,前面的arr[0]~arr[i-1]已经排好序,此时用arr[i]的排序码与arr[i-1],arr[i-2],…的排序码进行比较,找到插入的位置即将arr[i]插入,原来位置上的元素顺序后移。
1.1代码实现
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
//[0,end]有序,把end+1的值插入保持有序
//end从0开始与后面的数值进行比较
int end = i;
//用tmp保存要插入的数值
int tmp = a[end + 1];
//end小于0时说明tmp的值是最小的
while (end >= 0)
{
//要插入的数值比前面的数值小,则把前面的数值向后移动一下,(这个操作其实就是在移动数据)
if (tmp < a[end])
{
a[end + 1] = a[end];
end--;
}
//否则就是插入的数值比前面的数值大,当前位置就是我们要插入的数值的位置,直接退出循环
else
{
break;
}
}
//无论是end=-1结束,还是找到了要插入的位置结束,这一步都要进行,所以直接放在最外面,还能避免数组越界的问题。
a[end + 1] = tmp;
}
}
1.2直接插入排序特性总结
1.元素集合越接近有序,直接插入排序算法的时间效率越高
2.时间复杂度:O(N^2)
3.空间复杂度:O(1),他是一种稳定的排序算法
4.稳定性:稳定
2.希尔排序(缩小增量排序)
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成个组,所有距离为选定的整数的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
2.1代码实现
这么说可能有点太抽象了,很多人都不明白是什么意思,下面我们一起好好地分析一下。这里我们先假设一个gap值。
void ShellSort(int* a, int n)
{
先举个例子将代码写出来,下面是假设排序的数据量很小,设gap为3
int gap = 3;
for (int j = 0; j < gap; j++)//gap为多少,就将数据分为gap组,分别进行排序
{
//i=0,i=1,i=2,将三组数据排序,后面内容与插入排序思路一样,只不过将1改为了gap
for (int i = j; i < n - gap; i += gap)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end--;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
有了上面的例子以后就可以很好的理解我们的希尔排序了。
上面只是我们举的一个例子,但是他还是不够好,因为gap为3的话是不可能最终将整个集合排为有序的,只有gap最后等于1的时候,集合一定有序,我们前面给gap数值是为了让数组更接近有序。
所以gap是需要变化的,而且我们不需要将一组数据排完序以后再去排另外一组数据,直接从0开始进行遍历然后排序就可以了。下面这个才是完善后比较完美的希尔排序
// 希尔排序
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
//保证最后gap的值一定为1,gap=1,排序后数据即为有序,所以在最后面要有一个+1.
//gap为多少就将数据分为为多少组
//这里的gap / 3可以改变,看你自己
gap = gap / 3 + 1;
//i++可以从数据最开始依次给数组排序,不用先将一组排完再排另一组
//但是要注意结束条件,不要产生数组越界的问题
for (int i = 0; i < n - gap; i++)
{
//假设i位置的值就是当前一组的最后一个元素
int end = i;
//tmp就是要插入的元素
int tmp = a[end + gap];
while (end >= 0)
{
//tmp < a[end],就需要移动数据
if (tmp < a[end])
{
a[end + gap] = a[end];
//end一次移动gap个数据的距离,寻找属于自己组的元素
end -= gap;
}
//否则就是要插入的元素找到了自己所处的位置,直接结束循环
else
{
break;
}
}
//在正确的位置放入要插入的数据
a[end + gap] = tmp;
}
}
}
2.2希尔排序的特性总结
- 希尔排序是对直接插入排序的优化。
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
- 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排序的时间复杂度都不固定
- 这里我们可以记一个大概的数据O(n^1.25)
- 稳定性: 不稳定
(2)选择排序
基本思想
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
1.直接选择排序
1.在元素集合arr[i]~arr[n-1]中选择关键码最小(大)的数据元素
2.若塔不是这组元素中的最后一个(第一个元素),则将他与这组元素中的第一个(最后一个)元素交换
3.在剩余的元素集合中,重复上面的步骤
其实这个思路也是很好理解的,就是遍历这个集合,把每次找到的最小值放到最前面,注意:已经排完的最小元素就不在我们要遍历的集合里面了。
这里我们将其在进行优化一下,原思想是每次遍历去找集合中的最小值,然后放到最前面,我们每次遍历去寻找集合的最大值和最小值,然后去分别放入集合的前面和后面。
1.1代码实现
void SelectSort(int* a, int n)
{
//最开始begin,end分别指向最开始元素和最后元素的位置
int begin = 0, end = n - 1;
//以begin和end的关系作为循环结束标志
while (begin < end)
{
int mini = begin, maxi = begin;
//遍历[begin,end],找出最小值和最大值
for (int i = begin; i <= end; i++)
{
if (a[i] < a[mini])
mini = i;
if (a[i] > a[maxi])
maxi = i;
}
//找出来后进行交换
Swap(&a[mini], &a[begin]);
//9 8 7 6 5 4 3 2 1
//第一次寻找,maxi=0,也就是begin的位置,left = 9
//如果直接将a[end]与a[maxi]进行交换的话,进行最小值替换的时候,left会与begin位置的值进行替换,此时最大的值就不在下标为0的位置
//所以才有了下面的if语句,如果maxi与begin的值相同的话,就把mini(也就是替换后的最大值的位置)给maxi
if (maxi == begin)
maxi = mini;
Swap(&a[maxi], &a[end]);
//对begin和end的值进行变化,实现遍历
//这里++begin,--end后就是排完第一次min,max之后的子集合
++begin;
--end;
}
}
1.2直接选择排序的特性总结
1.直接选择排序思考非常好理解,但是效率不是很好,实际中很少使用。
2.时间复杂度:O(n^2)
3.空间复杂度:O(1)
4.稳定性:不稳定
2.堆排序
堆排序(HeapSort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,他是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
这里最核心的知识其实是两个算法:向上调整算法、向下调整算法
下面我们先来看一下这两个算法吧
堆的底层其实就是根据二叉树来实现的,遵从二叉树中孩子与父亲的性质。
我们知道二叉树的存储结构可以是数组,插入数据的话就是直接尾插,下面就是这种情况
//向上调整,实现大堆(从child的位置向上调整数据)
void AdjustUp(int* a, int child)
{
assert(a);
while (child > 0)
{
//尾插一个新数据到数组中,先求出其父节点的下标
int parent = (child - 1) / 2;
//如果插入的数据比parent对应的数据大,则将其与parent对应的数据进行交换
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
}
//如果插入的数据比parent对应的数据小,则不需要对数据进行操作
else
{
break;
}
}
}
//向下调整,实现大堆(从最开始的数据开始向下调整数据)
//向下调整,实现大堆(从parent开始向下调整数据)
void AdjustDown(int* a, int size, int parent)
{
//求出对应的child
int child = parent * 2 + 1;
//child小于size,不能越界
while (child < size)
{
//选出左右孩子中较大的那个孩子
//先假设child就为较大的孩子,如果child + 1对应的数据大于child对应的数据,则将child++
if (a[child + 1] > a[child] && child + 1 < size)
{
child++;
}
//child即为最大的数据
//将选出来的孩子与父亲进行比较,如果child对应的值大于parent对应的数据,则交换,然后更新父亲和孩子的下标
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
//否则直接结束循环
else
{
break;
}
}
}
堆排序
//堆排序
//升序,建大堆
//降序,建小堆
void HeapSort(int* a, int n)
{
//如果采用向上建堆的方式(建堆方式1)
//时间复杂度为O(N*logN),明显比第二种建堆方式复杂度大,不推荐
//耗费时间的原因(调节的原理):从第二行开始向上调节,一直到底层的叶子结点,
//而叶子节点数量会非常多,大大消耗了时间
/*for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}*/
//采用向下建堆的方式(建堆方式2)
//时间复杂度O(N)
//原理:从倒数第二层开始向下调节,依次调节为树,
//不需要调节叶子结点,节省时间
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
//将数组按照大堆进行排序。
AdjustDown(a, n, i);
}
//最后一个元素所在的位置。
int end = n - 1;
while (end > 0)
{
//将最后一个元素与堆顶元素进行交换,即把最大值换到最后面,然后再将换到堆顶的元素进行向下调整,再次形成新的大堆
Swap(&a[0], &a[end]);
//这里的end是精髓,end传入向下调整的算法中后,相当于最后一个元素(即我们交换后排在最后面的最大元素)已经不在我们要调整的堆里面了,让剩下的元素进行调整,然后再循环找新的堆中的最大值
AdjustDown(a, end, 0);
//end--,下一次让最后的值在与堆顶的元素进行交换,重复上述步骤。
end--;
}
}
可能有的小伙伴看到这里会有点蒙,为什么升序,建大堆,降序,建小堆, 这里我来问大家解释一下。
以升序举例。其实就是一个不断地拿出最大的元素放到最后面的过程。
我们先建一个大堆,堆顶的元素就是最大的,然后我们将集合最后的元素与堆顶的元素进行交换,这样最大的元素就被放在了集合的最后面,然后忽略最后一个元素(即最大的元素),形成一个新的集合,然后需要对交换后的堆顶的元素进行向下调整,以便形成一个新的大堆,否则的话就不是大堆了,然后再将堆顶的数据与新集合中的最后一个数据交换,再将堆顶的数据数据进行向下调整,又形成了新的大堆,然后重复上面的操作。
一直到新的集合中没有元素,这时所有的元素都按照从小到大的顺序排列好了,排序结束。
(3)交换排序
基本思想
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。
交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动
1.冒泡排序
冒泡排序的思想非常简单,就是从头到尾遍历整个集合,然后将前后元素依次进行比较,在一轮比较完后就将最大的元素放到了集合的最后面,然后在对剩下的集合依次进行相关的操作,直到最后一个元素。
1.1代码实现
//冒泡排序
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n; i++)
{
for (int j = 1; j < n - i; j++)
{
if (a[j - 1] > a[j])
{
Swap(&a[j - 1], &a[j]);
}
}
}
}
冒泡排序的代码也是很简单的,但是其实他还隐含着一个问题,假如说是这样的一组排序:1 8 9 2 3 5 6,经过两轮的冒泡排序后,我们就将8和9放到了对应的位置,但是这个时候整个集合的遍历还没结束,还要继续遍历剩下的元素,剩下的元素已经是有序的了,其中也不需要进行对数据的移动,相当于什么也没干。这样的话就很浪费时间,所以我们下面对它进行了一下优化。
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n; i++)
{
//定义一个标志
int flog = 0;
for (int j = 1; j < n - i; j++)
{
if (a[j - 1] > a[j])
{
//遍历一遍集合,如果进行了元素的交换就将flog置1
Swap(&a[j - 1], &a[j]);
flog = 1;
}
}
//如果没有进行元素的交换,说明已经是有序了,直接结束循环
if (flog == 0)
break;
}
}
1.2冒泡排序的特性总结
1.冒泡排序是一种非常容易理解的排序
2.时间复杂度: O(N^2)
3.空间复杂度:O(1)
4.稳定性: 稳定
2.快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序的实现方法有三个版本,这三个版本任选其一作为快速排序的子函数,我会依次为大家介绍,下面我们先来看一下快速排序的主要框架。
//按照升序对a数组中的[left,right]区间中的元素进行排序
void QuickSort1(int* a, int begin, int end)
{
if (begin >= end)
return;
//按照基准值对a数组的[left,right]区间中的元素进行划分,注意这里的keyi为数组下标
int keyi = PartSort(a, begin, end);
//划分成功后以keyi为边界形成了左右两部分[begin,keyi-1]和[keyi+1,end]
//递归排[a,0,keyi-1]
QuickSort1(a, 0, keyi - 1);
//递归排[a,keyi+1,end]
QuickSort1(a, keyi + 1, end);
}
看完快速排序递归实现的主框架以后,发现与二叉树前序遍历规则非常像,我们在写递归框架是可以想一想二叉树的前序遍历规则就可以快速的写出来。
这里的PartSort就是我们下面要实现的三个不同的版本。
2.1hoare版本
下面我们先举一个例子让大家知道hoare版本是如何进行排序的,直接上图。
这个图中我们只给出了一次排序的过程,然后进行递归,再利用同样的方法给左右区间进行排序,最后我们就可以得到一个已经排好序的区间。
这里还有一个问题就是为什么左边为key右边先走,右边为key左边先走?那我们就走一遍,看看最后的结果是什么样子的
相信看完上面图片中的内容之后疑惑就可以很好的得到解决了,下面我们来看代码。
注意: 为了使代码更加简洁,代码中我们以begin代替left,end代替right
2.2hoare版本代码实现
//子函数(以hoare思想实现)
int PartSort1(int* a, int begin, int end)
{
//最左边为key
int keyi = begin;
while (begin < end)
{
//右边先走,直到找到比key小的值
while (begin < end && a[end] >= a[keyi])
{
end--;
}
//右边找到后左边再走,直到找到比key大的值
while (begin < end && a[begin] <= a[keyi])
{
begin++;
}
//二者进行交换
Swap(&a[begin], &a[end]);
}
//将key与begin/end处的值进行交换
Swap(&a[keyi], &a[begin]);
//keyi为begin/end位置的下标
keyi = begin;
//返回下标,方便后面进行递归
return keyi;
}
//在快排函数中实现递归操作
void QuickSort1(int* a, int begin, int end)
{
//递归结束条件。
if (begin >= end)
return;
//得到返回的keyi值,方便进行区间的划分
int keyi = PartSort1(a, begin, end);
//[0,keyi - 1] keyi [keyi + 1,end]
QuickSort1(a, 0, keyi - 1);
QuickSort1(a, keyi + 1, end);
}
递归结束条件分析:
例如:
[1,1],即begin==end,说明只有一个数,则不需要排序直接返回
[0,-1],即begin>end,没有该区间,也不需要排序,直接返回
2.3挖坑法
上面图中给我出的也是进行一次排序的具体过程,后面的递归与hoare排序的递归相同。
这里其实比hoare方法更好理解:为什么左边为key先从右边寻找下雨key的值,右边为key先从左边寻找大于key的值,我们最开始记录好key值后,这个位置其实就形成了坑位,左边有坑拿右边的数据补,右边有坑拿左边的数据补, 看着也比较合理。
2.4挖坑法代码实现
//挖坑法
int PartSort2(int* a, int begin, int end)
{
//记录坑的位置
int piti = begin;
//记录key值(即最左边的值)
int key = a[piti];
while (begin < end)
{
//右找小往左坑填
while (begin < end && a[end] >= key)
{
end--;
}
//找到后填入左边的坑。
a[piti] = a[end];
//piti(记录新坑的下标)
piti = end;
//左找大往右坑填
while (begin < end && a[begin] <= key)
{
begin++;
}
//找到后填入右边的坑。
a[piti] = a[begin];
//piti(记录新坑的下标)
piti = begin;
}
//最后piti即为left,right相遇的位置
piti = begin;
//填入key值
a[piti] = key;
//返回piti,一遍后面递归使用
return piti;
}
//在快排函数中实现递归操作
void QuickSort2(int* a, int begin, int end)
{
//递归结束条件。
if (begin >= end)
return;
//得到返回的keyi值,方便进行区间的划分
int keyi = PartSort2(a, begin, end);
//[0,keyi - 1] keyi [keyi + 1,end]
QuickSort2(a, 0, keyi - 1);
QuickSort2(a, keyi + 1, end);
}
2.5前后指针版本
主要思想: 以最左边的元素作为key值,prev指向最开始的位置,cur为prev的下一个位置,然后cur按照顺序向后遍历元素,如果cur指向的元素大于key值,则直接移动到下一位,如果cur指向的元素小于key值,则先将prev移动到下一个位置,然后再将cur指向的位置的值与prev指向的位置的值交换,再让cur移动到下一位。
上面图中给我出的也是进行一次排序的具体过程,后面的递归与前面两种版本排序的递归相同。
2.6前后指针版本代码实现
int PartSort3(int* a, int begin, int end)
{
//prev指向最开始的位置
int prev = begin;
//cur指向prev的下一个位置
int cur = begin + 1;
//记录keyi,a[keyi]即为key
int keyi = begin;
while (cur <= end)
{
//如果cur指向位置的值小于key,则将prev移动到下一个位置,然后将prev指向位置的值与cur指向位置的值交换
//这里的++prev != cur意思是:
//如果prev不为前后的位置关系,才需要交换,下面我会画图说明
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[cur], &a[prev]);
}
//cur每次比较都会向后移动一位,所以直接写在循环的最后面
cur++;
}
//循环结束后即为第一次排序完毕,将prev所指的位置的值与keyi所指向的位置的值(即key)进行交换
Swap(&a[keyi], &a[prev]);
//为keyi重新赋值
keyi = prev;
//返回keyi,方便后续的递归
return keyi;
}
关于代码中的判断条件: prev++ != cur分析
一般情况下我们是建议使用前后指针版本去实现快速排序的,因为前面两种方法中的:为什么选择左边的值为key,右边先走,选择右边的值,左边先走,这个问题并不是很好理解,但是在下面的前后指针的版本中就不需要考虑这个问题。
2.7快速排序优化
以上面的这三种方式实现代码的话会存在一个问题,就是如果一个集合内的元素是有序的或者部分有序的,那么效率就会很低,我们直接上图。
就以上面的这个图为例,我们要用快速排序的方法对其进行排序,我们可以发现,他是趋近于有序的,1的位置根本不需要动,当你遍历一遍集合后,再次进行排序的集合为:
但是他还是和上面的一样,遍历一遍的话并没有元素移动。然后又是对除了元素2外的其他元素排序。
遇到这种情况后的排序效率是非常低的,所以我们有了下面的解决方法,因为我们建议使用前后指针的方法,所以直接对它进行优化。
2.7.1三数取中法选key
我们先实现一个选key的函数
int GetMidIndex(int* a, int begin, int end)
{
//我们取一个中间的位置。后续我们会将三个数进行比较
int mid = (begin + end) / 2;
//a[begin] < a[mid]为前提条件
if (a[begin] < a[mid])
{
//如果a[mid] < a[end],则mid为中间值,直接返回
if (a[mid] < a[end])
return mid;
//如果a[begin] < a[end],a[end] < a[mid],则end为中间值,返回end
else if (a[begin] < a[end])
return end;
//以上两种情况都不是的话,就返回begin
//即:a[end] < a[begin],a[begin] < a[mid]
else
return begin;
}
//不是前面的if条件的话,就为else中的情况
//a[mid] < a[begin]为前提条件
else
{
//如果a[mid] < a[begin],a[begin] < a[end],则begin为中间值。返回begin
if (a[begin] < a[end])
return begin;
//如果a[mid] < a[end],a[mid] < a[begin],则end为中间值,返回end
else if (a[mid] < a[end])
return end;
//以上两种情况都不是的话,就返回mid
//即:a[end] < a[mid],a[mid] < a[begin],返回mid
else
return mid;
}
}
再将新实现的函数与前后指针法结合起来
//三数取中法选key
int PartSort3(int* a, int begin, int end)
{
int prev = begin;
int cur = begin + 1;
int keyi = begin;
//加入三数取中的优化
int midi = GetMidIndex(a, begin, end);
Swap(&keyi, &begin);
while (cur <= end)
{
if (a[cur] < a[keyi] && ++prev != cur)
{
Swap(&a[cur], &a[prev]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
return keyi;
}
2.7.2递归到小的子区间时,可以考虑使用插入排序
void QuickSort3(int* a, int begin, int end)
{
if (begin >= end)
return;
//数据个数小于等于10,直接使用插入排序
if (end - begin + 1 <= 10)
InsertSort(a + begin, end - begin + 1);
else
{
int keyi = PartSort3(a, begin, end);
QuickSort3(a, 0, keyi - 1);
QuickSort3(a, keyi + 1, end);
}
}
2.8非递归实现快速排序
主要进行排序的函数还是我们上面所说的前后指针的版本,这里的非递归写法其实可以当做是递归写法的变形,这里我们需要用到之前学到的栈。
//非递归实现快排
void QuickSortNonR(int* a, int begin, int end)
{
//创建一个栈
ST st;
//对栈进行初始化
StackInit(&st);
//将end,begin进行压栈操作
StackPush(&st,end);
StackPush(&st,begin);
//若栈不为空,就说明还有数据存在,需要一直进行排序
while (!StackEmpty(&st))
{
//取排序区间,left,right
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
//接受keyi值,对后面进行分区间排序
int keyi = PartSort3(a, left, right);
//判断区间的合理性
if (keyi + 1 < right)
{
//合理入栈,后面取区间进行排序
StackPush(&st, right);
StackPush(&st, keyi + 1);
}
//判断区间的合理性
if (left < keyi - 1)
{
//合理入栈,后面取区间进行排序
StackPush(&st, keyi - 1);
StackPush(&st, left);
}
}
//排完序后销毁栈
StackDestroy(&st);
}
2.9快速排序的特性总结
1.快速排序整体的综合性能和使用场景都是比较好的。所以才敢叫快速排序。
2.时间复杂度:O(N*logN)
3.空间复杂度:O(logN)
4.稳定性:不稳定
(4)归并排序
4.1基本思想
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
我们先来看上面的这个图,归并排序的思想其实就是分治,先把一个大的区间分为两个小的区间,是两个小的区间有序,然后再将两个小的区间合并成为一个有序的大的区间。分解的过程就是递归的过程,合并的过程就是递归结束后进行的操作。
上面的图分解的部分就类似于二叉树的后序遍历,先走左区间,然后是右区间,当区间内只有一个值或者区间不存在的时候就开始return,这时对两个区间内的数值进行归并,就得到了一段新的有序的数列,然后再让得到的新区间内的数值与另一个新区间的数值进行归并,就会再得到一段有序的数列,以此类推,直到返回到最开始的大区间,就结束,这个时候整个集合内的数据就都是有序的了。
当然,上面我们画的恰好有8个数据进行二分,当有奇数个数据的时候其实思想都是一样的,先分解排序,再合并排序。
归并算法:
需要提前开辟一个空间。
这里我们以升序举例,左右区间分别有两个指针(或数组下标)控制begin1,end1为左区间内的指针,begin2,end2为右区间内的指针,begin1与begin2所指向的数值进行比较,小的值就放入我们新开辟的空间中,当存在begin1>end1或者begin2>end2的情况的时候,就结束操作,说明有一个区间内的数值都已经被放入到我们新开辟的空间中了,但是此时可能另一个区间中数据还没有放入新开辟好的空间中,所以我们要将剩下的数据也放入空间中。
4.2代码实现
4.2.1递归实现
//归并排序子函数,递归算法
//tmp即为我们传入的新开辟好的空间
void _MergeSort(int* a, int begin, int end, int* tmp)
{
//若begin>=end,则代表区间内只有一个数据或区间不存在,直接返回
if (begin >= end)
return;
//取中间的下标,进行二分(当然可能不是完全二分)
int mid = (begin + end) / 2;
//分区间进行递归
//[begin,mid],[mid+1,end]
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
//到这里说明左右区间都已经走到区间为一个值或者区间不存在的情况,直接将数据进行归并
//归并
//去两个区间的begin和end
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
//记录要比较时要插入到tmp中的哪一个位置,肯定是要从begin1开始
int i = begin1;
//两个区间内都还有数据时,就要一直进行比较
while (begin1 <= end1 && begin2 <= end2)
{
//哪一个数据小,就放入新开辟的空间
if (a[begin1] > a[begin2])
{
//插入数据后,新空间内的指针和插入了数据的区间内的指针都要向后移动
tmp[i++] = a[begin2++];
}
else
{
//同上
tmp[i++] = a[begin1++];
}
}
//到这里说明两个区间中有一个已经没有数据了
//比较谁的区间合法就代表谁区间内还有数据,直接插入到新开辟的空间的末尾
//比较左区间
while (begin1 <= end1)
{
tmp[i++] = a[begin1];
begin1++;
}
//比较右区间
while (begin2 <= end2)
{
tmp[i++] = a[begin2];
begin2++;
}
//将在我们新空间内放入的数据,再拷贝回去
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
//归并排序,主函数
void MergeSort(int* a, int n)
{
//开辟一个新空间,传入子函数中,存放数据
int* tmp = (int*)malloc(sizeof(int) * n);
//判断是否开辟成功
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
//调用子函数
_MergeSort(a, 0, n - 1, tmp);
//释放空间
free(tmp);
}
4.2.2非递归实现
相比较递归而言,非递归就不是那么容易了因为其中有很多的条件需要我们自己控制一下。
基本思路:
在上面的递归算法中我们是将数据分为两个区间进行分治,那么非递归我们可不可以借鉴一下他的思想呢,其实是可以的。
看上面图片中的内容,我们先把第一行中的元素自己为一个区间,然后让两个相邻的空间分别进行归并,归并完后就变成了第二行中拥有了两个元素的区间,然后在让两个新的区间进行归并,又会变成第三行的两个大区间,然后在让第三行的区间进行归并,最终就形成了一个有序集合。
看着是不是很简单,其实其中还有很多的细节,比如说越界问题,真的很让人头疼,下面我们一起来看一下。
我们先搭一个大的框架出来。
注意下面的这个代码只是一个框架,不是正确的代码
//归并排序非递归
void MergeSortNonR(int* a, int n)
{
//创建一个新的空间来存放归并时的数据
int* tmp = (int*)malloc(sizeof(int) * n);
//开辟空间是否成功
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
//定义一个gap来控制区间的大小,gap为1就代表一个区间内只有一个元素,也就是控制第一行的元素归并
int gap = 1;
//n为整个数组的长度,gap若大于等于n,则区间直接无效
while (gap < n)
{
//i从0开始,每一次要走2*gap步,才能越过已经进行归并了的两个区间
//例:[0,0][1,1]已经进行了归并,则直接要跳到[2,2][3,3]进行归并,一次要跳过2*gap
for (int i = 0; i < n; i += 2 * gap)
{
//以begin1为基础,计算两个区间的范围
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//j要记录最初始的位置,方便后面放入数据
int j = begin1;
//后面的三个循环和递归时的思路一样,不再过多阐述
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
//归并完后,要将数据全部拷贝回原数组。
memcpy(a, tmp, sizeof(int) * n);
//gap变为2倍,进行新一行的归并。
gap *= 2;
}
//释放空间
free(tmp);
}
大家看上面的代码是否存在一些问题,其中它存在的最大的问题就是越界,我将代码进行一些修改,我们来一起看一下运行起来后所有区间的范围。
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
printf("gap=%d->", gap);
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//打印区间范围
printf("[%d,%d][%d,%d]--", begin1, end1, begin2, end2);
int j = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
memcpy(a, tmp, sizeof(int) * n);
printf("\n");
gap *= 2;
}
free(tmp);
}
为了让大家更好地看到这里的结果,我将数据进行了一下修改,因为在一些偶然的情况下,这里是不会报出越界错误的。
void Test_MergeSort()
{
int arr[] = { 10,6,7,1,3,9,4,2,5 };
int size = sizeof(arr) / sizeof(int);
MergeSortNonR(arr, size);
for (int i = 0; i < size ; i++)
{
printf("%d ", arr[i]);
}
}
现在有两种处理的方法:
1.我们手动去修改那一些违法的区间,将他们修改成合法的区间
2.我们在遇到区间违法时直接不让它进行归并
正确代码实现1
//归并排序非递归
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
printf("gap=%d->", gap);
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//这里对区间进行修改
if (end1 >= n)//说明begin2和end2一定越界
{
//修正end1,因为还要进行归并
end1 = n - 1;
//使[begin2,end2]区间不存在,如果区间不存在就不会进行归并,相当于直接把第一段有效区间内的值直接放入新的空间中
begin2 = n;
end2 = n - 1;
}
else if(begin2 >= n)//使[begin2,end2]区间不存在,理由同上
{
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)//说明begin2没有越界,end2越界了,进行修正,begin2不越界说明后面还有值,要进行修正,否则剩下的值会丢失
{
end2 = n - 1;
}
printf("[%d,%d][%d,%d]--", begin1, end1, begin2, end2);
int j = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
}
memcpy(a, tmp, sizeof(int) * n);
printf("\n");
gap *= 2;
}
free(tmp);
}
我们可以看到这里的区间是完全正确的,这里最麻烦的地方就是区间的控制,解决了这个就很简单了。
正确代码实现2
//归并排序非递归
void MergeSortNonR1(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
int gap = 1;
while (gap < n)
{
printf("gap=%d->", gap);
for (int i = 0; i < n; i += 2 * gap)
{
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//这里的区间控制与以上不同,不在正常的区间内直接break,即不对剩下的元素归并
if (end1 >= n || begin2 >= n)
{
break;
}
//但是begin2没有越界的话,还是要修正一下。因为[begin1,end1]中还有数据要和区间2中的数据归并
else if (end2 >= n)//说明begin2没有越界,end2越界了
{
end2 = n - 1;
}
printf("[%d,%d][%d,%d]--", begin1, end1, begin2, end2);
//记录一下进行归并的区间的大小
int m = end2 - begin1 + 1;
int j = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[j++] = a[begin1++];
}
else
{
tmp[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[j++] = a[begin2++];
}
//进行完一次归并后,就要将数据先拷贝回去
memcpy(a + i, tmp + i, sizeof(int) * m);
}
printf("\n");
gap *= 2;
}
free(tmp);
}
可能有的人看了第二种方法不是很理解,下面我们画图来为大家介绍一下其主要的思想。
两种方法的对比
我们可以看到上面的两个方法对越界的处理不同,还有一个就是将数据拷贝回去的位置不同,第一种方法是将数据全部归并结束后再拷贝回去,但是第二种方法是将一小组数据归并结束后就拷贝回去,这就导致了越界处理的方式不同。
方法一:
将新数组中的数据全部拷回去就需要我们必须控制区间,因为如果不控制,像第二种情况一样的话就会导致数据的丢失,因为最后一个数据9没有拷贝到新的空间中,而我们在最后将新的空间的数据全部拷贝回去的话,就会将最后一个随机值覆盖掉之前的数据9,就会出现错误。
方法二:
因为我们是分一小组一小组的数据进行拷贝回去的,所以就不需要担心数据丢失的问题。但是在进行空间计算的时候就要先记录一下我们的一次归并区间内有多少个数据,方便后续的拷贝。
4.3归并排序的特性总结
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
(5)非比较排序(计数排序)
5.1基本思想
计数排序又称为鸽巢原理,是对哈希直接地址法的变形应用。
操作步骤:
1.统计相同元素出现的次数
2.根据统计的结果将序列回收到原来的序列中
下面我们来举个例子看一下具体的操作过程
以上就是整个排序的大概的流程,但是我们有时候得到的数据可能并不会这么小,也存在很大的数据,或者可能出现负数,比如说-2,-6,1,5,9,10或者1001,1005,1009,1010这样的数据,我们还能像上面那样操作么?
其实是可以的,我们可以计算一下最小值(min)和最大值(max),这样就能求出来我们要新建的区间的大小是多少,然后让每个数据都减去min,这样就可以将数据简化
比如说:-2,-3,1,0,5,7,min为-3减去min后就变为了 1 0 4 3 8 10,然后创建新的数组,根据数据在新的数组中计数。
再比如说:1001,1005,1009,1010,这个数据就很大,如果直接开辟这么大的数组就会很浪费空间,那么我们就都减去最小的一个值,min为1001,新数据为:1,5,9,10,这样只需要开辟11个空间就可以了,大大节省了空间。
5.2代码实现
void CountSort(int* a, int n)
{
int min = a[0], max = a[0];
for (int i = 1; i < n; i++)
{
//求数组中的最小值
if (min > a[i])
min = a[i];
//求数组中的最大值
if (max < a[i])
max = a[i];
}
//计算size,size即为要开辟的新数组的大小
int size = max - min + 1;
int* count = (int*)malloc(sizeof(int) * size);
if (count == NULL)
{
printf("malloc fail\n");
exit(-1);
}
//将新开辟的数组中的值都置为0
memset(count, 0, sizeof(int) * size);
//遍历整个原始数组,在新数组中开始计数
for (int i = 0; i < n; i++)
{
//a[i]-min就是每一个数据应该在的位置
//直接在count数组中的该位置++
count[a[i] - min]++;
}
int j = 0;
//将统计的数据再放回原来的数组中,完成排序
for (int i = 0; i < size; i++)
{
//count[i]为0的时候表示该下标的数据已经全部放入到了原数组中,结束循环
while(count[i]--)
{
a[j++] = i + min;
}
}
}
5.3计数排序的特性总结
- 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
- 时间复杂度:O(MAX(N,范围))
N指的是原数据的数据量的多少,范围指的值max-min,即最大值和最小值的差 - 空间复杂度:O(范围)
因为我们要开辟max-min个空间。 - 稳定性:稳定
三、排序算法复杂度及稳定性分析
四、补充知识(了解)
我们在前面还讲到了内排序和外排序,下面我们来简单的介绍一下
总结
以上就是我们要讲的排序算法的全部内容,可以看出其中的一些算法还是有些难度的,当然排序算法不仅仅只是这几个,还存在其他的排序算法,但是这一些都是比较热门并且经常使用的,所以大家下来要自己画一画图,进行一下对代码的实现,这样才能熟练的掌握。如果感觉我的内容对你有用的话,就给波三连吧,你们的支持就是我写作的最大动力。