必学算法之归并排序及其改进方案
算法名称:归并排序
要求掌握程度:熟练掌握
前言
在之前的文章中学习了必学算法之快速排序及其改进方案面试高频考点,今天我们来探讨一下另一个经典排序算法——归并排序。归并排序作为十大经典排序算法之一,主要采用分而治之的思想来实现排序,这个思想非常有助于我们解决问题,尤其是无序大数据在一定内存容量下转换成排序数据(面试高频考点,具体答案在文末给出)的时候经常会使用到,现在我们来学习一下这个算法及其改进版本。
归并排序(Merge Sort)
算法介绍
归并排序最早于1945年由约翰·冯·诺依曼(John von Neumann)提出,是创建在归并操作上的一种有效的排序算法。算法是采用分治法(Divide and Conquer)的一个非常典型的应用,主要思想为将已有序的子序列合并,得到完全有序的序列,即先使每个子序列有序,再使子序列段间有序,且各层分治递归可以同时进行。归并排序思路简单,速度仅次于快速排序,为稳定排序算法,一般用于对总体无序,但是各子项相对有序的数列。
基本思路
归并排序的主要思想是分治思想,即将问题分解成各个子问题解决后再进行合并,分治模式在每一层递归上主要进行以下两个步骤:
- 分解(Divide):将 n 个元素分成 2 个含 n/2 个元素的子序列。
- 合并(Conquer):用合并排序法对两个子序列递归的排序形成完整序列。
这个过程如下图所示,序列被不断划分成更小的子序列直到无法划分,再不断向上合并形成有序序列:
为了更加清晰地展示整个过程,可以参考以下这个动图演示,图源于网络:
实现逻辑
实现归并排序,主要可以采用递归法和迭代法两种实现方式,一般来说,更多的是采用递归方法,因为代码实现更简洁(个人感觉)。
迭代法实现
1.申请序列,使其大小为整个序列长度,该序列用来暂时存放合并序列的辅助序列
2.设定两个指针,最初位置分别为两个已经排序序列的起始位置
3.比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
4.重复步骤 3 直到某一指针到达序列尾
5.将另一序列剩下的所有元素直接复制到合并序列尾
递归法实现
1.将序列每相邻两个数字进行归并操作,形成floor(n/2)个序列,排序后每个序列包含两个元素
2.将上述序列再次归并,形成floor(n/4)个序列,每个序列包含四个元素
3.重复步骤 2,直到所有元素排序完毕
复杂度分析
平均时间复杂度:O(nlogn)
最佳时间复杂度:O(nlogn)
最差时间复杂度:O(nlogn)
空间复杂度:O(n)
稳定性:稳定
不管元素在什么情况下都要执行上述这些步骤,所以花销的时间是不变的,因此归并排序的最优时间复杂度和最差时间复杂度及平均时间复杂度都是一样的,为:O(nlogn)
归并的空间复杂度就是那个临时的数组和递归时压入栈的数据占用的空间:n + logn;所以空间复杂度为: O(n)
归并排序算法中,归并最后到底都是相邻元素之间的比较交换,并不会发生相同元素的相对位置发生变化,故是稳定性算法。
代码实现(C++)
递归法实现
// 1. 递归版归并排序
void merge_sort_recursive(vector<int> &arr, vector<int> ®, int start, int end)
{
if (start >= end)
return;
int len = end - start, mid = (len >> 1) + start;
int start1 = start, end1 = mid;
int start2 = mid + 1, end2 = end;
// 分解
merge_sort_recursive(arr, reg, start1, end1);
merge_sort_recursive(arr, reg, start2, end2);
// 合并
int i = start;
while (start1 <= end1 && start2 <= end2)
{
reg[i++] = arr[start1] <= arr[start2] ? arr[start1++] : arr[start2++];
}
while (start1 <= end1)
reg[i++] = arr[start1++];
while (start2 <= end2)
reg[i++] = arr[start2++];
// 复制到原数组
for (i = start; i <= end; ++i)
arr[i] = reg[i];
}
迭代法实现
// 2. 迭代版归并排序
void merge_sort_iterative(vector<int> &arr)
{
int len = arr.size();
vector<int> reg(len);
// 从两两排序到,全部一起排序
for (int seg = 1; seg < len; seg += seg)
{
// 从头到尾依次排序
for (int start = 0; start < len; start += seg + seg)
{
int low = start;
int mid = min(start + seg, len);
int high = min(start + seg + seg, len);
int k = low;
int start1 = low, end1 = mid;
int start2 = mid, end2 = high;
// 依次比较大小,并放入新数组b
while (start1 < end1 && start2 < end2)
reg[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
while (start1 < end1)
reg[k++] = arr[start1++];
while (start2 < end2)
reg[k++] = arr[start2++];
}
// 交换arr,b数组
vector<int> temp = move(arr);
arr = move(reg);
reg = move(temp);
}
reg.clear();
}
归并排序改进版本
上面的归并排序时间复杂度为 O(nlogn),空间复杂度为 O(n),因为使用到了辅助序列,那我们有没有办法将其在空间进一步优化到 O(1) 呢?如果你看到这里,那当然是可以的。
优化一:时间换空间
具体分析可以参考:这都不会,还说自己精通「归并排序」?,这个方法虽然将空间复杂度降到了 O(1),但同时也牺牲了时间复杂度,时间复杂度变成了 O(n^2) 。这里只给出其实现代码。
代码实现(C++)
// 3. 时间换空间,原地归并
void merge_sort_opt1(vector<int> &arr)
{
int len = arr.size();
for (int seg = 1; seg < len; seg *= 2) // 当前合并子序列大小, 1——>n/2
{
for (int start = 0; start < len; start += 2 * seg) // 标记子数组起点
{
int mid = min(start + seg, len);
int end = min(start + seg + seg, len);
// 归并
int k = start;
int start1 = start, end1 = mid;
int start2 = mid, end2 = end;
while (start1 < end1 && start2 < end2)
{
// arr[start1] <= arr[start2], start1 后移
if (arr[start1] <= arr[start2])
{
++start1;
}
// 否则右移 [start1, start2)数据,arr[start1] = arr[start2]
else
{
int value = arr[start2];
int index = start2;
while (index != start1)
{
arr[index] = arr[--index];
}
arr[start1] = value;
++start1;
++start2;
++mid;
}
}
}
}
}
优化二:数学运算优化
这个优化方法的核心思想是用一个数来表示两个数,这样在保证时间复杂度为O(nlogn)的前提下,将空间复杂度优化到O(1)。
对于数组中两个元素 arr[i]
和 arr[j]
,要将这两个元素保存到数组下标为 i 的数当中,我们可以通过取模和求余运算来实现。
具体如下:
首先找到一个比 arr[i]
和 arr[j]
都大的数 maxval
,这里我们取 max(arr_left, arr_right) + 1
,即子数组的最大值+1;
然后就可以用一个数 arr[i] = arr[i] + arr[j] * maxval
来同时表示原始数组中的 arr[i]
和 arr[j]
;
原始的 arr[i] = arr[i] % maxval
,而 arr[j] = arr[i] / maxval
.
举个简单的例子,比如数组中的两个元素分别为 arr[0] = 4
和 arr[4] = 3
,maxval = max(arr[i], arr[j]) + 1 = 4 + 1
,新的 arr[0] = arr[0] +arr[4] * maxval = 4 + 3 *5 = 19
。
原始数组中的 arr[0] = arr[0] % 5 = 19 % 5 = 4
,而原始的 arr[4] = arr[0] / 5 = 19 / 5 = 3
· 。
那么如何将这一个技巧应用到归并排序,并将其空间复杂度降至 呢?
要将空间复杂度降至 ,我们仅需要关注 merge
函数的实现,同样我们以一个例子作为说明。
最后一次合并前的数组如下所示:
此时原始数组已被分成了两个有序的子数组 [1,4,5]
和 [2,4,8]
,此时的合并步骤不再开辟两块空间的方式进行合并,而是采用我们上面讲到的技巧。
要保证数组中的任意两个数都可以用第三个数表示,我们需要将 maxval
设置为数组中的最大值加 1,即,maxval = 9
。
设置两个分别指向有序数组第一个元素的指针 i
和 j
:
第一步,比较 arr[i] % 9 = 1
和 arr[j] % 9 = 2
,1 < 2,所以 arr[0]
的位置要保存 1,我们这里将 arr[i]
作为商数,即 arr[0] = arr[0] + arr[i] * maxval = 1 + 1 * 9 = 10
,并将指针 i
右移(合并好的元素用橘黄色表示):
第二步,比较 arr[i] % 9 = 4
和 arr[j] % 9 = 2
,2 < 4 ,所以 arr[1]
的位置要保存 2,我们这里将 arr[j]
作为商数,即 arr[1] = arr[1] +arr[j] * maxval = 4 + 2 * 9 = 22
,并将指针 j
右移:
第三步,比较 arr[i] % 9 = 22 % 9 = 4
和 arr[j] % 9 = 4
,4 == 4 ,所以 arr[2]
的位置要保存 arr[i]
的值,故将 arr[i]
作为商数,arr[2] = arr[2] + arr[i] * maxval = 5 + 4 * 9 = 41
,然后将指针 i
右移。
第四步,比较 arr[i] % 9 = 41 % 9 = 5
和 arr[j] % 9 = 4
,4 < 5 ,所以 arr[3]
的位置要保存 arr[j]
的值,故将 arr[j]
作为商数,arr[3] = arr[3] + arr[i] * maxval = 2 + 4 * 9 = 38
,然后将指针 j
右移。
第五步,比较 arr[i] % 9 = 41 % 9 = 5
和 arr[j] % 9 = 8
,5 < 8 ,所以 arr[4]
的位置要保存 arr[i]
的值,故将 arr[i]
作为商数,arr[4] = arr[4] + arr[i] * maxval = 4 + 5 * 9 = 49
,然后将指针 i
右移。
第六步,发现指针 i
已经超出了边界,j
还没有越界,所以要对 j
之后剩余的元素进行处理,这里只有一个元素 arr[5]
没有处理,所以 arr[5] = arr[5] + arr[5] *maxval = 8 + 8 * 9 = 80
.
但是这并不是我们的原始数组呀,别急,接下来才是见证奇迹的时刻,我们对数组中的每一个元素除以 maxval = 9
,会发生什么呢?我们得到了一个原始数组的有序序列,这也就是为什么每次把要保存的值作为商的原因:
这样我们就得到了排序之后的数组,有没有感觉很奇妙,是的,我也这么觉得。
代码实现(C++)
// 4.原地合并,一个数表示两个数
void merge_combine(vector<int> &arr, int left, int mid, int right, int maxval)
{
int i = left, j = mid + 1;
int index = left; // 记录归并排序后保存数据的位置
while (i <= mid && j <= right)
{
// arr[i] <= arr[j] ---> ++i, ++index
if (arr[i] % maxval <= arr[j] % maxval)
{
arr[index] = arr[index++] + (arr[i++] % maxval) * maxval;
}
// arr[i] > arr[j] ---> ++j, ++index
else
{
arr[index] = arr[index++] + (arr[j++] % maxval) * maxval;
}
}
// 检查剩余部分
while (i <= mid)
{
arr[index] = arr[index++] + (arr[i++] % maxval) * maxval;
}
while (j <= right)
{
arr[index] = arr[index++] + (arr[j++] % maxval) * maxval;
}
// 转换成原始数据,即求 arr[xx]/maxval
for (i = left; i <= right; ++i)
{
arr[i] /= maxval;
}
}
void merge_sort_rec(vector<int> &arr, int left, int right, int maxval)
{
if (left >= right)
return;
int mid = left + ((right - left) >> 1);
// 分解
merge_sort_rec(arr, left, mid, maxval);
merge_sort_rec(arr, mid + 1, right, maxval);
// 合并
merge_combine(arr, left, mid, right, maxval);
}
void merge_sort_opt2(vector<int> &arr)
{
// 寻找数组最大值
int maxval = *max_element(arr.begin(), arr.end()) + 1;
merge_sort_rec(arr, 0, arr.size() - 1, maxval);
}
无序大数据在一定内存容量下转换成排序数据
当无序大数据在一定内存容量下无法全部加载到内存中时,我们可以采用**外排序(External Sorting)**的方法将它们转换为有序数据。
外排序是一种基于磁盘文件的排序算法,它通过多次读写磁盘文件来完成排序过程。其基本思想是将大文件拆分成多个较小的块,然后对这些块进行排序,最后将排序后的块进行归并,生成有序的输出文件。
以下是一般的外排序的步骤:
- 将无序大数据按照大小均匀地划分为多个大小相等的块,并将这些块分别写入磁盘文件中。
- 对每个磁盘文件进行内部排序,通常采用快速排序、归并排序等高效的排序算法。
- 为了保证归并操作的效率,需要将内存缓冲区划分为若干个大小相等的块(k>=2,即可以采用k路归并的方式),每个块可以加载一个已排好序的磁盘文件块。然后,从每个磁盘文件的头部读取一个元素,放入内存缓冲区中对应的块中。
- 在内存缓冲区中,找到所有块中最小的元素,将其输出到输出文件中。如果某个块已经没有元素,就从对应的磁盘文件中读取下一个元素放入内存缓冲区中。直到所有的块都被处理完毕,完成一轮归并操作。
- 可以重复执行第 4 步,直到所有块都被读取并排好序输出为一个有序文件。
需要注意的是,外排序需要尽量减少磁盘 I/O 操作的次数,因此对于内部排序算法和内存缓冲区大小的选择非常关键。同时还需要考虑可靠性、稳定性等因素,确保排序结果正确、高效。