首页 > 算法 数据结构 > 二路归并 & 插入归并 & 原地归并

二路归并 & 插入归并 & 原地归并

2012年5月31日 发表评论 阅读评论
文章作者:Yx.Ac   文章来源:勇幸|Thinking (http://www.ahathinking.com)   转载请注明,谢谢合作。

---

本节回顾整理归并排序算法常见的几种形式,并实现。一般来讲,我们所说的归并排序都是指二路归并(非多路归并),归并排序很基础,其涉及的分治思想应用也很广泛。这里不赘述基本归并排序的思想。

插入归并

归并排序的时间复杂度为O(nlgn),空间复杂度为O(n);

但是一般来讲,基于从单个记录开始两两归并的排序并不是特别提倡,一种比较常用的改进就是结合插入排序,即先利用插入排序获得较长的有序子序列,然后再两两归并(改进后的归并亦是稳定的,因为插入排序是稳定的)。之所以这样改进是有原因的:尽管插入排序的最坏情况是O(n^2),看起来大于归并的最坏情况O(nlgn),但通常情况下,由于插入排序的常数因子使得它在n比较小的情况下能运行的更快一些,因此,归并时,当子问题足够小时,采用插入排序是比较合适的。

复杂度分析

下面分析下插入归并排序最坏情况下的复杂度:假设整个序列长度为n,当子序列长度为k时,采取插入排序策略,这样一共有n/k个子序列。

子序列完成排序复杂度:最坏情况下,n/k个子序列完成排序的时间复杂度为O(nk)。证明:每个子序列完成插入排序复杂度为O(k^2),一共n/k个子序列,故为O(nk)。

子序列完成合并复杂度:最坏情况下,合并两个子序列的时间复杂度为O(n),一共有n/k个子序列,两两合并,共需要lg(n/k)步的合并,所以这些子序列完成合并的复杂度为O(nlg(n/k))。

所以改进后的插入归并排序的最坏情况的复杂度为O(nk+nlg(n/k)),这里k的最大取值不能超过lgn,显然如果k大于lgn,复杂度就不是与归并一个级别了,也就是说假设一个1000长度的数组,采用插入策略排序子序列时,子序列的最大长度不能超过10。

归并 & 插入归并的实现代码如下,测试用例见后面代码,也可以到本站博文部分代码下载相关代码:

/* 二路归并
*  在函数外申请一个临时数组作为参数传入
*  避免递归不断创建临时数组的开销
*/
void Merge(int arr[], int beg, int mid, int end, int temp_arr[])
{
	memcpy(temp_arr+beg, arr+beg, sizeof(int)*(end-beg+1)); // 更新临时数组

	int i = beg;
	int j = mid + 1;
	int k = beg;
	while(i <= mid && j <= end)
	{
		if(temp_arr[i] <= temp_arr[j])
		{
			arr[k++] = temp_arr[i++];
		}else
		{
			arr[k++] = temp_arr[j++];
		}
	}
	while(i <= mid)
	{
		arr[k++] = temp_arr[i++];
	}
	while(j <= end)
	{
		arr[k++] = temp_arr[j++];
	}
}

void MergeSort(int arr[], int beg, int end, int temp_arr[])
{
	if(beg < end)
	{
		int mid = (beg + end) / 2;
		MergeSort(arr, beg, mid, temp_arr);
		MergeSort(arr, mid+1, end, temp_arr);
		Merge(arr, beg, mid, end, temp_arr);
	}
}

/* 改进的归并算法:插入归并
*  先通过插入排序得到较长的有序串,然后归并
*  即,当分解的数组长度小于一定值时,不再分解,改用插入排序
*/

#define INSERT_BOUND 5

void InsertSort(int arr[], int beg, int end)
{
	for(int i = beg+1; i <= end; ++i)
	{
		int temp = arr[i];
		int j = i - 1;
		while(j >= beg && arr[j] > temp)
		{
			arr[j+1] = arr[j--];
		}
		arr[j+1] = temp;
	}
}

void Insert_MergeSort(int arr[], int beg, int end, int temp_arr[])
{
	if(end - beg + 1 <= INSERT_BOUND)
	{
		InsertSort(arr,beg,end);
	}else
	{
		int mid = (beg + end) / 2;
		Insert_MergeSort(arr, beg, mid, temp_arr);
		Insert_MergeSort(arr, mid+1, end, temp_arr);
		Merge(arr, beg, mid, end, temp_arr);
	}
}

原地归并

我们说归并排序相对于快排来讲,它需要O(n)的额外空间,这一度成为归并的缺点,不过好在归并排序也可以进行原地排序,只使用O(1)的额外空间。原地归并排序所利用的核心思想便是“反转内存”的变体,即“交换两段相邻内存块”,对于反转内存的相关文章,曾在文章“关于反转字符串(Reverse Words)的思考及三种解法”中对一道面试题做了分析。这一思想用到的地方很多,在《编程珠玑》中被称为“手摇算法”。通过手摇算法的交换内存的思想来进行原地归并又有不少变种,我们举例分析一种比较常见的情况,不同的方法还有基于二分查找的方法来确定交换的内存块,在《计算机编程艺术》中也有不同的思路提供,感兴趣见本文参考资料。

下面举例说明一种原地归并排序的思想。

我们知道,无论是基于单个记录的两两归并,还是利用插入排序先得到较长的子序列然后归并,在算法合并的过程中,我们都是在合并“两个相邻的有序子序列”。

在了解原地归并的思想之前,先回忆一下一般的归并算法,先是将有序子序列分别放入临时数组,然后设置两个指针依次从两个子序列的开始寻找最小元素放入归并数组中;那么原地归并的思想亦是如此,就是归并时要保证指针之前的数字始终是两个子序列中最小的那些元素。文字叙述多了无用,见示例图解,一看就明白。

假设我们现在有两个有序子序列如图a,进行原地合并的图解示例如图b开始

如图b,首先第一个子序列的值与第二个子序列的第一个值20比较,如果序列一的值小于20,则指针i向后移,直到找到比20大的值,即指针i移动到30;经过b,我们知道指针i之前的值一定是两个子序列中最小的块。

如图c,先用一个临时指针记录j的位置,然后用第二个子序列的值与序列一i所指的值30比较,如果序列二的值小于30,则j后移,直到找到比30大的值,即j移动到55的下标;

如图d,经过图c的过程,我们知道数组块 [index, j) 中的值一定是全部都小于指针i所指的值30,即数组块 [index, j) 中的值全部小于数组块 [i, index) 中的值,为了满足原地归并的原则:始终保证指针i之前的元素为两个序列中最小的那些元素,即i之前为已经归并好的元素。我们交换这两块数组的内存块,交换后i移动相应的步数,这个“步数”实际就是该步归并好的数值个数,即数组块[index, j)的个数。从而得到图e如下:

重复上述的过程,如图f,相当于图b的过程,直到最后,这就是原地归并的一种实现思想,具体代码如下。

/* 原地归并
*  可二路归并亦可与插入排序相结合,如代码
*/
//reverse array
void reverse(int arr[], int size)
{
	int left = 0;
	int right = size -1;
	while(left < right)
	{
		int temp = arr[left];
		arr[left++] = arr[right];
		arr[right--] = temp;
	}
}

// swap [arr,arr+headSize) and [arr+headSize,arr+headSize+endSize)
void SwapMemory(int arr[], int headSize, int endSize)
{
	reverse(arr, headSize);
	reverse(arr + headSize, endSize);
	reverse(arr, headSize + endSize);
}

void Inplace_Merge(int arr[], int beg, int mid, int end)
{
	int i = beg;     // 指示有序串1
	int j = mid + 1; // 指示有序串2
	while(i < j && j <= end)
	{
		while(i < j && arr[i] <= arr[j])
		{
			++i;
		}
		int index = j;
		while(j <= end && arr[j] <= arr[i])
		{
			++j;
		}
		SwapMemory(&arr[i], index-i, j-index);//swap [i,index) and [index,j)
		i += (j-index);
	}
}

void Inplace_MergeSort(int arr[], int beg, int end)
{
	if(beg < end)
	{
		int mid = (beg + end) / 2;
		Inplace_MergeSort(arr, beg, mid);
		Inplace_MergeSort(arr, mid+1, end);
		Inplace_Merge(arr, beg, mid, end);
	}

	/* // 结合插入排序
	if(end - beg + 1 <= INSERT_BOUND)
	{
		InsertSort(arr,beg,end);
	}else
	{
		int mid = (beg + end) / 2;
		Inplace_MergeSort(arr, beg, mid);
		Inplace_MergeSort(arr, mid+1, end);
		Inplace_Merge(arr, beg, mid, end);
	}
	*/
}

/* 简单测试用例 */
void main()
{
	int arr[] = {3,5,1,7,0,6,9,11,8};
	int temp_arr[] = {3,5,1,7,0,6,9,11,8};

	/* 测试不同的归并算法 */
	//MergeSort(arr,0,8,temp_arr);
	//Insert_MergeSort(arr,0,8,temp_arr);
	//Inplace_MergeSort(arr,0,8);

	for(int i = 0; i < 9; ++i)
	{
		cout<<arr[i]<<" ";
	}
	cout<<endl;

}

对于原地归并,它只是一种归并的手段,具体实现归并排序时,可以在二路归并中使用原地归并,也可以在基于插入排序改进的归并算法中采用,如代码所示。

(全文完)

参考资料:

算法导论》第二章,思考2-1

基于手摇算法的原地归并:http://blog.csdn.net/sysuwzl/article/details/7540316

基于二分的原地归并:http://www.cppblog.com/Chipset/archive/2011/08/16/153552.html

计算机编程艺术中的原地归并:http://duanple.blog.163.com/blog/static/709717672008103041136277/

  • liql2007

    对[1,3,5,7,9,11]和 [2,4,6,8,10,12]类型的数据进行原地归并,时间复杂度是O(n^2)吧?

    • Yx.Ac

      不是O(n^2),你举的例子在合并过程中的开销就是元素移动,非原地归并也是拷贝元素到新数组,怎么算出n^2呢,合并的过程就是n的常数量级,深度lgn,故还是nlgn的 :-)

  • Yx.Ac

    个人认为应该不会慢很多,内存的交换操作无非就是移动元素呗,非原地归并也是要不断将原数组元素拷贝到新数组啊,这个复制的开销不比原地要少吧,此外,原地归并有一些元素还不用进行复制操作;而且,非原地归并一般来讲是要不断开辟新数组(从设计角度讲应该这样,不应使用外部数组,算法导论的做法就是不断开辟新数组),这个开销也不小;哈哈,所以个人感觉差不了多少,如果有兴趣完全可以做测试啊,随机一个大数组,然后运行两种算法比较下时间,嘿,欢迎你测试啊@PhoenixNirvana

  • PhoenixNirvana

    看了博主不少文章,受益匪浅,首先感谢博主啦。有个疑问,如果采用这样的原地归并的话,在时间效率上是不是要比原来的归并排序要慢很多啊,感觉涉及到很多的内存交换操作,时间时间复杂度还是O(nlogn)?