算法系列之三 归并排序

归并排序是分治算法的另一个典型的体现。与快速排序一样,它依赖数据之间的比较进行排序。

其缺点是需要额外的空间来辅助排序的过程。这使得归并排序的实用性大大降低,因为在日常的应用中,使用归并排序的场景都可以使用快速排序来替代。但是它的优点是可以按照预期来切分数组,每一次都可以对半分,这样不会出现因为分割数组不均匀而使递归调用变成一个斜向二叉树(快速排序的缺点,详见《算法系列之一 快速排序》),在处理大量数据的情况下,这个优点变得非常受青睐。

基本算法

归并排序的原理大概如下:

  1. 将给定的乱序数组平均分割成两个子数组
  2. 重复第一步,直到每个小数组只剩下1个数
  3. 将相邻的两个子数组进行合并;合并的过程是:从开始遍历两个子数组,哪个数组的数值小就取哪个,直到遍历完成
  4. 重复第三步,直到所有的子数组合并完成
  5. 数组有序

简单的原理展示如下:
15247196775167

下面的图详细展示了当 N=16 时,归并算法子数组的依赖树

15247077014133

算法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void merge(int *a, int *aux, int lo, int mid, int hi){

int i = lo, j = mid+1;

for(int k = lo; k <= hi; k++)
aux[k] = a[k];

for(int k = lo; k <= hi; k++){

if(i > mid)
a[k] = aux[j++];

else if (j > hi)
a[k] = aux[i++];

else if (aux[j] < aux[i])
a[k] = aux[j++];

else
a[k] = aux[i++];
}
}


void merge_sort(int *a, int *aux, int lo, int hi){

if (hi <= lo) return; // 当数组只有一个数时,结束递归
int mid = lo + (hi - lo) / 2;
merge_sort(a, aux, lo, mid); // 排序 左边子数组
merge_sort(a, aux, mid+1, hi); // 排序 右边子数组

merge(a, aux, lo, mid, hi); // 合并两个子数组
}

对于上面的实现,是已经优化过一次的。优化的点在于:一次性需要申请一个 同原数组一样大小的辅助数组 aux。然后将 aux 传入每一次递归里面,这样,就不需要在每一次 merge 函数中进行申请数组的行为。

如果不这样做的话,那算法的大部分时间,其实是消耗在了辅助数组的申请上。

优化

1. 小数组排序优化

用不同的排序方法处理小规模问题,能改进大多数递归算法的性能,这是递归在小规模问题中调用太过频繁。同 快速排序 一样,在归并排序处理小数组问题的时候,使用插入排序来替代小规模排序可以提高一些性能。

merge_sort 改进如下:

1
2
3
4
5
6
7
8
9
10
11
12
void merge_sort(int *a, int *aux, int lo, int hi){

if (hi <= lo + M){ // M根据系统不同,取值在5-15之间,大部分时候都可以接受
insertion_sort(a, lo, hi);
return;
}
int mid = lo + (hi - lo) / 2;
merge_sort(a, aux, lo, mid); // 排序 左边子数组
merge_sort(a, aux, mid+1, hi); // 排序 右边子数组

merge(a, aux, lo, mid, hi); // 合并两个子数组
}

插入排序的实现及原理,参见《算法系列之二 基础排序

2. 判断数组是否有序

如果我们可以判断 两个子数组已经是有序的话,就可以跳过 merge 函数,这样任意有序的数组算法的运行时间就是线性的了。

改动merge_sort函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void merge_sort(int *a, int *aux, int lo, int hi){

if (hi <= lo + M){ // M根据系统不同,取值在5-15之间,大部分时候都可以接受
insertion_sort(a, lo, hi);
return;
}
int mid = lo + (hi - lo) / 2;
merge_sort(a, aux, lo, mid); // 排序 左边子数组
merge_sort(a, aux, mid+1, hi); // 排序 右边子数组

if(a[mid] <= a[mid+1]) return;

merge(a, aux, lo, mid, hi); // 合并两个子数组
}

3. 不将元素复制到辅助数组

要做到这一点,需要一点技巧,我们需要在递归调用的每一个层次替换辅助函数的角色。来达到这一点。

代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void merge(int *src, int *dst, int lo, int mid, int hi){

int i = lo, j = mid+1;

for(int k = lo; k <= hi; k++){

if(i > mid)
dst[k] = src[j++];

else if (j > hi)
dst[k] = src[i++];

else if (src[j] < src[i])
dst[k] = src[j++];

else
dst[k] = src[i++];
}
}


void merge_sort(int *src, int *dst, int lo, int hi){

if (hi <= lo) return;

int mid = lo + (hi - lo) / 2;
merge_sort(dst, src, lo, mid);
merge_sort(dst, src, mid+1, hi);

merge(src, dst, lo, mid, hi);
}


#define SSIZE 5

int main() {

int a[SSIZE] = {5,4,3,2,1}, aux[SSIZE];
memcpy(aux, a, sizeof(int)*SSIZE);

merge_sort(aux, a, 0, 4);

return 0;
}

上面的实现,通过两个参数 src 和 dst,对于函数 merge_sort 来说,每一层的 src 和 dst 都是相互交换过的。这样 每一层所依赖的src,其实是上一层调用的dst,这样就省去了每一次都复制一遍数组的过程了。

需要注意的是:
在调用 merge_sort 之前,辅助数组需要和原始数组有一样的数据。这里,我们使用memcpy进行拷贝,提高效率。

结尾

虽然,在大部分应用的时候,归并排序都能被快速排序替代。但是在一些场景下,优化过的归并排序还是非常具有吸引力的。

reposkeeper wechat
关注微信公众号