merge sort的一些实现和变化

简介

    讨论merge sort这个问题是因为它虽然看起来简单,但是针对这个问题本身的实现以及一些细小的性能提高却有很多值得注意的地方。这里不是从最初来介绍merge sort这个算法,更多的是站在它实现细节的角度来讨论一些思路。魔鬼隐藏在细节中,我们在后面的实现里一一道来。

 

最初实现

    我们知道merge sort里的基本思路就是递归的将要排序的数组划分成两个部分,然后将这两个子数组排序后在做归并,这样就得到一个排序后的数组。一个归并排序的过程如下图所示:

    在上图里,我们初始的数组是一个无序的,然后每次我们不断的进行划分,使得每一个划分里的数组都缩小,一直到足够小的时候我们再进行归并,通过递归的回溯再得到最终的结果。一个最初的实现代码如下:

public static void mergeSort(int[] array, int start, int end)
	{
		if(start < end)
		{
			int middle = start + (end - start) / 2;
			mergeSort(array, start, middle);
			mergeSort(array, middle + 1, end);
			merge(array, start, middle, end);
		}
	}


public static void merge(int[] a, int start, int middle, int end)
	{
		int[] array = new int[end - start + 1];
		int i = 0, j = start, k = middle + 1;
		while(j <= middle && k <= end)
		{
			if(a[j] <= a[k])
				array[i++] = a[j++];
			else
			{
				count += middle - j + 1;
				array[i++] = a[k++];
			}
		}
		while(j <= middle)
			array[i++] = a[j++];
		while(k <= end)
			array[i++] = a[k++];
		for(i = 0; i < array.length; i++)
			a[start + i] = array[i];
	}

    这个问题的核心就在于merge方法,需要在这个方法里将两个排序的序列归并成一个排序的序列。因为需要将两个序列里的元素从头到尾一个个的比较,然后再将元素按顺序放入到一个集合。所以我们这里就需要有一个数组来临时存放排序后的结果,然后再将元素拷贝回去。如果综合全部过程,在每次遍历完一个递归的时候,相当于总共创建了一遍数组长度的空间,而这里递归的深度有logN,实际上这里相当于创建和销毁了NlogN的空间。所以从空间使用的角度来说,这里还是有一点不足的,那么我们有没有办法稍微改进一点呢?

 

一点改进

    因为每次我们都需要一个临时的数组来保存排序出来的序列,前面是每次按照需要去临时创建一个。其实数组总共长度假设为n的话,需要的这个临时数组最多也不过是n而已,那么我们何不干脆创建一个长度为n的数组让它们在每个递归的过程里都可以使用呢?

    于是,我们这里定义了一个全局的数组:

private static int[] aux;

    于是merge方法就可以被修改成这样:

public static 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(a[j] < a[i]) a[k] = aux[j++];
            else a[k] = aux[i++];
        }
    }

    这里的变化有几个地方,一个是这里一开始就首先将数组里要排序的元素拷贝到临时数组里,然后通过遍历这个临时数组,将元素一个个的放回来。还有一个就是我们比较元素i, mid, j, hi这些位置来判断是否前面或者后面那一段已经遍历完了。这里实现的和前面代码是同样的效果,不过显得更加紧凑。

 

    我们刚才看到的那部分是对merge方法的改进。实际上,我们还有一些针对sort方法修改的地方。前面的代码里,我们实现merge sort用的是top down的方式。也就是说我们首先递归的划分了各个段,再针对每个段来归并。实际上,我们也可以倒过来,从下到上,也就是一种bottom up的方式。我们可以这样来看,既然我们每次都是合并两个段,而且从前面递归解法里最终的一个情况来看,它们就是当递归到每个段长度为1的时候,然后开始合并,然后返回。这样后面一轮的时候就针对每个长度为2的段归并,再针对长度为4的段归并这样一直下去。所以我们可以这样倒过来,每次根据长度来划分整个数组,然后再来归并。这种实现的代码如下:

