ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

归并排序详解:从分治思想到代码实现与优化应用

归并排序详解:从分治思想到代码实现与优化应用 我一直觉得排序算法是算法学习里最值得“较真”的一个板块而归并排序又是其中最特别的一个它不像快速排序那么锋芒毕露但胜在稳定可靠它的代码写起来有一定门槛但思想却简单得一句话就能说清。在我带过的应届生和转行候选人里能把归并排序讲透的人对分治、递归、复杂度分析这些基本功的理解通常也不会差。这篇文章就围绕归并排序展开从原理推导到代码实现再到优化技巧和实际应用把我这些年踩过的坑和积累的经验一并写出来希望对正在啃《数据结构与算法》或者准备算法面试的朋友有帮助。1. 归并排序的核心思想与整体设计1.1 从“合并两个有序数组”说起理解归并排序最关键的入口不是“排序”而是“合并”。如果你手上有两个已经排好序的数组比如[1, 3, 5]和[2, 4, 6]想要把它们合并成一个有序数组最直接的办法就是双指针两个指针分别指向两个数组的头部比较大小把较小的那个放入结果集然后移动对应指针。这个过程的时间复杂度是 O(n)而且非常容易实现。归并排序的整个算法就是建立在这个基础操作之上的。它把一个大数组不断对半分直到每个子数组只有一个元素——一个元素天然就是有序的。然后再两两合并这些有序子数组合成更大的有序子数组一路合并回去最终整个数组就变得有序。这个过程不需要像快速排序那样选择基准值也不依赖数据的初始分布任何输入对它来说都是一视同仁地“分到底再合起来”。我早期学习的时候总觉得归并排序比冒泡排序和选择排序难理解后来发现问题出在我一直盯着“排序”本身而没有先去吃透“合并有序数组”这个子问题。一旦你写熟了merge函数归并排序的骨架就是一棵递归树剩下的都是套路。1.2 分治思想在归并排序中的落地方式分治思想Divide and Conquer是计算机科学里极其重要的一种问题拆解策略而归并排序是它最教科书级的代表。分治三步走分解、解决、合并。归并排序的体现也正好是这三步分解把当前区间[left, right]从中间位置mid断开分成[left, mid]和[mid 1, right]两个子区间。解决递归地对这两个子区间调用归并排序直到子区间长度为 1此时子区间天然有序。合并将两个已经有序的子区间用双指针法合并成一个有序区间然后拷贝回原数组。我反复强调这三步是有原因的很多人写归并排序写不对不是不会写递归而是在“合并”这一步把临时数组的下标搞乱了或者忘记把临时数组的结果拷回去。分治的递归部分其实很机械真正的变量管理难点全在合并的代码里。这里有个很容易忽略的细节分解的时候不是物理上把数组切断而是逻辑上通过left、mid、right三个下标来划定区间。整个排序过程是在原数组上加一个辅助数组完成的不需要真的创建很多子数组否则空间复杂度会失控。1.3 与快速排序、堆排序的横向对比既然聊到排序算法归并排序绕不开和另外两个 O(n log n) 级别的算法做对比快速排序和堆排序。我做一个简单的对比表格方便读者记忆指标归并排序快速排序堆排序时间复杂度平均O(n log n)O(n log n)O(n log n)时间复杂度最坏O(n log n)O(n²)O(n log n)空间复杂度O(n)O(log n)递归栈O(1)稳定性稳定不稳定不稳定是否适合链表非常适合较难实现不适合这个对比能解释很多面试题为什么归并排序是稳定排序而快排不是因为合并两个有序子数组的时候只要在比较相等元素时优先取左边子数组的元素那么相同元素的相对顺序就不会改变。快速排序在划分时基准值会直接交换到某个位置这个过程很容易破坏相同元素的相对顺序。而堆排序虽然空间复杂度做到了 O(1)但它是不稳定的而且在实际运行中因为缓存不友好往往比归并排序和快速排序都慢。归并排序相比之下是一个各方面都非常“稳妥”的算法代价就是那份 O(n) 的额外空间。2. 归并排序的复杂度分析与稳定性探究2.1 时间复杂度为什么稳定在 O(n log n)归并排序的时间复杂度推导非常适合作为递归函数的复杂度分析入门题。假设规模为 n 的数组分解需要 T(n) 的时间递推关系可以写成T(n) 2·T(n/2) O(n)意思是先把问题拆成两个规模为 n/2 的子问题各自需要 T(n/2) 的时间合并两个有序子数组最坏情况下需要比较 n 次所以是 O(n)。这个递推式展开后每一层的总代价是 O(n)层数是 log n因为每次规模减半所以总时间复杂度是 O(n log n)。这个推导的直观理解是每一层递归树中所有子问题加起来的规模加起来还是 n只是被切成了更多块。所以不管数据是正序、逆序还是乱序归并排序的比较次数基本稳定在同一个量级不像快速排序那样可能退化到 O(n²)。我记得有一道经典的笔试题是“归并排序在最好、最坏、平均情况下的时间复杂度分别是多少”答案是三者都是 O(n log n)。这一点在做算法选型的时候很有参考价值——如果你的应用场景里数据分布可能极其不均匀归并排序的稳定性在时间层面也是优势。2.2 空间复杂度递归栈与辅助数组归并排序的空间复杂度是初学者很容易算错的地方。很多人只看到merge函数里申请了辅助数组就觉得空间复杂度是 O(n)但忽略了递归调用栈的空间。递归深度是 log n 层每层调用需要保存一些局部变量和返回地址这部分空间是 O(log n)。而合并时需要一个长度为 n 的辅助数组这部分占 O(n)。所以归并排序的空间复杂度是 O(n log n)也就是 O(n)。我在实际写代码时会把辅助数组一次性分配好然后通过下标传递而不是在每次合并时都新建数组。这样既减少了频繁创建对象的开销也避免了额外的内存碎片。等会儿在代码实现部分我会详细展示这种写法。2.3 稳定性分析它为什么是稳定排序稳定性是排序算法一个容易被新手忽略、但在实际工程中非常重要的属性。什么叫做稳定就是如果两个元素值相等排序后它们在数组中的相对顺序和排序前保持一致。归并排序的稳定性来源于合并过程中对相等元素的处理方式。在merge函数里当左子数组的元素小于等于右子数组的元素时我们把左子数组的元素先放入辅助数组。这里的“小于等于”是关键——如果用“小于”而不是“小于等于”相等元素的相对顺序就会被打破稳定性就丢失了。有一个我在实际开发中遇到的例子可以说明稳定性的价值一个学生列表先按班级排序再按成绩排序。如果第二次排序用的是不稳定的算法那么成绩相同的学生班级顺序可能会被打乱如果用的是稳定的归并排序那么两次排序后班级和成绩的顺序都能保持正确。3. 归并排序的代码实现与细节解析3.1 递归版实现的完整代码与逐行注解先给出一份我实际使用过的递归版归并排序实现语言选用 C因为热词里也有“冒泡排序算法c”说明很多读者可能在用 C 学数据结构与算法我提供 C 版本对针对性更强#include iostream #include vector using namespace std; // 合并两个有序区间 [left, mid] 和 [mid1, right] void merge(vectorint nums, int left, int mid, int right, vectorint temp) { int i left; // 左子数组的起始位置 int j mid 1; // 右子数组的起始位置 int k left; // 临时数组的起始位置注意要和 left 对齐 // 双指针比较把较小的元素放入 temp while (i mid j right) { // 这里用 保证稳定性 if (nums[i] nums[j]) { temp[k] nums[i]; } else { temp[k] nums[j]; } } // 左子数组有剩余全部拷入 temp while (i mid) { temp[k] nums[i]; } // 右子数组有剩余全部拷入 temp while (j right) { temp[k] nums[j]; } // 把合并好的有序区间拷回原数组 for (int idx left; idx right; idx) { nums[idx] temp[idx]; } } void mergeSort(vectorint nums, int left, int right, vectorint temp) { if (left right) { return; // 区间为空或只有一个元素天然有序 } int mid left (right - left) / 2; // 防溢出的写法 mergeSort(nums, left, mid, temp); mergeSort(nums, mid 1, right, temp); merge(nums, left, mid, right, temp); }这份代码里有几个细节值得专门说明。第一mid用left (right - left) / 2而不是(left right) / 2这是为了避免 left 和 right 很大的时候加法溢出整数范围虽然 N 不大的时候两者没区别但好习惯要早早养成。第二temp数组的起始下标k我设置为left而不是从 0 开始。这样在最后回拷的时候直接temp[idx]对应nums[idx]不需要做下标偏移逻辑上更直观也减少出错的可能。第三递归终止条件是left right。当区间里只有一个元素时它就是有序的不需要继续分解和合并直接返回即可。3.2 非递归版迭代法的实现思路递归版代码简洁但有一个潜在问题当数据规模很大时递归深度为 log n如果 n 是 10 亿级别log n 大概是 30 层其实还好。但有些语言环境下递归调用本身有开销或者调用栈受限这种情况下可以考虑用迭代法。迭代法的思路是用一个变量width表示当前有序子数组的长度初始为 1然后不断翻倍。每一轮把数组按照width划分成若干对子数组每对的左子数组和右子数组长度都是width最后一个可能不满对每一对调用merge。void mergeSortIterative(vectorint nums) { int n nums.size(); vectorint temp(n); for (int width 1; width n; width * 2) { for (int left 0; left n; left 2 * width) { int mid min(left width - 1, n - 1); int right min(left 2 * width - 1, n - 1); if (mid right) { merge(nums, left, mid, right, temp); } } } }迭代法的好处是不用递归逻辑上是从小到大不断合并非常像“自底向上”的归并过程。我在讲分治思想的时候经常用这个版本来强调“合并是一切的核心”因为它把递归和分解隐藏掉了只留下了一个纯粹的合并循环。迭代法写起来要特别小心边界mid和right都不能越过n - 1。尤其是当数组长度不是 2 的幂次时最后一组子数组的长度可能不足width需要用min截断。3.3 边界条件与下标计算的易错点归并排序的边界条件是新手最容易翻车的地方我甚至见过工作五年的工程师在面试时把merge里的下标写错。主要的易错点集中在三个地方分解时mid的计算分解必须保证左右子区间没有重叠且并集覆盖整个区间。left (right - left) / 2是向下取整所以左子区间[left, mid]的长度是(right - left) / 2 1右子区间[mid1, right]的长度是right - mid。当区间长度为 2 时mid left左区间[left, left]一个元素右区间[left1, right]一个元素完美。合并时临时数组的下标即使temp数组是全局复用的每次合并也只能覆盖当前区间的部分。回拷时不要忘记合并在temp里完成后目标数组对应的位置必须被更新否则后续的合并会拿着旧数据操作。我在写工程代码时有一个自己的土办法每写完一个merge调用就手动模拟一个长度为 3 或 4 的数组走一遍完整的递归过程把所有下标变化写出来。这套笨办法帮我避开了绝大多数边界Bug。4. 归并排序的优化策略与性能调优4.1 结合插入排序的混合优化归并排序的递归树越往下子问题的规模越小而递归调用的固定开销占比就越高。当子数组长度小于某个阈值时插入排序的效率反而更高因为插入排序在小规模数据上有极好的常数因子并且对部分有序的数据非常友好。一个常见的优化策略是在递归过程中如果right - left 1 阈值比如 16 或 32就直接使用插入排序对这段区间排序不再继续递归分解。实验表明这种混合策略在大量实际数据上能带来 10% 到 20% 的性能提升。void mergeSortOptimized(vectorint nums, int left, int right, vectorint temp) { // 小规模数据用插入排序减少递归开销 if (right - left 1 16) { for (int i left 1; i right; i) { int key nums[i]; int j i - 1; while (j left nums[j] key) { nums[j 1] nums[j]; j--; } nums[j 1] key; } return; } int mid left (right - left) / 2; mergeSortOptimized(nums, left, mid, temp); mergeSortOptimized(nums, mid 1, right, temp); // 如果左子数组的最大值小于等于右子数组的最小值说明整体已经有序不需要合并 if (nums[mid] nums[mid 1]) { return; } merge(nums, left, mid, right, temp); }这里面还加了一个小优化如果nums[mid] nums[mid 1]说明左子数组的所有元素都已经小于等于右子数组的所有元素整个区间已经有序不需要执行合并。这个优化对接近有序的数据很有效能省下不少比较和拷贝操作。阈值的选择不是越大越好。阈值太大插入排序的优势会被 O(k²) 的时间复杂度吃掉阈值太小优化效果不明显。16 到 32 是一个在实践中表现不错的区间我通常会根据数据规模做一两次基准测试来微调。4.2 减少数组拷贝与空间利用归并排序的一大诟病就是额外空间。常规实现里每次merge都要把结果从临时数组拷回原数组这一拷一拷之间时间其实花了不少。优化方向有两个一是尽量复用同一个辅助数组二是交替使用原数组和辅助数组来避免来回拷贝。交替法也叫原地归并的变体的思路是在递归过程中让“输入数组”和“输出数组”不断交换身份。比如第一次合并时从左到右把数据合并到辅助数组里下一次合并就不再拷贝回原数组而是把辅助数组作为输入原数组作为输出。这样一轮下来数据来回搬运的次数减少到原来的一半。我用伪代码说明一下void mergeSortAlternate(vectorint nums, vectorint buffer, int left, int right, bool isBufferOutput) { if (left right) { if (isBufferOutput) { buffer[left] nums[left]; } return; } int mid left (right - left) / 2; // 递归时切换输入输出数组的身份 mergeSortAlternate(nums, buffer, left, mid, !isBufferOutput); mergeSortAlternate(nums, buffer, mid 1, right, !isBufferOutput); if (isBufferOutput) { merge(nums, left, mid, right, buffer); // nums - buffer } else { merge(buffer, left, mid, right, nums); // buffer - nums } }这种写法在代码理解上有点反直觉但确实能省掉每次合并结束后的回拷步骤。我在大规模数据排序的场景里实测过性能提升大约在 5% 到 10% 之间算不上脱胎换骨不过在内存带宽吃紧的平台上值得考虑。4.3 如何有效利用局部性原理归并排序在缓存性能上天生不如快速排序因为它的访问模式是“分块后顺序扫描”而快速排序是“围绕基准值左右跳跃”。不过我们在 C/C 工程实践中可以通过几个手段缓解这个问题尽量使用连续内存用vector或std::array而不是链表。减少临时对象的创建一开始就分配好辅助数组全程复用。合并时优先处理连续区间不要在一个很大的辅助数组里东写一块西写一块尽量保持顺序写入。我有一次在嵌入式环境里跑大数据排序发现归并排序的主要瓶颈竟然不是 CPU而是内存带宽。后来用交替法减少拷贝又配合 16 字节对齐的分配器才把性能拉到了接近快速排序的水平。看门道的人会明白算法复杂度只是起点真实世界的性能还要考虑计算机体系结构。5. 归并排序的常见问题与排查技巧5.1 递归深入导致的栈溢出风险递归版归并排序在数据规模非常大比如几千万时虽然递归深度只有 log n但在某些受限的运行环境比如嵌入式系统或者递归栈特别小的脚本语言里仍然可能遇到栈溢出的问题。排查方法是先打印递归深度看看实际到了多少层如果系统栈确实太小就改用迭代版的归并排序。我在 Python 里遇到过类似问题递归深度到 100 层左右就开始告警后来直接用迭代版解决了。另一个简单粗暴的办法是到 main 函数开头调大线程栈大小的 API但这是环境特定写法不推荐作为通用方案。5.2 合并过程中数据覆盖的经典陷阱合并时数据覆盖是最隐蔽的 Bug 之一。比如你把合并结果写到辅助数组的[left, right]区间但遍历原数组的时候同时在用原数组[left, mid]和[mid1, right]的数据——如果目标数组和源数组是同一个而你又先覆盖了还没读到的位置数据就丢了。我在国内某大厂面试候选人的时候专门用一道归并排序笔试题测试过这个点。正确的做法是合并阶段永远从辅助数组写回原数组或者从原数组读入辅助数组源和目的必须分开。如果你发现排序结果里出现随机的大数大概率就是这块写错了。这里给一个自查建议在merge函数第一行打印left、mid、right和辅助数组当前内容然后单步跟踪。对正确性不确定的代码单步调试永远是最高效的排查方案。5.3 稳定性校验自查清单如果你写了一个归并排序想验证它是否稳定可以构造一个带重复元素的数组比如[3a, 1, 2, 3b, 1]这里的 a、b 只是用来标记两个 3 的原始顺序排序后检查3a是否仍然在3b之前。这类测试我建议做成自动化用例因为纯靠人工观察很容易漏。稳定性的自查清单总结如下合并时是否使用了而不是。相等元素是否始终优先取自左子数组。递归分解时左子数组是否始终对应原数组靠前的位置。拷贝辅助数组回原数组时是否保持了最终整体顺序。我在工程项目中一直保留着这个标记法测试用例一旦有人改动过排序代码跑一遍就能确认有没有破坏稳定性。6. 归并排序的实际应用场景6.1 链表排序归并排序的主场我之前写过一篇关于链表排序的文章结论很明确如果需要对单向链表排序优先考虑归并排序。原因很简单——链表不支持随机访问快速排序需要频繁跳转和交换节点实现起来非常别扭堆排序更是需要数组下标来模拟完全二叉树在链表上几乎不可行。而归并排序只需要顺序访问节点天然契合链表的数据结构。C 中 STL 的list::sort在底层实现就是归并排序的思想只不过它用的是迭代版的非递归归并。这个事实本身就说明归并排序和链表的适配度有多高。我在做缓存淘汰策略项目时用双向链表保存访问记录定期对链表做一次归并排序来整理数据效果非常好。6.2 外部排序处理无法完全装入内存的数据归并排序最“出圈”的应用是外部排序。当数据量远超内存容量时比如要对磁盘上几十 GB 的日志文件排序传统的排序算法全部失效。外部排序的思路是先分块读入内存用快速排序或堆排序把每块排序好写回磁盘形成一个一个有序的临时文件然后再用归并排序的思路把这些有序文件两两合并最后得到一个完全有序的大文件。MapReduce/Hadoop 生态里的 Shuffle 和 Sort 阶段本质上也是外部归并排序。我在处理数据仓库离线任务时经常要和这类排序打交道理解了归并排序就理解了分布式计算框架里大部分排序问题的底层原理。6.3 求逆序对与归并排序的奇妙结合归并排序还能顺手解决一个经典问题统计数组中的逆序对数量。逆序对是指i j但nums[i] nums[j]这样的数对。朴素解法是双重循环 O(n²)而用归并排序可以在 O(n log n) 时间内完成。原理是在合并两个有序子数组时如果左子数组的nums[i] 右子数组的 nums[j]那么从左子数组当前位置往后的所有元素[i, mid]都比nums[j]大因为左子数组是有序的。所以这一下就能统计出mid - i 1个逆序对不需要一个一个数。int mergeCount(vectorint nums, int left, int mid, int right, vectorint temp) { int i left; int j mid 1; int k left; int count 0; while (i mid j right) { if (nums[i] nums[j]) { temp[k] nums[i]; } else { count mid - i 1; // 左子数组剩余元素都比 nums[j] 大 temp[k] nums[j]; } } while (i mid) temp[k] nums[i]; while (j right) temp[k] nums[j]; for (int idx left; idx right; idx) { nums[idx] temp[idx]; } return count; }我当年第一次接触这个技巧时觉得这是一种“算法审美”上的降维打击利用排序过程中的结构信息顺手解决另一个看似无关的问题。后来在面试候选人的时候我也喜欢用这道题来考察分治思想的迁移能力。7. 学习归并排序的实操建议与常见误区7.1 从手写模拟到代码落地的练习路径如果读者是初学者我的建议是不要一上来就写代码先手动模拟一遍归并排序。拿一幅扑克牌或者写一个[5, 2, 8, 1, 9, 3]数组画一棵递归树标出每一步的左区间、右区间和合并结果。把这个过程走顺了再开始写代码你会发现那些下标问题在纸上其实都已经演练过了。第二步是只写核心的merge函数用两个现成的有序数组去测它。确保merge完全正确之后再写递归函数调用它。模块化练习能让排错范围缩小这是工程上“小步快走”思路在算法学习里的应用。我个人不太推荐一开始就背代码因为归并排序的边界细节不是靠背能记牢的。我见过很多背下代码的人在面试时一换语言就写错了原因是他们不理解每一步在解决什么问题。用纸笔模拟和模块化练习才能真正吃透这个算法。7.2 误区盘点我见过的最常见的犯错方式我在带人和面试过程中总结过归并排序最常见的几个误区误区一认为归并排序是原地排序。它需要额外空间这是它的固有特征不要和快速排序混淆。误区二合并时没有判断左子数组有序性就盲目合并。实际上递归已经保证了子数组有序但很多初学者仍然会在merge里做重复的排序操作。误区三递归终止条件写错。有人写成left right在空区间的情况下会无限递归。误区四忽略了稳定性对“等于”情况的处理。merge 里无论用还是都能得到正确的排序结果但稳定性截然不同。我也经常看到有人在merge里用std::sort去排序两个子区间那已经完全不是归并排序了属于对算法理解不到位导致的“伪实现”。7.3 如何在不同编程语言间迁移归并排序的思想是语言无关的但不同语言写出来的代码风格差异很大。C 里我用vector 下标区间Java 里常用Arrays.copyOfRange做子数组切片Python 里我倾向用列表切片来简化逻辑但注意切片会创建新列表空间复杂度更高适合演示不适合极致性能场景。这里放一个 Python 的简洁版本def merge_sort(nums): if len(nums) 1: return nums mid len(nums) // 2 left merge_sort(nums[:mid]) right merge_sort(nums[mid:]) result [] i j 0 while i len(left) and j len(right): if left[i] right[j]: result.append(left[i]) i 1 else: result.append(right[j]) j 1 result.extend(left[i:]) result.extend(right[j:]) return result这种写法虽然不节省空间但逻辑极其清晰非常适合作为教学演示。在工作中如果对空间敏感就在 C 里用辅助数组版本如果只是临时处理一份数据Python 这种简洁版本完全够用。语言迁移的关键是不变的算法骨架而不是死记某种语言的语法细节。再到后面我建议你试试手写一个“自底向上的归并排序”再试试“合并两个有序链表”把这些变体都做一遍归并排序就彻底变成你自己的技能了。这套从半天啃不懂到一小时写完的路径我自己走过也带很多人走过反馈都很不错。
返回列表