标签:
归并排序(Merge Sort,台湾译作:合并排序)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。
归并操作(Merge),也叫归并算法,指的是将两个已经排序的序列合并成一个序列的操作。归并排序算法依赖归并操作。归并排序有多路归并排序、两路归并排序 , 可用于内排序,也可以用于外排序。这里仅对内排序的两路归并方法进行讨论。
归并排序的步骤如下:
1)Divide: 将待排序序列(原问题)分成两个规模大致相等的子序列(子问题);
2)Conquer: 递归地排序两个子序列,当子序列规模足够小的时候直接完成排序;
3)Combine: 合并两个已序的子序列得到原序列的排序结果。
动画演示可以参照这个网址。
对于归并排序,最重要的就是归并操作。对于归并操作,可作如下理解:
1)针对输入的两段等合并的有序子序列,我们申请两个变量,分别对它们进行备份;
2)将这两段序列有序地合并到原序列中。因为这段序列是有序的,所以,当我们要判断将哪一个数据填入原序列时,只需判断两段序列中位置最低、且尚未被填入原序列的数据的大小,再将较小的数据填入原序列就可以了。
一个比较详细的程序如下:
1 void MergeSort::mergesort(int low, int high) 2 { 3 if (high <= low) return; 4 5 int mid = (low + high) / 2; 6 mergesort(low, mid); 7 mergesort(mid + 1, high); 8 merge(low, mid, high); 9 } 10 11 void MergeSort::merge(int low, int mid, int high) 12 { 13 int nOfLeft = mid - low + 1; 14 int nOfRight = high - mid; 15 int * copyOfLeft = new(nothrow) int[nOfLeft]; 16 int * copyOfRight = new(nothrow) int[nOfRight]; 17 assert(copyOfLeft != nullptr); 18 assert(copyOfRight != nullptr); 19 20 // Copy left part 21 int i; 22 for (i = 0; i < nOfLeft; i++) 23 { 24 copyOfLeft[i] = arr[i + low]; 25 } 26 27 // Copy right part 28 int j; 29 for (j = 0; j < nOfRight; j++) 30 { 31 copyOfRight[j] = arr[j + mid + 1]; 32 } 33 34 // 在这里需要意识到的一个问题:copyOfLeft及copyOfRight已经是“有序”序列 35 // Merge 36 i = 0; 37 j = 0; 38 for (int k = low; k < high + 1; k++) 39 { 40 if (i > nOfLeft - 1) 41 { 42 arr[k] = copyOfRight[j]; 43 j++; 44 } 45 else if (j > nOfRight - 1) 46 { 47 arr[k] = copyOfLeft[i]; 48 i++; 49 } 50 else if (less(copyOfLeft[i], copyOfRight[j])) 51 { 52 arr[k] = copyOfLeft[i]; 53 i++; 54 } 55 else 56 { 57 arr[k] = copyOfRight[j]; 58 j++; 59 } 60 } 61 62 delete[] copyOfLeft; 63 delete[] copyOfRight; 64 }
如果我们对这段程序进行压缩,就可以写成下边这样:
1 void MergeSort::merge(int low, int mid, int high) 2 { 3 int numOfArr = high - low + 1; 4 int * copyOfArr = new(nothrow) int[numOfArr]; 5 assert(copyOfArr != nullptr); 6 7 // Copy 8 for (int k = 0; k < numOfArr; k++) 9 copyOfArr[k] = arr[low + k]; 10 11 // Merge 12 int i = low; 13 int j = mid + 1; 14 for (int k = low; k < high + 1; k++) 15 { 16 if (i > mid) 17 { 18 arr[k] = copyOfArr[j - low]; 19 j++; 20 } 21 else if (j > high) 22 { 23 arr[k] = copyOfArr[i - low]; 24 i++; 25 } 26 else if (less(copyOfArr[j - low], copyOfArr[i - low])) 27 { 28 arr[k] = copyOfArr[j - low]; 29 j++; 30 } 31 else 32 { 33 arr[k] = copyOfArr[i - low]; 34 i++; 35 } 36 } 37 38 delete[] copyOfArr; 39 }
完整程序请见Github.
有两篇博文谈到了对归并排序优化的方法:
归并排序是一种相当稳健的排序算法,无论何种输入序列,其期望时间复杂度和最坏时间复杂度都是Θ(nlogn),这已经达到了基于比较排序算法的渐进下界。
因此归并排序时常会用于对可能导致quicksort退化的序列排序。
但是在实践中,归并排序花费的时间往往超过预期,对于普通的序列而言,所花费的时间甚至远远超过quicksort。
究其原因,和归并排序的内存策略有关。
归并排序不是原地排序,需要额外的存储空间。并且在每次Merge过程中,需要动态分配一块内存以完成对两个数据堆的排序合并。并且排序完毕之后,我们需要将存储空间中的数据复制并覆盖原序列。
最后一步操作是由归并排序自身性质决定,无法优化,所以我们只能针对Merge操作。
经过分析很容易知道,对于长度为n的序列,要执行logn次的merge操作,这意味着需要进行logn次的内存分配和回收。内存操作开销较大。
如果能够一次性分配长度为n的存储空间,那么就省掉了大量的分配操作,可以极大提高效率。
由于归并的分治特性,我们需要在原来的函数基础之上,包装一层驱动函数(driver function)
1 // driver function 2 void _MergeSort() 3 { 4 // allocation once 5 int* pTmpBuf = new(nothrow) int[len]; 6 assert(pTmpBuf != nullptr); 7 mergesort(pTmpBuf, 0, len - 1); 8 delete[] pTmpBuf; 9 }
根据作者所做实现,改进后的算法基本上比原始的要快30~40倍。
刚开始写程序的时候,我就一直在想,为什么在合并的时候不直接用插入排序呢?这样不就解决了问题了?而且,插入排序不用申请额外空间。
诚然,这样子做是可以的,但是我们这样子做就没有好好利用合并操作中“输入的两段子序列是有序的”这个条件,是个浪费;另外,插入排序的平均时间复杂度是O(n2),当n很大时,对合并排序贡献的时间复杂度是不可忽略的。
那有没有一个临界值,使得插入排序的加入使得合并排序的时间复杂度降低了呢?
下边摘自博文归并排序利用插入排序优化:
对于归并排序的优化,除了采用一次性内存分配策略外,还可以对小规模数组采用插入排序以提高效率。
相比较而言,插入排序的原地、迭代实现的性质使得其对于小规模数组的排序更具优势。那么,一个值得思考的问题是,当子问题规模为多大时,适合采用插入排序?
考虑一个理想化的模型:有n/k个具有k个元素的列表,我们需要对每个列表采用插入排序,再利用标准合并过程完成整个排序。那么我们可以得到如下的分析:
(0) 对每个列表排序的最坏时间是Θ(k2),则n/k个列表需要Θ(nk)的渐进时间。
(1) 合并这些列表需要Θ(nlog(n/k))的时间:最初合并n/k个规模为k的列表需要 cn/k * k = Θ(n),再利用数学归纳法可证每次合并都需要Θ(n),并且需要log(n/k)次合并。或者也可以通过递归树尽心分析。
(2) 总时间为Θ(nk+nlog(n/k)),我们可以利用这个总渐进时间,导出k取值的界
由反证法可以得到,k的阶取值不能大于Θ(logn),并且这个界可以保证插排优化的渐进时间不会慢于原始归并排序。
由于对数函数的增长特点,结合实际排序规模,k得实际取值一般在10~20间。
在归并中利用插入排序不仅可以减少递归次数,还可以减少内存分配次数(针对于原始版本)。
为了比较实际效果,我分别写了四个版本的代码,分别对应:原始版本, 插排优化, 内存分配策略优化, 内存分配策略+插排优化。并且对1000W和1亿个随机数进行了测试,得到了如下结果
考虑到数据规模,插排优化的k的取值为20。N/A表示未进行测试。
对于1000W的规模,优化版本的时间均可以控制在5S内,而原始版本需要40S+。并且优化内存分配策略的版本效率比起插排优化有微弱的优势。
对于1亿的数据规模(我放弃了测试原版性能,因为时间真的太长了…),内存分配的优化比起插入排序要更加明显,而结合二者的优化也只比前者快了几秒。
另外,在1亿的数据规模测试中,我试探性地把k从20调到了25,发现对于同时采用插排优化和内存优化测了的版本基本上只快了1S。
所以可以预见的是,对于更大规模的数据,动态内存分配是一个很大的瓶颈,我们可以稍稍计算下n个元素的合并需要多少次内存分配。
利用归纳可以很容易的算出:
也就是说,原始的归并排序对于n个元素需要Θ(n)的分配。这个瓶颈是很明显的。
其他参考博文:
标签:
原文地址:http://www.cnblogs.com/xiehongfeng100/p/4418638.html