归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
假设有一个数组,如果其左半区间和右半区间有序,将其进行归并,则整个数组就都有序了。如果是链表的话,可以取两段有效区间,一次比较取小的进行尾插,但是如果是数组的话就会有差异,因为数组的空间是连续的,而且不能在原数组进行归并,会导致数据覆盖的问题。所以数组需要一个额外的空间,将数据尾插到新数组最后再拷贝给原数组。
如何让数组的左半区间和右半区间有序?也是利用了递归分割的方法,图示如下:
注意这里并不是开辟一块一块的数组进行排序,而是在一个新建的组中进行排序,排序完成将新建数组中的数据拷贝给原数组。
首先动态开辟一个数组,由于需要递归实现,所以还需要写一个子函数用于实现归并的递归。而子函数实现的功能是某段区间的归并,所以我们这里定义包括左右区间在内的 4 个参数。
代码如下:
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 = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] <= a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
//以上执行完代表有一个区间已经走完了,直接将另一个区间剩下的元素放入tmp数组
//如果是左区间没走完,就执行这个while
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
//如果是右区间没走完,就执行这个while
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
//归并好了将tmp拷贝回原数组
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
由于每一层都是 N 个数据,一共有 logN 层,所以这里的时间复杂度为严格的 O(N*logN)。 由于额外开辟了 N 个数据的空间,高度为 logN 递归的时候建立了 log N 个栈帧,所以这里的空间复杂度为 O(N + logN)。
注意归并排序每次都是二分,分到最后一定是 1 个数和 1 个数为一组,再将其进行归并,归并成 2 个数为一组的有序,再将 2 个数为一组归并成 4 个数为一组的有序,以此类推,所以这里只需要控制单个区间的大小即可。
1、通过 rangeN 来控制区间的大小。rangeN 指的是一个区间的数据个数,第一次归并是单个数为一组,将两组数为归并成 2 个有序的数。并将归并完成的数据拷贝给原数组。
2、进行第二次归并,让 rangeN * 2,这个时候变成 2 个数为一组。归并成 4 个数为一组的有序,再拷贝给原数组。
3、再继续进行归并,此时 4 个数为一组,将两组数归并成 8 个数为一组的有序,再将其拷贝给原数组。
如上就完成了 8 个数的归并排序。
但是需要注意,如果有 10 个数据,就会有越界问题,下面我们来进行越界情况的分析:
1、我们将每次两两为一组的两组数据划分左右区间,取变量 i 为每组首个元素的位置,每执行完一组的排序,i 就变为下一组的首元素位置
2、设置 begin1 = i,end1 = i + rangeN -1 为进行两两为一组归并排序的第一组区间的左端点和右端点
3、设置 begin2 = i + rangeN, end2 = i + 2 * rangeN - 1 为进行两两为一组归并排序的第二组区间的左端点和右端点。
第一种越界情况: end1、begin2、end2 越界
第二种越界情况: begin2、end2 越界
第三种越界情况: end2 越界
所以在写代码的时候要加入对以上三种情况的判断,有两种思路。
思路一: 修正的写法
因为当越界的时候,我们可以将其区间修改为不存在,这样就不会进入到下一次循环,不存在区间的值也就不会放到 tmp 数组中。只将存在的区间里的数据存到 tmp 数组中,再拷贝给原数组。
思路二: 不修正的写法
第二个思路就是如果区间不存在就 break 出去,前面已经将排好的数拷贝回原数组,说明原数组中区间存在的数已经有序了,而 break 后区间不存在的那一组数不会放入 tmp 数组中,也不会拷贝回原数组,所以原数组也排序完成。
修正写法代码如下:
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
// 归并每组数据个数,从1开始,因为1个认为是有序的,可以直接归并
//先让rangeN为1,进行归并
int rangeN = 1;
//rangeN 小于 n 个数就继续,否则就结束
while (rangeN < n)
{
//for循环里实现一组一组的归并
//期望每次都是一组一组的归并,一组为rangeN个,所以下次i需要跳到下一组,即跳2*rangeN个位置
for (int i = 0; i < n; i += 2 * rangeN)
{
// [begin1,end1][begin2,end2] 归并
//第一组左右区间
int begin1 = i, end1 = i + rangeN - 1;
//第二组左右区间
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
int j = i;
// 如果第1组数不存在,将其修正成为不存在的区间,循环就不会进去,后面归并过程正常
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;
}
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) * (end2 - i + 1));
}
//也可以整体归并完了再拷贝
//memcpy(a, tmp, sizeof(int) * n);
rangeN *= 2;
}
free(tmp);
tmp = NULL;
}
不修正写法代码如下:
代码如下:
```cpp
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
// 归并每组数据个数,从1开始,因为1个认为是有序的,可以直接归并
//先让rangeN为1,进行归并
int rangeN = 1;
//rangeN 小于 n 个数就继续,否则就结束
while (rangeN < n)
{
//for循环里实现一组一组的归并
//期望每次都是一组一组的归并,一组为rangeN个,所以下次i需要跳到下一组,即跳2*rangeN个位置
for (int i = 0; i < n; i += 2 * rangeN)
{
// [begin1,end1][begin2,end2] 归并
//第一组左右区间
int begin1 = i, end1 = i + rangeN - 1;
//第二组左右区间
int begin2 = i + rangeN, end2 = i + 2 * rangeN - 1;
printf("[%d,%d][%d,%d]\n", begin1, end1, begin2, end2);
int j = i;
// 如果第1组数不存在,将其修正成为不存在的区间,循环就不会进去,后面归并过程正常
if (end1 >= n)
{
//不修正的写法,直接break
break;
}
//
else if (begin2 >= n)
{
//不修正的写法,直接break
break;
}
else if (end2 >= n)
{
//此种情况只能修正
end2 = n-1;
}
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) * (end2 - i + 1));
}
rangeN *= 2;
}
free(tmp);
tmp = NULL;
}
注意:
以上是部分拷贝的分析情况,部份拷贝是排完一部分就拷贝回原数组,而还有整体拷贝的分析情况,整体拷贝就是所有数都在 tmp 里面排完了后,再一起拷贝给原数组。整体拷贝的实现就是将 memcpy 函数放在 while 循环外即可,其次整体拷贝只能采取修正的方式而不能采取直接 break 的方式。
计数排序的算法思想是统计每个数据出现的次数,就是用一个数组来记录原数组中每个位置的数据出现的次数。
有一个需要排序的数组,它的排序过程如下:
1、新建一个数组,将其所有位置的数据初始化为 0,此数组为计数数组,存的是原下标对应值出现的次数
2、原数组第一个位置为 9,所以第二个数组的 9 号位置 ++
3、原数组的第二个位置为 6,所以第二个数组的 6 号位置 ++
4、直到统计完原数组中所有数据出现的次数
5、然后按照每个数据出现的次数将其写到第一个数组中,完成排序的过程。
在使用绝对映射的时候,会出现一个问题:如果数据有复数或者数值太大,这个计数数组需要开多大?
这个时候我们不能采取上面介绍的绝对映射方式,而是采取相对映射的方式,相对映射的第二个数组开辟的空间大小为第一个数组中元素范围。图示如下:
原数组中最大数据为 max,最小数据为 min,计数数组需要开辟 max - min+1 个空间,数组元素的值 - min 就是存到计数数组中的位置。
代码如下:
// 时间复杂度:O(N+range)
// 空间复杂度:O(range)
// 适合数据范围集中,也就是range小
// 只适合整数,不适合浮点数、字符串等
void CountSort(int* a, int n)
{
//找出数组中最大数和最小数
int max = a[0], min = a[0];
for (int i = 1; i < n; ++i)
{
if (a[i] > max)
{
max = a[i];
}
if (a[i] < min)
{
min = a[i];
}
}
//算出计数数组的范围
int range = max - min + 1;
// 统计次数
int* countA = (int*)malloc(sizeof(int) * range);
if (countA == NULL)
{
perror("malloc fail");
return;
}
memset(countA, 0, sizeof(int) * range);
//用相对映射统计次数
for (int i = 0; i < n; ++i)
{
countA[a[i] - min]++;
}
// 排序
int j = 0;
//遍历计数数组
for (int i = 0; i < range; ++i)
{
//计算计数数组每个位置的值,如果大于0,就将其的相对映射对应的值写到原数组
//且每写完一次计数数组此位置的值就-1,当减到0就停止
while (countA[i]--)
{
a[j] = i + min;
++j;
}
}
free(countA);
}
1、计数排序的时间复杂度其实是由 range 和 N 的关系来衡量的,因为找出数组中最大数和最小数是 N,而遍历计数数组是第一层 for 循环是 range,而 while 循环里面是将原数组重新排列,其实走了 N 次,所以实际上时间复杂度是 N + range
2、当我们不确定 range 和 N 的大小时,我们可以认为 计数排序的时间复杂度为 O(max(N,range)) ,取较大的一个
3、因为只开辟了计数数组,所以空间复杂度则是 O(range)
4、计数排序的时空复杂度都较优,时间复杂度可以认为近似 O(N) ,并且空间复杂度也不会太大。
5、但是计数排序也有一些局限性,如只适用于整型,如浮点数类型等就不能使用计数排序。
6、对于范围分散,跨度大的序列也不适合用计数排序,因此计数排序的适用范围有限。