文章目录
- 一、排序的概念及运用
-
- [1.1 概念](#1.1 概念)
- [1.2 运用](#1.2 运用)
- 二、四类排序算法
-
- [2.1 插入排序](#2.1 插入排序)
-
- [2.1.1 直接插入排序](#2.1.1 直接插入排序)
- [2.1.2 希尔排序](#2.1.2 希尔排序)
- [2.2 选择排序](#2.2 选择排序)
-
- [2.2.1 直接选择排序](#2.2.1 直接选择排序)
- [2.2 2 堆排序](#2.2 2 堆排序)
- [2.3 交换排序](#2.3 交换排序)
-
- [2.3.1 冒泡排序](#2.3.1 冒泡排序)
- [2.3.2 快速排序](#2.3.2 快速排序)
-
- [(1) hoare版本](#(1) hoare版本)
- [(2) 挖坑法](#(2) 挖坑法)
- [(3) 前后指针法](#(3) 前后指针法)
- 2.3.2.1快速排序的时间及空间复杂度
- 2.3.2.2快速排序的优化
- 2.3.2.3非递归版快速排序
- 2.3.3快速排序的OJ测试
- [2.4 归并排序](#2.4 归并排序)
-
- [2.4.1 递归版归并排序](#2.4.1 递归版归并排序)
- 2.4.2非递归版归并排序
- 三、非比较排序
-
- [3.1 计数排序](#3.1 计数排序)
- 四、排序算法的时间测试
-
- [4.1 排序算法的稳定性分析](#4.1 排序算法的稳定性分析)
- [4.2 各种排序算法的时间复杂度表](#4.2 各种排序算法的时间复杂度表)
一、排序的概念及运用
1.1 概念
排序: 所谓排序就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减地排列起来的操作就是排序。
1.2 运用
(1) 购物筛选排序
(2) 院校排名
二、四类排序算法

2.1 插入排序
基本思想:
直接插入排序是一种简单的插入排序法,其基本思想是: 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。
实际中我们玩扑克牌时,就用了插入排序的思想。
2.1.1 直接插入排序
当插入第 i (i>=1) 个元素时,前面的 array[0],array[1],...,array[i-1]已经排好序,此时用 array[i] 的排序码与 array[i-1],array[i-2],...,array[0]的排序码顺序进行比较,找到插入位置后再将array[i]插入,原来位置上的元素顺序后移。
c
//直接插入排序算法
void InsertSort(int* arr, int n)
{
for (int i = 1; i < n; i++)
{
//[0,end]之间的数据有序,则让tmp插入以后依旧有序
int end = i - 1;
int tmp = arr[i];
while (end >= 0)
{
if (arr[end]>tmp)
{
arr[end + 1] = arr[end];
end--;
}
else
{
break;
}
}
arr[end + 1] = tmp;
}
}
外层的循环是遍历控制数组arr里的所有元素的,而内层的while循环是控制单趟比较插入:即让tmp位置的元素从i-1位置从后往前依次比较插入,使插入后的0~i位置的元素都是有序的。
直接插入排序的特性总结
1.元素集合越接近有序,直接插入排序算法的时间效率越高
2.时间复杂度: O(N^2^) [最差的情况是数组是逆序]
3.空间复杂度: O(1)
2.1.2 希尔排序
希尔排序法又称缩小增量法。希尔排序法的基本思想是: 先选定一个整数(通常是gap=n/3+1),把待排序文件的所有元素分成各组,所有的距离相等的元素分在同一组内,并对每一组内的元素进行插入排序,然后让gap=gap/3+1得到下一个整数,再将数组分成各组,进行插入排序,当gap=1时,就相当于直接插入排序。
它的基本思想是将待排序的元素分组,每组进行插入排序,随着分组的间隔逐渐减小,最终达到整体有序的效果。希尔排序通过分组排序和逐步减小增量的方式,提高了排序效率,特别适合处理大规模数据。
它是在直接插入排序算法的基础上改进而来的,综合来说它的效率肯定是要高于直接插入排序算法的。
(1) 现在详细讲解一下:gap是对数组分组以后每一组元素之间的间距以及分出的组数:
至于间隔gap的取值情况下面会讲解。
(2) 对分组以后的数据进行插入排序:(预排序)
这样对3组数据进行插入排序以后可以看到,数组中的数据大致快要接近有序了,但不是有序。如果此时我们把gap缩减为1,再对数组中的数据进行插入排序,此时就是直接插入排序,上面数组中的元素就会呈现有序。
(3) 还有一个问题就是gap的值要怎么选取,我们需要根据数组中的有效数据个数来取gap的值,也就是gap的取值与数组的有效元素个数有关。如果数组元素个数很多,而gap取得比较小,那就不太好!因为希尔排序的思想就是让数组中间隔为gap的元素能够跳跃式的往后进行插入排序,所以gap的选取要跟数组元素个数有关:
通常我们会选取gap==n/3 + 1或者gap==n/2,这样gap不会很大也不至于很小。比如:我们对gap分别取值为5、3、1对上面的逆序数据进行插入排序后进行对比:
通过对比上面排序后的结果可知:
● gap越大, 则大的数可以更快的挪动到后面, 小的数可以更快的挪动到前面(数据越不接近有序)
● gap越小, 则大的数和小的数挪动越慢,但是它越接近有序
● gap == 1, 就是直接插入排序
通过上面的分析可以知道gap>1的话是进行预排序,就是让排序后的数据非常接近有序,但不是有序。最后让gap==1既直接插入排序,即可实现真正的有序:
c
void ShellSort(int* arr, int n)
{
//1.gap>1 预排序
//2.gap==1 直接插入排序
int gap = n;
while (gap>1)
{
gap = gap / 3 + 1; //+1是保证让gap最后能走到1
//gap = gap/2;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tmp = arr[end + gap];
while (end >= 0)
{
if (arr[end]>tmp)
{
arr[end + gap] = arr[end];
end -= gap;
}
else
{
break;
}
}
arr[end + gap] = tmp;
}
}
}
上面的代码中需要理解gap是在变动的,即让gap = gap/3+1这样每次gap就会递减,直到gap最后等于1,即直接插入排序实现数据的有序。+1是为了保证gap最后能走到1。比如:n==20,如果gap=gap/3的话,值依次递减为6、2、0;gap没有走到1,所以才要让gap=gap/3+1这样一定能保证gap递减到最后能走到1。当然,如果选取的是gap/2那就一定能走到1。
(4) 希尔排序的特性总结
1.希尔排序是对直接插入排序的优化。
2.当gap >1时都是预排序,目的是让数组更接近于有序。当gap递减到1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
2.1.2.1希尔排序的时间复杂度
希尔排序的时间复杂度估算:
1.外层循环:
外层循环的时间复杂度可以直接给出为:O(log~3~n)或者O(log~2~n), 即O(logn):
2.内层循环:
因此,希尔排序在最初和最后的排序次数都为n,即前一阶段排序次数是逐渐上升的状态,当到达某一顶点时,排序次数逐渐下降至n,而该顶点的计算暂时无法给出具体的计算过程。
希尔排序的时间复杂度不好计算,因为 gap 的取值很多,导致很难去计算,因此很多书中给出的希尔排序的时间复杂度都不固定。《数据结构(C语言版)》--- 严蔚敏书中给出的时间复杂度为:
2.2 选择排序
选择排序的基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
2.2.1 直接选择排序
1.在元素集合array[i]---array[n-1]中选择关键码最大(小)的数据元素
2.若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
3.在剩余的array[i]---array[n-2](array[i+1]---array[n-1])集合中,重复上述步骤,直到集合剩余1个元素
这里是每次去找数据集合中最小的那个元素,然后把与0下标位置的元素相交换,这样最小的数据就放到了最前面(区间为[0,n-1]),然后缩小区间为[1,n-1],又继续在剩下的集合里找次小的放到下标为1的位置。
我们可以优化一下这个选择排序:第一次在区间[0,n-1]里找最小的同时,把最大的元素也找到,然后将最小的元素与0下标位置的元素交换,最大的元素与n-1下标位置的元素交换,这样数据集合中最大的和最小的就都放在最前面和最后面,然后将区间缩小为[1,n-2],又去找该区间里次小和次大的元素与对应1和n-2下标位置的元素相交换,上述步骤重复进行,直到区间缩小为空为止:
c
//交换两个变量的值
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//选择排序算法
void SelectSort(int* arr, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int maxi = begin, mini = begin;
for (int i = begin; i <= end; i++)
{
if (arr[i] < arr[mini])
{
mini = i;
}
if (arr[i]>arr[maxi])
{
maxi = i;
}
}
Swap(&arr[begin], &arr[mini]);
//如果maxi就在begin位置就修正一下maxi
if (maxi == begin)
{
maxi = mini;
}
Swap(&arr[end], &arr[maxi]);
begin++;
end--;
}
}
需要注意:如果在查找完最小值和最大值以后,发现最大值的下标maxi就在区间的begin处,那就要注意了!当arr[begin]与arr[mini]交换了以后,最大值其实就被换到mini下标的位置处,所以如遇这种情况修正一下maxi即可。
直接选择排序的特性总结:
1.直接选择排序思想非常好理解, 但是效率不是很好。实际中很少使用
2.时间复杂度: O(N^2^)
空间复杂度: O(1)
2.2 2 堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆 ,排降序建小堆。
c
//向下调整算法
void AdjustDown(int* arr, int n, int parent)
{
assert(arr != NULL);
int child = parent * 2 + 1; //parent的左孩子
while (child < n)
{
//如果右孩子存在并且右孩子的值小于左孩子,则让child+1(选出小的那个)
if (child+1 < n && arr[child + 1] < arr[child])
{
child++;
}
if (arr[child] < arr[parent])
{
Swap(&arr[child], &arr[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* arr, int n)
{
//建堆--向下调整建堆
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(arr, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&arr[0], &arr[end]);
AdjustDown(arr, end, 0);
end--;
}
}
在二叉树-堆那一章中已经详细介绍过堆排序的思想以及实现过程,这里不再详细讲解堆排序。需要了解的小伙伴可以访问此链接:HeapSort
上面的堆排序采用的是向下调整建堆,上面的堆排序算法的时间复杂度为: O(n)
2.3 交换排序
交换排序基本思想:
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。
交换排序的特点是: 将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动
2.3.1 冒泡排序
冒泡排序是很有教学意义的一种排序算法,但是实际中没有任何的应用价值。在学习C语言初阶的时候我们就已经接触过冒泡排序的思想了,冒泡排序是一种最基础的交换排序。之所以叫做冒泡排序,因为每一个元素都可以像小气泡一样,根据自身大小一点一点向数组的一侧移动。
c
void BubbleSort(int* arr, int n)
{
for (int i = 0; i < n; i++)
{
bool exchange = false;
for (int j = 0; j < n- 1 - i; j++)
{
//若前面的数据比后面的大则交换
if (arr[j] > arr[j + 1])
{
Swap(&arr[j], &arr[j + 1]);
exchange = true;
}
}
//若在一趟冒泡排序中没有发生数据的交换,则说明数据已经有序了
if (exchange == false)
{
break;
}
}
}
冒泡排序的特性总结
时间复杂度: O(N^2^) [最差的情况为一个等差数列和]
空间复杂度: O(1)
2.3.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为: 任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
(1) hoare版本
算法思路:
① 创建左右指针(下标),确定基准值keyi(下标)
② 从右向左找出比基准值小的数据,从左向右找出比基准值大的数据,左右指针数据交换,进入下次循环
如上图:基准值key选在了左边(left),则此时要让right指针(下标)从右往左找比key(6)小的值,然后再让left从左往右找比key大的值,然后交换left与right所指向的值,之后让left与right继续找;当left遇到right时就停下,然后将left(right也可以, 因为right与left相同)指向的值与key交换。这样就会实现一个效果:
问题一:key的值选在了左边,那可以选在右边吗?答案是可以的。只不过这里要遵循一个规则,那就是如果key选在了左边,那right就要先从右往左移动找比key小的值,其次才让left从左往右移动找比key大的值;并且left要从key的位置开始才行!比如我们看下面的例子:
同理:如果key选在了右边,则就要让left先从左往右移动找大,然后再让right从右往左找小(且right要从key开始)。那为什么left要从key开始呢?看下图:
上面两个问题是,left与right在找大找小的过程中,不能越界;所以left要小于right;其次left要从key的位置开始才行。同理:key选在的右边,也是对称的性质。还有一种情况就是如果left和right分别在找大找小的过程中,如果遇到了跟key相等的值也是跳过,因为与key相等的值不管是放在key的左边,还是放在key的右边都没有影响。
问题二:还有一个问题就是left与right相遇的时候,所指向的值一定是小于key或者等于key吗?(假设key选在左边) 答案是一定的。因为left与right在找大找小的过程中,已经交换过一些轮次了,导致left及left左边的值都是比key小的或者有等于的;而且我们限定了下标left要小于right,所以当right遇到left后它们所指向的值不是小于key就是等于key。若不理解可以画图推导。
当我们通过上面的方式将key与left的位置值交换以后,这样key的位置就不用动了,因为它已经排在了它所在的位置。(若数组完全排好序续以后,key现在就在它排好序的位置) 代码实现如下:
c
int PartSort1(int* arr, int left, int right)
{
int keyi = left;
while (left < right)
{
while (left < right && arr[right] >= arr[keyi])
{
right--;
}
while (left < right && arr[left] <= arr[keyi])
{
left++;
}
if (left < right)
Swap(&arr[left], &arr[right]);
}
if (keyi != left)
Swap(&arr[keyi], &arr[left]);
return left;
}
上面的代码中通过key值的下标keyi来标识key即可。但是这个代码只是将[0,n-1]区间的key选出来了,让keyi分隔了比key小的值在key的左边,比key大的值在key的右边;并没有让数据集合有序。那这里就可以递归实现快速排序:
刚才上面只是找出了[0,n-1]区间的key,我们可以分而治之,对现在的[0,keyi-1]和[keyi+1,n-1]这两个区间我们又可以通过上面的方法又去找这两个区间里的key1和key2,实现key1和key2是两个区间的分界点,当递归到只有一个数据的时候就不用再找key了。这样递归完毕后,整个数组就达到了有序。这才是完整的快速排序算法思想:
c
void QuickSort(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int keyi = PartSort1(arr, begin, end);
//区间划分为: [begin, keyi-1] keyi [keyi+1,end]
QuickSort(arr, begin, keyi - 1);
QuickSort(arr, keyi+1, end);
}
(2) 挖坑法
对于上面的霍尔版本的快速排序算法,代码实现上有一些问题需要注意,可能有些地方难以理解。我们还可以实现另一种版本的快速排序:
思路:(坑的单词为:hole)
创建左右指针。首先从右向左找出比基准key小的数据,找到后立即放入左边坑中,当前位置变为新的"坑",然后从左向右找出比基准大的数据,找到后立即放入右边坑中,当前位置变为新的"坑",循环结束后将最开始存储的分界值放入当前的"坑"中,返回当前"坑"下标(即分界值下标)
c
int PartSort2(int* arr, int left, int right)
{
int key = arr[left];
int hole = left;
while (left < right)
{
while(left < right && arr[right] >= key)
{
right--;
}
arr[hole] = arr[right];
hole = right;
while (left < right && arr[left] <= key)
{
left++;
}
arr[hole] = arr[left];
hole = left;
}
arr[hole] = key;
return hole;
}
(3) 前后指针法
创建前后指针,从左往右找比基准值小的进行交换,使得小的都排在基准值的左边。
思想:
1.最开始prev和cur下标是相邻的
2.当cur遇到比key的大的值以后,prev和cur之间的值都是比key大的值
3.当cur找到小的以后,跟++prev位置的值交换,相当于把大的值翻滚式地往右边推,同时把小的值换到左边
c
int PartSort3(int* arr, int left, int right)
{
int prev = left;
int cur = left + 1;
int keyi = left;
while (cur <= right)
{
if (arr[cur] < arr[keyi] && ++prev != cur)
{
Swap(&arr[cur], &arr[prev]);
}
cur++;
}
Swap(&arr[prev], &arr[keyi]);
keyi = prev;
return keyi;
}
2.3.2.1快速排序的时间及空间复杂度
快速排序的时间复杂度在理想情况下,也就是每次找到的基准值key都大致在中位数的位置。这样能够很好的平衡keyi分出的两段区间(排序效率就比较好)。在最优情况下快速排序的时间法复杂度就为O(n*log~2~n),空间复杂度就为O(log~2~n),其中n为数组的元素个数。
但是上面只是理想情况下每次找到的keyi都是趋近于中位数的位置,但是实际中,可能会出现最差的情况;比如数据就是有序的,如果一开始key都选在左边,那每次选出来的key就是最左边的值,此时的时间复杂度就为最差的O(n^2^):
所以总结下来就是:
1.最优时间复杂度: 当每次分区间操作都能将数组均匀地分成两部分时,快速排序的时间复杂度为(n*log~2~n)。这种情况下,每次选择的keyi都能将数组平分,递归的深度(空间复杂度)为log~2~n,每层递归处理n个元素,因此总的时间复杂度为O(n*log~2~n)。
2.平均时间复杂度: 在平均情况下,快速排序的时间复杂度也是 O(n*log~2~n)。这是因为快速排序通过分治法将数组分成两部分,并递归地对这两部分进行排序。这种分治策略使得其平均时间复杂度保持在O(n*log~2~n)。
3.最差时间复杂度: 当每次分区间操作都将数组分成一个元素和其余部分时,快速排序的时间复杂度为 O(n^2^)。这种情况通常发生在数组已经有序或keyi选择不当的情况下,导致每次划分都极不平衡,从而使得时间复杂度退化为O(n^2^)。
2.3.2.2快速排序的优化
快速排序的优化方法:
为了减少最差情况的发生,通常采用以下几种优化策略:
1.随机数选择 : 随机选择一个元素作为key,这样可以大大降低最坏情况出现的概率。
2.三数取中法: 从数组的开头、结尾和中间选择三个元素,取它们的中位数作为key,这样可以提高分区间的平衡性概率。
三数取中:
c
int GetMidIndex(int* arr, int left, int right)
{
int mid = (left + right) / 2;
if (arr[left] > arr[mid])
{
if (arr[mid] > arr[right])
{
return mid;
}
else if (arr[left] > arr[right])
{
return right;
}
else
{
return left;
}
}
else
{
if (arr[right] > arr[mid])
{
return mid;
}
else if (arr[left] > arr[right])
{
return left;
}
else
{
return right;
}
}
}
上面就是三数取中的算法,将此算法加在PartSort中即可实现优化。比如我们加在hoare版本中:
c
int PartSort1(int* arr, int left, int right)
{
int midi = GetMidIndex(arr, left, right);
Swap(&arr[left], &arr[midi]);
int keyi = left;
while (left < right)
{
while (left < right && arr[right] >= arr[keyi])
{
right--;
}
while (left < right && arr[left] <= arr[keyi])
{
left++;
}
if (left < right)
Swap(&arr[left], &arr[right]);
}
if (keyi != left)
Swap(&arr[keyi], &arr[left]);
return left;
}
2.3.2.3非递归版快速排序
非递归版本的快速排序需要借助数据结构: 栈。
注意:在上面的递归版本快速排序中,虽然是递归实现的排序,但实际都是在操作arr数组。通过对下标区间进行拆分,将区间拆分为[left,keyi-1] keyi [keyi+1,right],然后就是分治的思想,再将两个区间又查分为原来区间的子区间;每个区间都会在一趟排序后拆分为两个区间。由于递归版本的快速排序是有缺陷的,如果数据量很大,可能会出现栈溢出的问题。所以如果用非递归的来实现快速排序,我们可以借助栈这种数据结构,因为我们的栈空间是在堆上进行开辟的,所以空间很大:
实现非递归快速排序要先有一个数据结构栈:
c
void QuickSortNonR(int* arr, int left, int right)
{
ST st;
STInit(&st);
STPush(&st, right);
STPush(&st, left);
while (!STEmpty(&st))
{
int left = STTop(&st);
STPop(&st);
int right = STTop(&st);
STPop(&st);
int keyi = PartSort1(arr, left, right);
//[left,keyi-1] keyi [keyi+1,right]
if (keyi - 1 > left)
{
STPush(&st, keyi - 1);
STPush(&st, left);
}
if (right > keyi + 1)
{
STPush(&st, right);
STPush(&st, keyi + 1);
}
}
STDestroy(&st);
}
快速排序在实际应用中的表现
在实际应用中,快速排序通常表现非常良好,其平均时间复杂度保持在n*log~2~n。通过合理的枢轴(key)选择和优化策略,可以显著提高排序的效率,尤其在处理大规模数据时表现出色。
2.3.3快速排序的OJ测试
(链接:SortAnArray)
这道力扣OJ题针对快速排序设计了一些测试用例,所以上面学习的快速排序可能在这道题中不能提交通过,需要做进一步的改动。上面所学的叫二路划分快速排序,也就是找到的key会划分出两个区间。力扣中有一个测试用例如下:
这个测试用例在找key的时候,就会出现最差的情况,每次找到的key就在最左边,而划分的区间只有右边,没有左边,所以针对快速排序力扣出了这样一道测试用例。那我们针对这种情况也对快速排序进行了改进,这种改进的方法叫做三路划分。
2.3.3.1三路划分
三路划分的思想是:考虑到一个数组中如果选到的key在数组中非常居多,那就可以用三路划分的方法将key集中起来放到中间。这个方法其实是霍尔版本与前后指针法的结合:
c
//三路划分
void QuickSort2(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
//定义left在左边 right在右边 cur在left+1位置
int left = begin;
int right = end;
int cur = left+1;
//三数取中
int mid = GetMidIndex(arr, left, right);
Swap(&arr[mid], &arr[left]);
int key = arr[left];
//进行三路划分
while (cur <= right)
{
if (arr[cur] < key)
{
Swap(&arr[cur], &arr[left]);
cur++;
left++;
}
else if (arr[cur] > key)
{
Swap(&arr[cur], &arr[right]);
right--;
}
else
{
cur++;
}
}
//区间被划分为[begin,left-1][left,right][right+1,end]
QuickSort2(arr, begin, left - 1);
QuickSort2(arr, right + 1, end);
}
这个案例解决了,但是又有新的测试用例通不过:
原因是力扣对快速排序的三数取中又做了针对性的案例,所以以前我们写的三数取中在这里就不再适用。那三数取中不行,我们还可以采用随机数法进行key的选取:
c
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
int GetMidIndex(int* arr, int left, int right)
{
mid选取中间
//int mid = (left + right) / 2;
//mid随机选取
int midi = left + (rand()%(right-left));
if (arr[left] > arr[midi])
{
if (arr[midi] > arr[right])
{
return midi;
}
else if (arr[left] > arr[right])
{
return right;
}
else
{
return left;
}
}
else
{
if (arr[right] > arr[midi])
{
return midi;
}
else if (arr[left] > arr[right])
{
return left;
}
else
{
return right;
}
}
}
void QuickSort2(int* arr, int begin, int end)
{
if (begin >= end)
{
return;
}
int left = begin;
int right = end;
int cur = left+1;
//随机数取中
int mid = GetMidIndex(arr, left, right);
Swap(&arr[mid], &arr[left]);
int key = arr[left];
//三路划分
while (cur <= right)
{
if (arr[cur] < key)
{
Swap(&arr[cur], &arr[left]);
cur++;
left++;
}
else if (arr[cur] > key)
{
Swap(&arr[cur], &arr[right]);
right--;
}
else
{
cur++;
}
}
//区间被划分为[begin,left-1][left,right][right+1,end]
QuickSort2(arr, begin, left - 1);
QuickSort2(arr, right + 1, end);
}
int* sortArray(int* nums, int numsSize, int* returnSize)
{
srand(time(NULL));
QuickSort2(nums,0,numsSize-1);
*returnSize = numsSize;
return nums;
}
随机数取中,是随机的在区间中进行mid的选取,这样针对快排的测试用例就能通过了!
2.4 归并排序
2.4.1 递归版归并排序
归并排序算法的思想:
归并排序(MERGE-SORT)是建立在归并操作上的一种有效排序算法, 该算法是采用分治法(Divideand Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列; 即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序核心步骤:
在讲顺序表的OJ题时,提到过两个有序数组合并在一起后是有序的方法是:两个数组从头开始比较,让小的尾插到临时数组tmp中,当两个数组比较完以后,再将tmp数组里的值拷贝回原数组arr中,这样拷贝完以后的数组arr就是一个有序数组。那归并排序的思想就是这个。不过这里如果要采用递归的方法实现归并排序的话,要采用后续遍历的方法进行归并排序:
c
void _MergeSort(int* arr, int begin, int end, int* tmp)
{
if (begin == end)
return;
int mid = (begin + end) / 2;
//区间被分为:[begin,mid][mid+1,end]
_MergeSort(arr, begin, mid, tmp);
_MergeSort(arr, mid+1, end, tmp);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[i++] = arr[begin1++];
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
memcpy(arr+begin, tmp+begin, sizeof(int)*(end - begin + 1));
}
void MergeSort(int* arr, int n)
{
int* tmp = (int*)malloc(n*sizeof(int));
if (tmp == NULL)
{
perror("malloc fail!");
return;
}
_MergeSort(arr, 0, n - 1, tmp);
free(tmp);
}
本来一开始的数组arr就不是有序的,所以要想让数组arr进行归并就要先将arr数组拆分成两组进行归并,但是拆分以后的两个数组也不是有序的,我们说过两个数组进行归并的前提是两个数组都是有序的才可以!那这里并不是有序的,所以这里就要有分治的思想:拆分后的两个数组不是有序的,那就继续将两个区间又进行拆分,直到拆分到不能再拆分为止,即拆到区间里只有一个元素的时候,就是有序的啦!因为一个数就是有序的,那就可以进行合并了:
注意虽然是递归,但是实际操作的都是arr数组。
2.4.1.1归并排序的时间和空间复杂度
递归版归并排序的时间复杂度通过上图可以看到,就是类似二叉树的递归,所以递归的层数为log~2~n;每层都是要遍历拆分后的两个组的所有元素,所以每层合计起来就是n(n是数组元素个数)。
所以归并排序的时间复杂度为: O(n*log~2~n)
至于空间复杂度,由于开辟了临时数组tmp,所以其中一个空间的消耗为n,加上函数递归开辟的函数栈帧深度为log~2~n, 所以归并排序的空间复杂度为O(n+log~2~n)。当n很大时:log~2~n相较于n是非常小的,所以可以省略掉log~2~n。
则归并排序的空间复杂度为: O(n)
2.4.1.2小区间优化
针对递归版本的归并排序我们其实还可以进行优化,由于归并排序的递归是类似二叉树的结构,而且我们知道二叉树在最下面一层的节点个数已经占了整棵二叉树节点的一半左右。那这里归并排序的最后一层的递归次数相较于所有递归次数也即占了50%左右,那我们其实就可以进行优化:当递归到某层发现这组的元素个数已经只有差不多10个左右,我们即可通过其他的排序方法,来实现拆分区间的排序,而不再进行递归排序了!比如我们可以选择插入排序来实现归并排序的优化:
c
void _MergeSort(int* arr, int begin, int end, int* tmp)
{
if (begin == end)
return;
//小区间优化
if (end - begin + 1 < 10)
{
InsertSort(arr + begin, end - begin + 1);
return;
}
int mid = (begin + end) / 2;
//区间被分为:[begin,mid][mid+1,end]
_MergeSort(arr, begin, mid, tmp);
_MergeSort(arr, mid+1, end, tmp);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[i++] = arr[begin1++];
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
memcpy(arr+begin, tmp+begin, sizeof(int)*(end - begin + 1));
}
void MergeSort(int* arr, int n)
{
int* tmp = (int*)malloc(n*sizeof(int));
if (tmp == NULL)
{
perror("malloc fail!");
return;
}
_MergeSort(arr, 0, n - 1, tmp);
free(tmp);
}
为什么选择当区间里的元素个数为10个左右才进行优化呢?在上面我们说过这里归并排序的递归跟二叉树的结构类似,倒数的第一二三层节点个数其实已经差不多占了二叉树全部节点的87.5%,再往上,其实已经没有多大的作用了,因为越往上,节点个数成倍减少!所以这里递归的次数也是类似二叉树的结构,选择10个元素左右的区间作为优化的条件是比较合适的:
上面的优化叫做小区间优化,也就是当递归到区间大小在10个元素左右就可以通过其他的排序方法进行排序,而不再进行递归排序。这样就能减少递归的次数,上面也可以看到差不多减少了87.5%的递归调用次数。上面的优化方法在实际中是能很好的优化归并排序的,但只是锦上添花的效果,只能优化一点儿;这个优化对归并排序并没有实质性的影响。
2.4.2非递归版归并排序
非递归来实现归并排序的话,会有很多的小问题要注意到!非递归的思想如下:
gap为数组arr分组以后,每组的元素个数。
c
void MergeSortNonR1(int* arr,int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail!");
return;
}
int gap = 1;
while (gap < n)
{
int i = 0;
for (int j = 0; j < n; j += 2*gap)
{
int begin1 = j, end1 = j + gap - 1;
int begin2 = j + gap, end2 = j + 2 * gap - 1;
//printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
if (end1 >= n || begin2 >= n)
{
break;
}
//修正end2
if (end2 >= n)
{
end2 = n-1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[i++] = arr[begin1++];
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
//归并一组,拷贝一组
memcpy(arr+j, tmp+j, sizeof(int)*(end2-j+1));
}
gap *= 2;
}
free(tmp);
}
这里会出现很多的问题:在上面的代码中最外层的while循环是控制每组要归并多少个元素,而i变量是tmp数组的下标;而for循环就是控制所有组进行归并;j每次加的量为2*gap才会跳到下一组进行归并,那问题也会出在这个循环上面!因为j每次都是以2*gap的距离往后移动,但是数组的下标上限为n-1,所以如果数组arr的元素个数不是2的次方数,那在j往后移动的过程中[begin1,end1]和[begin2,end2]这两个要归并的区间就可能会超出[0,n-1]的范围,大致有下面三种情况:
因为所给数组的元素个数,并不是所有时候都是2的次方数,所以j在往后移动的过程中就可能会出现上面的三种越界情况!所以我们就在代码中加入了判断:
上面三种情况: 如果出现了第1种和第2种,直接跳出循环即可!为什么呢?因为是在操作数组,对于第1种和第2种情况,不用进行[begin1,end1]和[begin2,end2]的归并,这样就不会操作这两个区间。对于end2越界的情况修正一下end2即可。
还有要解决的问题就是当归并到tmp数组中以后,还要将tmp数组中的值再拷贝回原数组arr中!这里最好的就是归并一组就拷贝一组,这样如果有区间越界就不用拷贝了:
我们举一个例子来分析j一趟循环会出现的情况:
可以看到上面的arr数组在归并的过程中区间就存在越界的情况。而且上面是归并一组,拷贝一组。我们也可以整组拷贝:
c
void MergeSortNonR2(int* arr, int n)
{
int* tmp = (int*)malloc(sizeof(int)* n);
if (tmp == NULL)
{
perror("malloc fail!");
return;
}
int gap = 1;
while (gap < n)
{
int i = 0;
for (int j = 0; j < n; j += 2 * gap)
{
int begin1 = j, end1 = j + gap - 1;
int begin2 = j + gap, end2 = j + 2 * gap - 1;
//printf("修正前:[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
if (end1 >= n)
{
end1 = n - 1;
//修改成不存在的区间
begin2 = n;
end2 = n - 1;
}
else if (begin2 >= n)
{
//修改成不存在的区间
begin2 = n;
end2 = n - 1;
}
else if (end2 >= n)
{
end2 = n-1;
}
//printf("修正后:[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[i++] = arr[begin1++];
}
else
{
tmp[i++] = arr[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = arr[begin2++];
}
}
//整组拷贝
memcpy(arr, tmp, sizeof(int)*n);
gap *= 2;
printf("\n");
}
free(tmp);
}
整组拷贝的话,就需要修正[begin1,end1]和[begin2,end2]这两个区间。假设现在所给数组如下图所示:
三、非比较排序
3.1 计数排序
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。操作步骤
(1) 统计相同元素出现的次数
(2) 根据统计的结果将序列回收到原来的序列中
c
void CountSort(int* arr, int n)
{
int min = arr[0], max = arr[0];
//找最大值max和最小值min
for (int i = 0; i < n; i++)
{
if (arr[i] < min)
{
min = arr[i];
}
if (arr[i]>max)
{
max = arr[i];
}
}
//开辟计数数组CountA
int range = max - min + 1;
int* CountA = (int*)malloc(sizeof(int)*range);
if (CountA == NULL)
{
perror("malloc fail!");
return;
}
//初始化CountA数组为全0
memset(CountA, 0, sizeof(int)*range);
//计数(相对映射)
for (int i = 0; i < n; i++)
{
CountA[arr[i] - min]++;
}
//排序
int k = 0;
for (int j = 0; j < range; j++)
{
while (CountA[j]--)
{
arr[k++] = j + min;
}
}
free(CountA);
}
对于计数排序的时间和空间复杂度很好计算:
1.上面的代码中查找min和max遍历了一遍数组, 计数的时候又遍历了一遍数组, 最后就是遍历count数组, 所以计数排序的时间复杂度应该是取决于n和range两者中较大的那个
故计数排序的时间复杂度: O(max(N,range))
2.由于开辟了count数组用来计数
故计数排序的空间复杂度: O(range)
计数排序的缺点:
1.依赖数据范围, 适用于范围集中的数组。适用的范围及场景有限。
2.只能用于整形
四、排序算法的时间测试
上面学习了这么多的排序方法,我们可以通过案例测试一下他们对于数据排序的时间消耗:
c
void TestSort()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
int* a8 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
a8[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
BubbleSort(a5, N);
int end5 = clock();
int begin6 = clock();
QuickSort1(a6, 0, N - 1);
int end6 = clock();
int begin7 = clock();
MergeSort(a7, N);
int end7 = clock();
int begin8 = clock();
CountSort(a8, N);
int end8 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("BubbleSort:%d\n", end5 - begin5);
printf("QuickSort:%d\n", end6 - begin6);
printf("MergeSort:%d\n", end7 - begin7);
printf("CountSort:%d\n", end8 - begin8);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
free(a8);
}
int main()
{
TestSort();
return 0;
}
clock函数的作用是从系统启动到该函数的时间,单位是毫秒(1s == 1000ms)。如果我们要测试每个排序方法对于数据的排序时间,就可以将排序函数放在两个clock函数之间来计算它们的时间差,这样就能得到每个排序函数排序数据的时间是多少!上面的程序运行结果为:
可以看到插入排序、选择排序、冒泡排序的时间消耗相比起其他的函数,排序效率是非常差的!而希尔排序、堆排序、快速排序、归并排序这四个函数的排序效率非常好。而计数排序好像又非常的厉害,但是我们说过,计数排序依赖于数据的范围,如果数据范围波动很大,那计数排序的效率可能就比较差,在实际中应用不多。
我们可以针对不同的数据多测几次:
通过上图可以看到,除开时间复杂度不在一个量级的插入、选择和冒泡排序,还有应用不多的计数排序以外;在希尔、堆排、快速和归并排序中效率很好的就是快速排序,所以快速排序在实际中应用比较好。但是希尔、堆排、快速和归并排序相较于其他的排序来说都是非常好的排序方法,各有其应用价值。
4.1 排序算法的稳定性分析
稳定性: 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的; 否则称为不稳定的。