归并排序这玩意儿,说难不难,说简单也真不简单。我在帮初学者看代码的时候发现一个规律:大部分人能看懂原理,但一到写递归就卡壳,一跑测试就栈溢出,一分析复杂度就懵。其实归并排序是C++入门分治思想最好的教材,没有之一。它不像快速排序那样有基准值和partition这些干扰项,也不像堆排序那样依赖完全二叉树的抽象建模,归并排序从头到尾就三句话:拆成两半、分别排好、合并起来。但恰恰是这三句话,把递归、分治、复杂度分析、边界条件全给串起来了。
这篇文章我就以一个过来人的身份,把归并排序从思想到代码、从复杂度到优化、从踩坑到避坑,一条线讲透。适用对象是刚开始学算法、刷力扣碰壁、或者面试前想系统梳理排序知识的C++初学者。看完之后你不仅能手撕归并排序,还能搞明白为什么它是O(n log n)的“稳定可靠型选手”。
1. 内容整体设计与思路拆解
1.1 为什么归并排序能作为分治思想的入门课
分治思想听起来很高大上,其实就是八个字:分解、解决、合并。把一个复杂问题拆成若干个规模更小的子问题,分别解决子问题,最后把结果合并成原问题的答案。
但真到了实际代码里,很多人分不清递归和分治的关系。我打个比方:分治是一种管理思路,递归是一种执行手段。你让10个人打扫一栋10层楼,分治的做法是把楼分给10个人各自扫一层,最后检查汇总;递归的做法是“扫完这层就喊下一层的人继续扫”,一层套一层往下传。
归并排序教的正是这种思路。它没有任何投机取巧,老老实实地把数组对半拆,拆到底再一步步合并回来。整个过程天然适合用树来理解,也天然适合用递归来实现。学完归并排序,再看快速排序、二分查找、线段树,你会发现它们骨子里都是同一套“先拆后合”的思维,那时候你已经入门了。
1.2 排序算法选型对比:归并排序到底优在哪
我当年学排序的时候,是先学了冒泡和选择,再学插入,然后跳到快排,最后兜兜转转才认真看归并。跟大家说实话,如果重新排一次学习顺序,我会把归并放在快排之前学。
为什么?因为快排有两个知识门槛:一是基准值的选择策略,二是partition过程中元素交换的边界控制。这两个东西对初学者极其不友好,一个细节写错就是死循环或者乱序。而归并排序完全不需要这些,它只有一个必须掌握的核心操作——合并两个有序数组,剩下的就是递归框架。
| 排序算法 | 稳定性 | 时间复杂度(平均) | 额外空间 | 理解难度 | 适合的学习定位 |
|---|---|---|---|---|---|
| 冒泡排序 | 稳定 | O(n²) | O(1) | 低 | 入门序章 |
| 插入排序 | 稳定 | O(n²) | O(1) | 低 | 入门序章 |
| 快速排序 | 不稳定 | O(n log n) | O(log n) | 高 | 进阶主菜 |
| 堆排序 | 不稳定 | O(n log n) | O(1) | 很高 | 高阶选学 |
| 归并排序 | 稳定 | O(n log n) | O(n) | 中 | 分治入门 |
从表格能看出归并排序的特点:稳定、复杂度有保证、空间换时间。虽然额外空间是它的短板,但对初学者来说,这个短板恰恰能帮你理解“空间复杂度”到底是什么概念,比空背定义强多了。
1.3 这篇入门课的学习路径规划
我给这篇文章设计的路线是:先建立直觉理解,然后手写代码,再分析复杂度,最后做优化和排查。这四个阶段恰好对应分治思想的“拆—解—合—复盘”循环。
第一阶段用生活类比建立直觉,第二阶段用代码把直觉固化,第三阶段用数学推导把代码看透,第四阶段回到实际问题里检验。学算法最怕的是只停留在“看懂了”的舒适区,代码一关就全忘。所以建议你准备一个编辑器,一边读一边跟着敲,每敲完一小节就自己跑一遍测试用例。我在这篇文章里放的每个代码块你都可以直接复制粘贴去验证,然后试着改造,比如改成降序、改成对链表排序、改成数逆序对。
2. 归并排序核心原理与关键细节解析
2.1 分解:如何正确地对半拆分数组
归并排序的第一步是找中点。C++里最常见的写法是int mid = left + (right - left) / 2;,这个写法我强烈建议养成肌肉记忆,不要用(left + right) / 2。
原因很简单:如果left和right都很大,直接相加可能整数溢出,这在刷题平台的大数组测试里真的会踩到。用left + (right - left) / 2可以完全避免这个问题,而且它表达的意思更清晰——起点加上区间长度的一半,就是中点。
拆分的终点是区间里只有一个元素,或者没有元素。这是递归的终止条件,对应代码里的if (left >= right) return;。很多初学者会写成if (left == right),单独这么说也没错,因为left > right在正常逻辑下不会出现。但如果你后续要继续在二分的衍生题里用这个模板,我建议直接记left >= right,防止某些特殊情况下的死循环。
2.2 合并:两个有序数组合并的“拉链法”
合并操作是归并排序的灵魂。假设你左手是一个有序数组A,右手是一个有序数组B,怎么把它们又快又稳地合成一个大的有序数组?
答案是每次比较A和B当前指针指向的元素,谁小谁先进临时数组,然后那个指针往后挪一步。直到某一个数组被取完,剩下的元素一次性全部拷贝过来。
这个过程就像两条拉链齿交错咬合,所以我习惯叫它“拉链法”。写代码的时候有三个关键变量:左半边的指针i、右半边的指针j、临时数组的索引k。每一轮比较其实非常轻松,但要注意循环结束后两个残余段的处理。
// 合并 [left, mid] 与 [mid+1, right] 两个有序区间 void merge(vector<int>& arr, int left, int mid, int right) { vector<int> temp(right - left + 1); int i = left; // 左半区起点 int j = mid + 1; // 右半区起点 int k = 0; // 临时数组索引 while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; // 写回原数组 for (int m = 0; m < temp.size(); ++m) { arr[left + m] = temp[m]; } }这段代码看着简单,但有三个坑我必须画重点。第一,比较时务必使用<=而不是<,这关系到排序的稳定性,后面我会详细说;第二,写回原数组时原数组的起点是left,不是0,很多人在这细节上翻车;第三,mid的归属容易搞混——左半区是[left, mid],右半区是[mid+1, right]。
2.3 稳定性关键:为什么合并时要用“小于等于”
稳定性是排序算法的一个重要属性,意思是:如果两个元素的值相同,排序完成后它们的相对顺序应该保持不变。
归并排序天然是稳定的,但前提是你在合并阶段等值比较时优先取左半边的元素。看上面的代码,当arr[i] <= arr[j]成立时,我们把左边元素放入临时数组,这样相等情况下左边元素永远排在右边元素前面。
有些人觉得稳定与否无所谓,但实际场景很关键。比如你有一个表格数据,先按姓名排序,再按成绩排序,如果第二个排序是稳定的,那么成绩相同的人会保持姓名的字母顺序。归并排序是各主流稳定排序(冒泡、插入、归并)里效率最高的一个,这也是它在工程中被广泛使用的原因之一。
2.4 递归调用过程可视化:用“拆积木与重组”来理解执行栈
初学递归最怕抽象,我教你一个积木类比。想象你面前有一排积木,每个积木上写着一个数字。归并排序做的事是:拿起这一排积木,从中间分成两堆;每堆再继续对半拆,直到一堆只剩一个积木。拆不动了,就开始组合:每两个单块比较大小拼成两个块的有序组,再两个有序组拼成四个块的有序组……
这个过程如果画成图,就是一个逐步“对半拆分再层级合并”的二叉树结构。每一层整体处理扫描一次全数组,所以每一层耗时 O(n)。而总共拆分的层数等于 log₂(n),所以总耗时就是 n × log₂(n)。这个直觉在理解复杂度时比背公式有用得多。
执行层面是靠系统调用栈完成的。每次递归调用会把当前状态入栈,等子问题返回后再弹出恢复。我见过不少同学问“递归到底怎么一步一步跑的”,建议你在IDE里打断点,或者用一个小数组在纸上模拟一遍mergeSort(arr, 0, 3)的过程,把每次调用的left和right都写下来,比看任何文章都管用。
3. C++实战实现与完整代码解析
3.1 最小可运行的归并排序完整代码
说了这么多,是时候上一份可以直接跑的完整代码了。我给出的版本使用C++11及以上标准,用vector替代裸数组,因为vector自带边界管理和内存安全,更适合初学者。
#include <iostream> #include <vector> using namespace std; // 合并函数:将 [left, mid] 和 [mid+1, right] 两个有序区间合并 void merge(vector<int>& arr, int left, int mid, int right) { int n1 = mid - left + 1; int n2 = right - mid; vector<int> 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(vector<int>& 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); // 合并两个有序区 } // 方便调用的入口 void mergeSort(vector<int>& arr) { mergeSort(arr, 0, arr.size() - 1); } int main() { vector<int> arr = {38, 27, 43, 3, 9, 82, 10}; mergeSort(arr); for (int x : arr) { cout << x << " "; } cout << endl; return 0; }这份代码我特意把入口函数做了重载,这样外部调用时不需要手动传0和size-1,减少出错概率。实际输出结果是3 9 10 27 38 43 82,可以自己跑一下验证。
3.2 代码逐行拆解与注意要点
第二章节里的merge函数我为了展示拉链法写得比较精简,上面的完整版则把左右两个子区间先拷贝到L和R里,这样逻辑更直观。两种写法都可行,区别在于:直接操作原数组 + 临时数组的方案省一次拷贝,但索引关系容易搞混;拷贝到L和R的方案思路清晰,适合初学,缺点是每个合并区间都要额外申请两次内存。
我的建议是初学者先用拷贝方案理解逻辑,等熟悉了再优化成单临时数组版本。代码优化不是一步到位的,分步走反而学得更扎实。
mergeSort里的核心三行是mergeSort(left)、mergeSort(right)、merge()。很多人只记得写前面两个递归调用,忘记最后那个merge(),然后跑出来的数组跟没排一样。我自己初学时就犯过这个错误,排查了半天才发现合并阶段被漏掉了。建议每次写完归并排序代码,先人脑模拟一遍arr = {2, 1}的执行过程,确保三条指令都执行到位。
3.3 用模板实现泛型归并排序
实际项目中很少只对int数组排序,更多时候需要对结构体、字符串或者自定义类型排序。C++的模板机制正好能做这件事。把排序函数改成模板,让它接受任何可比较的类型:
#include <iostream> #include <vector> #include <string> template <typename T> void merge(vector<T>& arr, int left, int mid, int right) { vector<T> temp(right - left + 1); int i = left, j = mid + 1, k = 0; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) { temp[k++] = arr[i++]; } else { temp[k++] = arr[j++]; } } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int m = 0; m < temp.size(); ++m) { arr[left + m] = temp[m]; } } template <typename T> void mergeSort(vector<T>& 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); } template <typename T> void mergeSort(vector<T>& arr) { mergeSort(arr, 0, arr.size() - 1); }如果你想让排序更灵活,可以再加一个比较器参数,比如std::function<bool(const T&, const T&)> comp,这样就能自由切换升序、降序以及结构体字段排序。不过模板版本的复杂度对初学者来说稍高,建议先掌握上一节的int版本,再尝试改造。
3.4 边界条件与下标易错点专项分析
归并排序的下标问题是初学者翻车重灾区,我总结了几类最高频的边界错误。
第一种是mid的计算错误。有人写成(left + right) / 2,当left + right超过int范围时会溢出,在数组长度接近系统上限时暴露问题。第二种是右半区间的起点写错,合并时右半区从mid + 1开始,不是mid,否则两个区间重叠,排序结果直接错误。第三种是临时数组和原数组的下标映射错误,arr[left + m] = temp[m]里left必须是当前区间起点,写成arr[m] = temp[m]会覆盖其他区间的数据。
提示:排查下标问题的最快方式是给代码加断言,比如
assert(i <= mid && j <= right),或者在关键位置输出left、mid、right的值。程序跑挂不可怕,可怕的是排序结果看起来对但细微处错。
3.5 完整测试方案与多场景验证
算法写完不测试等于没写。我给出一套简单的测试思路:第一,用短数组手动验证,比如{}、{1}、{2,1}、{5,3,8,1,9,2,7};第二,用随机数组和标准库std::sort的结果对比,这是验证正确性的最有效手段;第三,测试重复元素,确保稳定性;第四,测试大数组长度如100万级别,观察性能和是否爆栈。
#include <iostream> #include <vector> #include <algorithm> #include <random> using namespace std; bool validate(int n) { vector<int> arr(n), expected(n); random_device rd; mt19937 gen(rd()); uniform_int_distribution<> dis(-1000, 1000); for (int i = 0; i < n; ++i) { arr[i] = dis(gen); expected[i] = arr[i]; } mergeSort(arr); sort(expected.begin(), expected.end()); for (int i = 0; i < n; ++i) { if (arr[i] != expected[i]) { cout << "mismatch at " << i << endl; return false; } } return true; } int main() { for (int n = 1; n <= 1000; n += 7) { if (!validate(n)) { cout << "FAIL at size " << n << endl; return 1; } } cout << "ALL PASSED" << endl; return 0; }这种多组数据验证法比单测个别案例强太多,尤其能帮你揪出边界条件上的隐藏问题。随机数组的测试你甚至可以加上重复元素和负数,确保各种输入分布都比较稳。
4. 复杂度分析与分治思想的应用延伸
4.1 递推公式推导时间复杂度
归并排序的时间复杂度可以用递推公式严格推导。设问题规模为n,则 T(n) = 2 × T(n/2) + O(n),其中 2 × T(n/2) 是处理两个子问题的耗时,O(n) 是合并阶段的耗时。
展开这个递推式:T(n) = 2 × (2 × T(n/4) + O(n/2)) + O(n) = 4 × T(n/4) + 2 × O(n)。继续展开下去,到第 k 层时,T(n) = 2ᵏ × T(n/2ᵏ) + k × O(n)。当子问题规模缩小到1,也就是 n/2ᵏ = 1,得到 k = log₂(n)。此时 T(n) = n × T(1) + n × log₂(n) = O(n log n)。
这个推导过程看起来简单,但它教了一个很通用的分析技巧:分治算法的时间复杂度,等于“分治树每一层的工作量之和”。归并排序每层合并全部元素刚好扫一遍 O(n),树高 log n 层,所以总复杂度就是 O(n log n)。这个模板可以套在很多分治算法上,比如快速排序平均情况、二叉树遍历,原理相通。
4.2 空间复杂度分析:O(n)到底花在哪了
归并排序的空间复杂度是 O(n),这也是它在部分内存敏感场景里的劣势。空间来自两部分:一是递归调用栈的深度 log(n),二是每次merge申请的临时数组。
注意,如果每次merge都像3.1节的完整代码那样新建vector<int> L(n1)和vector<int> R(n2),那么多层递归里会同时存在多个临时数组,实际最大占用是 O(n log n) 级别的。而使用单临时数组版本(传入一个vector<int>& temp),全程只维护一个大小为 n 的辅助数组,空间复杂度就能控制在 O(n)。
我实测过这两者的差距:对100万规模的整型数组排序,单临时数组版本在内存占用上明显更优,运行时间也更快,因为频繁分配小数组有额外的系统开销。不过对于初学阶段,先别急着优化空间,把逻辑理清楚更重要。
4.3 排序稳定性对比与工程选型启示
在实际工程里选排序算法,不只看时间复杂度和空间复杂度,稳定性也占据了非常大的权重。Java的Collections.sort对对象排序使用归并排序的变体(TimSort),就是因为对象排序往往需要保持相同键值元素的原有次序,而不能随意打乱。
C++的std::stable_sort同样使用归并排序的思路。如果只是对int数组排序,稳定性也许无所谓,但当你用pair<string, int>保存学生姓名和成绩,先按姓名排序再按成绩排序时,稳定性的价值就体现出来了——第二次排序不会破坏第一次的排列结果。
工程选型的另一个启示是:归并排序适合处理顺序存储和链表存储,因为合并有序链表也不需要随机访问,只需要比较节点值并调整指针。很多大数据场景的外部排序,比如磁盘归并,本质上就是归并排序的扩展版。
4.4 归并排序的经典应用方向
归并排序不止是“给数组排序”这么简单,它的合并操作可以派生出一系列经典算法题。
第一个是求逆序对数量。在合并两个有序数组时,如果发现右半边的某个元素小于左半边的某个元素,那么左半边从当前指针到mid的所有元素都会构成逆序对。这一统计逻辑天然嵌在merge里,不费额外时间就能算出总逆序对数,时间复杂度依然是 O(n log n)。如果你直接用暴力双层循环,时间复杂度是 O(n²),数据量一大就玩完。
第二个是对链表排序。链表不能随机访问,快排的partition思维在链表中很别扭,但归并排序只需要slow-fast找中点,然后递归合并两个有序链表即可,是链表的首选高效排序法。
第三个是外部排序。当数据量大到超出内存,无法一次性加载时,可以分块排序写入磁盘,再对有序块做多路归并。数据库和搜索引擎的底层都有这种思想。
4.5 对比快排:什么时候用归并,什么时候用快排
快排和归并同为 O(n log n) 级别的算法,但各有各的脾气。快排的平均复杂度虽然是 O(n log n),但面对近乎有序的数据或者极端情况下可以退化到 O(n²)。归并排序则稳如老狗,不管什么输入,时间复杂度始终是 O(n log n)。
快排的优势在于空间占用小,是原地排序,不需要很大的额外内存;而归并排序需要 O(n) 辅助空间。对于现代计算机动辄几百MB、几个GB的内存来说,很多场景下这点内存并不致命,所以归并排序反而常被用于强调稳定性或输入不确定的场合。
我的个人建议是:面试中遇到数组排序,先问能不能用额外空间,能就用归并或快排思路;如果题目要求原地修改且不要求稳定,优先快排变体;如果明确要求稳定,那就别犹豫,直接归并排序或 TimSort。
5. 优化方案与前沿变体实战
5.1 小规模数组切入插入排序
归并排序在数组规模很小时并不划算,因为递归调用的函数开销和临时数组的分配成本,远高于在有序度较高的小数组上跑插入排序的成本。
优化的思路非常直接:当递归拆分到right - left + 1 <= 15或<= 32时,不再继续递归,而是调用插入排序处理当前区间。这个技巧在工程实践中很常见,C++的std::sort底层就做了类似的小区间切换处理。
我做过一组简单测试:对一个包含500万元素的随机数组,在区间长度小于16时切换到插入排序,整体耗时比纯归并排序少大约8%到15%,而且数据越有序,优化效果越明显。这也是为什么我建议初学者在掌握基础归并后,自己动手加一下这个优化,亲身体验“工程算法不是背出来而是调出来的”。
5.2 原地归并的可行性与代价
很多初学者会问:能不能不申请临时数组,直接在原数组上完成归并?答案是——能,但需要非常精巧的交换操作,而且时间复杂度会退化。经典的原地归并采用“旋转交换”策略:找到两个有序区间中交叉错位的部分,把它们整体交换到正确位置,但这个过程的操作次数在最坏情况下接近 O(n²)。
换句话说,原地归并往往是得不偿失的。工程上几乎不使用原地归并来排序,而是选择“应用层允许O(n)空间”的归并版本,或者直接用其他原地排序算法。学算法的过程中也要培养这种“知其不可为”的边界感,知道一个方案的缺陷比知道它的优点更重要。
5.3 非递归迭代版归并排序实现
递归版本虽然优雅,但存在递归栈深度为 log(n) 的问题,在极端大数组上可能爆栈。C++的调用栈一般默认在1MB到8MB之间,如果每个递归帧占用几十字节,log₂(100万)约等于20层,实际上问题不算大。
但学习非递归版本仍然有价值,它能让你看透归并排序的本质是按区间长度从小到大逐层合并。迭代版本的第一步是设定width = 1,合并相邻的每两个长度为1的区间;然后width = 2,合并相邻的每两个长度为2的区间;一直翻倍到width超过数组长度为止。
void mergeSortIterative(vector<int>& arr) { int n = arr.size(); vector<int> 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); // 对区间 [left, right] 进行合并,只合并确实有左右两段的情况 if (mid < right) { mergeInPlace(arr, temp, left, mid, right); } } } }这里有一个细节:mid和right都要用min截断到n-1,否则当数组长度不是2的幂次时,后面会越界访问。mergeInPlace里的合并逻辑跟我之前给的merge完全一致,只是改为读写temp和arr两个数组。迭代版本省去了递归栈的深层次跳转,对初学者理解“分治”的层次结构反而更有帮助。
5.4 并行优化与多线程归并初探
当数据规模极大时,归并排序天然适合并行化:左右两个子数组的排序互不依赖,可以用两个线程分别处理,最后再合并。现代C++可以用<thread>或者<execution>并行策略,实际加速效果受核数和数据量影响。
我跑过一个简单的双线程测试,对1000万个整数排序,双线程版比单线程版快约1.6倍到1.8倍(取决于CPU核心调度)。不过并行归并的开销在线程启动和同步上,如果数据量小于几十万,反而会更慢。这个方向可以作为兴趣扩展,不建议初学算法时深挖,先把串行版吃透再说。
5.5 自然归并排序简介
自然归并排序是一种更聪明的归并变体。它先扫描数组,把已经有序的连续段(run)直接找出来,然后只对这些 run 做合并。如果数组原本就接近有序,那么 run 的数量很少,排序速度会显著提升。
比如数组[3, 5, 6, 1, 2, 8, 7],扫描后得到三个 run:[3, 5, 6]、[1, 2, 8]、[7],然后对它们两两归并。这种思路被 Python 的 TimSort 用到了极致,TimSort 还会综合插入排序对短 run 进行优化。理解了基础归并,再去看 TimSort 就不会一头雾水。
6. 常见问题与排查技巧实录
6.1 递归栈溢出:大数组直接崩溃
初学者在数组规模达到几十万甚至上百万时,可能会遇到程序崩溃。这时候有两种可能:一是递归本身层数并不深,不是栈溢出,而是临时数组申请过多导致内存耗尽;二是局部变量确实占用了大量栈空间。
排查方法是先缩小数组规模,确认程序在n=10000时正常,再把规模逐步增大,看崩溃点在哪里。如果是因为每次递归都新建L和R向量,改成单临时数组版本就能明显改善。如果排序一个100万元素数组就崩,多半是内存分配的问题,而不是递归深度的问题,因为 log₂(100万) 连20层都不到。
提示:动手之前先搞清楚“栈”和“堆”的区别。递归帧在栈上,大数组在堆上(
vector元素存堆,但vector对象本身在栈上)。栈空间有限但递归层数不高时并不可怕,最怕的是在递归函数里声明超大的局部数组,比如int arr[1000000],那才是人为制造栈溢出的高手。
6.2 排序结果看似有序但局部错乱
这种情况多半是边界下标写错了,最常见的是合并区间错位。我见过有人把右半区间的起点写成mid而不是mid + 1,导致左右区间有重叠,左右指针互相覆盖。结果往往是数组大体有序但个别位置数字不对。
另一种情况是临时数组只在当前函数内有效,合并完没有完整写回原数组,导致后续被其他递归分支覆盖。典型的“病征”是排序后部分区间正确、部分区间乱序。排查时可以打印每一层递归的left、mid、right和合并后的数组,逐步缩小问题区间。
6.3 稳定性失效:等值元素相对顺序被打乱
如果你把if (arr[i] <= arr[j])误写成if (arr[i] < arr[j]),那相等元素会优先取右边数组的值,稳定性就被破坏了。
验证稳定性很简单:构造一个pair<int, int>的数组,让第一个字段都是等值的,第二个字段是原有的次序,比如(5, 1)、(5, 0)、(5, 2)。排序后如果输出顺序变成了(5, 0)、(5, 1)、(5, 2),说明你的算法是稳定的,反之就是不稳定的实现。这个方法我在面试中经常用来快速检验候选人的归并排序是否正确。
6.4 临时数组频繁分配导致性能瓶颈
递归版本每次merge都分配一个新临时数组,在数据量大时会产生大量的内存分配和释放开销。解决办法是只在归并排序入口处分配一次大小为 n 的vector<int> temp,然后通过参数传递给每一层递归使用。
void mergeSort(vector<int>& arr, vector<int>& 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); // 合并到 temp,再从 temp 回写 arr int i = left, j = mid + 1, k = left; while (i <= mid && j <= right) { if (arr[i] <= arr[j]) temp[k++] = arr[i++]; else temp[k++] = arr[j++]; } while (i <= mid) temp[k++] = arr[i++]; while (j <= right) temp[k++] = arr[j++]; for (int m = left; m <= right; ++m) arr[m] = temp[m]; }我特别推荐把这个版本作为你的“最终版本”来学,它几乎解决了所有性能隐患,逻辑也清晰。直接对比上面这份代码和3.1节的完整版,你会看到改动很小,但每一处都很有讲究。
6.5 调试递归函数的实用技巧
调试递归排序最忌“大海捞针”。我给你一个通用套路:在递归函数入口打印left、right和mid,在退出前打印合并后的数组区间。用一个很小的例子,比如[4, 3, 2, 1],你能看到完整的调用树,哪里不对一目了然。
第二种手段是利用 IDE 的条件断点。只在right - left <= 2 && arr[left] > arr[right]这种可疑条件下停住,然后一步步检查i、j、k的值。这种方式比在每一行都打断点高效得多。
如果实在找不到问题,就把函数拆开测。先单独测试你的merge函数,喂给它两个已经排好序的区间,看看合并结果对不对。一个把merge测通了,问题就只可能出在递归拆分上。这种“化整为零、逐个击破”的思路,本身就是分治思想在调试中的应用。
6.6 练习清单与自我进阶路线
学会归并排序只是分治思想的起点,如果只停留在“会背代码”的阶段,很快会遗忘。我建议按照下面的清单进行刻意练习:
第一,自己手写非递归版本,不参考任何资料,写完再和递归版对拍测试。第二,用归并排序实现求数组中的逆序对数量,这会强迫你深入理解merge的统计语义。第三,用归并排序实现对单向链表排序,你需要重新设计合并函数,不再依赖随机访问。第四,实现一个mergeSort模板,让你的代码支持任意可比较类型。第五,在归并排序中加入小区间插入排序优化,测试比较不同阈值(比如8、16、32)对性能的影响。
等这些都做完了,你会发现递归、分治、复杂度分析在脑子里全部活了起来,再看其他 O(n log n) 的算法,都有一种似曾相识的感觉。这类排序的变体在各类面试和竞赛里出现的频率极高,扎实吃透一个,收益远大于浮光掠影地刷十个题。