文章目录
一、归并排序
归并排序(MERGE-SORT)是建⽴在归并操作上的⼀种有效的排序算法,该算法是采⽤分治法(Divide andConquer)的⼀个⾮常典型的应⽤
大致就是将已有序的⼦序列合并,得到完全有序的序列,即先使每个⼦序列有序,再使⼦序列段间有序,若将两个有序表合并成⼀个有序表,称为⼆路归并
通俗一点说,就是将一个数组无脑递归二分,一直二分到只剩下一个元素,然后开始回归,在回归的时候,将元素进行排序,类似于将两个有序数组合并为一个数组的过程,我们画个图演示一下:
整个归并排序就是如图所示的分解以及合并的过程,先将数组不断二分,直到只剩一个元素,然后开始合并,每一次合并都是将两个有序序列进行合并,所以合并不会很难
那么接下来我们就先来学习如何分解,其实就是利用递归,进行划分,和我们二叉树的后序遍历有点相似,将整个过程看作二叉树的后序遍历,先将给出的数,组划分成左右两部分,然后再将左右继续划分成两部分,这就相当于递归左右子树,这就是分解的过程
然后就是对分解好的序列进行合并,我们可以发现,每一次合并时都是对有序序列进行合并,如果只有单个元素的话,也可以看作有序,两个单独的元素合并后,又变成了有序的序列,如上面演示的图,所以我们的合并其实就是对两组有序序列进行合并
但是我们考虑到,在合并时不方便直接对原数组进行调整,所以我们可以重新开一个和原数组大小相同的数组tmp,用来暂时存放我们合并后的数据,当每一次子合并完成后就将tmp中的数据拷贝回原数组
于是我们又发现,如果直接对当前的函数进行递归,那么递归多少次,就要开辟多少个tmp数组,非常不值得,所以我们可以在函数中创建一个tmp数组,然后创建一个子函数来递归解决问题,如下:
c
//归并排序
void MergeSort(int* arr, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc");
return;
}
//创建子函数用于递归调用(分解、合并)
_MergeSort(arr, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
上面就是我们归并排序的大致框架,其中子函数需要区间范围的下标方便我们进行分解和合并,接下来我们就按照之前的思路来设计子函数,其中合并的过程就是将两个有序序列合并成一个有序序列,之前在顺序表刷题那里讲过这道题,这里就不再多说,直接写代码,如下:
c
void _MergeSort(int* arr, int left, int right, int* tmp)
{
//left = right说明这个区间只有一个元素,直接返回,无需处理
if (left >= right)
{
return;
}
//算出中间值方便将当前序列二分
int mid = left + (right - left) / 2;
//二分后的左序列范围[left, mid],右序列范围:[mid + 1, right]
//接着根据两个序列的范围继续二分
_MergeSort(arr, left, mid, tmp);
_MergeSort(arr, mid + 1, right, tmp);
//上面就是分解的步骤,分解到最后我们进行合并操作
//其实就是将两个有序数组合并到另一个数组,并且保持有序的那个题的做法
//记录第一个有序序列的开始和结束下标
int begin1 = left, end1 = mid;
//记录第二个有序序列的开始和结束下标
int begin2 = mid + 1, end2 = right;
//由于不方便对arr数组直接进行原地操作
//所以接下来将它们合并到tmp数组的对应位置上
int index = begin1;
while (begin1 <= end1 && begin2 <= end2)
{
if (arr[begin1] < arr[begin2])
{
tmp[index++] = arr[begin1++];
}
else
{
tmp[index++] = arr[begin2++];
}
}
//走到这里说明有一个序列已经放完了,接着将另一个序列按顺序放入tmp数组中
while (begin1 <= end1)
{
tmp[index++] = arr[begin1++];
}
while (begin2 <= end2)
{
tmp[index++] = arr[begin2++];
}
//现在将两个有序序列合并为一个有序序列存放在tmp后
//将tmp中的数据重新拷贝回arr
for (int i = left; i <= right; i++)
{
arr[i] = tmp[i];
}
}
这就是完整的归并排序了,接着我们来测试一下,如图:
可以看到归并排序成功帮组我们完成了排序任务,接着我们来分析一下它的时间复杂度,首先递归需要log N次,每一次递归按最坏情况计算就是O(N),所以它的时间复杂度大致就是O(N * log N)
接着由于它在排序时需要申请和原数组一样大小的空间来辅助排序,所以它的空间复杂度为O(N)
二、非比较排序之计数排序
在我们之前学习的排序算法中,无论怎样都需要比较两个数的大小,随后进行排序,而计数排序则非常巧妙,无需任何两个数之间的比较就可以对数组排序,接着我们来认识一下它
计数排序是对哈希直接定址法的变形应⽤,如果了解过哈希表那么这个排序就是小菜一碟,但是为了能给大家讲清楚,我们先画画图,来看看计数排序的运作过程,不求看懂,只求留点印象,然后我们再来讲思路,否则直接讲思路肯定会绕进去,如图:
我们可以看到,如果我们能够实现上述操作,将所有元素映射到一个数组的下标上,然后从这个数组中重新取出这些元素,就能将原数组中的元素排成升序,这个过程中没有对任何元素进行比较,依赖的就是数组的下标有序,是不是非常巧妙呢?
但是其实上面画的图的思路还可以优化一下,避免一些特殊情况,接下来我们就来讲讲真正的计数排序的大概思路,随后我们按照这个思路一点一点来实现出这个计数排序,大致思路就是:
- 预处理一个count数组,默认count数组里面全部存放0,将待排序的数组的元素映射到count数组的下标元素上,比如一个元素3就放入count数组下标为3的位置,放入的方法就是让count[3]++,这就代表了3元素出现了一次
- 当我们按照第1步将所有元素放入count数组后,我们再遍历count数组,将里面的元素重新放入原数组中,即可排成有序,利用的就是数组下标有序
- 如果最小的元素都特别大,可能浪费空间,比如只有两个数1001和1000,count数组就要开辟1002个空间,这样才能创造出0到1001的下标,将1001和1000放进去,所以可以进行一下优化,开原数组最大值 - 最小值 + 1个空间大小的数组,然后放的时候就让每个元素都减去最小值即可
以上是计数排序的大致思路,接着我们来按照上面的思路进行具体实现的分析,剖析它是怎么做到的,首先是第一步,我们要对count数组的空间进行优化,所以要先找出最大值和最小值,如下:
c
int min = arr[0], max = arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] < min)
min = arr[i];
if (arr[i] > max)
max = arr[i];
}
这个很简单就不再多说,接下来我们来开辟count数组,首先我们要根据最大值和最小值来算出count数组的具体大小,方法就是max - min + 1,可能会对这个+1比较迷惑,我们举一个例子就知道了,比如以下数组:
如果我们直接用max - min,得到的结果就是17,开完count数组后,它的下标范围为0 - 16,我们要将每个数减去最小值992然后映射到count数组,其它的元素都没有问题,当我们放最大元素1009时,减去最小值992等于17,说明最大值要映射到下标17的位置
但是我们刚刚开的数组的大小是17,最大下标也才16,也就导致了越界访问,所以我们在开空间的时候要多开一个空间,防止存放最大值时出现越界行为
接着开空间的时候我们也要注意,需要将数组的每一个元素都初始化成0,因为我们将元素映射到count数组时,要让对应元素位置的值++,如图:
所以根据上图的原因,如果我们不将数组初始化为0,里面是随机值就会导致后续操作出错,其中有两种方法,一种方法就是使用calloc开辟count数组,这样count数组一开辟出来里面的值就被初始化成了0
第二种方法就是使用malloc开辟count数组,然后使用memset函数将所有元素初始化为0,这里为了方便,我们可以直接使用calloc函数开辟数组,如下:
c
int range = max - min + 1;
int* count = (int*)calloc(sizeof(int), range);
if (count == NULL)
{
perror("calloc");
return;
}
现成我们已经将count数组开好了,接下来就是遍历待排序数组arr,将其中的元素映射到count数组中,如下:
c
for (int i = 0; i < n; i++)
{
//将arr[i] - min映射到count数组中
//在恢复时加上最小值min即可
count[arr[i] - min]++;
}
我们将所有元素映射到count数组后,这些元素已经按照下标排好序了,不需要我们去比较,这就是非比较排序,那么接下来我们就遍历整个count数组,将它按顺序把数据还原到原数组,如下:
c
int j = 0;
for (int i = 0; i < range; i++)
{
//这样写的原因就是如果一个元素出现多次,那么count[i]就大于1
//我们要把所有元素取出,所以这样写
while (count[i]--)
{
//下标+min就能将元素还原
arr[j++] = i + min;
}
}
这样我们的计数排序就完成了,我们来看看完成代码:
c
//计数排序
void CountSort(int* arr, int n)
{
//定义min和max去找最小和最大值
int max = arr[0], min = arr[0];
for (int i = 1; i < n; i++)
{
if (arr[i] < min)
min = arr[i];
if (arr[i] > max)
max = arr[i];
}
//求出count数组的大小,以及开辟count数组
int range = max - min + 1;
//注意,如果不用calloc的话,要使用memset将所有元素初始化为0
int* count = (int*)calloc(sizeof(int), range);
if (count == NULL)
{
perror("calloc");
return;
}
//将arr中的数据映射到count数组中
for (int i = 0; i < n; i++)
{
count[arr[i] - min]++;
}
int j = 0;
//按照下标顺序依次将count中的数据取回arr数组
for (int i = 0; i < range; i++)
{
while (count[i]--)
{
arr[j++] = i + min;
}
}
free(count);
count = NULL;
}
我们来测试一下计数排序:
可以看到计数排序很好地完成了排序任务,接着我们来分析分析计数排序的时间复杂度和空间复杂度,以及它的一些缺陷
首先由于只需要遍历原数组一次将元素映射到count数组,然后再取出来,所以时间复杂度为O(N) ,由于需要开辟count数组,并且count数组的大小是根据数组元素之间的差值来决定,所以空间复杂度为O(N + Range)
它的缺陷就是 :只能排序整数,包括了负数,因为是通过最大和最小值之间的差值来控制映射关系,所以负数也可以映射到count数组中,这个是没有问题的,但是如果要排序浮点数,那么计数排序做不到
其次,计数排序不适用于最大值和最小值差距过大的场景,比如数组[3, 100001, 323, 23],虽然只有4个元素,但是最大值和最小值差距差不多10万,这样我们使用计数排序就会造成空间浪费,这就是计数排序的一些缺陷,但是同时它是很快的,我们后面测试就知道了
三、归并排序和计数排序的性能测试
在前面我们介绍了归并排序和计数排序,其中归并排序的时间复杂度为O(N * log N),计数排序的时间复杂度为O(N),我们来简单对比一下它们在同一设备上的表现,还是老样子,写一个排序函数,如下:
c
void TestOP()
{
srand((unsigned int)time(NULL));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
}
int begin1 = clock();
MergeSort(a1, N);
int end1 = clock();
int begin2 = clock();
CountSort(a2, N);
int end2 = clock();
printf("MergeSort:%d\n", end1 - begin1);
printf("CountSort:%d\n", end2 - begin2);
free(a1);
free(a2);
}
int main()
{
TestOP();
return 0;
}
接着我们来运行一下代码,看看归并排序和计数排序排10万个随机数的速度,记得将VS切换到Release版本 ,以免不准确,如下:
可以看到它们的表现都特别好,其中计数排序排10万个随机数更是1毫秒都没有用到,而归并排序4毫秒也不差,这就是O(N * log N )和O(N)的力量,如果感兴趣,可以自行测试更大的数据,这里我就不演示了
那么今天的排序算法就介绍到这里啦,八大排序算法基本上都已经介绍完了,接下来我们再来一篇讲解非递归版快排和归并排序就可以结束初阶数据结构与算法阶段,到达C++阶段了,敬请期待吧!
bye~