归并排序入门:分治思想与C++递归实现详解

发布时间:2026/10/22 19:38:40
归并排序入门:分治思想与C++递归实现详解
1. 为什么归并排序是初学者的“分治第一课”——先建立完整直觉接触排序算法的时候很多人的路径是冒泡、选择、插入一路走过来然后碰到快速排序和归并排序就开始卡壳。我自己带过的初学者里十个有八个在第一次看到归并排序代码时会冒出一个共同疑问明明一个数组放那儿好好的为什么要拆成两半排完序再合回去这不折腾吗这个疑问非常正常因为前面那几种简单排序都是“原地比较交换”的思路而归并排序第一次引入了真正的递归思想先把问题变小变到不能再小为止再逐步组装出答案。它不追求一步到位而是靠“拆得足够小 合得足够稳”达成结果。这种思路乍看绕路实际上却是分治思想最完整、最不用赌运气的体现。归并排序值得作为初学者理解递归和分治的跳板有几个原因第一它逻辑结构极度清晰。整个过程就三件事切开数组、递归排序、合并有序数组。没有中心元素划分的争议没有左右指针的跳变每一步都能在白纸上画出来。对还不习惯递归大脑的初学者来说这种“一眼看到底”的算法特别友好。第二它是最容易“手推验证”的排序算法。数组长度很小的时候你可以拿纸笔一步步拆开再一步步合并回来整个流程和二十分钟后的代码行为完全一致。这种确定性能让初学者把注意力集中在递归调用栈本身而不是被交换逻辑干扰。第三它是后续很多进阶算法的原型。归并排序的时间复杂度稳定在 O(n log n)空间复杂度是 O(n)这些特性天然延伸到计算逆序对、外部排序、数据库归并等场景。理解归并排序等于为后面学习树形递归、分治优化、甚至分布式排序都打好了地基。那这篇内容会覆盖什么我会从分治思想的直觉建立开始带你完整走一遍例子接着落到 C 代码实现一行一行拆开讲清楚每个变量的作用再推导一遍时间复杂度和空间复杂度最后整理初学者最容易踩的坑和调试方法并延伸聊一下逆序对计数这个经典应用。整个过程适合已经掌握基础语法、写过一两道数组题目的初学者如果你已经会写冒泡排序但第一次面对递归感到害怕那这篇文章就是为你准备的。2. 手推核心流程分解、递归、合并三个阶段到底发生了什么2.1 一句话总结归并排序分到不能再分合出整体有序先看一个核心逻辑框架。给定一个数组归并排序做的事情可以用三句话描述把数组从中间切成左右两半。对左半边排序对右半边排序。把两个已经分别有序的半边合并成一个整体有序的数组。第二步里出现了递归对左半边排序时仍然采用“切两半、递归排序、合并”的方式对右半边也是这样。递归的终止条件是什么当子数组只剩下一个元素的时候它就天然有序不需要继续切分直接返回。这就是整个算法的出口。这里我建议初学者不要一上来就盯代码先在脑子里建立这层递归直觉排序函数调用自己处理越来越小的区间最小的区间长度为 1 时停止然后层层返回每返回一层就做一次合并合并结果越来越大最后一层合并完成后整个数组有序。这是一种“由小到大”的构建过程和冒泡排序那种“相邻逐步交换”的局部优化逻辑完全不同。2.2 一个完整例子的手工走查从拆到合十八步我用一个经典例子来说明整个过程。假设有数组[38, 27, 43, 3, 9, 82, 10]注意这个数组长度是 7中间位置我们通常取(左端 右端) / 2或者左端 (右端 - 左端) / 2习惯上偏向取左中位。我们就按中间 (0 6) / 2 3来切左半边是索引 0 到 3右半边是索引 4 到 6。第一次拆分左半边[38, 27, 43, 3]右半边[9, 82, 10]继续对左半边 [38, 27, 43, 3] 拆分。它的中间位置是 (0 3) / 2 1切出的结果是左[38, 27]右[43, 3]再往下拆[38, 27] 拆成 [38] 和 [27][43, 3] 拆成 [43] 和 [3]。到这里长度为 1 的子数组出现了递归开始“返程”。不过先别急着合并右边也要走同样的流程。[9, 82, 10] 的中间位置是 (4 6) / 2 5拆成 [9, 82] 和 [10][9, 82] 再拆成 [9] 和 [82][10] 本身长度为 1不需要继续拆。到这一步所有长度为 1 的子数组都出现在我们面前[38] [27] [43] [3] [9] [82] [10]接下来进入合并且排序的阶段合并 [38] 和 [27]比较两者把较小者 27 放入临时结果然后是 38得 [27, 38]。合并 [43] 和 [3]得 [3, 43]。合并 [27, 38] 和 [3, 43]。这个过程会依次比较3 和 27 比3 更小放 343 和 27 比27 更小放 2743 和 38 比38 更小放 38剩下 43 直接放上去最终得到 [3, 27, 38, 43]。左半边有序完成。右侧合并 [9] 和 [82]得 [9, 82]再和 [10] 合并比较 9 和 109 更小放 982 和 10 比10 更小放 10剩余 82 放上去得到 [9, 10, 82]。右半边有序完成。最后合并 [3, 27, 38, 43] 和 [9, 10, 82]得到完整有序数组 [3, 9, 10, 27, 38, 43, 82]。整个流程下来你会发现一个规律合并两个有序数组的操作是归并排序最精华的部分拆分只是为它创造前提。对比两个有序数组的头部元素谁小谁先出列这种思路你也可以理解为“两个排好队的队伍合并成一个队伍每次从两队队头中挑更矮的那个人”。2.3 分治思想的三个标准环节分解、治理、合并看到这里分治思想就能自然归纳为三个步骤分解Divide把原问题拆成规模更小、结构相似的子问题。归并排序里就是把数组拆成两个长度大致相等的子数组。治理Conquer对子问题进行递归求解。当问题规模小到可以直接求解时长度为 1 的数组天然有序就停止递归。合并Combine把子问题的解组合成原问题的解。归并排序中这是合并两个有序数组的关键步骤。我特别想强调一点分治思想之所以能降低复杂度是因为每一步都把问题规模减半使得总的操作次数从“和元素个数成正比的变化趋势”转变成“和 log 级别成正比的变化趋势”。这个推导我会在后面的复杂度部分正式展开但初学者可以先记住这个直觉切分的层数只有 log n 层每一层的合并总代价是 O(n)所以总代价是 O(n log n)。3. C代码实现从最朴素的递归版到工程可读版本3.1 合并两个有序子数组核心代码逐行拆解先看最核心的部分合并函数。假设当前要合并的区间是[left, right]中间位置是mid。也就是说[left, mid]已经有序[mid 1, right]已经有序现在要合并出一个完整有序的[left, right]。最简单的实现方式是用一个临时数组暂存合并结果最后拷贝回去。直接看代码void merge(std::vectorint arr, int left, int mid, int right) { int n1 mid - left 1; int n2 right - mid; std::vectorint L(n1), R(n2); for (int i 0; i n1; i) L[i] arr[left i]; for (int j 0; j n2; j) R[j] arr[mid 1 j]; int i 0, j 0, k left; while (i n1 j n2) { if (L[i] R[j]) { arr[k] L[i]; } else { arr[k] R[j]; } } while (i n1) arr[k] L[i]; while (j n2) arr[k] R[j]; }这里几个关键点mid 1是右半部分的起点。很多初学者会把右半部分的起点写成mid这是经典的差一错误会导致元素被重复覆盖或者越界。k left表示把合并结果写回原数组从left开始的连续位置。不要写成从 0 开始否则会覆盖尚未需要处理的区域。主循环结束后会有两个while处理剩余元素。因为两个子数组可能长度不等某个队伍先消耗完了另一个队伍剩下的元素都是有序的直接依次填入即可。3.2 递归调用函数排序的骨架接着写递归函数void mergeSort(std::vectorint arr, int left, int right) { if (left right) return; int mid left (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid 1, right); merge(arr, left, mid, right); }这个函数极其简洁但信息量很大递归终止条件是left right。当区间长度为 0 或 1 时不需要任何操作。mid left (right - left) / 2写这个形式而不是(left right) / 2是为了防止left right可能产生的整数溢出。虽然现在很多环境下 int 不会轻易溢出但从一开始养成这个习惯没坏处。先递归排序左半再递归排序右半最后合并。这个顺序不能乱合并必须发生在这两半都返回之后否则你合并的是尚未有序的数据。3.3 完整可运行代码与调用方式把上面的代码拼起来加上main函数就是一个可以完整运行的程序#include iostream #include vector void merge(std::vectorint arr, int left, int mid, int right) { int n1 mid - left 1; int n2 right - mid; std::vectorint L(n1), R(n2); for (int i 0; i n1; i) L[i] arr[left i]; for (int j 0; j n2; j) R[j] arr[mid 1 j]; int i 0, j 0, k left; while (i n1 j n2) { if (L[i] R[j]) arr[k] L[i]; else arr[k] R[j]; } while (i n1) arr[k] L[i]; while (j n2) arr[k] R[j]; } void mergeSort(std::vectorint arr, int left, int right) { if (left right) return; int mid left (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid 1, right); merge(arr, left, mid, right); } int main() { std::vectorint arr {38, 27, 43, 3, 9, 82, 10}; mergeSort(arr, 0, arr.size() - 1); for (int x : arr) std::cout x ; std::cout std::endl; return 0; }调用时注意边界arr.size()返回的是元素个数最后一个元素的索引是arr.size() - 1。如果直接把arr.size()传给right会导致访问越界。这也是初学者特别容易踩到的点。3.4 为什么要用临时数组空间换时间的典型体现有初学者问合并的时候能不能不创建临时数组直接在原数组里交换元素严格来说归并排序的特性就是需要额外空间来暂存合并结果。如果完全在原数组内交换很容易破坏其中某一侧已经有序的排列导致排序失效。这也是归并排序相对快速排序的重要区别快排是原地排序而归并排序需要 O(n) 辅助空间。很多工程场景下如果内存敏感你可能会考虑原地归并变体但那些变体要么引入更复杂的索引计算要么时间复杂度退化。对初学者而言先用临时数组理解流程是最稳妥的选择。等把递归结构吃透了再回头研究空间优化不迟。4. 复杂度推导与边界条件为什么归并排序能把复杂度做到 O(n log n)4.1 时间复杂度的数学推导递推式展开归并排序的时间复杂度可以这样推导。设T(n)表示对长度为n的数组排序所需的时间。一次排序操作需要做三件事对左半边排序耗时T(n/2)对右半边排序耗时T(n/2)合并两个有序半边需要比较并放置所有元素耗时O(n)。于是得到递推式T(n) 2 * T(n/2) cn其中c是常数表示合并步骤中每个元素平均的赋值和比较开销。当n 1时T(1) O(1)。展开这个递推式第一次展开T(n) 2 * T(n/2) cn第二次展开T(n) 4 * T(n/4) 2cn第三次展开T(n) 8 * T(n/8) 3cn可以看到递归进行了k次之后问题规模变成n / 2^k。当n / 2^k 1时递归结束此时k log2(n)。把k代回得到总代价T(n) n * T(1) cn * log2(n) O(n) O(n log n) O(n log n)这就是归并排序时间复杂度稳定在 O(n log n) 的来源。对比一下冒泡排序和选择排序是 O(n^2)在 n 从 1000 增长到 10000 时操作数量增长 100 倍归并排序只增长约 log2(10000) / log2(1000)约 1.33 倍。这个差距在数据量大时非常悬殊。4.2 空间复杂度分析递归栈与临时数组都要算归并排序的空间复杂度分为两部分临时数组的空间每次合并操作创建n1 n2大小的临时数组但注意它们会在函数返回时释放。递归的每一层临时数组总大小加起来是 O(n)。所以在最坏情况下辅助空间是 O(n)。递归调用栈的空间递归深度是 log2(n)所以栈空间消耗是 O(log n)。在大多数分析中以临时数组为主所以合并排序空间复杂度记为 O(n)。初学者经常会问那到底是多少空间答案是 O(n log n)渐进意义上就是 O(n)。虽然我不会说它空间效率好但它的稳定性和可预测性在许多场景下是值得的。4.3 稳定性什么条件下归并排序能保证相同元素的相对顺序稳定排序的定义是如果两个元素的值相等排序后它们的相对顺序保持不变。归并排序天然具备这个性质但前提是合并代码中当L[i] R[j]时优先取左侧元素。如果你把条件写成L[i] R[j]那么相等元素会优先取右侧元素相等元素的相对顺序就颠倒了稳定排序的性质也就被破坏了。具体来说if (L[i] R[j])稳定相等时左侧元素先出。if (L[i] R[j])不稳定右侧元素可能先出。许多工程场景下稳定性很重要比如按多个字段排序先按姓名排序再按年龄排序。如果第二次排序是稳定排序相同年龄的人仍然保持姓名顺序如果非稳定排序可能导致姓名排序结果被打乱。所以理解这个细节不只是为了应试而是为了在真实业务里做出正确选择。4.4 边界条件与空数组处理归并排序能处理空数组吗当然可以。但你的调用代码要保证正确if (arr.empty()) return; mergeSort(arr, 0, arr.size() - 1);如果arr为空arr.size() - 1会变成无符号整数的最大值导致严重越界。所以在调用前检查空数组是个必要的防御性习惯。另外如果数组长度为 2那么排序过程只有一次分解和一次合并这可以作为验证递归结束条件是否正确的最小测试用例。5. 初学者务必避开的五个经典错误以及一套排查思路5.1 错误一临时数组开在了 mergeSort 函数里面有一种很常见的错误写法void mergeSort(std::vectorint arr, int left, int right) { // 错误示例不要这样写 std::vectorint temp(right - left 1); if (left right) return; int mid left (right - left) / 2; mergeSort(arr, left, mid); mergeSort(arr, mid 1, right); merge(arr, left, mid, right); }问题在于每一层递归都创建一个新的临时数组且这个数组在 merge 调用后也不会被继续使用。这样做虽然能运行但会造成大量不必要的内存分配。当数组长度为 10 万时递归会深度调用每一层都分配一个数组性能会显著下降。正确做法是单独创建一个与原始数组等长的临时数组把它作为参数传给递归函数和合并函数。这样可以避免反复分配。void merge(std::vectorint arr, std::vectorint temp, int left, int mid, int right) { // 使用 temp[left..right] 这一段空间 } void mergeSort(std::vectorint arr, std::vectorint temp, int left, int right) { if (left right) return; int mid left (right - left) / 2; mergeSort(arr, temp, left, mid); mergeSort(arr, temp, mid 1, right); merge(arr, temp, left, mid, right); }main函数中只需要分配一次临时数组std::vectorint temp(arr.size()); mergeSort(arr, temp, 0, arr.size() - 1);5.2 错误二递归区间边界写错导致死循环或越界最常见的是把右半部分的起点写成mid而不是mid 1。如果写成mergeSort(arr, mid, right)那么区间[mid, mid]会在右半部分递归中不断被调用因为你从未排除这个单元素区间最终导致无限递归。另一个常见问题是递归终止条件写成if (left right) return;。当数组长度为偶数时递归通常会正常中止但当遇到right left的情况时例如某些中间返回值导致区间交叉left right这种判断无法覆盖会导致函数继续执行下去并访问越界。更稳妥的写法是if (left right) return;。5.3 错误三合并时索引错位覆盖了不该覆盖的位置假设你在merge函数中把k初始化成了0那么每次合并都会从数组开头写起。当left不在 0 位置时就会把原本属于左侧序号的元素覆盖掉导致排序结果混乱。正确的做法是用k left来定位写回位置。同时拷贝临时数组时也要把临时数据的起点和原始数组区间的起点对齐。如果使用全局临时数组可以这样做for (int i left; i right; i) temp[i] arr[i];然后再用i和j两个指针分别从left和mid 1开始比较。这种写法比单独创建两个局部子数组L和R更容易避免越界。5.4 错误四稳定性判断写成而不是我之前提到稳定排序的关键在于相等元素处理if (L[i] R[j]) { arr[k] L[i]; } else { arr[k] R[j]; }如果你把改成了那么当左右两侧元素相等时右侧元素会被优先放入。表面上排序结果仍然是正确的因为元素值相同看不出差异但如果你在排序对象是结构体、并且根据某个键值排序时稳定性就会被破坏。我建议从一开始就养成使用的习惯这样归并排序的稳定性质能不自觉地被保留。5.5 调试思路打印递归过程与最小化用例如果你写的归并排序结果不对不要盯着整个乱序输出干瞪眼。我推荐一套排查步骤第一步用一个小数组测试比如[5, 2]。如果这个都排不对说明合并逻辑或边界有问题。第二步在合并函数入口打印信息std::cout merge: left left mid mid right right std::endl;第三步打印合并前和合并后的一段区间数据确认合并是否只影响目标区间。这一步往往能快速定位是否把数据写到了错误位置。第四步检查递归函数的终止条件和区间划分。用纸笔手动模拟一个长度 4 的数组把每次调用的[left, right]写下来和代码输出的调用记录比对不一致的地方就是问题所在。这套排查思路能覆盖绝大多数初学者的问题比逐行读代码要快得多。6. 有基础的扩展逆序对计数、外部排序与排序算法选型6.1 逆序对计数的分治解法逆序对问题是归并排序最经典的衍生应用。定义很简单如果i j且a[i] a[j]那么(i, j)构成一个逆序对。求一个数组中逆序对的总数量。暴力解是两重循环O(n^2)。用归并排序可以在合并过程中顺便统计逆序对数量。关键思想是合并两个有序数组时如果左侧的L[i]大于右侧的R[j]那么L中从i到末尾的所有元素都大于R[j]因为这些元素都在L[i]的右侧而L整体有序。所以此时可以一次性统计出n1 - i个逆序对。实现时只需在合并循环中加入计数逻辑long long count 0; // 使用 long long因为逆序对数可能很大 if (L[i] R[j]) { arr[k] L[i]; } else { count (n1 - i); // L[i..] 都大于 R[j] arr[k] R[j]; }时间复杂度仍然是 O(n log n)同时解决了问题。我常跟初学者说如果你做完普通归并排序还有余力逆序对计数是检验你是否真正理解合并过程的最佳练习题因为它强迫你思考合并过程中元素之间的大小关系。6.2 外部排序归并思想在超大文件上的延伸当数据量远超内存容量时无法一次性把全部数据读入内存排序。这时候工程上常用的方案就是外部排序它正是归并排序思想的延伸。基本流程是将大文件分成若干块每块大小契合可用内存。对每块数据分别排序写回磁盘得到多个有序片段。使用归并排序的思路从每个有序片段中读取一个最小元素放入一个容量为 k 的最小堆中每次取出堆顶元素写入输出文件再从对应片段读入下一个元素如此反复直到全部处理完毕。这种方式把归并排序“合并有序数组”的核心能力迁移到了磁盘场景。理解内存归并排序之后外部排序的很多概念就变得顺理成章。6.3 什么时候该选归并排序排序算法选型视角工程实践中归并排序不是万能的但它在特定场景下优势明显当要求稳定排序时归并排序是首选。当数据无法全部载入内存时归并排序的思想是外部排序的基石。当数据分布最坏情况也不希望性能回退时稳定 O(n log n) 的归并排序完胜快速排序。但如果是内存敏感的小数组排序归并排序需要 O(n) 额外空间会显得比较奢侈这时候插入排序或者选择排序可能更合适。很多标准库的实现其实会在小规模子数组上切换到插入排序比如某些库的 sort 实现会这样做本质上就是工程上对常数因子的调优。举个简单对比维度归并排序快速排序冒泡排序平均时间复杂度O(n log n)O(n log n)O(n^2)最坏时间复杂度O(n log n)O(n^2)O(n^2)空间复杂度O(n)O(log n)O(1)稳定性稳定不稳定稳定是否适合大数据适合但内存占用高适合不适合是否适合外部排序核心思想不直接适用不适用数据量很小时比如不到 20 个元素插入排序的实际运行速度往往比归并排序快因为归并排序的递归调用和临时数组合并都有常数开销。工程上常见的做法是设定一个阈值小数组用插入排序大数组用归并排序或快速排序。7. 我的一点实操总结写熟归并排序比背代码重要得多如果你完整读到这里应该已经理解了分治思想的来龙去脉。拿我自己这几年的经验来说很多初学者第一次写对归并排序之后会经历一个非常典型的心理变化先从“这递归到底怎么运行的”变成“原来递归就是不断套娃”再变成“我居然能不看代码把这个过程推出来”。这种转变的关键不是背代码而是亲手模拟几遍完整流程。我建议你找一张纸写下一个长度为 6 到 8 的随机数组用手工方式完成分解和合并再对着你的 C 程序验证每一步结果。哪怕只是写了这么一次你对递归调用栈、区间边界和合并顺序的理解都会比以前深刻得多。实际操作中我还有一个习惯在代码里临时加几条打印语句观察递归调用和合并区间。虽然提交到评测系统前会删掉但在本地调试阶段它们能清晰揭示程序行为。归并排序不像很多复杂项目它的状态空间非常直观打印几段关键信息就能把问题缩小到某一行。最后再分享一个小技巧如果你觉得归并排序代码有点绕可以先把 merge 函数单独拎出来测试。自己构造两个已经有序的小数组手动调用 merge 函数验证合并结果正确后再套上递归外壳。这样把问题解耦调试难度会下降很多。先把局部跑通再把整体串起来这种思路以后学任何复杂算法都用得上。