🔥二路归并排序:分治思想的优雅实践,从原理到优化全解析!
🔥为了更好的让大家理解算法这里推荐一个算法可视化的网站https://staying.fun/zh/features/algorithm-visualize
复制文章中JavaScript代码示例到这个网站上就可以看到可视化算法运算的过程了!大家快点来试试吧!!!!
一、算法原理:分治思想的完美演绎
二路归并排序是一种基于分治思想的高效排序算法,其核心思想是将数组分成两半,分别排序后再合并。整个过程如同将一副打乱的扑克牌分成两堆,分别整理有序后再合并成一副有序的牌。
1.1 算法步骤
分解阶段:将数组递归拆分为两个子数组,直到每个子数组仅含一个元素(天然有序)。
合并阶段:将两个有序子数组合并为一个有序数组,逐层向上合并直到完成整体排序。💡 类比场景:整理书架时,先将书籍分成左右两堆,分别按字母顺序排列后再合并。
二、JavaScript 代码实现与注释
接下来我们用 JavaScript 来实现二路归并排序,分别通过递归和非递归两种方式。
2.1 递归实现
function mergeSort(arr) {
// 如果数组长度小于等于1,说明已经有序,直接返回
if (arr.length <= 1) {
return arr;
}
// 找到数组的中间位置
const mid = Math.floor(arr.length / 2);
// 分割数组为左半部分
const left = arr.slice(0, mid);
// 分割数组为右半部分
const right = arr.slice(mid);
// 递归地对左右两部分进行排序
return merge(mergeSort(left), mergeSort(right));
}
function merge(left, right) {
const result = [];
let leftIndex = 0;
let rightIndex = 0;
// 比较左右数组的元素,按顺序放入结果数组
while (leftIndex < left.length && rightIndex < right.length) {
if (left[leftIndex] < right[rightIndex]) {
result.push(left[leftIndex]);
leftIndex++;
} else {
result.push(right[rightIndex]);
rightIndex++;
}
}
// 将左数组剩余元素添加到结果数组
return result.concat(left.slice(leftIndex)).concat(right.slice(rightIndex));
}
// 测试示例
const array = [38, 27, 43, 3, 9, 82, 10];
const sortedArray = mergeSort(array);
console.log('递归实现的归并排序结果:', sortedArray);
在递归实现中,mergeSort
函数不断将数组二分,直到子数组长度为 1,然后通过 merge
函数将有序子数组合并。
2.2 非递归实现(优化栈溢出问题)
function mergeSortNonRecursive(arr) {
const len = arr.length;
if (len <= 1) {
return arr;
}
// 步长,从1开始,每次翻倍
let step = 1;
while (step < len) {
for (let start = 0; start < len; start += 2 * step) {
let mid = Math.min(start + step, len);
let end = Math.min(start + 2 * step, len);
let left = arr.slice(start, mid);
let right = arr.slice(mid, end);
let leftIndex = 0;
let rightIndex = 0;
let resultIndex = start;
// 合并两个有序子数组
while (leftIndex < left.length && rightIndex < right.length) {
if (left[leftIndex] < right[rightIndex]) {
arr[resultIndex] = left[leftIndex];
leftIndex++;
} else {
arr[resultIndex] = right[rightIndex];
rightIndex++;
}
resultIndex++;
}
// 将左数组剩余元素放回原数组
while (leftIndex < left.length) {
arr[resultIndex] = left[leftIndex];
leftIndex++;
resultIndex++;
}
// 将右数组剩余元素放回原数组
while (rightIndex < right.length) {
arr[resultIndex] = right[rightIndex];
rightIndex++;
resultIndex++;
}
}
// 步长翻倍
step *= 2;
}
return arr;
}
// 测试示例
const array2 = [38, 27, 43, 3, 9, 82, 10];
const sortedArray2 = mergeSortNonRecursive(array2);
console.log('非递归实现的归并排序结果:', sortedArray2);
非递归实现通过循环和步长控制,避免了递归调用带来的栈溢出风险,更加适合处理大规模数据。
三、算法关键点解析
3.1 分治策略
递归分解:归并排序通过不断将数组二分,将大问题转化为小问题。这个递归分解的过程就像把一个大蛋糕切成小块,直到每一小块都足够小(数组只剩一个元素)。由于每次都将数组一分为二,所以分解的层数为 log n \log n logn,时间复杂度为 O ( log n ) O(\log n) O(logn)。例如,一个长度为 8 的数组,经过 3 次分解就可以得到 8 个单个元素的子数组( log 2 8 = 3 \log_2 8 = 3 log28=3)。
合并操作:当子数组被分解到最小单元后,就需要将它们合并起来。合并过程使用双指针技术,同时遍历两个有序子数组,比较指针指向的元素大小,将较小的元素依次放入结果数组。这就好比将两堆按顺序摆放的书籍合并成一堆有序的书籍,每次比较两堆书的最上面一本,将页码小的那本先放入新的书堆。这个合并操作对于长度为 n n n 的数组,时间复杂度为 O ( n ) O(n) O(n),因为每个元素都需要被比较和移动一次。
3.2 稳定性保证
二路归并排序是一种稳定的排序算法。这意味着在排序过程中,相同元素的相对顺序不会改变。在合并操作中,当比较两个子数组的元素时,如果两个元素相等,我们先将左边子数组的元素放入结果数组,这样就保证了相同元素在原数组中的顺序在排序后得以保留。比如,数组 [3, 3*, 1]
(这里 3*
表示另一个值为 3 的元素),排序后仍然是 [1, 3, 3*]
,而不会变成 [1, 3*, 3]
。这种稳定性在一些需要保持元素原有顺序的场景中非常重要,如按成绩排序学生信息时,成绩相同的学生原来的先后顺序不变。
3.3 空间复杂度
递归实现:在递归实现中,由于需要额外的空间来存储临时数组用于合并操作,并且递归调用会在栈上占用空间,所以空间复杂度为 O ( n ) O(n) O(n)。例如,对一个长度为 n n n 的数组进行排序,需要开辟一个大小为 n n n 的临时数组来存储合并结果 ,再加上递归调用栈的空间,总体空间复杂度达到 O ( n ) O(n) O(n)。
非递归实现:非递归实现虽然避免了递归调用栈的开销,但在合并过程中同样需要临时数组,所以空间复杂度在最坏情况下仍为 O ( n ) O(n) O(n)。不过,通过一些优化技巧,如原地归并(但实现较为复杂且效率提升有限),理论上可以将空间复杂度优化到 O ( 1 ) O(1) O(1) ,即不使用额外的与输入数据规模相关的空间 。在实际应用中,非递归实现对于大规模数据可以减少栈溢出的风险,同时在空间利用上相对递归实现更可控。
四、算法复杂度分析
时间复杂度:二路归并排序的时间复杂度为 O ( n log n ) O(n \log n) O(nlogn)。在分解阶段,每次将数组一分为二,需要 log n \log n logn 层分解,每层分解都需要对数组元素进行比较和合并操作,合并操作的时间复杂度为 O ( n ) O(n) O(n)。根据主定理(Master Theorem),对于 T ( n ) = 2 T ( n 2 ) + O ( n ) T(n) = 2T(\frac{n}{2}) + O(n) T(n)=2T(2n)+O(n) 这种形式(其中 T ( n ) T(n) T(n) 是排序 n n n 个元素所需的时间),其时间复杂度为 O ( n log n ) O(n \log n) O(nlogn)。无论是最好情况(数组已经有序)、最坏情况(数组逆序)还是平均情况,时间复杂度均为 O ( n log n ) O(n \log n) O(nlogn) ,这是因为分治策略保证了每次处理的数据规模都是按对数级别减少的 。例如,对于一个长度为 16 的数组,需要进行 4 层分解( log 2 16 = 4 \log_2 16 = 4 log216=4),每层都需要对 16 个元素进行合并操作,所以总的时间复杂度为 O ( 16 × 4 ) = O ( n log n ) O(16 \times 4) = O(n \log n) O(16×4)=O(nlogn)。
空间复杂度:空间复杂度为 O ( n ) O(n) O(n) ,主要来自两部分:一是递归调用栈的空间,深度为 log n \log n logn ,空间复杂度为 O ( log n ) O(\log n) O(logn);二是合并过程中使用的辅助数组,大小为 n n n ,空间复杂度为 O ( n ) O(n) O(n)。在最坏情况下,递归调用栈达到最深,辅助数组也占用最大空间,所以总的空间复杂度为 O ( n ) O(n) O(n)。例如,对一个长度为 n n n 的数组进行排序,递归调用栈在某一时刻最多存储 log n \log n logn 个函数调用信息,同时辅助数组需要存储 n n n 个元素 ,因此空间复杂度为 O ( n ) O(n) O(n)。
适用场景:由于其稳定且高效的特性,二路归并排序适用于大规模数据排序场景。在外部排序中,当数据量超过内存容量时,可以将数据分批读入内存,利用归并排序进行排序后再合并结果 。此外,当排序稳定性要求较高时,如对学生成绩按分数排序且保持相同分数学生的原有顺序,二路归并排序是很好的选择 。
五、优化策略与拓展
5.1 优化合并过程
在合并两个有序子数组时,可以使用哨兵技术简化代码。在子数组末尾添加一个极大值(如 Infinity
)作为哨兵,这样在比较时就无需每次判断子数组是否越界,从而减少代码的复杂性和运行时的判断开销 。例如,在合并函数中,可以这样修改:
function merge(left, right) {
left.push(Infinity);
right.push(Infinity);
const result = [];
let leftIndex = 0;
let rightIndex = 0;
while (true) {
if (left[leftIndex] < right[rightIndex]) {
result.push(left[leftIndex]);
leftIndex++;
} else {
result.push(right[rightIndex]);
rightIndex++;
}
if (leftIndex === left.length - 1 || rightIndex === right.length - 1) {
break;
}
}
return result;
}
这样,在比较过程中,只要有一个子数组遍历到哨兵,就可以确定另一个子数组剩余元素都大于已遍历的元素,直接将剩余元素添加到结果数组即可,无需再进行额外的边界检查。
5.2 处理小数组
当子数组长度小于阈值(如 10)时,改用插入排序可以提升效率。插入排序在处理小数组时,常数因子较小,实际运行速度比归并排序更快。因为归并排序的递归调用和额外的空间开销在小数组场景下显得得不偿失 。可以在递归过程中加入如下判断:
function mergeSort(arr) {
if (arr.length <= 1) {
return arr;
}
if (arr.length < 10) {
return insertionSort(arr);
}
const mid = Math.floor(arr.length / 2);
const left = arr.slice(0, mid);
const right = arr.slice(mid);
return merge(mergeSort(left), mergeSort(right));
}
function insertionSort(arr) {
for (let i = 1; i < arr.length; i++) {
const key = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
return arr;
}
通过这种方式,在处理小数组时利用插入排序的优势,在处理大数组时利用归并排序的优势,从而提高整体排序效率。
5.3 并行化处理
利用多线程同时排序左右子数组可以显著提升排序速度,尤其在多核处理器环境下。在 JavaScript 中,可以使用 Web Workers 实现并行化(注意 Web Workers 有同源策略限制,且只能处理可序列化的数据)。大致思路是将数组分成两半,分别交给两个 Web Workers 进行排序,然后再合并结果 。但需要注意线程安全问题,如多个线程同时访问和修改共享数据时可能导致数据不一致。例如:
// main.js
const worker1 = new Worker('worker.js');
const worker2 = new Worker('worker.js');
const arr = [38, 27, 43, 3, 9, 82, 10];
const mid = Math.floor(arr.length / 2);
const left = arr.slice(0, mid);
const right = arr.slice(mid);
worker1.postMessage(left);
worker2.postMessage(right);
worker1.onmessage = function (e) {
const sortedLeft = e.data;
worker2.onmessage = function (e) {
const sortedRight = e.data;
const sortedArray = merge(sortedLeft, sortedRight);
console.log('并行化归并排序结果:', sortedArray);
};
};
// worker.js
self.onmessage = function (e) {
const arr = e.data;
const sortedArr = mergeSort(arr);
self.postMessage(sortedArr);
};
在这个示例中,main.js
将数组分成两半分别发送给两个 Web Workers,Web Workers 内部使用归并排序对接收的数组进行排序,排序完成后将结果返回给 main.js
进行合并 。在实际应用中,还需要处理错误、资源管理等问题,确保并行化的高效和稳定。
六、应用场景与优缺点
6.1 优点
高效稳定:时间复杂度稳定在 O ( n log n ) O(n \log n) O(nlogn) ,无论数据初始状态如何,都能保持较好的性能,适用于大规模数据排序。在处理海量数据时,相比一些时间复杂度为 O ( n 2 ) O(n^2) O(n2) 的排序算法(如冒泡排序、插入排序),效率优势明显。而且,它是稳定排序算法,在排序过程中相同元素的相对顺序保持不变,这在对含有相同关键字的数据进行排序时非常重要,比如对学生成绩进行排序,相同分数的学生原来的先后顺序不会因为排序而改变 。
外排序支持:能够处理无法一次性加载到内存中的数据。在处理大数据集时,可以将数据分成多个小部分,分别在内存中排序后,再利用归并操作将这些有序的小部分合并成一个大的有序数据集。例如,在处理 GB 级别的日志文件排序时,二路归并排序可以通过分块读取和合并的方式完成排序任务 ,这是很多其他排序算法无法做到的。
6.2 缺点
空间占用:需要额外的辅助数组来完成合并操作,空间复杂度为 O ( n ) O(n) O(n) 。在内存资源受限的情况下,可能会因为内存不足而导致程序运行失败或性能下降 。比如在嵌入式系统中,内存空间非常有限,使用二路归并排序就需要谨慎考虑内存的使用情况。
递归深度:递归实现的二路归并排序在处理非常大的数组时,可能会因为递归调用过深导致栈溢出错误 。虽然可以通过设置递归深度限制或采用非递归实现(如之前代码示例中的非递归版本)来解决这个问题,但这增加了代码的复杂性和开发成本。
6.3 典型应用
数据库排序:数据库中经常需要对大量数据进行排序操作,二路归并排序的稳定性和高效性使其成为一种常用的排序算法。在对数据库表中的记录按照某个字段进行排序时,如按照用户注册时间对用户表进行排序,二路归并排序可以保证相同注册时间的用户顺序不变,并且能够快速处理大规模的数据 。
分布式计算:在分布式计算框架(如 Hadoop 的 MapReduce)中,二路归并排序用于数据合并阶段。MapReduce 将大规模数据分割成多个小块,在不同的节点上并行处理(Map 阶段),然后将处理结果进行合并(Reduce 阶段) 。二路归并排序可以有效地将各个节点上产生的局部有序结果合并成全局有序的结果,确保数据的正确性和一致性 。
图像渲染:在计算机图形学中,按深度排序多边形以正确渲染遮挡关系时会用到二路归并排序。当渲染一幅包含多个多边形的场景时,需要根据多边形到观察者的距离(深度)对它们进行排序,以便正确显示前面的多边形遮挡后面的多边形 。二路归并排序的稳定性可以保证在深度值相同的情况下,多边形的相对顺序保持不变,从而避免渲染错误 。
七、避坑指南
在实现二路归并排序时,有几个常见的问题需要注意:
索引越界:在合并两个子数组时,一定要确保指针不会超出数组范围。在非递归实现中,计算子数组的起始和结束索引时要特别小心,防止访问到不存在的元素 。比如在设置右子数组的结束索引时,要使用 Math.min
函数来避免索引超出数组长度,像这样 let end = Math.min(start + 2 * step, len);
,确保 end
不会超过数组 arr
的长度 len
。
稳定性维护:如果需要保持排序的稳定性,在比较元素时应使用 <=
而不是 <
。因为当两个元素相等时,使用 <
可能会导致相同元素的顺序在合并过程中被交换 ,从而破坏稳定性。例如在合并函数中,if (left[leftIndex] < right[rightIndex])
这一行,如果要保证稳定性,应改为 if (left[leftIndex] <= right[rightIndex])
。
内存优化:非递归实现中,可以通过复用辅助数组来减少内存分配次数。如果每次合并都创建新的辅助数组,会造成不必要的内存开销 。在实际应用中,可以提前创建一个固定大小的辅助数组,在每次合并时重复使用它,而不是在每次合并时都重新创建,这样可以提高内存的使用效率 。
八、总结
二路归并排序通过分治思想和双指针技巧,在保证稳定性的同时实现了高效排序。掌握其核心原理和优化策略,能够在实际开发中应对各种排序需求。你在哪些场景用过归并排序?欢迎在评论区交流!👇# 排序算法 #归并排序 #JavaScript #算法优化