public static void sort(int[] a) {
        int n = a.length;
        aux = new int[n];
        for(int sz = 1; sz < n; sz = sz + sz)
            for(int lo = 0; lo < n - sz; lo += sz + sz)
                merge(a, lo, lo + sz - 1, Math.min(lo + sz + sz - 1, n - 1));
    }

    这里比较有意思的就是,我们这里的sz设置为每次要归并的段的长度,首先为1,然后再每次设置为原来的两倍。而后面循环里merge考察的是两个段,所以每次取的范围为lo到lo + sz + sz 。没想到的就是这种思路实现的代码也非常简洁。

    除了前面这些的调整和改进,我们还有没有更进一步调整改进的空间呢?

 

再进一步

    我们知道,很多排序的算法它的执行效率和输入的参数也有很大的关系,比如像insertion sort,在一些已经大致排好序的情况下,它的执行效率还是非常好的。在我们这里,也可以从这些角度来考虑一下。

数组规模

    我们知道,在一些数组规模很小的情况下,往往那些我们觉得看起来很傻瓜化的方法执行效率更加好。比如说我们知道merge sort的时间复杂度为O(NlogN),而insertion sort的时间复杂度为O(N * N)。可是在数组规模很小的时候,insertion sort甚至根本就不需要创建一个额外的数组,这些来回拷贝的开销也会有比较大的影响。所以很多时候,我们可以考虑在数组长度小到一定程度的时候,将原来的方法替换为insertion sort。

 

数组递增情况预判

    还有一个值得我们考虑的地方就是,每次我们要归并两个数组的时候,都是假设两个递增的序列里的元素都是交错在一个范围内的。如果在有的情况,一个数组的所有元素比另外一个元素里所有的元素都大呢?针对这种情况,我们可以直接将他们合并起来就完了,连来回拷贝和读取判断的功夫都省了。这也算是一种可选的改进。从实现的角度来说,我们只需要在方法里判断一下a[mid] <= a[mid + 1]是否成立就可以了。

 

减少数组之间的拷贝

    我们注意到,前面的merge方法里,因为要保存归并后的结果,需要将结果保存到一个临时的数组里,然后再将数组里的元素拷贝回来。如果我们能够将他们之间拷贝元素的过程减少的话,这样也可以得到一定的性能的提升。

    按照前面的思路,我们可以将原来的代码修改为如下:

 private static void sort(int[] a, int[] aux, int lo, int hi) {
        if(hi - lo <= 16) {  // 数组长度足够小的时候,切换成insertion sort
            Insertion.sort(a, lo, hi);
            return;
        }
        int mid = lo + (hi - lo) / 2;
        sort(aux, a, lo, mid);
        sort(aux, a, mid + 1, hi);
        merge(a, aux, lo, mid, hi);
    }

    前面的代码里增加了一个参数int[] aux, 这个数组作为一个存放上一次归并结果的数组,同时它将作为下一次归并的输入。而且在数组长度在一定范围的时候,我们将它切换成另外一个排序的方法。因为insertion sort相对比较简单,这里就省略了。

 

public static void merge(int[] a, int[] aux, int lo, int mid, int hi) {
        if(a[mid] <= a[mid + 1])
            return;
        int i = lo, j = mid + 1;

        for(int k = lo; k <= hi; k++) {
            if(i > mid) aux[k] = a[j++];
            else if(j > hi) aux[k] = a[i++];
            else if(a[j] < a[i]) aux[k] = a[j++];
            else aux[k] = a[i++];
        }
    }

     这里相对也比较简单,直接增加的这个a[mid] <= a[mid + 1]判断可以直接跳过已经排好序的序列。

 

总结

    一个看似烂熟的merge sort,如果我们换一个视角,并对一些细节的地方进行优化,会发现实际上可以改进的地方还是不少的。这些细小的地方还是很值得琢磨的。

 

参考材料

Algorithms

Introduction to algorithms

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值