整理自weiss老师的《数据结构与算法分析——C语言描述》
基本思想:
归并排序是经典的分治策略,它将问题分成一些小的问题然后递归求解,在“治”的阶段将分的阶段解得的各个答案修补到一起。归并排序以O(NlogN)最坏情形运行时间运行,而所使用的比较次数几乎是最优的。
归并排序的基本操作:合并两个有序的表。因为两个表是有序的,所以若将输出放到第三个表中则该算法就可以通过对输入数据一趟排序完成。
合并算法的基本操作:取两个输入数组A【0,1,...,N-1】和B【0,1,...,M-1】,一个输出数组C【0,1,...,N+M-1】,以及3个计数器Aptr,Bptr,Cptr,它们一开始指向各自数组的第一个元素。A【Aptr】和B【Bptr】中的较小者被拷贝到C中的下一个位置,相关计数器向前推进一步。当两个输入表有一个用完的时候,则将另一个表剩余部分拷贝到C中。下面是一个合并例程工作的实例:
合并两个有序的表的时间是线性的,最多进行N-1次比较,N是元素的总数。
因为归并算法是递归的,所以要考虑递归终止条件:显然N=1时,只有一个元素需要排序,不需要合并,递归终止;否则,递归地将前半部分和后半部分数据各自归并排序,得到排序后的两部分数据使用上面描述的合并思想进行合并。
下面是归并排序的代码实现:
#include <stdio.h>
#include <stdlib.h>
void Merge(int A [], int TmpArray [], int LPos, int RPos, int RightEnd){
int TmpPos = LPos;
int LeftEnd = RPos - 1;
int NumElements = RightEnd - LPos + 1;
int i;
while (LPos <= LeftEnd&&RPos <= RightEnd){
if (A[LPos] <= A[RPos]){
TmpArray[TmpPos++] = A[LPos++];
}
else{
TmpArray[TmpPos++] = A[RPos++];
}
}
while (LPos <= LeftEnd){
TmpArray[TmpPos++] = A[LPos++];
}
while (RPos <= RightEnd){
TmpArray[TmpPos++] = A[RPos++];
}
for (i = 0; i < NumElements; i++, RightEnd--){
A[RightEnd] = TmpArray[RightEnd];
}
}
void MSort(int A [], int TmpArray [], int Left, int Right){
int Center;
if (Left < Right){
Center = (Left + Right) / 2;
MSort(A, TmpArray, Left, Center);
MSort(A, TmpArray, Center + 1, Right);
Merge(A, TmpArray, Left, Center + 1, Right);
}
}
void MergeSort(int A[], int N){
int *TmpArray = malloc(N*sizeof(int));
if (TmpArray != NULL){
MSort(A, TmpArray, 0, N - 1);
free(TmpArray);
}
else{
printf("No space for tmp array!\n");
exit(1);
}
}
int main(){
int A[9] = { 0, -3, -6, 18, 7, 23, 88, 5, 28 };
MergeSort(A, 9);
for (int i = 0; i < 9; i++){
printf("%d ", A[i]);
}
printf("\n");
return 0;
}
上面MergeSort是递归例程MSort的一个驱动。另外,如果对Merge函数的每个递归调用均局部声明一个临时数组,那么在任意一个时刻就有可能有logN个临时数组处在活动期,这对内存小的机器是致命的;另一方面,如果Merge函数动态分配并释放最小量临时内存,那么由malloc占用的时间会很多。但是,上面Merge位于MSort的最后一行,在任意时刻只需要一个临时数组活动,而且可以使用该临时数组的任意部分,我们使用与输入数组A相同的部分。
下面是《算法设计与分析基础》中关于归并排序的代码实现,它里面就在每次递归时使用两个额外的数组,开销比较大:
#include "stdio.h"
#define LEN 20
void Merge(int b[],int c[],int a[],int n,int m){
int i=0,j=0;
int k=0;
while(i<n&&j<m){
if(b[i]<c[j]){
a[k]=b[i];
i++;
}else{
a[k]=c[j];
j++;
}
k++;
}
if(i==n){
while(j<m){
a[k]=c[j];
j++;
k++;
}
}else{
while(i<n){
a[k]=b[i];
i++;
k++;
}
}
}
void MergeSort(int a[],int n){
if(n<=1)return;
int b[LEN];
int c[LEN];
int i=0,j=0;
for(i=0;i<n/2;i++){
b[i]=a[i];
}
for(i=n/2;i<n;i++){
c[j]=a[i];
j++;
}
MergeSort(b,n/2);
MergeSort(c,n-n/2);
Merge(b,c,a,n/2,n-n/2);
}
int main(){
int a[9]={-1,34,2,0,13,-55,4,34,44};
MergeSort(a,9);
for(int i=0;i<9;i++){
printf("%d ",a[i]);
}
printf("\n");
return 0;
}
时间与空间复杂度分析:
归并排序是通过递归实现的,所以必须给运行时间写一个递归关系。这里假设N=2^k,这样每次都能把它分裂成两个都是偶数的部分。对N=1,归并所用时间是常数,记为1,否则,对N个数归并排序用时等于完成两个大小为N/2的递归排序加上合并的时间。
T(1)= 1
T(N)= 2 * T (N/2)+ N
这是一个标准的递归关系式,求T(N)有多种方法,这里进介绍一种:
对等号两边同时求和,消去化简,最后求得T(N)= NlogN+N = O(NlogN)。
虽然归并排序的运行时间是O(NlogN),但是它很难用于主存排序,主要问题在于合并两个排序的表需要线性附加内存,在整个算法中还要将数据拷贝到临时数组再拷回来,附加了部分工作,严重放慢了排序的速度。不过,合并的例程是大多数外部排序算法的基石。