归并排序 java_归并排序

不同算法中往往蕴含着通用的思想,分治法就是最常用的一种。

分治法使用递归的方式,将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解从而得到原问题的解。

仍然考虑排序问题,本文将要介绍的归并排序就是使用分治法的典型案例。

思路

把待排序数组从中间分割为两个子数组,这两个子数组分别排序,排完序后再把它们合并起来。该思路的关键在于子数组如何排序,以及如何把排完序的两个子数组合并起来。

第一个问题,分治法的精髓就在于把大问题分解为小问题,因此子数组仍然要使用归并排序,这是一个递归的过程。递归结束的条件是问题不能进一步分解,即数组只有一个元素,处于自然有序状态。

第二个问题,排完序的两个子数组只需要同时遍历一遍,每次都取两者中较小的数放入最终数组,直到取完所有元素。

整个算法从细分到合并的详细过程如下图所示。

43885a8edfd1b11016a13631bfac40a0.png

每次分割,取

为数组的中间位置,于是
为第一个子数组,
为第二个子数组,进入下一层递归。当子数组的长度为1时,达到递归终止条件,开始进入归并阶段。每次归并,都会把两个有序的子数组合并为一个数组,直到最终得到一个完整的有序数组。

下图形象地描述了归并排序的完整流程。

de7c0e15b2879afa0b44beb5407171d6.gif

实现代码

代码主要包括三个函数。第一个sort是封装好的调用接口。 第二个mergeSort是归并排序的递归实现,为了复用数据,我们每次都把完整的数组引用传进去,同时传入当前处理的子数组的范围。mergeSort内部实现很简单,只需要将子数组一分为二,再次分别调用mergeSort,最后调用merge将其合并。但需要注意的是,if (p < r)作为终止条件必不可少,当分割到最后一层时,子数组的长度为1,此时pr具有相同的值,条件判断不再成立,从而结束递归。

/**
 * Merge sort.
 * @param arr Integer array to be sorted.
 */
public void sort(int[] arr) {
	mergeSort(arr, 0, arr.length - 1);
}

/**
 * Merge sort a subarray recursively.
 * @param arr The integer array where the subarray resides.
 * @param p The start index of the subarray to be sorted.
 * @param r Thn end index(included) of the subarray to be sorted.
 */
public void mergeSort(int[] arr, int p, int r) {
	if (p < r) {
		int q = (p + r) / 2;
		mergeSort(arr, p, q);
		mergeSort(arr, q + 1, r);
		merge(arr, p, q, r);
	}
}

/**
 * Merge the two sorted subarray.
 * @param arr The integer array where the two sorted subarrays reside.
 * @param p The start index of the first subarray.
 * @param q The end index(included) of the first subarray.
 * @param r The end index(included) of the second subarray. Note that the start index of the second subarray is
 *          always the next position of the end of the first subarray.
 */
public void merge(int[] arr, int p, int q, int r) {
	int n1 = q - p + 1;
	int n2 = r - q;
	int[] arrL = new int[n1 + 1];
	int[] arrR = new int[n2 + 1];
	for (int i = 0; i < n1; i++) {
		arrL[i] = arr[p + i];
	}
	for (int i = 0; i < n2; i++) {
		arrR[i] = arr[q + 1 + i];
	}
	arrL[n1] = Integer.MAX_VALUE;
	arrR[n2] = Integer.MAX_VALUE;
	int i = 0;
	int j = 0;
	for (int k = p; k <= r; k++) {
		if (arrL[i] <= arrR[j]) {
			arr[k] = arrL[i];
			i++;
		}
		else {
			arr[k] = arrR[j];
			j++;
		}
	}
}

merge的实现稍微麻烦一些, 需要额外创建两个数组,把排好序的两个子数组拷贝进去。这是为了把原来的位置腾出来,方便放置最终结果。接下来的for循环循环

