快进来看看常见的几种排序
👀先看这里👈
😀作者:江不平
📖博客:江不平的博客
📕学如逆水行舟,不进则退
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
❀本人水平有限,如果发现有错误的地方希望可以告诉我,共同进步👍
🏐1.排序是什么
排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。 分内部排序和外部排序
内部排序:若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序。内部排序的过程是一个逐步扩大记录的有序序列长度的过程。
外部排序:若参加排序的记录数量很大,整个序列的排序过程不可能在内存中完成,则称此类排序问题为外部排序。
排序的应用
我们在生活中处处都是排序,在你网购时,在购物平台上推荐的Top榜,在每次考试后的成绩表……,这些都是排序的应用。
我们常见的排序有插入排序,希尔排序,归并排序,桶排序基数排序等,本文我们介绍其中的几种
🏐2.插入排序
2.1🏀直接插入排序
当插入第i(i>=1)个元素时,前面array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移
2.1.1⚽原理
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列
我理解的:好比一个数组多开辟了个空间,这个多开辟的空间存放的是插入数据,用拷贝到变量后,这样最后一个位置就可以被覆盖了,就相当于空的,该位置之前的数据通过与插入数据的比较,往后进行挪动,到不需要挪动的时候,直接就有个位置供插入数据进行插入。
一般的,我们认为插入的数据在数组最后,所以我们进行排序,需要将其与前面的数据进行比较,然后进行插入,做到有序,显然要做到插入后数组还是有序的,之前的数组必然也是有序的,也就是说,我们需要实现的就是,在一个有序数组中插入数据后保持有序的状态。
我们在写这种循环的代码时,我们通常要先写出一般情况,然后再进行循环,那么我们就先来看一下对于一个有序数组插入一个数据时的情况
void InsertSort(int* a, int n)
{
// [0,end]有序,把end+1位置的值插入,保持有序
int end = n-2;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])//升序
//if(tmp>a[end])//降序
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
写完一般情况后,就要考虑循环写法了,从只有一个数据开始写循环,我们发现把end的初始值进行了修改完,其他没有发生变化。
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; ++i)
{
// [0,end]有序,把end+1位置的值插入,保持有序
int end = i;
int tmp = a[end + 1];
while (end >= 0)
{
if (tmp < a[end])//升序
//if(tmp>a[end])//降序
{
a[end + 1] = a[end];
--end;
}
else
{
break;
}
}
a[end + 1] = tmp;
}
}
时间复杂度:O(N^2)
我们稍加分析,最坏的情况就是数组的数据是逆序的,这样每个数据都要进行一遍比较和插入,如果一个接近有序的序列,那么时间复杂度会接近O(N)
2.2🏀希尔排序
通过我们在直接插入排序最后的分析,可以看到接近有序的序列,再进行直接插入排序,时间复杂度降低,效率提升。希尔排序就是利用这点出现的排序方式。
2.1.1⚽原理
先选定一个整数,把待排序文件中所有数据以整数gap为间隔分为小组,然后对其进行插入排序。重复上述工作,gap逐渐缩小,直到gap为1结束,此时所有数据排序完毕。
我们需要先将序列变成接近有序的序列,再直接插入排序
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; ++i)
{
int end = i;
int tmp = a[end + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
这个地方的时间复杂度不好算,因为gap的不同导致最后进行直接插入时候的执行次数也会发生改变,难以进行估算,通过
查阅资料,因为咱们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:O(n^1.25) 到O (1.6n ^1.25)来算。
🏐3.选择排序
3.1🏀直接选择排序
原理:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。以此类推,直到全部待排序的数据元素的个数为零。
我理解的:在一组数据中,通过遍历整组数据,选出最小(最大)的数据,放到开头(结尾),做到有序。
void Swap(int* p1, int* p2)
{
int tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
void SelectSort(int* a, int n)
{
assert(a);
int begin = 0, end = n - 1;
while (begin < end)
{
int mini = begin, maxi = begin;
for (int i = begin + 1; i <= end; ++i)
{
if (a[i] < a[mini])
mini = i;
if (a[i] > a[maxi])
maxi = i;
}
Swap(&a[begin], &a[mini]);
// 如果begin和maxi重叠,那么交换后原begin位置变成了mini,begin不是原来的begin,要修正一下maxi的位置
if (begin == maxi)
{
maxi = mini;
}
Swap(&a[end], &a[maxi]);
++begin;
--end;
}
}
时间复杂度:O(N^2)
我们可以看到每一次都要进行遍历,第一次走N躺,逐步减少到一次,是个等差数列,得出时间复杂度为O(N^2)
3.2🏀堆排序
堆排序是指利用树型结构中堆的概念进行排序,实际也是选择排序的一种,只不过选择数据采用的是堆结构。关于堆排序的内容在之前的文章已经做过详细说明,详见堆排序
需要注意的是排升序要建大堆,排降序建小堆
时间复杂度:O(N*logN)
🏐4.交换排序
4.1🏀冒泡排序
冒泡排序比较浅显易懂,过程就像名字一样,将过程中的数据通过交换慢慢“浮”到数列的顶端
原理:
- 比较相邻的元素。如果第一个比第二个大,就交换他们两个。
- 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。在这一点,最后的元素应该会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
以升序为例,每一趟的冒泡排序都是把一个最大的数放到最后面,如果 a[i-1]>a[i],我们将i-1,i的值进行交换,依次循环反复。
void BubbleSort(int* a, int n)
{
int i = 0;
int j = 0;
int count = 1;
for (j=0;j<n-1 ;j++)
{
for (i = 0; i < n-1; i++)
{
if (a[i] > a[i + 1])
{
Swap(&a[i], &a[i + 1]);
}
}
count++;
}
}
这段代码有两个地方可以进行优化,一个是若在某处开始后面完全有序,则跳出循环,排序结束,另一个是进行i次排序,则从最后位置往前数i位置肯定是有序的,不用再往后遍历进行排序,优化后的代码如下
void BubbleSort(int* a, int n)
{
int i = 0;
int j = 0;
int count = 1;
for (j=0;j<n-1 ;j++)
{
int flag = -1;
for (i = 0; i < n-j; i++)//j的值就是进行了多少躺循环
{
if (a[i] > a[i + 1])
{
Swap(&a[i], &a[i + 1]);
flag = 1;
}
}
if (flag == -1)//若flag未发生改变,说明整组数据有序,直接结束
{
break;
}
}
}
时间复杂度:O(N^2)
4.2🏀快速排序
快速排序算法通过多次比较和交换来实现排序,其排序流程如下:
- 首先设定一个分界值,通过该分界值将数组分成左右两部分。
- 将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。此时,左边部分中各元素都小于分界值,而右边部分中各元素都大于或等于分界值。
- 然后,左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。
- 重复上述过程,可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。
将区间按照基准值划分为左右两半部分的常见方式有三种
⚽递归方式
我们一般取的基准值都是在最左端或最右端,我们这里先叫做key。
关键词取左,右边先找小再左边找大;关键词取右,左边先找大再右边找小。可以保证相遇位置比Key小
排升序为例
hoare法
int QuickSort1(int* a, int begin, int end)
{
// 区间不存在,或者只有一个值则不需要在处理
if (begin >= end)
{
return;
}
int left = begin, right = end;
int keyi = left;
while (left < right)
{
// 右边先走,找小
//(5,5,2,3,5)
//没有等于的问题是,如果left下标对应的值和right下标对应的值和keyi下标对应的值相等,则会死循环
//(1,2,3,4,5)
//如果没有判断left<right,数组是升序的话,right和left会一直访问直到越界
while (left < right && a[right] >= a[keyi])
{
--right;
}
// 左边再走,找大
while (left < right && a[left] <= a[keyi])
{
++left;
}
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
return keyi;
}
挖坑法
因此挖坑法与hoare法没有什么大区别,但是二者单趟排序后有可能数组中数据顺序不一样。
int QuickSort2(int* a, int begin, int end)
{
int key = a[begin];
int piti = begin;
while (begin < end)
{
// 右边找小,填到左边的坑里面去。这个位置形成新的坑
while (begin < end && a[end] >= key)
{
--end;
}
a[piti] = a[end];
piti = end;
// 左边找大,填到右边的坑里面去。这个位置形成新的坑
while (begin < end && a[begin] <= key)
{
++begin;
}
a[piti] = a[begin];
piti = begin;
}
a[piti] = key;
return piti;
}
前后指针法
int QuickSort3(int* a, int begin, int end)
{
int prev = begin;
int cur = begin + 1;
int keyi = begin;
while (cur <= end)
{
// cur一直往后走,不管指向的值是大于还是小于key,只是遇到比key小的就停下来处理一下
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[prev], &a[cur]);
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
return keyi;
}
快速排序我们还可以进行优化
我们发现处理接近有序数据时效率大大降低,甚至出现栈溢出的状况。
因为快排的效率取决于a[keyi]的选取是否得当,如果每次选取的a[keyi]均为中间位置的值,那么时间复杂度就是N*logN,如果有序或接近有序状态下,选取的为最小或最大就会造成N^2的时间复杂度。此时效率极大的降低,并且在数据多时会造成栈溢出的现象。
int GetMidIndex(int* a, int begin, int end)
{
int mid = (begin + end) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return end;
}
else
{
return begin;
}
}
else // (a[begin] >= a[mid])
{
if (a[mid] > a[end])
{
return mid;
}
else if (a[begin] < a[end])
{
return begin;
}
else
{
return end;
}
}
}
遇到小区间时
小区间优化是为了解决递归层数过多而提出的一种优化方式,如果一直使用递归来进行排序,当数据量很大时,快排具有明显优势,但是当数据量较少,例如只有10个或者20个数据的时候,使用递归来一层一层进行排序,将会被递归调用很多次,浪费空间时间。
那么我们就要考虑针对区间较小时候我们采用什么排序方式好呢?
希尔排序适应的是比较多的数据才有优势,堆排序需要建堆,其他三个直接插入排序、选择排序和冒泡排序相比,在处理数据量很小的排序时,直接插入排序是非常有效的一种排序,因此小区间优化就是在使用快排排序时,遇到小区间使用插入排序来代替递归,从而达到优化的目的。
单趟的代码写完后就要调用他们,把整个过程的写出来,在单趟内部运用了三数折中优化,在整体实现里运用了小区间优化
void QuickSort(int* a, int begin, int end)
{
//callCount++;
//printf("%p\n", &callCount);
// 区间不存在,或者只有一个值则不需要在处理
if (begin >= end)
{
return;
}
if (end - begin > 10)
{
int keyi = PartSort3(a, begin, end);
// [begin, keyi-1] keyi [keyi+1, end]
QuickSort(a, begin, keyi - 1);
QuickSort(a, keyi + 1, end);
}
else
{
InsertSort(a+begin, end - begin + 1);
}
}
⚽非递归方式
递归改非递归通常有两种办法,一种是用循环来更改,例如斐波那契数列,归并排序;一种是使用数据结构的栈或者队列来模拟。
void QuickSortNonR(int* a, int begin, int end)
{
ST st;
StackInit(&st);
StackPush(&st, end);
StackPush(&st, begin);
while (!StackEmpty(&st))
{
int left = StackTop(&st);
StackPop(&st);
int right = StackTop(&st);
StackPop(&st);
int keyi = PartSort3(a, left, right);
// [left, keyi-1] keyi[keyi+1, right]
//每个区间进行完单趟排序后都要再拿它的子区间继续入栈直到只剩一个数据,就是这两个if,顺序可颠倒
if (keyi + 1 < right)
{
StackPush(&st, right);
StackPush(&st,keyi + 1);
}
if (left < keyi - 1)
{
StackPush(&st, keyi - 1);
StackPush(&st, left);
}
}
StackDestroy(&st);
}
🏐5.归并排序
前面的排序都是内排序,数据在内存,访问速度快,但是访问量小,下标随机访问,归并排序是外排序,数据在磁盘,访问速度漫,但是访问量大,串行访问。
归并排序是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
整个过程理解为两个部分,先拆分再归并。因此我们进入函数后需要先计算出左右区间的分界线mid,然后继续调用函数进行递归拆分。当完全拆分后,需要对有序区间进行合并
void _MergeSort(int* a, int begin, int end, int* tmp)
{
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, mid] [mid+1, end]
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin1;//要拷贝回去,设置个i,注意这里i!=0;因为每组数据的开头不一定是0,可能在其他位置
while (begin1 <= end1 && begin2 <= end2)//这个循环仔细看就能看懂
{
if (a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
//下面要进行判断哪组数据还没有完全排进tmp数组中,我们也可以直接写两个循环,条件为假就进不去,这里肯定只走其中一个
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
// 把归并数据拷贝回原数组
memcpy(a + begin, tmp + begin, (end - begin + 1)*sizeof(int));
}
非递归版本
栈适合模拟前序,归并这里是后序,我们进行手动归并,队列这里也不合适,快排将左右区间排序就好,而归并还要回来不断归并
//栈适合模拟前序,归并这里是后序,我们进行手动归并,队列这里也不合适,快排将左右区间排序就好,而归并还要回来不断归并
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
printf("malloc fail\n");
exit(-1);
}
//这里不分了,跳过二分操作,我们直接一个一个归并,从最小往回走,这里有点像斐波那契数列递归该非递归
//我们用gap,1,2,4往下走
int gap = 1;
while (gap < n)
{
//printf("gap=%d->", gap);
//循环是这么来的,想着如何做到两两归并,那么我们肯定首先要去找区间,而且是两个区间,这两个区间归并后再往下找两个区间,循环往复
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;
// end1越界或者begin2越界,则可以不归并了
if (end1 >= n || begin2 >= n)
{
break;
}
else if (end2 >= n)
{
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);
}
gap *= 2;
}
free(tmp);
}
与归并的递归版本还是有很多类似的地方,两个对着来理解会更好
🏐6.一些小点
- 我们学习内容时,尤其是初学者,更多的时候是学习这里的思想,而不是为了达到同样的这个效果就硬杠,比如注意这里直接插入排序,其实有点像对于有序序列,起点在末尾然后往前比较的冒泡排序,代码看上去差不多,但有差别。
- 我们要多去尝试将递归的代码用非递归方法写出来,对于我们理解会有很大帮助,觉得困难可以先写递归版本,写递归又先把一般情况写一遍。
以上就是常见排序的相关内容了,觉得还不错的铁子点赞关注一下吧!😀