今天我们来看排序,排序在生活中经常使用,非常重要,是必学的内容。
目录
1.插入排序
1.1直接插入排序
1.2希尔排序
2.选择排序
2.1直接选择排序
2.2堆排序
3.交换排序
3.1冒泡排序
3.2快速排序
3.2.1挖坑法
3.2.2左右指针法
3.2.3前后指针法
3.2.4快速排序的非递归
4.归并排序
4.1归并排序的递归
4.2归并排序的非递归
5.基数排序/桶排序
6.计数排序
7.各排序对比以及性能测试
1.插入排序
基本思想
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。实际中我们玩扑克牌时,就用了插入排序的思想
1.1直接插入排序
当插入第 i(i>=1) 个元素时,前面的 array[0],array[1],…,array[i-1] 已经排好序,此时用 array[i] 的排 序码与array[i-1],array[i-2],… 的排序码顺序进行比较,找到插入位置即将 array[i] 插入,原来位置 上的元素顺序后移
就拿上面这图举例子,上面的8,2,6,4,9,7,1,刚开始在一个数组里面,插入排序会先选择第一个元素8放入到数组里面(没有创建新数组,我们把数组分为两块,一块是排序后的,一块是未排序的),此时数组的单独一个8就是一个排序过的区间,然后取第二个元素2,插入到排序过的区间里,8和2进行比较,8比2大,把8向后移动1位,然后把2插入到8的前面,此时的2和8是排序过的区间,其余元素是未排序的,接着取第三个元素6,先和区间里最后一个元素8进行比较,8比6大,8向后移动一位,然后6和2比较,6比2大,所以6放在2的后面,此时2,6,8就是排序过的区间,以此类推
下面我们来看一下代码是如何实现的
void InsertSort(int* a, int n) {//插入排序
int i = 0;
for (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;
}
}
我们看参数,a是数组,n是数组元素的个数,我们先看for循环内部,我们先定一个end,用来指向排序区间的最后一个元素,然后创建一个tmp,用来保存排序区间的后一个元素,比如我们上边讲的,排序区间里有2,8,那么tmp里存的就是6,然后开始循环,循环条件是end>=0,也就是最坏的情况,即区间里所有元素都比tmp要大,如果数组[end]元素比tmp大,就让该元素向后移动一位,然后end-1,否则此时前面的元素都比tmp小,tmp应该放在这里,此时我们直接退出循环,然后把tmp赋值给a[end+1],无论是因为不符合if结束的循环,还是因为不符合循环条件而结束的循环,tmp最终都应该放在end+1的位置,这是插入一个数的排序,然后我们看for循环,i要小于n-1,也就是i最终会等于n-1,为什么不是小于n呢?因为数组元素下标最后一个是n-1,并且我们排序时的tmp最终要存a[n-1],所以要记住,这里是小于n-1。
我们来测试一个例子看看
我们写的是升序排序,大家需要降序的话反过来就可以,也可以多设置一个参数,用来执行升序或者降序。
插入排序的时间复杂度是O(N^2),最坏的情况是将逆序排为升序,比如9,8,7,6,5,4排成升序,如果有N个数字,要排1+2+3+...+N-1次,而最好的情况就是已经排好的情况下,不需要进行移动数据,此时时间复杂度为O(N)。
1.2希尔排序
希尔排序是直接插入排序的优化,或者说,直接插入排序是一种特殊的希尔排序都行。
希尔排序是比较难的,它要先进行多次预排序,使数组接近有序,最后进行直接插入排序
预排序要分组排序,我们举个例子
9,8,7,6,5,4,3,2,1,0,我们要把这个数组排为升序,预排序的分组是间隔gap为一组
比如此时我们让gap=3,那么分组如下
我们会发现gap为多少,就分了多少组,我们看此时的情况,我们把相同颜色的提取出来,9,6,3,0看为一组,此时就当作直接插入排序,排完是0,3,6,9,其余两组以此类推,排完后结果为 0,2,1,3,5,4,6,8,7,9,我们会发现此时已经接近有序,然后gap会由大到小,进行多次预排序,gap越大,大的数可以越快到后面,小的数可以越快到前面,gap越大,排完越不接近有序,gap越小,越接近有序,gap为1时就是直接插入排序
我们来看一下代码是如何实现的
void ShellSort(int* a, int n) {//希尔排序
int gap = n;
while (gap>1) {
//gap = gap/2;
gap =gap/ 3 + 1;
for (int i = 0; i < n - gap; i++) {
int end = i;
int tmp = a[end + gap];
while (end >= 0) {
if (a[end] > tmp) {
a[end + gap] = a[end];
end -= gap;
}
else {
break;
}
}
a[end + gap] = tmp;
}
}
}
我们先看for循环里面,是不是很眼熟?把gap换成1就是直接插入排序,所以他的思想是一样的,for循环就是把多组预排序同时进行,我们上面的那个例子分为了3组,但他实际运行起来是先插入9,然后插入8,然后7,然后6,6和9是一组,6放在9前面,此时就是6,8,7,9,然后5,5和8是一组,此时就是6,5,7,9,8,依此类推,并不是一组排完再一组,他是同时进行排序的,然后我们看外面的while循环,还有gap,gap>1时是预排序,让数组接近有序,gap为1就是直接插入排序,让数组有序,我们要让gap不断减少,最终要减为1(必须为1),gap的初始值我们可以设置为n,但是gap每次减少多少呢?我们有两种选择,一种是gap每次除以2,另一种是除以3,但是除以3最终不一定能变为1,所以我们除以3后要+1.
我们来看一下测试
这时可能会有人问,我们进行了那么多次预排序,然后还进行了直接插入排序,为什么要存在希尔排序呢?当然是因为希尔排序的效率比直接插入排序要高,希尔排序的时间复杂度是O(longN*N),这是每次除以2的情况,除以3的情况下,时间复杂度是O(long3N*N)(log3N是long以3为底n的对数),经过计算,平均时间复杂度是O(N^1.3),当我们要排10w个数据时,希尔排序要比直接插入排序快100多倍,排的越多,差的就越多,是不是很奇妙呢?
2.选择排序
2.1直接选择排序
直接选择排序的思想非常简单,就是遍历一遍数组,找出最大的元素放在最后,最小的放在最前面
void SelectSort(int* a, int n) {//直接选择排序
int begin = 0, end = n - 1;
int i;
while (begin < end) {
int maxi = begin;
int mini = begin;
for (i = begin; i <= end; i++) {
if (a[i] < a[mini]) {
mini = i;
}
if (a[maxi] < a[i]) {
maxi = i;
}
}
Swap(&a[mini], &a[begin]);
Swap(&a[maxi], &a[end]);
++begin;
--end;
}
}
我们设置一个起始位置begin,一个末尾位置end,我们看while循环里面,我们还要设置一个最大值的下标,一个最小值的下标,然后我们开始遍历数组,找出最大和最小的下标,然后交换即可
我们来看测试结果
到这就完了吗?我们再加几个数字看看
这是怎么回事呢?是因为我们第一遍遍历时,mini指向-1,maxi指向第一个9,然后我们把mini位置的元素放在了第一个位置,接着把maxi位置的元素放在了最后一个位置,因为我们记录的是下标,第一次交换完后,-1就到了maxi的位置,所以-1又被交换了一遍
所以我们需要修正一下
void SelectSort(int* a, int n) {//直接选择排序
int begin = 0, end = n - 1;
int i;
while (begin < end) {
int maxi = begin;
int mini = begin;
for (i = begin; i <= end; i++) {
if (a[i] < a[mini]) {
mini = i;
}
if (a[maxi] < a[i]) {
maxi = i;
}
}
Swap(&a[mini], &a[begin]);
if (maxi == begin) {
maxi = mini;
}
Swap(&a[maxi], &a[end]);
++begin;
--end;
}
}
在第一次交换完后,我们要看maxi是否和begin重合,如果重合,我们需要把mini赋值给maxi, 因为begin最开始是我们这次选出最大的元素,它和mini位置交换,所以此时mini位置的元素就是我们这次选出的最大元素,我们来看测试结果
同样的,直接选择排序的时间复杂度是O(N^2) ,而且它在效率最差的排序那一档
2.2堆排序
堆排序是选择排序的一种,堆排序也是比较难的一个排序,也非常神奇,我们来看一看
首先堆的逻辑结构是一棵完全二叉树,而物理结构是一个数组,举个例子
堆有两个特性:1.结构性:用数组表示完全二叉树
2.有序性:任一节点的关键字是其子树所有节点的最大值(或最小值)
最大堆也称大顶堆:最大值
最小堆也称小顶堆:最小值
大堆要求:树中所有父亲都大于孩子
小堆要求:树中所有父亲都小于孩子
那我们如何用数组来表示二叉树呢?我们可以用下标来表示父子节点
leftchild=parent*2+1,rightchild=parent*2+2,parent=(child-1)/2
要进行堆排序,首先我们要建堆,比如说建小堆
我们需要知道向下调整算法,它的前提条件是左右子树都是小堆,我们举个例子
规则是从根节点开始,选出左右孩子中小的那一个,和父亲比较,如果比父亲小就交换,然后继续向下调整,比如根节点是27,左孩子是15,右孩子是19,较小的孩子是15,比27小,那么交换15和27,就变成了这样
然后继续看27的左孩子和右孩子,分别为18和28,小的是18,比27小,继续交换,依此类推,最后变成这样
这个算法如何实现呢?我们来看代码
void Swap(int* a, int* b) {//交换两个数
int tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustDown(int* a, int n,int root) {//向下调整算法
int parent = root;
int child = root * 2 + 1;//默认取左孩子
while (child<n) {
//选取较小的孩子
if (child+1<n && a[child + 1] < a[child]) {
child += 1;
}
if (a[child] < a[parent]) {
Swap(&a[child], &a[parent]);//如果孩子比父亲小,交换孩子和父亲
parent = child;
child = parent * 2 + 1;
}
else {
break;
}
}
}
我们先设置parent和child,child我们默认先取左孩子,然后我们看while循环里面,我们先比较左右孩子,取出较小的那一个,然后我们比较孩子和父亲对应下标的数字,进行交换,我们再看while循环的条件,child<n,要防止越界,我们再看第一个if的条件,child+1<n,这是防止极端情况,比如上面的28就没有右孩子
此时我们就建成了小堆,诶,那如果我们使用时不满足这个前提怎么办呢?
我们要倒着来看,举个例子
这样一个堆,很明显不符合条件,我们要让3的左子树变成小堆,就要让7和8变成小堆,要让7和8变成小堆,就要让9,4,0先变成小堆,但是叶子并不需要调整,所以我们要从最后一个非叶子节点开始调整,那最后一个非叶子节点的下标我们如何确定呢?我们发现8就是最后一个非叶子节点,而数组最后一个元素0正好是它的孩子,此时我们就可以计算出8的下标了
代码这样实现
//建堆
for (int i = (n-1-1)/2; i >=0; i--) {
AdjustDown(a, n, i);
}
我们要排升序,需要建大堆,上面的向下调整我们是建小堆,修改一下大于小于符号即可变化,为什么排升序要建大堆呢?
如果是建小堆,最小数在堆顶,已经被选出来了,现在从剩下的数中再去选数,但是剩下的数已经乱了,需要重新建堆才能选下一个数,建堆的时间复杂度为O(N),这样堆排序就没有效率优势了
比如我们上面的数组
它建出来的堆为
我们发现下面的顺序全乱了,而我们建大堆后
此时我们回到堆排序,建成大堆后,我们要把第一个元素和最后一个交换,把最后一个元素不看做堆里面,我们再对前n-1个数向下调整,选出次大的数,和倒数第二个数交换,依此类推,就可以排序
void HeapSort(int* a, int 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]);
AdjustDown(a, end, 0);
--end;
}
}
我们来看测试
向下调整需要进行高度次,时间复杂度为longN,建堆的时间复杂度为O(N),对于堆排序,第一次建堆是O(N),剩下的部分选数用向下调整,为O(longN),所以堆排序的时间复杂度为O(N*logN)
这个计算就是计算极端情况,即满二叉树,每一层的节点个数乘以最多需要调整次数,然后相加
最后我们来缕一缕堆排序的思路,我们想使用堆排序的话就需要有堆,而正常情况下数组并不是堆,所以我们需要把数组建成堆,而建堆需要我们使用向下调整算法,从最后一个节点的父亲开始使用向下调整算法,最后到最顶端。最后一定要记住,排升序建大堆,排降序建小堆
3.交换排序
3.1冒泡排序
冒泡排序是我们经常使用的一种排序,它简单易懂,写起来也快
冒泡排序的思想是我们遍历一遍数组,找到最大的那个数放到最后一个位置,然后再次遍历找到次大的数放到倒数第二个位置,依此类推,就像水中的气泡一样,大的不断向上冒,所以叫冒泡排序
我们来看一下代码是如何实现的
void BubbleSort(int* a, int n) {//冒泡排序
for (int j = 0; j < n; j++) {
int exchange = 0;
for (int i = 1; i < n-j; i++) {
if (a[i - 1] > a[i]) {
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0) {
break;
}
}
}
用i-1和i比较可以防止越界,同时设置exchange可以提高代码效率,比如数组已经是有序的情况下,不写exchang的话,后续代码还会不停的比较,而加上exchange可以直接退出
冒泡排序的时间复杂度是O(N^2),最好的情况下是O(N)。
冒泡排序相对于直接选择排序更优,但不如直接插入排序,对于有序,接近有序以及局部有序,插入排序的适应性更强。
3.2快速排序
终于到了我们的快排,相信很多人之前都听说过它的大名,我猜也肯定有很多人是为了这个排序才来看的,那我们废话不多说,来看快排是如何实现的
3.2.1挖坑法
我们会选取一个关键字key ,比如我们选了6(一般选取第一个和最后一个数作为key),然后让6左边的数字都比它小,6右边的数字都比它大,此时的6就已经到了正确的位置,不需要再变动,接着我们使用分治的思想,在6右边的区间里再选取一个key,进行同样的操作,左边也是一样,这样不断的划分区间,直到所有数有序为止
那这个该怎么实现呢?我们选取了关键字key之后(key为6),此时数组里6的位置就是一个坑(空余位置),我们让数组从右边开始,不断的向左边移动,直到找到比6小的元素,比如我们找到的第一个元素是5,然后把5放到坑里,此时原来5的位置就变成了坑,然后我们让数组从左边开始,不断向右,找比6大的元素,我们找到的第一个元素是7,放到原来5的位置,此时7的位置就变成了坑,然后依此类推,不断的挖坑填坑,直到左右两个选数的箭头重合,这就是最后一个坑,也是我们该放key的位置,然后我们把key放进去,此时的key就到了正确的位置,接着就是我们的分治思想了,这就是挖坑法,我们来看看
我们来看代码是如何实现的
void QuickSort(int* a, int left,int right) {
if (left >= right) {
return;
}
int begin = left, end =right;
int pivot = begin;
int key = a[begin];
while (begin < end) {
//右边找大
while (begin < end && key <= a[end]) {
--end;
}
a[pivot] = a[end];
pivot = end;
//左边找小
while (begin < end && key >= a[begin]) {
++begin;
}
a[pivot] = a[begin];
pivot = begin;
}
//key放在正确的位置(pivot)里
pivot = begin;
a[pivot] = key;
//分治递归,区间分为 [left,pivot-1] pivot [pivot+1,right]
QuickSort(a,left,pivot-1);
QuickSort(a,pivot+1,right);
}
函数一进来的判断语句用来终于递归,>是不存在,=是只有一个值,因为我们给的是闭区间,所以我们在传参时也要传对应的数(下标从0到n-1),我们来看一下测试,再看内层循环的两个while,都有一个&&,条件是和外层while是一样的,这个是防止内部选数时导致begin和end位置不符合循环条件,但又无法退出而设置的
很好,这就是挖坑法的实现,挖坑法本质上和我们之前学的二叉树的遍历是类似的
快速排序的时间复杂度,我们先看单趟排序,虽然套了两层循环,但复杂度可不是O(N^2),所以我们计算时间复杂度时切忌不要数循环!!!单趟循环里begin和end不断向中间走,时间复杂度是O(N),总体时间复杂度为O(N*logN),快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序,空间复杂度为O(logN)
在有序的情况下(不论顺序还是逆序),对于快排是最坏的情况,我们每次选key都是最边缘的一个,相当于一个只有左子树(或右子树)的二叉树,此时的时间复杂度就是O(N^2),这也是快排的一个致命缺陷
为了解决这个问题,我们采用一种名叫三数取中的办法,我们取区间的最左边的数,最右边的数,和最中间的数,比较他们的大小,我们选取中间的数,这样以来,我们每次选key都不会选到最大和最小,我们修改代码如下
int GetMidIndex(int* a, int left, int right) {//三数取中
int mid = (left + right) / 2;
if (a[left] < a[mid]) {
if (a[mid] < a[right]) {
return mid;
}
else if (a[left] > a[right]) {
return left;
}
else {
return right;
}
}
else {//a[left] >= a[mid]
if (a[mid] > a[right]) {
return mid;
}
else if (a[left]<a[right]) {
return left;
}
else {
return right;
}
}
}
void QuickSort(int* a, int left,int right) {//快速排序
if (left >= right) {
return;
}
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int begin = left, end =right;
int pivot = begin;
int key = a[begin];
while (begin < end) {
//右边找大
while (begin < end && key <= a[end]) {
--end;
}
a[pivot] = a[end];
pivot = end;
//左边找小
while (begin < end && key >= a[begin]) {
++begin;
}
a[pivot] = a[begin];
pivot = begin;
}
//key放在正确的位置(pivot)里
pivot = begin;
a[pivot] = key;
//分治递归,区间分为 [left,pivot-1] pivot [pivot+1,right]
QuickSort(a,left,pivot-1);
QuickSort(a,pivot+1,right);
}
我们看快排里的变化,我们设置index取三数中间的下标,然后交换left和index对应的数字,这样我们每次选取的key仍然是第一个位置的数,我们来看测试结果
一样可以很好的解决问题,同时可以避免最坏的情况,此时的快排的效率就非常强大了,综合性能非常强大,快排和希尔排序还有堆排序虽然在一个量级,但是也要比它们更快,所以才叫快排
我们的快排还能更快,当我们排的数据量很大的时候,比如100w个数,我们知道递归时不断分割数据区间,越往下面越多,举个例子,这100w个数里边,80w个数在底层,即10个数字还分区间,这样显然会影响效率,这时我们就可以采用小区间优化,让快排变的更快
void QuickSort(int* a, int left,int right) {//快速排序
if (left >= right) {
return;
}
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int begin = left, end =right;
int pivot = begin;
int key = a[begin];
while (begin < end) {
//右边找大
while (begin < end && key <= a[end]) {
--end;
}
a[pivot] = a[end];
pivot = end;
//左边找小
while (begin < end && key >= a[begin]) {
++begin;
}
a[pivot] = a[begin];
pivot = begin;
}
//key放在正确的位置(pivot)里
pivot = begin;
a[pivot] = key;
//分治递归,区间分为 [left,pivot-1] pivot [pivot+1,right]
if (pivot - 1 - left > 10) {
QuickSort(a, left, pivot - 1);
}
else {
InsertSort(a + left, pivot - 1 - left + 1);
}
if (right - (pivot + 1) > 10) {
QuickSort(a, pivot + 1, right);
}
else {
InsertSort(a+ pivot + 1, right - (pivot + 1)+1);
}
}
我们在小区间时选用插入排序来代替,这样效率更优,注:a+left是数组起始位置,可以自己画图理解一下,因为是闭区间,所以数组个数我们相减后要+1,比如[0,9]是有十个数字,是9-0+1,>10也可以优化为>100,会更快一点,这个需要根据数组大小给定,不固定,一般写10就可以
我们把快排的公共部分提取出来,后续修改对应的PartSort对应的123即可使用对应方法
void QuickSort(int* a, int left,int right) {//快速排序
if (left >= right) {
return;
}
int keyIndex = PartSort2(a, left, right);
//分治递归,区间分为 [left,pivot-1] pivot [pivot+1,right]
/*QuickSort(a, left, keyIndex - 1);
QuickSort(a, keyIndex + 1, right);*/
//小区间优化,数据过大时可以使用,可以比原来的快一点点
if (keyIndex - 1 - left > 10) {
QuickSort(a, left, keyIndex - 1);
}
else {
InsertSort(a + left, keyIndex - 1 - left + 1);
}
if (right - (keyIndex + 1) > 10) {
QuickSort(a, keyIndex + 1, right);
}
else {
InsertSort(a+ keyIndex + 1, right - (keyIndex + 1)+1);
}
}
int PartSort1(int* a, int left, int right) {//快速排序,挖坑法,单趟排序
if (left >= right) {
return;
}
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int begin = left, end = right;
int pivot = begin;
int key = a[begin];
while (begin < end) {
//右边找大
while (begin < end && key <= a[end]) {
--end;
}
a[pivot] = a[end];
pivot = end;
//左边找小
while (begin < end && key >= a[begin]) {
++begin;
}
a[pivot] = a[begin];
pivot = begin;
}
//key放在正确的位置(pivot)里
pivot = begin;
a[pivot] = key;
return pivot;
}
3.2.2左右指针法
左右指针法的思想和挖坑法类型,我们直接举例说明
还是这个数组,我们定义两个指针,一个begin从左往右,一个end从右往左, 同样我们选取第一个值作为key(key=6),然后begin找比6大的数,比如我们第一次找到的是7,end找比6小的数,比如我们第一次找到了5,然后交换这两个数,第二次交换9和4,然后再走会发现两个指针3的位置相遇了,此时我们再交换6和3即可,我们发现左右指针法和挖坑法第一趟排完后的顺序是不一样的,大家在遇到问快速排序第一趟排完后的顺序这种题时,要记得这几种方法都要尝试(之后还有前后指针法),我们来看代码是如何实现
int PartSort2(int* a, int left, int right) {//快速排序,左右指针法
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int begin = left, end = right;
int keyi = begin;
while (begin < end) {
//找小
while (begin < end && a[end] >= a[keyi]) {
--end;
}
//找大
while (begin < end && a[begin] <= a[keyi]) {
++begin;
}
Swap(&a[begin], &a[end]);
}
Swap(&a[begin],&a[keyi]);
return begin;
}
我们来看内层while循环的条件也是&&,同时我们注意是a[end]>=a[keyi],以及下边也是<=,这里的=是因为没有=可能变成死循环,举个例子,一个数组[5,12,7,5,35,6,5,8,9]我们选取了key=5,如果没有=,两个指针就会不停的交换两个5,因为5并不大于5,end和begin也不会改变,不会结束循环
3.2.3前后指针法
还是用这个数组来看, 前后指针法同样要选key,我们还是选取key=6,然后需要两个变量,一个prev,一个cur,我们让prev=left,cur=left+1,然后我们让cur不断向后,找比key小的值,每次遇到比key小的值就停下来,然后让prev+1,然后交换prev和cur位置的值,比如,cur最开始在1的位置,比6小,然后我们++prev,让prev也到1的位置,然后交换(此时数组没有发生变化),然后cur继续向后,第二次交换了2(数组没变,2和2交换),第三次时,cur在3的位置停下,prev在7的位置,我们交换3和7,然后是4和9的交换,然后是5和7交换,然后cur不断向后,最后也没有找到比key小的值,这时我们再交换prev和key即可,同样也可以做到让key左边比他小,右边比他大
我们来看代码是如何实现
int PartSort3(int* a, int left, int right) {//快速排序,前后指针法
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int keyi = left;
int prev = left, cur = left + 1;
while (cur <= right) {
if (a[cur] < a[keyi]) {
++prev;
Swap(&a[cur], &a[prev]);
}
++cur;
}
Swap(&a[prev], &a[keyi]);
return prev;
}
这个代码写起来就很简单了,但是我们发现,在cur向后走的时候,有时候会发生自己和自己交换的情况,这样就很麻烦,我们来把它优化掉
int PartSort3(int* a, int left, int right) {//快速排序,前后指针法
int index = GetMidIndex(a, left, right);
Swap(&a[index], &a[left]);
int keyi = left;
int prev = left, cur = left + 1;
while (cur <= right) {
if (a[cur] < a[keyi] && ++prev != cur) {
Swap(&a[cur], &a[prev]);
}
++cur;
}
Swap(&a[prev], &a[keyi]);
return prev;
}
利用&&的逻辑能力,只有当a[cur]<a[keyi]时,才会让prev++,同时prev+1后,如果和cur不相等才会进行交换。快速排序的思想和二叉树的前序遍历有点类似。
3.2.4快速排序的非递归
可能有人会问,我们为什么要学习非递归呢?递归就可以很轻松解决问题了,还学非递归干什么?
其实,这不仅是学习知识,而且还可以防止极端情况,递归的致命缺陷是在栈帧深度太深时,空间会不够用,可能会发生溢出,比如我们写斐波那契数量的递归,要计算一个大一点的就要非常久,而特别大的斐波那契数可能就会导致栈溢出。
而递归改非递归,可以有两种办法,一种是直接改循环,这种在简单的递归里使用,而复杂一点的递归,需要我们借助数据结构栈来进行模拟递归。
这里我已经准备好了一个栈,大家可以拿去用
(1条消息) 栈和队列及其多种接口实现-c语言_KLZUQ的博客-CSDN博客
我们来举个例子看快排非递归的思想
我们有这样一组数, 一共8个数,下标为0~7,我们先把0和7存入栈里,然后再把0和7取出来,进行单趟排序,排完之后,我们选的key在3的位置
此时就剩下两段子区间需要递归,左边为0~2,右边为4~7,我们要先处理左边的区间,那就需要先把右边先压入栈,所以我们把4和7入栈,再把0和2入栈,接着我们再把0~2拿出来,进行单趟排序,同时我们选出了一个key,key为27,此时左右子区间都只剩一个元素,已经有序了,所以不需要再压栈,这时,左边就已经排好了。
现在栈里还剩4和7,我们接着把他们取出来,进行单趟排序,同时选出key为76
此时7的位置只有一个值,已经有序,不需要压栈,左边为4~5 ,我们把4和5压入栈里,接着把4和5取出来,再次进行单趟排序,左右都已有序,就不用再压栈了
此时栈已经空了, 我们的排序也就结束了,我们是把需要单趟排序的区间压入栈里
我们来看代码是如何实现的
void QuickSortNonR(int* a, int n) {//快速排序,非递归
ST st;
StackInit(&st);
StackPush(&st, n - 1);
StackPush(&st, 0);
while (!StackEmpty(&st)) {
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
int keyIndex = PartSort1(a, left, right);
//区间划分为 [left,keyIndex-1] keyIndex [keyIndex+1,right]
if (keyIndex + 1 < right) {
StackPush(&st, right);
StackPush(&st, keyIndex + 1);
}
if (left < keyIndex - 1) {
StackPush(&st, keyIndex - 1);
StackPush(&st, left);
}
}
StackDestory(&st);
}
首先我们创建一个栈,对他进行初始化,栈的特性是后进先出,我们想要先出左,就要先入右,我们使用的是闭区间,所以我们先入n-1,再入0,接着看while循环,只要栈不为空,说明需要排序,然后我们先出左,再出右,然后进行单趟排序,这里单趟排序我们随便选择之前的一个即可,排完之后我们得到了key的位置,区间也被划分,当left<keyIndex时,说明左区间还有多个值,需要继续进行排序,我们把它入栈,因为我们想要先处理左区间,所以要先入右区间,此时我们的排序就已经完成,最后不要忘记销毁栈,否则可能造成内存泄漏
最后再说一点,网上很多人都说递归的效率低,其实并不是这样,十几二十年前的电脑,可能效率会低,但是现在的电脑已经非常强大了,递归和非递归的效率其实并没有太大差别,只是递归可能会造成栈溢出而已,我们使用的数据结构模拟的栈,是在堆是开辟的空间,堆的空间比栈要大的多,完全足够我们使用。而且快速排序的非递归不止用栈可以实现,用队列也是可以的,但是那样就不像模拟递归了
4.归并排序
4.1归并排序的递归
归并还是需要用到我们的分治思想,看上面这个数组,我们把它先分为两个,要让数组整体有序,我们可以让左边的区间先有序,再让右边的区间先有序,我们就可以进行归并,我们用两个指针指向两个区间开头,然后我们创建一个临时数组,把两个数组里较小的那一个值放入临时数组,然后让这个指针向后移动,不断的放入,不断的向后,最终会有一个区间被全部放入,比如我们看上面的例子,整个数组被分为了10,6,7,1和3,9,4,2两个区间,他俩有序后是1,6,7,10和2,3,4,9,然后我们取两个数组里较小的那一个值,这时我们取1放入临时数组,然后是2,3,4,接着是6,7,然后是9,此时第二个区间已经全部放入临时数组,接着我们把第一个区间剩下的值全部放入临时数组,接着再把临时数组拷贝到原来的数组就可以了,那么怎么让两个区间有序呢?我们把两个区间再分为4个,不断的划分,直到不能划分为止,就如上边的图片那样,当然,我们从始至终只创建一个数组,和原数组一样大,并不是每次归并都创建一个数组,这样太麻烦了
我们来看代码是如何实现的
void _MergeSort(int* a, int left,int right,int* tmp) {//子函数
if (left >= right) {
return;
}
int mid = (left + right) >> 1;//>>1相当于除以2
//假设[left,mid] [mid+1,right]有序,我们就可以进行归并
_MergeSort(a, left, mid, tmp);
_MergeSort(a,mid + 1, right, tmp);
//归并
int begin1 = left, end1 = mid;
int begin2 = mid + 1, end2 = right;
int index = left;
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[index++] = a[begin1++];
}
else {
tmp[index++] = a[begin2++];
}
}
//将剩余的一个区间里的元素放入临时数组
while (begin1 <= end1) {
tmp[index++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[index++] = a[begin2++];
}
//拷贝到原数组
for (int i = left; i <= right; i++) {
a[i] = tmp[i];
}
}
void MergeSort(int* a, int n) {//归并排序
int* tmp = (int*)malloc(sizeof(int) * n);
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
因为归并用到的是递归思想,所以我们要写出递归的终止条件,然后我们需要计算mid(中间值,用来划分左右区间),整个数组就被分为了[left,mid] [mid+1,right],然后我们用递归来使区间再次划分并且有序,等到这两个区间有序后,我们就开始归并,我们看第一个while,它的条件是&&,因为这是继续的条件,只有两个指针都没到两个区间的末尾,才会继续,然后我们看下面的两个while,虽然是两个,但它只会进去一个while,最后就是拷贝了。
我们发现归并的思想,其实和二叉树的后续遍历是类似的,我们来看测试
很好,接着我们来看归并的非递归是如何实现的
4.2归并排序的非递归
归并排序的非递归不需要和快排的非递归一样借助数据结构栈,直接进行排序即可,我们先看它的思想
我们有这样一个数组,我们最开始把一个数分为一组,每两组进行一次归并,也就是10和6进行归并,7和1进行归并,3和9进行归并,4和2进行归并,第一次归并完后结果为
6,10,1,7,3,9,2,4
接着我们把每两个数分为一组,每两组进行一次归并,也就是6,10,1,7进行归并,3,9,2,4进行归并,第二次归并完结果为
1,6,7,10,2,3,4,9
接着我们把每四个数分为一组,每两组进行一次归并,也就是最后一次归并了,结果为
1,2,3,4,6,7,9,10
知道了它的思想,我们来看代码是如何实现的
void MergeSortNonR(int* a, int n) {//归并排序,非递归
int* tmp = (int*)malloc(sizeof(int) * n);
int gap = 1;//每一组的数据个数
while (gap<n) {
for (int i = 0; i < n; i += 2 * gap) {
//将[i,i+gap-1] [i+gap,i+2*gap-1]这两组数进行归并
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
int index = i;
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[index++] = a[begin1++];
}
else {
tmp[index++] = a[begin2++];
}
}
//将剩余的一个区间里的元素放入临时数组
while (begin1 <= end1) {
tmp[index++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[index++] = a[begin2++];
}
}
//拷贝到原数组
for (int j = 0; j <= n; j++) {
a[j] = tmp[j];
}
gap *= 2;
}
free(tmp);
}
我们设置一个gap,用来记录每组数据个数,比如我们第一次排是将1个数单独分为一组,第二次是两个,然后我们看while循环内部,我们让for循环从0到n,每次+2*gap,就可以划分区间,[i,i+gap-1] [i+gap,i+2*gap-1]我们归并时会将这两组进行归并,比如我们第一次,是[0,0] [1,1],接着是[2,2] [3,3]依此类推,第二次是[0,1] [2,3],接着是[4,5] [6,7],第三次是[0,3] [4,7]进行归并,第三次归并完后数组就有序了,我们的归并直接复制之前递归里的归并代码即可,然后要稍作修改,修改区间两端已经一些变量,然后我们看外层while循环条件,只要gap<n就要继续排序,比如我们第三次时gap=4,排完后gap=8,此时就会退出循环,最后要记得释放临时数组的空间
大家认为写完了吗?没有,此时的归并还有一些问题,我们这次排序是因为数据两刚好对上了,现在是8个,最后刚好可以归到1,如果再多一个就不行了,那我们该怎么修改呢?
我们分析出数组可能会越界,而越界有两种情况,第一种是右半区间不存在,比如我们排9个数,9和10要进行归并,但我们没有10,第二种是右半区间存在,但我们数据量不够归并,比如我们有10个数,第三次归并时是8个数进行归并,为[0,7] [8,15],但我们并没有这么多数,除了右半区间有问题,左半区间可能有问题吗?比如我们在原数组上加几个数
10,6,7,1,3,9,4,2,2,3,4
我们后边四个四个归时,10,6,7,1一归,3,9,4,2一归,到2,3,4时,它不仅没有右半区间,左半区间也是不完整的。
void MergeSortNonR(int* a, int n) {//归并排序,非递归
int* tmp = (int*)malloc(sizeof(int) * n);
int gap = 1;//每一组的数据个数
while (gap<n) {
for (int i = 0; i < n; i += 2 * gap) {
//将[i,i+gap-1] [i+gap,i+2*gap-1]这两组数进行归并
int begin1 = i, end1 = i + gap - 1;
int begin2 = i + gap, end2 = i + 2 * gap - 1;
//归并过程中,右半区间可能不存在
if (begin2 >= n) {
break;
}
//归并过程中,右半区间算多了,修正一下
if (end2 >= n) {
end2 = n - 1;
}
int index = i;
while (begin1 <= end1 && begin2 <= end2) {
if (a[begin1] < a[begin2]) {
tmp[index++] = a[begin1++];
}
else {
tmp[index++] = a[begin2++];
}
}
//将剩余的一个区间里的元素放入临时数组
while (begin1 <= end1) {
tmp[index++] = a[begin1++];
}
while (begin2 <= end2) {
tmp[index++] = a[begin2++];
}
//拷贝到原数组
for (int j = 0; j <= end2; j++) {
a[j] = tmp[j];
}
}
gap *= 2;
}
free(tmp);
}
我们这样修改就可以,当然上面修改完后,下面也要跟着修改,拷贝到原数组里我们放到里面,这样防止拷贝进去随机值,并且修改终止条件为j<end2,不然这里也是越界的,我们来看测试
这里最难的就是修正边界,非常麻烦,归并排序也叫外排序,我们可以用归并的思想对文件中的数据进行排序,比如我们要排10G数据,但我们只有1G内存,我们可以把10G切成10个1G文件,让10个1G文件有序,我们依次读取文件,每次读取1G到内存中的一个数组,用快速排序对其排序(综合而已快排比较好),再写到一个文件,再继续读取下一个1G的文件,最后进行归并即可有序,归并时我们要借助硬盘,1G和1G归成2G,2G和2G归成4G,最后一次是8G和2G归变为10G,因为我们归并的思想可以在磁盘里使用,所以也叫外排序(磁盘只能依次读取数据)。
5.基数排序/桶排序
基数排序是一种很奇特的排序,我们来看一下它的思想
我们有这样一组数:123,45,12,9,88,43
排序时,我们会依次取他们的个数,十位,百位...进行排序
比如第一次排序结果为:12,123,43,45,88,9
第二次排序结果为:9,12,123,43,45,88
第三次排序结果为:9,12,43,45,88,123
基数排序在实际上意义不大,它只能对整数进行排序,基本不使用,所以我们简单看看思想就行
6.计数排序
我们有这样一组数,4,4,6,8,9,3,3,0,0
计数排序会这样排,我们会先开一个空间为10的数组(因为最大的数为9)
它会统计每个数出现的次数,使用了映射(每个值对应下标位置)
我们发现第一个和第二个数是4,所以下标为4的地方就变成了2,6的位置是1,8和9的位置也为1,3和0的位置为2
接着我们就使用次数来进行排序,0的位置为2,所以写入0,0,3的位置是2,所以写入3,3,4的位置也为2,写入4,4,依此类推,最后就变成了0,0,3,3,4,4,6,8,9
计数排序也叫非比较排序,和它的名字一样。
如果我们要排100,101,102,103,109,105,那我们也得创建一个110的数组,我们发现造成了很大的空间浪费,这种方法叫做绝对映射(数是几就对应几),所以我们可以用最大值减去最小值,开辟大小为10的空间,这叫做相对映射,此时的5就代表105,9代表109
我们来看代码是如何实现的
void CountSort(int* a, int n) {//计数排序
int max = a[0], min = a[0];
for (int i = 0; i < n; i++) {
if (a[i] > max) {
max = a[i];
}
if (a[i] < min) {
min = a[i];
}
}
int range = max - min + 1;
int* count =(int*)malloc(sizeof(int) * range);
memset(count, 0, sizeof(int) * range);//初始化
for (int i = 0; i < n; i++) {//统计次数
count[a[i]-min]++;
}
int j = 0;
for (int i = 0; i < range; i++) {//排序
while (count[i]--) {
a[j++] = i+min;
}
}
free(count);
}
我们先找最大值和最小值,然后开辟空间,然后给数组初始化为0,接着我们开始计数,因为是相对映射,所以我们用对应的值减去最小值即可,比如109-100等于9,我们就只要开辟10个空间,同时也可以写入数组, 接着我们就可以排序了,我们要多写一个while循环,来看count[i]的位置有几个值,我们就要写入几次,因为i是相对值,加上min即可恢复原来的值,最后我们不要忘记释放空间
同样的,计数排序也是非比较排序
计数排序的时间复杂度为O(N+range),我们看到,范围越小,它越快,说明这种排序适用于范围相对集中的整形数据
空间复杂度为O(range)
计数排序的思想很巧,但适用范围具有局限性。
7.各排序对比以及性能测试
其中快速排序如果加了三数取中,基本不会出现最坏的情况。
稳定性 :假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记 录的相对次序保持不变,即在原序列中,r[i]=r[j] ,且 r[i] 在 r[j] 之前,而在排序后的序列中, r[i] 仍在r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。
比如一个数组,5,3,2,1,5,这五个数,排完序后,如果第一个5不在第二个5后面,就是稳定的(相对顺序不变),否则为不稳定,稳定性也是有意义的,在实际生活里,比如一些竞赛里,两个人都考了100分,相同的分数用时少的人排名应该在前面,要保证这一点就应该具有稳定性。
选择排序是不稳定,比如1,2,5,9,8,5,3,4,5 ,在进行排序时,最三个5和9进行交换,我们是无法控制稳定的。
希尔排序的不稳定是因为希尔排序需要分组,相同的数可能分到不同的组。
堆排序也是因为交换时可能把底层的数交换到上层,但中间层有一样的,从而导致相对位置变化。
快速排序也是不稳定的,比如5,1,2,3,5,4,6,7,5,我们选第一个5为key,但无法保证剩下两个5的相对位置不变。
以下为性能测试代码
// 测试排序的性能对比
void TestOP()
{
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);
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];
}
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();
QuickSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = 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("QuickSort:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
进行性能测试时,大家可以把Debug版本改为Release版本,Release优化很多,测试起来会很快
以上就是我们这次排序的全部内容,希望大家有所收获,全文超过两万字,希望大家可以真正掌握这些排序算法!
如有错误,还请指正。