目录
何为归并排序
归并排序 (merge sort) 是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列。即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
排序步骤
归并排序在结构上是递归的,按照分治模式在每层递归时都有3个步骤:
分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列。
解决:使用归并排序递归地排序两个子序列。
合并:合并两个已排序的子序列以产生已排序的答案。
当待排序的序列长度为1时,递归“开始回升”,即开始合并。
合并过程
归并排序的关键操作为“合并”,下面用一个 MERGE (A, p, q, r) 的伪代码来表示合并的过程。其中A是一个数组,p、q、r 为数组下标,满足 p ≤ q < r 。假设子数组 A [p..q] 和 A [q+1..r] 都已排好序。
MERGE (A, p, q, r) 的伪代码如下:
MERGE(A, p, q, r)
n1 = q - p + 1
n2 = r - q
let L[1..n1 + 1] and R[1..n2 + 1] be new arrays
for i = 1 to n1
L[i] = A[p + i - 1]
for j = 1 to n2
R[j] = A[q + j]
L[n1 + 1] = ∞
R[n2 + 1] = ∞
i = 1
j = 1
for k = p to r
if L[i] ≤ R[j]
A[k] = L[i]
i = i + 1
else A[k] = R[j]
j = j + 1
在 MERGE 过程中,先计算了两个子数组的长度,然后在两个子数组的最后额外加了一个哨兵标记,即 ∞ 。接着对两个子数组中最小的值进行比较,将较小的一个元素放入原数组中。循环这个过程,直到检查到两个子数组中任意一个子数组的哨兵标记。当检查到其中一个子数组的哨兵标记,判断该子数组完成了全部元素的比较。另一个子数组中剩下的元素都比哨兵标记 ∞ 小,于是将另一个子数组中剩下的全部元素按顺序放入到原数组中,合并过程结束,并且原数组已排好序。
全过程
将 MERGE 过程作为全过程的子程序来使用,下面用 MERGE-SORT (A, p, r) 来表示归并排序的全过程。A 为子数组 A[p..r],p、r 为子数组下标。若 p ≥ r ,则该子数组最多有一个元素,所以已经排好序。否则,分解步骤将计算一个下标q,并将子数组 A [p..r] 分解为两个子数组 A [p..q] 和 A [q+1..r] 。这两个子数组包含 n/2 个元素。
MERGE-SORT (A, p, r) 的伪代码如下:
MERGE-SORT(A, p, r)
if p < r
q = (p + r)/2
MERGE-SORT(A, p, q)
MERGE-SORT(A, q+1, r)
MERGE(A, p, q, r)
全过程分析:在 MERGE-SORT 过程中,先将长度为n的原数组分解为两个子数组,如果两个子数组中的元素多于一个,则递归调用 MERGE-SORT 过程将两个子数组分解为四个子数组,以此类推,直到全部的子数组中只含有一个元素。即最小子数组。接着调用 MERGE 过程对所有的最小子数组进行合并。由于最小子数组中只包含一个元素,所以每个最小子数组已排好序。将每个最小子数组按照大小进行合并,合并后产生一个包含两个元素的子数组,并且已排好序。以此类推,最终将所有的子数组合并成长度为n的数组,并且已排好序。
归并排序实现代码(C语言描述)
实现代码如下:
#include <stdio.h>
void merge(int* array1, int* array2, int left, int mid, int right){ //定义合并函数
int i = left, j = mid+1, k = left; //定义左、右子数组和暂存数组的索引
while(i!=mid+1 && j!=right+1){ //循环判断是否检查到哨兵标记
if(*(array1+i) > *(array1+j)) //判断左、右子数组中的较小值
*(array2+k++) = *(array1+j++); //将较小值放入暂存数组中,子数组、暂存数组的索引加1
else
*(array2+k++) = *(array1+i++);
}
while(i != mid+1) //当检查到右子数组的哨兵标记
*(array2+k++) = *(array1+i++); //将左子数组剩下的全部元素按顺序放入暂存数组
while(j != right+1) //当检查到左子数组的哨兵标记
*(array2+k++) = *(array1+j++); //将右子数组剩下的全部元素按顺序放入暂存数组
for(i=left; i<=right; i++) //将暂存数组中的元素放回原数组
*(array1+i) = *(array2+i);
}
void merge_sort(int* array1, int* array2, int left, int right){ //array1为指向初始数组的指针,array2为指向暂存数组的指针,left、right为初始数组起始索引和终止索引
if(left<right){ //判断是否对子数组进行分解
int mid = left + (right - left)/2; //计算将数组分解的中间下标
merge_sort(array1, array2, left, mid); //递归调用归并排序函数将左子数组A[left..mid]分解
merge_sort(array1, array2, mid+1, right); //递归调用归并排序函数将右子数组A[mid+1..right]分解
merge(array1, array2, left, mid, right); //调用合并函数合并子数组A[left..mid]和A[mid+1..right]
}
}
int main(){
int a1[8]={2, 4, 5, 7, 1, 2, 3, 6}; //定义初始数组a1
int a2[8], i; //定义暂存数组a2,用于保存排序过程中产生的中间变量
merge_sort(a1, a2, 0, 7); //调用归并排序函数
for(i=0;i<8;i++) //循环输出排序后的结果
printf("%d ", a1[i]);
return 0;
}
输出结果:
1 2 2 3 4 5 6 7
实现代码解析:
从31行进入 merge_sort 函数,函数先判断是否对参数 array1 所指向的数组进行分解,即比较该数组起始索引 left 和终止索引 right 的大小。若起始索引 left 小于终止索引 right ,则表明该数组中的元素多于一个,并对该数组进行分解;若起始索引 left 等于终止索引 right ,则表明该数组中只有一个元素,停止对该数组的分解。变量 mid 被定义为分解该数组的中间下标,该变量将该数组二分为左子数组 A [left..mid] 和右子数组 A [mid+1..right] ,并且作为左子数组 A [left..mid] 的终止索引,加1后作为右子数组 A [mid+1..right] 的起始索引。分解完初始数组后将继续递归调用 merge_sort 函数对左、右子数组进行分解,直到所有的子数组只包含一个元素。
仔细观察 merge_sort 函数的递归调用,即22、23行,会发现 merge_sort 函数的第一个和第二个参数一直没有改变。在 merge_sort 函数中实际操作的是数组的索引,即第三个和第四个参数,通过索引的值来控制子数组的范围。实际上并没有对初始数组做任何分解,真正分解的是初始数组的索引 left 和 right 。将初始数组的索引 [left..right] 分解为 [left..mid] 、 [mid+1..right] ,通过分解索引达到将一个数组分解为两个子数组的效果。
当 merge_sort 函数递归结束,即所有的子数组只包含一个元素时,进入 merge 函数,开始对所有的子数组进行合并。在 merge 函数中,先定义了左、右子数组和暂存数组的起始索引 left 、 mid+1 和 left 。然后开始 while 循环,并对左、右子数组进行比较。每次比较前先检查当前左、右子数组的索引是否为哨兵标记的索引。检查哨兵标记是为了判断是否完成了子数组中所有元素的比较。哨兵标记为子数组最后一个元素的后一个元素,在这里左、右子数组的哨兵标记为索引 mid+1 和 right+1 所对应的元素。每次只对索引进行检查,不在乎该索引所对应的元素(可能会想 mid+1 和 right+1 所对应的元素不在子数组中)。
刚开始比较时,左、右子数组的索引为起始索引 left 和 mid+1 ,所对应的元素为数组中的第一个元素。暂存数组的索引为起始索引 left ,所对应的元素为数组中的第一个元素。比较之后将较小的元素放入暂存数组的索引所对应的位置,并且将该元素所在的子数组的索引和暂存数组的索引加1。重复比较操作,当检查到哨兵标记时, while 循环停止。
检查到哨兵标记,表明已完成了某一个子数组中全部元素的比较,即该数组的全部元素都放入了暂存数组。11、13行的 while 循环为检查到哨兵标记后对另一个子数组的处理。当某一个子数组中的全部元素完成比较后,另一个子数组中未完成比较的元素必定比完成比较的元素大(因为子数组均为上一次合并排好序的数组,比较从起始索引开始,未比较的元素必定大于正在比较的元素),因此将剩下的元素按照该子数组中的顺序放入暂存数组中。最后将暂存数组中的元素放回原数组中。至此完成了两个子数组的合并,并且已排好序。
每一层 merge_sort 函数的递归都会调用一次 merge 函数来合并这一层分解的两个子数组,通过递归回升不断地合并每一层的两个子数组,最终将所有子数组合并成原数组,并且已排好序。最后将排好序的原数组输出。
复杂度分析
假设求解规模为1的问题需要常量时间 c 。在过程 MERGE 中,需要合并 n 个数,运行所需要的时间为 cn 。下面分析建立归并排序 n 个数的最坏情况运行时间的递归式。当 n > 1 个元素时,分解运行时间如下:
分解:分解步骤仅仅计算子数组的中间位置,需要常量时间 c 。
解决:递归地求解两个规模均为 n/2 的子问题,将贡献 2T(n/2) 的运行时间。
合并:合并一个具有 n 个元素的子数组需要 cn 的时间。
当 n 大于1时,需要将解决和合并的运行时间相加,由此得到归并排序的递归式:
为了更加直观地表示求解递归式,可以描绘一棵递归式的树,即递归树。
a 部分图示了 T (n),由递归式 T(n) = 2T(n/2) + cn 可得 b 部分。 按此将递归树逐渐扩展,直到问题规模下降到1时,每个子问题需要消耗的时间代价为 c 。顶层的代价为 cn ,下一层的代价为 cn/2 + cn/2 = cn ,顶层之下的第 i 层具有 个结点,每层代价为 c(n/
) ,那么第 i 层的代价为
c(n/
) = cn 。底层具有 n 个结点,每个结点的代价为 c ,该层的代价为 cn 。从 cn/2 到底层的 cn/n 需要
步,因此该递归树共有
+1 层,总代价为 cn(
+1) = cn
+ cn 。忽略低阶项和常量 c 便给出了期望结果 O(n
) ,即归并排序的时间复杂度 O(n
) 。
在归并排序的过程中,使用了大小为 n 的暂存数组来保存中间变量。在递归回升过程中,每次合并需要额外的内存空间来保存中间变量,即暂存数组。暂存数组的大小为 n ,因此归并排序的空间复杂度为 O(n) 。
归并排序的优缺点
归并排序的速度仅次于快速排序,效率非常高,时间复杂度为 O(n ) ,是基于比较的算法的最高巅峰。归并排序为稳定排序算法(即在排序过程中大小相同的元素能够保持排序前的顺序,例如3(1)、1(2)、2(3)、2(4),括号中为排序前的序号,排序后为1(2)、2(3)、2(4)、3(1),两个2的顺序保持不变),在某些情况下这一性质将至关重要。归并排序为常用的外排序算法(处理超过内存上限的数据的算法),外排序一般采用“排序-归并”策略,即先将数据划分为可读入内存的分段,读入每段数据后进行排序,并将排序后的数据输出到临时文件。重复操作完所有分段的数据后,将所有的临时文件归并为一个有序的数据。
归并排序需要大小为 n 的额外内存空间,空间复杂度为 O(n) ,在同效率的算法中空间复杂度略高。