次,每次从两个子数组中取出当前最小的数,依次放入原数组
arr中。这里用了一个小技巧,往两个子数组的最后额外添加了一个超级大的数,这样在比较大小的时候,如果一个子数组已经空了,不至于影响整个比较流程进行到最后。

完整代码见MergeSort.java。

分析

现在,我们来分析一下该算法的时间复杂度。归并排序采用了二等分的分治策略,因此每一级分治使问题规模减小一半,所以直到问题规模减小到1时应该经历了

级分治。每一级的归并操作需要的步数和数组长度是一致的,也就是
的执行时间。于是,总执行时间为
。与
的插入排序相比,在数据规模较大时,归并排序的耗时会少很多。

改进空间

但是,归并排序也有一些缺点。与插入排序不同的是,在任何情况下,归并排序的时间复杂度都是

,即便数组本身已经是有序的,归并排序的整个流程并不会减少任何运算步骤。此外,当递归到最后几层时,子数组的长度已经非常小,这样分割出来的大量小数组会额外花费大量时间用来内存申请和释放,函数栈的调用和弹出也会非常耗费时间。为了改善这一状况,我们可以将插入排序和归并排序结合起来,当子数组长度小于某个阈值时,切换到插入排序。具体实现也非常简单,请看下面的代码。
/**
 * Hybrid merge sort implemented by merge sort and insertion sort.
 * @param arr Integer array to be sorted.
 */
public void hybridSort(int[] arr) {
	mergeInsertionSort(arr, 0, arr.length - 1);
}

/**
 * Merge sort a subarray with the help of insertion sort.
 * @param arr The integer array where the subarray resides.
 * @param p The start index of the subarray to be sorted.
 * @param r The end index(included) of the subarray to be sorted.
 */
private void mergeInsertionSort(int[] arr, int p, int r) {
	if (p < r) {
		if (r - p < k) {
			// To simplify our implementation, we just copy arr to another array, insertion sort it and copy it
			// back.
			int[] arrCopy = new int[r - p + 1];
			System.arraycopy(arr, p, arrCopy, 0, arrCopy.length);
			insertionSort.sort(arrCopy);
			System.arraycopy(arrCopy, 0, arr, p, arrCopy.length);
		} else {
			int q = (p + r) / 2;
			mergeInsertionSort(arr, p, q);
			mergeInsertionSort(arr, q + 1, r);
			merge(arr, p, q, r);
		}
	}
}

// Switch merge sort to insertion sort when the length of subarray is below k.
private final int k = 200;

// Inner insertion sort object.
private final Sort insertionSort = new InsertionSort();

mergeInsertionSort中,我们根据子数组的长度来决定使用哪种策略。当子数组长度小于200时,切换到插入排序,一次性将这个子数组排序完成;否则继续递归调用归并排序。

我在自己电脑上测试了原始版本的归并排序和改进版的归并排序 ,各自耗时如下(数组长度为20000000,Core i5 7th Gen,单位ms)。

kMergeMerge+Insertion
221892021
2022391617
20021861482
200021822429
20000220223712

可以发现,取不同的k,结果大不相同,但存在一个最优值。当k过小时,算法接近于原版归并排序,当k过大时,算法接近于插入排序,在该测试中,k在200左右效果最好,用时低于原版归并排序。

当然,这只是一个非常简单的改进,实现非常粗暴(从代码中的两次System.arraycopy 可见一斑)。在实际应用中,还有更多改进手段,比如预先分配内存,搜索局部有序片段,将单调下降片段反转等等。把所有可行的优化手段都用上,就得到了目前业内应用最普遍的排序算法,称为TimSort,也是Python和Java等语言的内置排序算法。虽然我们的实现距离TimSort还有些距离,但基本思想是一致的,都是归并排序结合插入排序。

改进版本的完整代码见HybridSort.java。

参考资料

十大经典排序算法(动图演示) 一像素

除了冒泡排序,你知道Python内建的排序算法吗? Brandon Skerritt

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值