文章目录
- 前言
- 1.插入排序
- 2.希尔排序
- 3.选择排序
- 4.堆排序
- 5.冒泡排序
- 6.快速排序
- hoare方式版本快排实现
- 非递归方式实现快排
- 挖坑法实现快排
- 前后指针法(双指针法)
- 快排的各种优化
- 1.减少后几层的递归调用(小区间优化)
- 2.三数取中优化
- 3.三路划分(处理大量重复数据)
- 7.归并排序
- 1.递归实现归并排序
- 2.归并排序的非递归实现
- 8.计数排序
- 9.额外知识关于各类排序算法的稳定性
- 10.总结
前言
排序是对一种比较常见的对数据处理方式。排序算法在计算机领域中应用广泛且重要。本文主要对几种比较经典的排序算法进行讲解,之前对冒泡排序和堆排序有过讲解,本文也会回顾一下这两种排序算法。(本文动图均来自菜鸟教程,如有侵权联系可删)
1.插入排序
基本思想:直接插入排序是一种简单的插入排序法,把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
其实我们玩扑克牌的时候就用了插入排序的思想,在起牌时数值较大的排往后放,数值较小的牌往前放。起排结束后手里的牌就按一定的顺序排列了。
联想玩牌时插入,6暂时放在13的后面,从尾到头进行比较。6比13大,13到6的位置上,6往前走到13原来的位置接着比较;6比10大,6往前走,10往后走,接着比较;6比9小,6往前走,9往后走,接着比较。6比7小,7往后走,6往前走,接着比较;6比4大,6走到了合适的位置,插入过程结束。
这是模拟一次插入过程,当然我知道数组中数据不一定是有序的,所以我们假设第一个元素就是有序的,依次对往后的数据进行插入排序,整个插入插入过程就是有序的了。这就相当于假设起牌时第一张就是有序的,后序起到的牌依次进行调整。
我们先将的一趟的插入写出来
对比着上面图中的插入流程,一躺插入就可以确定了。完整的插入流程是从第一个元素开始重复上述插入比较流程,所以外层套个循环整个插入排序就写出来了。
完整代码示例
void InsertSort(int* a, int n)
{
for (int i = 0;i<n-1;i++)
{
int end = i;
//防止越界i<n-1
int tem = a[end + 1];
while (end >= 0)
{
if (a[end] > tem)
{
a[end + 1] = a[end];
end--;
}
else
{
break;
}
}
a[end+1] = tem;
}
}
插入排序的时间复杂度我们分析一下
总的来说插入排序的时间复杂度是O(n^2),空间复杂度是O(1)
2.希尔排序
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数作为间距,把待排序数据按间距分成个组,并对每一组内的记录进行插入排序。然后不断缩小间距重复上述分组和排序的工作。当到间距==1时,进行最后一次排序。
上述的操作基本可以分为两步:1.预排序(不断缩小间距分组进行插入排序);2.间距为1时最后一次排序(也就是直接进行插入排序)
我们画图分析一下:
这个间距划分只要数组中元素位置满足条件就可以归为同一组。图中第一组从2位置划到3位置时,3往后的间距不够5了所以就没有接着划分了。第二组划分从2开始接着是7,最后到10,10后面只有一个位置,不满足间距划分条件。
那么代码怎么写呢?希尔排序本质还是采用插入排序,唯一的区别就是插入排序的区间被划分了出来。确定了排序区间希尔排序就写出来了。我们从图可以看出其实起始还是从第一个元素开始,分组时第一个元素后面的第二个元素就是要插入的元素,第一次区间划分时,2后面要插入的是3;第二次区间划分时,分组中2后面是7。通过观察发现插入的元素和第一个元素相差了一个间距的距离。这就确定了end后面不再是加1,而是在end基础上加上间距。
代码示例
void ShellSort(int* arr, int n)
{
int gap = n;
while (gap > 1)
{ //这个间距+1确保最后一次gap为1
gap = gap / 3 + 1;
for (int i = 0; i < n - gap; i++)
{
int end = i;
int tem = arr[end + gap];
while (end >= 0)
{
if (tem < arr[end])
{
arr[end + gap] = arr[end];
end-=gap;
}
else
{
break;
}
}
arr[end + gap] = tem;
}
}
}
我们发现如果gap==1,上述代码就是插入排序。通过gap分组预排序会使数据越来越趋近于有序。当间距gap越来越小时,数据也会越来越有序。数据越有序,插入排序的性能越好。希尔排序就是根据这一特点在插入排序的基础上延申出了预排序。
因为每次gap先进行更新再执行排序逻辑,循环条件也应该是gap>1,不用取等。当然gap每次缩小3倍所以为了保证最后的结果为1,需要加1。如果gap=gap/2就不用加1,这样除2,无论gap为何值最后的结果一定是1.
希尔排序的时间复杂度很不算,需要用到高等数学进行推导。所以只用记住希尔排序的时间复杂度为O(n^1.3),空间复杂度为O(1)
3.选择排序
基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
选择排序就是直接选出最大或最小的元素放在合适的位置
不过选择排序选最大元素和最小元素可以同时进行,画图详解一下。
从图中可以看出选择排序就是每次选再一个区间范围中选出最大或者最小的元素移动到区间边界的位置上去。每次处理一趟过后,区间就会相应的缩小。直到区间范围重合时,这个排序过程就结束了。
代码示例
//交换函数
void swap(int* a, int* b)
{
int tem = *a;
*a = *b;
*b = tem;
}
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int max = begin, min = begin;
//找最小元素和最大元素的位置
for (int i = begin + 1; i <= end; i++)
{
if (a[max] < a[i])
{
max = i;
}
if (a[min] > a[i])
{
min = i;
}
}
swap(&a[begin], &a[min]);
//防止begin和max重合
if (begin == max)
{
max = min;
}
swap(&a[end], &a[max]);
end--;
begin++;
}
}
这个交换函数需要自己写。同时如果begin刚好和max重合或者end和min重合,因为交换顺序的问题可能会造成bug,所以重要单独处理
,只针对一种情况判断处理即可。根据代码中的交换顺序设置不同的判断,我这里是先交换的begin和min位置上的元素,所以判断的是begin和max.
选择排序的时间复杂度是O(n^2),其实结合选择排序思想来看,它的时间复杂度也就是等差数列求和,空间复杂度O(1).
4.堆排序
堆排序是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
堆排序之前的博客有过介绍,这里就不画图分析了。它实现就是两步,第一步对对数据建堆,第二步每次将堆顶数据换到末尾位置进行堆调整。
代码示例
//向下调整
void AdjustDown(int* arr, int n,int parents)//向下调整
{
//n的作用只是用来约束孩子和双亲防止越界
int child = parents * 2 + 1;
while (child < n)
{ //保证child是指向大孩子的
if (child + 1 < n && arr[child + 1] > arr[child])
{
child++;
}
if (arr[parents] < arr[child])
{
Swap(&arr[parents], &arr[child]);
parents = child;
child = parents * 2 + 1;
}
else
{
break;
}
}
return;
}
//交换函数
void Swap(int* a, int* b)
{
int tem = *a;
*a = *b;
*b = tem;
}
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--;
}
return;
}
堆排序时间复杂度是O(N*logN),空间复杂度O(1).
堆排序具体介绍可以看之前写的关于实现堆的博客
5.冒泡排序
基本思想:重复地走访过要排序的数列,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
每趟冒泡排序可以将搞定一个数,也就是让较大的数依次到右端。这个冒泡排序排序我之前的博客也有介绍,这里就不画图分析了。
代码示例
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])
{
int tem = a[j - 1];
a[j - 1] = a[j];
a[j] = tem;
}
}
}
}
冒泡排序算是一种比较简单的排序算法,时间复杂度O(n^2),空间复杂度为O(1)。关于冒泡排序的具体介绍可以翻我之前的博客。
6.快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
hoare方式版本快排实现
快速排序是排序中的重点之一,同时排序有多个版本我们依次分析,首先来画图分析一下hoare版本的排序。
hoare版本的快排就是先选一个元素作为key值,然后用两个变量left right分别指向数据的起始位置和末尾位置。该位置的数据元素分别和key值进行比较。left指向的值如果大于key就停下来,等right停下来的时候和right位置处的元素交换,否则就往下走。right指向的值如果小于key值就停下来,等left停下来来的时候和left位置处的元素交换,否则right就往下走。直到right和left相遇,相遇之后,如果最开始选的key是最左边的元素,就把key位置和left位置上的元素进行交换,否则就是right位置。同时,注意如果选的是left做key,那么right先走比较,否则就是right后走。然后就是递归处理了。
但是要注意选谁做key,如果选最左边的位置的元素做key,右边一定先走。反之亦然。left和right并不是同时走的。如果左边做key,right先走。遇到小于key的,right停住,left开始走,遇到大于key的值也停住,然后left和right位置上的元素进行交换。然后接着right先走,重复上述的过程。直到left和right相遇,key和left位置上的元素交换。
代码示例
void swap(int* a, int* b)
{
int tem = *a;
*a = *b;
*b = tem;
}
void QuickSort_1(int* a, int begin, int end)
{
if (begin > end)
{
return;
}
int left = begin;
int right = end;
int key = left;
while (left < right)
{
//左边作为key,右边先走,反之亦然
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[left], &a[key]);
key = left;
QuickSort_1(a, begin, key - 1);
QuickSort_1(a, key + 1, end);
}
这个递归条件就是begin>end,随着区间不断分隔,区间会范围会越缩越小。当区间范围不存在时就停止递归。谁做key,最后key就和谁交换。key就更新成谁,谁就后走。
left<right不能省,而且left<righ的条件一定要放在前面,先进行判断。还有比较的等号不能省略,不然如果有重复的元素就可能陷入死循环了。其实一处加了等号另一处可以不用加。但是为了使得代码更加规范可读还是都加上为好。
快排的实现很像二叉树的前序遍历,先将要一趟排序处理的情况情况分析确定好,同时,确定递归边界条件。剩下的就是对左半区间和对右半区间的递归处理。
分析一下这个代码时间复杂度。
快排很有优化方式,在介绍优化方式之前我们先介绍其他几种快排的实现。
非递归方式实现快排
递归就是程序的重复调用,也就是说对每个问题的处理方式都是一样的。对于快排的递归来说,每次排序的流程都是确定好的,递归主要是为了对分隔出的区间进行处理。我们只用借助别的数据结构将每次划分的区间先存储起来,在对每个区间进行同样的排序处理即可。递归我们用的是函数栈帧,非递归实现我们就借助栈这种数据结构来实现。
为了使代码逻辑更加清晰,我们将排序处理逻辑封装成一个函数。在快排内调用这个函数对区间进行处理即可。
代码示例
int QuickSort_2(int* a, int begin, int end)
{
int left = begin;
int right = end;
int key = left;
while (left < right)
{
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[left], &a[key]);
return left;
}
void QuickSortNonR(int* a, int begin, int end)
{
//先建立栈
Stack st;
StackInit(&st);
StackPush(&st, begin);
StackPush(&st, end);
while (!StackEmpty(&st))
{ //先进去的是左区间后进的右区间,出来是先出右边区间后出左区间
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
int key = QuickSort_2(a, left, right);
//if判断相当于之前的递归中止条件
//分割成只有一个数的区间事就不用继续分隔割
if (left < key - 1)
{ //前面已经确定先左后右,接着按这个顺序
StackPush(&st, left);
StackPush(&st, key - 1);
}
if (key + 1 < right)
{
StackPush(&st, key + 1);
StackPush(&st, right);
}
}
StackDestroy(&st);
}
我们先将数组的整个的区间范围入栈,在出栈区间调用函数排序处理。处理完了之后再进左右区间,直到栈中数据为空为止。注意入栈的顺序。先入栈的后出栈。代码中先入栈的是左边界,后入栈的右边界。所以先出栈的是右边界,后出的左边界。同时注意,每次数据出栈后都要Pop掉。之前递归的时候有递归边界条件,现在是非递归所以要保证区间范围的合法性,需要添加if判断,合法的区间边界才能入栈。
c语言中没有不提供数据结构的接口,所以需要直接实现栈的相关接口。我之前的博客有讲栈的相关实现。
挖坑法实现快排
挖坑法是在hoare版本的基础进行的变形。其中元素比较的过程和left right移动过程差不多类似,但是它不涉及交换。它是用元素覆盖也就是赋值的形式更新数据。挖洞法会先找一个位置为坑位,如果left和rigt指向的元素不满足条件了就会将这个元素放入坑位。同时更新坑位,最后left和right相遇时,将key放入坑位。一般最开始选择最左边的位置作为坑位。
/挖坑法写快排 元素覆盖挪动,不涉及位置上的元素交换
void QuickSort_2(int* a, int begin, int end)
{
if (begin > end)
{
return;
}
int left = begin;
int right = end;
int key = a[begin];
int hole = begin;
while (left < right)
{
//左边做key,右边先走
while (left < right && a[right] >= key)
{
right--;
}
a[hole] = a[right];
hole = right;
while (left < right && a[left] <= key)
{
left++;
}
a[hole] = a[left];
hole = left;
}
a[hole] = key;
int index = hole;
QuickSort_2(a, begin, index - 1);
QuickSort_2(a, index + 1, end);
}
挖坑法比较容易理解,不满足条件的就放入坑位,之后更新坑位。left和right相遇后,只用将key填入坑位,坑位是最后为key准备的。
前后指针法(双指针法)
双指针实现快排就是用两个变量,一前一后。如果走在前面的变量指向的元素小于key,后面的变量就向前一步走,再将这两个变量指向的元素进行交换。之后前面的指针接着走,就是说无论何种情况前面的变量肯定会往前走。后面的变量只有在特定情况下才能走一步。前面的变量走完所有的位置后,key和后面变量指向的元素进行交换。
画图分析
可以看出这样的实现方法每次排序也可以分割区间和排定一个key。
代码示例
void swap(int* a, int* b)
{
int tem = *a;
*a = *b;
*b = tem;
}
void QuickSort_3(int* a, int begin, int end)
{
if (begin > end)
{
return;
}
int cur = begin + 1;
int prev = begin;
int key = begin;
while (cur <= end)
{
if (a[cur] < a[key] && ++prev != cur)
{
swap(&a[cur], &a[prev]);
}
cur++;
}
swap(&a[prev], &a[key]);
QuickSort_3(a, begin, prev - 1);
QuickSort_3(a, prev + 1, end);
}
现在区间相当于分割成了[begin,prev-1] prev [prev+1,end]。代码中可以看到加了++prev与cur判断,这样可以过滤无用的交换。
快排的各种优化
1.减少后几层的递归调用(小区间优化)
递归调用可能有栈溢出的风险。我们可以在递归的最后几层进行插入排序的处理。对分割出来的小区间进行优化。之前提到快排的递归调用类似于二叉树的前序遍历,每次递归调用都是以2的指数倍增长,所以最后几层的调用占总调用的次数比例是很大的,最后3到5层的调用大概占总次数的80%,因此在最后几层不用递归采用插入排序可以大大较少递归次数。
代码示例
void swap(int* a, int* b)
{
int tem = *a;
*a = *b;
*b = tem;
}
void QuickSort_1_2(int* a, int begin, int end)
{
if (begin > end)
{
return;
}
// 小区间用直接插入替代,减少递归调用次数
else if ((end - begin + 1) < 15)
{
//插入排序的第二个参数是元素个数需要加1
//第二个参数的数组的起始空间地址,随着区间的分割起始空间就是a+begin
InsertSort(a + begin, end - begin + 1);
}
int mid = GetMidIndex(a, begin, end);
swap(&a[begin], &a[mid]);
int left = begin;
int right = end;
int key = left;
while (left < right)
{
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <= a[key])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[left], &a[key]);
key = left;
QuickSort_1_2(a, begin, key - 1);
QuickSort_1_2(a, key + 1, end);
}
插入排序是需要自己去实现的,代码中间区间范围控制在15,也就是如果划分的区间中有15个以内个数的数据就进行插入排序。
2.三数取中优化
之前分析快排时间复杂度提到了如果每次选取的key都是较为中间的值,快排的效果非常好。所以我们将begin end和这个区间的中间位置的元素进行比较,选取中间的元素作为key。
代码示例
//得到较为中间的数
int GetMidIndex(int* a, int begin, int end)
{
int mid = begin + (end - begin) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
{
return mid;
}
else if (a[begin] > a[end])
{
return begin;
}
else
{
return end;
}
}
else
{
if (a[begin] < a[end])
{
return begin;
}
else if (a[mid] > a[end])
{
return mid;
}
else
{
return end;
}
}
}
//三路取中优化快排
void QuickSort_1_1(int* a, int begin, int end)
{
if (begin>end)
{
return;
}
int mid = GetMidIndex(a, begin, end);
swap(&a[begin], &a[mid]);
int left = begin;
int right = end;
int key = left;
while (left < right)
{
while (left < right && a[right] >= a[key])
{
right--;
}
while (left < right && a[left] <=a[key])
{
left++;
}
swap(&a[left], &a[right]);
}
swap(&a[left], &a[key]);
key = left;
QuickSort_1_1(a, begin, key - 1);
QuickSort_1_1(a, key + 1, end);
}
这个3数取中就是在begin和end以及mid这3个位置选取中间大的元素作为key。但是实际上还是可以进行优化,这个mid是固定位置,mid位置应该更随机一定比较好,这样选到中间数作为key的概率就会大大增加。
可以做出如下改动:
int mid = begin + rand()%(end-begin+1);
rand() % (b-a+1)+ a ,表示 a~b 之间的一个随机整数。这样产生的key就会较为随机了。关于rand函数可以在官网文档中进行查阅。
3.三路划分(处理大量重复数据)
三路划分大概的思想就是将划分3个区间 [begin,key-1] [key] [key+1,end]。看似三个区间的划分和之前没区别,但是key这个区间中放入的都是和key相同的元素。这样能够处理有大量相同数据的情况,如果不将和key相同的元素划分到同一区间中,一旦出现大量重复的元素,快排的效率其实是大幅度降低的。
大致思路就是最开始cur指向left前一个位置,cur指向的元素大于key就和right交换,right退一步,cur不动。如果cur指向的元素小于key,cur和left交换,left和cur都向前一步。如果cur指向元素等于key,cur往前走就可以了。
代码示例
void QuickSort(int* a, int begin, int end)
{
if (begin > end)
{
return;
}
int left = begin;
int right = end;
int key = a[begin];
int cur = begin + 1;
while (cur <= right)
{
if (a[cur] < key)
{
swap(&a[cur], &a[left]);
left++;
cur++;
}
else if (a[cur] > key)
{
swap(&a[cur], &a[right]);
right--;
}
else
{
cur++;
}
}
QuickSort(a, begin, left- 1);
QuickSort(a, right + 1, end);
}
代码中通过引入一个变量cur实现区间的划分,cur会将小于key的元素集中到左边,大于key的元素集中到右边,中间的位置留给等于key的元素。代码中只是涉及left和right位置的元素移动,最后循环结束时并没有将key交换移动。这里的循环条件是left<=right取等了,也就是说在循环中就已经处理了和key的交换。和cur交换时lcur要移动,开始时cur和left只差一个位置,cur不移动就可能在交换时造成元素覆盖,影响结果。
综上所述,快排的时间复杂度是N*logN到N^2之间,辅助空间是logN和N之间。这都取决于数据元素好坏的情况。
对了,之前的几种优化方法都可以写在都一份快排代码中,使得快排优化到最好成为名副其实的快排。
7.归并排序
1.递归实现归并排序
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并.
归并排序就是不断拆分区间,当区间只有一个元素时就暂时认为它是有序的,再回头对拆分出的区间排序合并。但是拆分出的区间数据需要用另一个临时空间保存,这个临时空间保存排序好了的元素数据。最后将临时空间的数据在拷贝回原数组空间中即可。
这个不断拆分区间的过程就需要用到递归。但是临时空间的开辟只需要开一次即可,所以我们将排序逻辑拆开单独写成一个函数。在归并排序的函数中调用这个排序逻辑即可。
接着我们就思考排序逻辑怎么确定。区间每次都是在上一个区间基础上二分出来的。假定有这么有个区间【begin,end】,二分之后就是【begin,mid】【mid+1,end】。这两个区间就当于两个数组,将两个数组的元素进行排序合并到另一个数组中。之前的力扣习题有讲解过类似的合并处理,我们用bgein1和begin2两个变量开始指向区间元素的首部,然后依次比较。最后如果有一个区间的没有比较完,直接将该区间的剩余元素接上直接放入临时数组即可。
图中的代码逻辑就是排序的主要逻辑。因为归并排序是先将数据分割成只有一个元素的区间,再排序合并。对于只有一个元素的区间来说,这个区间就是有序的。随着不断排序合并,临时数组中的元素也会越来越有序。同时既然是递归那么递归边界条件就是区间分隔成只有一个元素时停止,也就是begin==end.
那么代码就很好写了,代码示例如下:
void merge_sort_1(int* a, int* tem, int begin, int end)
{
if (begin == end)
{
return;
}
int mid = (begin + end) / 2;
merge_sort_1(a, tem, begin, mid);
merge_sort_1(a, tem, mid + 1, end);
int begin1 = begin, end1 = mid;
int begin2 = mid + 1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tem[i++] = a[begin1++];
}
else
{
tem[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tem[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tem[i++] = a[begin2++];
}
memcpy(a + begin, tem + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort_1(int* a, int n)
{
int* tem = (int*)malloc(sizeof(int) * n);
if (tem == NULL)
{
perror("malloc fail !");
return;
}
merge_sort_1(a, tem, 0, n - 1);
free(tem);
tem = NULL;
}
虽然快排和归并排序都是分割区间递归。但是两者还是有区别的,当数据是最开始的完整区间时,就可以进行排序处理。然后接着分割排序。但是归并排序是将数据分隔成有一个元素的区间时,再回过头来进行排序,也就是先分区间,再排序。快排是类似于先序遍历,归并相当于后序遍历。
2.归并排序的非递归实现
归并排序的递归是后序遍历,所以不能像快排那样很轻松借助额外的数据结构就能解决。这个时候我们直接用迭代的方式来解决。斐波那契数列相信很多人都写过,斐波那契数列的非递归就是从头开始算往前迭代。那我们也可以先从只有一个元素的的时候开始算,从也就当于区间分割到底了。定义一个变量用来控制区间范围,从只有一个元素开始排序,区间范围逐渐增大。这样迭代着往前走。
代码示例
void MergeSort_2(int* a, int n)
{
int* tem = (int*)malloc(sizeof(int) * n);
if (tem == NULL)
{
perror("malloc fail!");
return;
}
//归并每组数据个数,从1开始,因为1个认为是有序的,可以直接归并
int range = 1;
while (range < n)
{
for (int i = 0; i < n; i += range * 2)
{
int begin1 = i, end1 = i + range - 1;
int begin2 = i + range, end2 = i + 2 * range - 1;
int j = i;
//对边界条件处理防止越界
if (end1 >= n)
{
break;
}
else if (begin2 >= n)
{
break;
}
else if (end2 >= n)
{
end2 = n - 1;
}
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tem[j++] = a[begin1++];
}
else
{
tem[j++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tem[j++] = a[begin1++];
}
while (begin2 <= end2)
{
tem[j++] = a[begin2++];
}
memcpy(a + i, tem + i, sizeof(int) * (end2 - i + 1));
}
range *= 2;
}
free(tem);
tem = NULL;
}
上面的代码有很多要注意的细节,我们来好好分析一下。
代码大致思路:
range控制区间范围,之前是通过递归的方式来达到将区间分隔的目的。从区间只有一个元素开始逐渐扩大区间的范围。排序逻辑和递归版的都是类似的,然后用拷贝函数将临时数组拷贝到原数组中。拷贝可以放到除了放到循环里面每次拷贝外,还可以放到for循环外面整体拷贝,也就是将数据排序好了放入临时数组中,再将临时数组的拷贝到原数组。
细节注意(区间可能越界的处理)
这里看到begin1=i,end1=i+range-1;begin2=i+range.end2=i+range*2-1;因为i始终是小于n的所以begin1没有越界的风险。当数组的元素个数不是2的指数倍,就可能越界了。
我们将每次划分的区间打印出来,同时屏蔽掉防止区间越界的处理代码
打印结果如下
我们看到打印如图所示,没啥问题。此时n的大小是8,如果我们将它换成10呢?
可以看到这里面有些区间已经越界了。对此,我们必须做出区间越界的处理。
分析区间越界,无非就是以下几种情况:首先bgein1不会越界,那么就是end1越界,如果end1越界后面的begin2和end2肯定必会越界。接着就是begin2越界,begin2越界后面的end2也肯定越界,最后一种情况就是只有end2越界了。所以分析下来只要处理3种情况即可,end1和begin2以及end2越界的处理。
这里处理防止区间越界有两种方式,一种是修正区间,还有一种是跳出循环。对于在循环里的每次拷贝来说,这两种方法都可以,但是对于循环外的整体拷贝来说只能修正区间。
像这种循环外的单次拷贝如果跳出循环可能会将一些随机值拷贝到数组中或者造成将一些元素给覆盖掉。这个时候只能修正区间了,需要后面的while(begin1<end1)将数据排到临时空间中。
这个时候需要修正区间,将begin2和end2修正成一个不存在的区间。但是如果拷写在for循环中就不用修正,直接跳出循环就可以。因为后续的拷贝还是这些元素,不影响后续的排序拷贝。
接着处理begin2越界的情况,begin2的越界的话,begin2和end2都是错误区间,对于在for循环中的分组多次拷贝来说直接break即可。如果对于在循环外整体拷贝来说就需要修正区间将begin2和end2改成不存在的区间即可。因为后续的begin1和end1区间内的元素是需要放进临时空间的。
最后就是end2越界了,end2越界,划分的区间中肯定有一部分是属于正常的范围内,对于end2来说只能采用修正区间的方法,不能跳过,否则最后一段区间就不会排序进入临时空间,造成排序的不完整。不管memcpy拷贝写在何处,end需要修正成n-1。这样才能正确排好序。
归并排序的时间复杂度是N*logN,每次对区间进行二分可以划分成logN次,同时每次对划分出的小区间都会遍历,相当于遍历完了整个元素。空间复杂度需要临时空间就是O(N).
8.计数排序
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。计算排序是将待排的数据映射到数组下标中,利用数组下标的顺序性来达到排序的目的。之前讲解力扣题时就用到了类似的做法,将数组元素转成另一个数组的下标。计数排序也是类似的方法。
计数排序通过将数组元素转成另一个数组的下标来排序,这里就需要临时空间了,为了节省空间。我们直接将空间范围确定为0到待排数组元素中最大值max与最小值min差值加1,也就是max-min+1.
代码示例
void CountSort(int* a, int n)
{
int max = a[0];
int min = a[0];
int i = 0;
for (i=1; i < n; i++)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
int* count=(int*)calloc(max-min+1,sizeof(int));
if (count == NULL)
{
perror(" calloc fail!");
return;
}
for (i=0; i < n; i++)
{
count[a[i] - min]++;
}
int k = 0;
for (i = 0; i < max-min+1; i++)
{
while (count[i]--)
{
a[k++] = min+i;
}
}
free(count);
count = NULL;
}
当然也可以不用calloc,直接写成int count[max-min+1]={0};在赋值回去的时候需要将min加上。从代码中可以看出复杂度:O(MAX(N,范围)),空间复杂度:O(范围),这个范围就是max-min+1
计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。只是适用于对整型(负整型也可以)排序。
9.额外知识关于各类排序算法的稳定性
所谓稳定性就是,如果数组中有重复的元素,排序之后不影响相对位置就是稳定的排序。比如1 5 4 3
4
6排序之后应该是1 3 44
5 6,红色的4在后面要保持相对位置不变。接着我们来讨论讨论上述8种算法的稳定性。
插入排序的稳定性
插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。刚开始这个有序的小序列只有1个元素,就是第一个元素,插入的元素从end后开始比较往前移动。如果end位置的元素和要插入的元素相等,可以将相等或者大于a[end]放入end的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定排序算法。
冒泡排序的稳定性
冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,没有动作;两个相同元素就算有间隔也不影响,随着不断挪动相相同的元素会相邻,又回到了相邻元素相等没有交换。冒泡也是稳定的。
归并排序
归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素或者2个序列,然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。
选择排序的稳定性
选择排序是一种不稳定的排序方式,当数组中出现重复元素了,在找max和min的位置后会和begin end位置元素进行交换,当交换的位置是某个重复元素的为止时,原有顺序就会被破坏,从而影响相对顺序.
希尔排序的稳定性
希尔排序也是不稳定的,希尔排序的先进行预分组的方式排序,在进行预分组的时候随着区间的跳动是没法控制相同元素的相对位置的。相同的元素可能会因为预处理的时候被从前面移动到后面将原有的顺序打乱
快排的稳定性
快排也是不稳定的,我们知道快排每次都选key,如果有相同的元素且它们大于key话,它们会集中到key的右侧,中间会涉及到交换,先出现的元素会被放到后面,后出现的元素会被放到前面,这样相对位置就会被影响。同时,如果相同的元素做key,因为最后要交换key,前面做key的元素也会被移动到后面。
堆排序的稳定性
堆排序也是不稳定的,以建大堆为例,假如建堆的时候就保证了顺序的稳定性,但是堆顶的数据会被移动到数组后面的位置,等到另一个相同的元素作为堆顶的时候就会被放入靠前面的位置,这样稳定性就被破坏了。
计数排序的稳定性
计数排序毫无疑问是没有稳定性的,计数排序只关心元素出现的次数,无法控制相对顺序。这点通过代码或者它的排序思想就可以感受到。
10.总结
- 以上便是对经典的八大排序讲解,以上内容如有问题,欢迎指正!