简介
讨论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,如果我们换一个视角,并对一些细节的地方进行优化,会发现实际上可以改进的地方还是不少的。这些细小的地方还是很值得琢磨的。
相关推荐
5. **归并排序(Merge Sort)**:归并排序也是基于分治策略,将数组分为两半,分别进行排序,然后再合并。Java中通常使用递归,以及额外的存储空间来合并两个已排序的子数组。 6. **堆排序(Heap Sort)**:堆排序...
随着业务需求的变化和技术的进步,开发者不仅要关注算法的功能实现,还需要考虑其效率、可维护性和扩展性。设计模式作为一种优秀的编程实践方法,能够帮助程序员更好地组织代码,提高软件的质量。本文将探讨如何利用...
书中详细介绍了插入排序(Insertion sort)、归并排序(Merge sort)、快速排序(Quick sort)、堆排序(Heap sort)等常见的排序算法,以及它们的时间复杂度分析。堆排序特别强调了堆(Heap)这一数据结构的应用,...
容器是基本的数据结构,如vector、list、map、set等,而算法是常用的操作,如find、sort、merge等。泛型开发思想使得容器和算法可以独立发展和变化,而不互相影响。 Iterator是泛型开发思想的核心概念,Iterator是...
4. **合并排序(Merge Sort)**: 合并排序是采用分治法的一个典型应用。它将待排序的序列分成两半,分别对左右两半进行排序,然后再将两个有序的子序列合并成一个有序序列。这种排序算法的时间复杂度为O(n log n)...
- 示例:Java实现希尔排序会使用嵌套循环,外层循环控制增量d的变化,内层循环则执行插入排序操作。 - 代码实现(见上文的`shellSort`方法) 3. **简单选择排序(Simple Selection Sort)** - 基本思想:简单...
合并排序(Merge Sort)和快速排序(Quick Sort)是两种非常经典的排序算法,它们在算法设计和编程中广泛应用。在本实验中,我们将比较这两种排序算法的原理、实现过程、效率等方面,并通过代码实现及图例辅助说明,...
以下是一些从给定的标题和描述中提取的C语言实现的经典算法: 1. **汉诺塔(Hanoi Tower)**:这是一个递归算法问题,目标是将一个柱子上的所有圆盘移动到另一个柱子上,遵循每次只能移动一个圆盘且大盘子不能在小...
4. 在文档中提到了一些算法的具体实现,如插入排序(Insertion Sort)和选择排序(Selection Sort)。插入排序的性能在某些情况下会超过归并排序(Merge Sort),特别是当数组大小较小时。文档中给出了一个临界点的...
在`sort-visualizer-master`压缩包中,包含的是项目的源代码,开发者可以查看和学习实现这些功能的具体代码,这对于深入理解 JavaScript 编程和算法实现有极大帮助。 总之,`sort-visualizer` 是一个实用的工具,...
其效率通常优于O(n log n),但具体实现细节可能会根据编译器和库版本的不同而变化。 这些排序算法各有特点,适用于不同的场景。理解并掌握它们可以帮助开发者根据实际需求选择最适合的排序方法,从而提高程序的运行...
总结,`list`链表实现技术在C++中提供了高效且灵活的数据结构,适合处理动态变化的序列,而自定义键的比较函数则是使用自定义类型在STL容器中正确工作的关键。在处理结构体或其他自定义类型时,确保它们满足容器的...
- **合并排序算法(Merge Sort)**:是一种高效的排序算法,采用分治法的思想,将数组分成两部分分别进行排序,然后将排序好的两部分合并起来。文件中展示的voidMerge函数是合并排序算法中用于合并两个有序数组的...
归并排序 (Merge Sort) - **时间复杂度**:始终为O(nlogn)。 - **空间复杂度**:O(n),需要额外的空间。 - **稳定性**:稳定。 - **实现原理**:采用分治策略,将数组分割成两半,分别排序后再合并。 ##### 5. 堆...
文件内容中还提到了部分课程内容,包含渐进符号、最坏情况分析、以及合并排序(Merge Sort)。 渐进符号是算法分析中的基本工具,用于描述算法性能随着输入规模增长的变化趋势。渐进符号中最重要的是大O(Big O)、...
通过对Mapred-site.xml和core-site.xml中的各项参数进行细致调整,并结合合适的压缩算法和调度策略,可以实现Hadoop集群性能的最大化。这需要根据具体的工作负载和硬件环境进行试验和分析,找到最适合的配置组合。
1. **表连接方式**:四种常见的表连接方式包括哈希连接(Hash Join)、归并连接(Merge Join)、嵌套循环连接(Nested Loop Join,也称为Cluster Join)和索引连接(Index Join)。每种连接方式都有其适用场景和效率...
- **带符号乘法/减法**:带符号的乘法和减法操作除了需要处理一般的乘法和减法逻辑外,还需要正确处理正负号的变化。 - **浮点运算**:虽然在大多数ACM比赛中处理浮点数不是主要需求,但是了解如何实现大整数的浮点...
Oracle内存管理的精细度和灵活性对于实现高性能数据库环境至关重要。理解SGA和PGA的结构、功能及其关键参数,可以帮助数据库管理员和开发人员更有效地配置和优化Oracle实例,从而提高系统性能,减少故障和资源浪费。...