码迷,mamicode.com
首页 > 编程语言 > 详细

“《算法》第4版第2章‘排序’”:归并排序

时间:2015-04-11 23:53:09      阅读:210      评论:0      收藏:0      [点我收藏+]

标签:

  归并排序(Merge Sort,台湾译作:合并排序)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

  归并操作(Merge),也叫归并算法,指的是将两个已经排序的序列合并成一个序列的操作。归并排序算法依赖归并操作。归并排序有多路归并排序、两路归并排序 , 可用于内排序,也可以用于外排序。这里仅对内排序的两路归并方法进行讨论。

  归并排序的步骤如下:

  1)Divide: 将待排序序列(原问题)分成两个规模大致相等的子序列(子问题);

  2)Conquer: 递归地排序两个子序列,当子序列规模足够小的时候直接完成排序;

  3)Combine: 合并两个已序的子序列得到原序列的排序结果。

  动画演示可以参照这个网址

 1. 归并操作

  对于归并排序,最重要的就是归并操作。对于归并操作,可作如下理解:

  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  }
View Code

  如果我们对这段程序进行压缩,就可以写成下边这样:

技术分享
 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 }
View Code

   

  完整程序请见Github.

 2. 归并排序优化

  有两篇博文谈到了对归并排序优化的方法:

  更改内存分配策略改善归并排序效率

  归并排序利用插入排序优化

  2.1 更改内存分配

  归并排序是一种相当稳健的排序算法,无论何种输入序列,其期望时间复杂度和最坏时间复杂度都是Θ(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倍。

  2.2 结合插入排序

  刚开始写程序的时候,我就一直在想,为什么在合并的时候不直接用插入排序呢?这样不就解决了问题了?而且,插入排序不用申请额外空间。

  诚然,这样子做是可以的,但是我们这样子做就没有好好利用合并操作中“输入的两段子序列是有序的”这个条件,是个浪费;另外,插入排序的平均时间复杂度是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)的分配。这个瓶颈是很明显的。

 

  其他参考博文:

  常见排序算法 - 归并排序 (Merge Sort)

  Merge Sort

  分久必合 -- 图说归并排序

  

 

“《算法》第4版第2章‘排序’”:归并排序

标签:

原文地址:http://www.cnblogs.com/xiehongfeng100/p/4418638.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!