资讯动态

归并排序全解:分治思想、复杂度分析与力扣经典题实战

发布时间:2026/10/20 22:41:18 来源:尧图企业网站定制
归并排序这题我在力扣上一共刷了不下几十遍每次笔试面试前还要拿出来默写一遍。时间是检验真理的唯一标准算法这条路走得越久越能体会“基础不牢、地动山摇”这句话的含义。很多人问我刷题有没有捷径我的回答永远一样把归并排序这种经典算法吃透你就已经赢了一半正在准备面试的竞争者。这篇文章我打算把我对归并排序在力扣中所有考法的理解、手写模板、复杂度的拆解、以及真实刷题时踩过的那些坑全部整理出来。不管你是什么基础只要跟着我的思路过一遍都能把这题彻底拿下。1. 题目拆解力扣归并排序题到底在考什么1.1 核心考点从来不止“排序”在力扣上搜“归并排序”直接相关的题目其实数量不算多但每道都是经典中的经典。很多人以为这题就是考你能不能把一个数组排好序实际上这道题深挖下去考点非常密集。先说最基础的力扣第912题《排序数组》这就是归并排序最直接的题目给你一个整数数组要求升序排列。但如果你只是为了通过这道题完全可以用内建函数一行代码就结束了。关键问题是面试官让你手写归并排序到底在考核你的什么能力我的理解是归并排序这道题核心考核的是分治思想。所谓分治就是“分而治之”把大问题拆成互不相干的小问题小问题解决完之后再合并结果。这种思想在整个算法面试中无处不在逆序对问题、链表排序、区间合并、甚至某些树形DP题底层逻辑都有它的影子。还有一个极易被忽略的考点稳定性。排序算法的稳定性是指如果两个元素的值相等排序之后它们的相对位置有没有改变。在大多数排序算法里快排是不稳定的堆排序是不稳定的而归并排序天生就是稳定的因为它的合并过程是从左到右依次比较相同值的元素永远是左边的先并入结果数组。这个特性在某些题目里是致命的后续我会用一个具体的例子说明。1.2 题目变体远比想象中丰富归并排序在力扣上真正的威力体现在它的各种变体题目上我按难度和考查方向把它们分了几个层级。第一层级是纯排序代表题目是第912题。这一层主要考核你对分治框架的熟练度。第二层级开始进入归并排序的核心价值区就是第315题《计算右侧小于当前元素的个数》。这道题表面上是求逆序数的进阶版本本质上就是归并过程中的“溢出量”统计你能数清楚归并时右边有多少元素比左边小就能做出来。第三层级我把它归为“归并思想的跨界应用”包括第148题《排序链表》、第327题《区间和的个数》、第493题《翻转对》甚至第23题《合并K个升序链表》这些题都能用归并的分治框架去套。我见过很多刷题者题目做了一遍就再也不看了其实这是最大的浪费。变体题之间有一个共同的思维主线只要能找到“每一层合并时需要额外统计什么信息”所有变体题都能从基础模板里延伸出来。这也是我写这篇文章想带你建立的思维体系。2. 归并排序原理深入拆解为什么它总是面试状元题2.1 分治过程一眼看透本质归并排序的核心操作就两个拆、合。假设有一个乱序数组 [8, 4, 5, 7, 1, 3, 6, 2]第一件事是把数组对半拆开得到左半 [8, 4, 5, 7] 和右半 [1, 3, 6, 2]继续拆直到每个子数组只有一个元素。长度为1的数组天然是有序的这个就是递归的出口。然后是合并把两个已经排序好的子数组按照大小顺序重新组合成一个有序数组。合并过程需要一个新的辅助数组左右两个子数组从头开始比较谁小谁先放入辅助数组直到一边放完再把剩下的整体接上。你可以把归并排序想象成一场淘汰赛。起初64个选手每人单独站在赛场上然后两两PK决出32强继续两两PK决出16强最终决出冠军。每轮比赛只需要比较相邻的两组选手即可这种逻辑简单到令人发指但正是这份简单让它的时间复杂度稳定可控。2.2 时间复杂度为什么任何情况都是O(n log n)这是归并排序在面试中最常被“突然一问”的点很多人答不上来核心原因只知道记结论。我们从合并的层数来看。数组长度为n每折半一次能折log2(n)次所以整棵递归树的层数是log n。每一层上所有子数组的长度加起来是n而每层的合并操作需要处理所有n个元素因此每层的时间是O(n)。层数和每层代价相乘就是O(n log n)。关键在于“任何情况”四个字。快排平均时间复杂度也是O(n log n)但一旦枢纽元选得不好就退化成O(n^2)。而归并排序的拆分不看元素排列永远对半切所以无论输入是已经是完全有序、完全逆序、还是随机生成它的复杂度都是稳稳的O(n log n)。我面试时回答这个问题一定会主动说出“归并排序时间复杂度不随初始序列变化”这是区分刷题派和原理派的瞬间。空间复杂度也不用回避O(n)这个n指的是合并时需要用一个和原数组等长的辅助数组。递归本身还会有栈空间但归并的递归深度是log n可以忽略不计。2.3 手写模板以个人瞎写版本为例的逐行拆解直接上一个我经过无数笔面试验证的模板语言我用C写但逻辑可以无成本迁移到Java、Python、Govoid 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); int i left, j mid 1; int pos left; while (i mid j right) { if (nums[i] nums[j]) { temp[pos] nums[i]; } else { temp[pos] nums[j]; } } while (i mid) temp[pos] nums[i]; while (j right) temp[pos] nums[j]; for (int k left; k right; k) { nums[k] temp[k]; } } vectorint sortArray(vectorint nums) { vectorint temp(nums.size()); mergeSort(nums, 0, nums.size() - 1, temp); return nums; }这段代码里有几个关键点我必须特意强调。mid left (right - left) / 2这个地方千万不能写成(left right) / 2。表面上看两者等价但当left和right都是很大的正数时left right可能整型溢出这在力扣的极限数据用例中真实发生过我不止一次看到有人在这上面栽跟头。合并时if (nums[i] nums[j])注意我的比较符号带了等于号。这个等于号决定了算法的稳定性。如果写成了小于号当两个子数组中存在相同值时右侧的元素会先进入结果顺序就被颠倒了稳定性就丢了这会在特定变体题中引发错误。很多人写排序题时图省事用array.slice这种函数分数组每次递归都新建子数组。这种写法功能上是正确的但实测在力扣上会额外消耗大量时间和内存。面试场景你如果这么写面试官大概率会追问一句“你是不是没考虑空间开销”。所以老老实实开一个全局辅助数组这是从业者的基本素养。3. 力扣实战两道必须掌握的归并排序经典题目3.1 第912题《排序数组》完整实现这道题就是归并排序本身直接套上面的模板即可。但我建议你不要只写一遍亲手跑三遍每一遍都做点小变化第一遍不看任何参考第二遍刻意把改成感受稳定性差异对结果的影响第三遍尝试把递归改成迭代。这三遍做完你才算真正“碰过”归并排序。提交时需要注意的细节其实不少。首先输入数组可能为空sortArray函数要能正常返回空数组不能因为mid -1之类的操作崩溃。其次就是之前提到的辅助数组一次性分配好别在递归内部反复分配。我在本地测试过反复分配辅助数组会使小规模排序耗时增加两到三倍数据量越大差距越明显。这道题在力扣上是可以一提交就通过的但面试时的追问才是重头戏。考官可能突然问你“归并排序和快速排序选哪个”这时候你必须能说出场景差异对链表排序时归并排序是首选因为链表没有随机访问能力快排的partition操作在链表上效率极低对数组全量排序时快排通常更快因为它的常数更小。归并排序适合需要稳定性、不允许最坏情况发生的场景。3.2 第315题《计算右侧小于当前元素的个数》一道题吃透归并精髓这道题我没见过哪个刷题者能一遍过我自己第一次做的时候挂得很惨提交了六七次发现错误全出在对索引和计数的处理上。题目的要求是给定数组 [5,2,6,1]返回一个数组每个位置表示原始数组中该元素右边有多少个元素比它小。预期结果是 [2,1,1,0]。暴力解法很简单双重循环一遍n平方复杂度力扣直接给超时警告。正确的解法是归并排序的变体。核心思路是这样归并的过程中当左半边元素准备放入结果数组时此时右半边已经按从小到大的顺序排列好了已经放入结果数组的那些右半元素一定都比这个左半元素小。所以左半这个元素“右侧比自己小的数量”就是右半边已经放进结果数组的元素个数。但难点在于排序会改变元素的原始位置。如果你只把数值参与归并等你统计完就不知道这个值对应原始数组中的哪个下标了。解决办法是用索引数组参与排序值不动只有下标数组动。我用的方案是定义一个下标数组index初始为[0,1,2,3]让index按照nums[index[i]]的大小进行归并排序。排完之后index数组里的值就是按原始数组元素大小排好序的原始下标。这样我们在归并过程中通过下标去原数组取值比较同时把每次“右半提前放入”的个数累加到result[index[i]]上。代码逻辑我贴一下关键部分void mergeSortIndex(vectorint nums, vectorint index, int left, int right, vectorint temp, vectorint result) { if (left right) return; int mid left (right - left) / 2; mergeSortIndex(nums, index, left, mid, temp, result); mergeSortIndex(nums, index, mid 1, right, temp, result); int i left, j mid 1; int pos left; int rightCount 0; while (i mid j right) { if (nums[index[i]] nums[index[j]]) { temp[pos] index[j]; rightCount; } else { result[index[i]] rightCount; temp[pos] index[i]; } } while (i mid) { result[index[i]] rightCount; temp[pos] index[i]; } while (j right) { temp[pos] index[j]; } for (int k left; k right; k) { index[k] temp[k]; } }这里我踩过的坑是右边已经放进去的数量不能等到左边元素归并结束再一次性统计因为左边当前这个元素只和此时已经“越过”它的右半元素有关。如果左边下一个元素更小它能“吃掉”的右半元素又会变多。所以我用一个rightCount变量记录当前左半元素已经越过了多少右半元素每放一个右半元素就加一每放一个左半元素就把当前计数累加到它的答案上。这样逻辑严密不会有漏记或重复。这道题做完你已经吃透了“归并即统计”的核心套路。后面再做第493题《翻转对》无非是归并前先查一下nums[i] 2 * nums[j]思路完全一致多了个检查条件的先后顺序罢了。4. 归并排序在链表和区间问题中的应用记录4.1 第148题《排序链表》的归并思路变化链表排序这道题考的就是你有没有活学活用归并排序的能力。数组归并用下标拿到左右边界链表归并则需要两个指针快指针每次走两步慢指针每次走一步快指针走到尽头时慢指针恰好停在链表中点。这个过程有点像一个经典谜题一只兔子和一只乌龟同时从链表头部出发兔子速度是乌龟的两倍兔子到达终点时乌龟正好走了半程。这个方法叫快慢指针法时间复杂度O(n)整个过程只需一次遍历。链表归并的时候不需要额外的O(n)辅助数组因为链表本身就可以通过指针掐断和拼接实现原地合并这使得链表归并排序的空间复杂度是O(log n)只来自递归栈。这道题我实际调试时遇到一个常见错误对半拆分后忘记把左半链表末节点的next指针置空导致归并时链表结构混乱出现循环引用。正确做法是找到中点后temp slow-next; slow-next nullptr;再分别把头和temp传入递归。4.2 归并思想在“区间合并”类题中的渗透区间合并类题目最典型的是第56题《合并区间》虽然主流解法是排序扫描但你仔细看它的内核区间先按起点排序再逐个合并相邻有重叠的部分本身就是一次“归并”思想的变形。归并排序教会我们的是一套有序合并的操作范式在真实工程中合并日志、合并用户数据、合并间隔时间全部是对这一思想的运用。这个点在我的工作经历中反复出现。比如一个线上服务会产生大量用户行为记录时间戳交错分布全量排序很浪费因为每个来源的数据本身已经有序。这时候我们用归并排序的思想像拉链一样逐个比较不同流的头部数据线性时间就能完成全局有序合并。你看力扣题不是象牙塔它训练的能力直接映射到生产环境。5. 常见问题与错误实录这些坑我替你们踩过了5.1 递归边界漏了等于号就栈溢出归并排序边界没写好最常见的报错是栈溢出。很多新手会把递归出口写成if (left right) return;看起来没毛病。但问题出在极端情况当right left 1时mid left左半是[left, mid]右半是[mid1, right]没有问题。而一旦某个递归调用出现left right表示区间为空如果出口只写了left right就去递归右半个空区间那就永远等不到出口栈直接被击穿。我的经验是无脑写if (left right) return;一个大于号堵住所有边界缝隙。这种代码风格在力扣的极端测试用例下永远不会翻车。5.2 辅助数组的共享与污染问题我在刷题群见过一个特别典型的错误把辅助数组定义成局部变量每次递归都新建一个这直接导致运行时间暴涨内存峰值也高得吓人。还有人把辅助数组定义成全局的结果同一个测试用例跑了两次第一次用的脏数据没清干净第二次就错了。正确的做法是在主函数里开一个等长的temp以引用的形式贯穿整个递归过程。每一轮合并结束后务必把temp的相应区间拷回nums这样下一次递归的合并操作才能基于正确的数据源。5.3 稳定性理解不透彻导致变体题全部做错我觉得这是最可惜的一类错误。有一道题要求按字符串长度升序排序长度相同的保持原顺序。解法可以是对原数组过一个映射带上原始序号排序规则里先比长度再比序号。归并排序因为稳定直接对长度排序长度相同的后缀自然保持原顺序不需要额外存储原序号。我见过有人在这道题上用快排库函数结果同样长度的字符串顺序全乱最后不得不加一堆补丁代码。这就是稳定性的实战价值不是理论空谈。为了确认自己是不是真的理解了我建议你做一个练习随意生成一个数组里面包含重复数字分别用快排和归并排一次打印排序前后的元素和它本身的原始下标。对比一下你会直观看到哪些元素的相对顺序被改变了哪些没有。这个练习做一遍比背十遍定义都管用。5.4 逆序对计算中的计数时机错误第315题衍生出来的逆序对计算问题不少人逻辑其实写对了但错误出现在统计时机的选择上。比如有人在右半元素放入结果时把“left剩余数量”加到答案里这完全反了。右半元素的正确含义是它比右半剩余元素大这点跟题目要的“右侧比当前元素小”没有任何关系。我总结了一个闭合口诀只有左半元素被放入时才累加计数只有右半元素被放入时计数器才增加。两条规则配合执行永远不会混。写代码前你可以在纸上先画一遍归并的过程把每个元素进入结果数组的次序标出来再对照答案统计很快就能形成肌肉记忆。6. 从刷题到面试归并排序的高频追问全预案6.1 面试官问到归并排序时喜欢追问什么根据我多次作为面试者和面试官的经验归并排序题面前方的高频追问十有八九集中在以下三个方向。第一问归并排序的空间复杂度是多少为什么很多人的回答停在“O(n)”就结束了。我建议你补上这句“除了结果数组外递归调用栈最深处占用O(log n)空间但通常表述复杂度时主体合并操作的空间O(n)已构成主项所以综合空间复杂度记为O(n)。”听到这个细节面试官会觉得你是真的懂。第二问归并排序是稳定排序为什么这个问题要求你答到代码层面。稳定性的关键在于合并时左右两个子数组的比较条件只有“左 右”才优先取左元素才能保证相等值的原始顺序不变。你把这句话说出来比背定义强一万倍。第三问归并排序和快排应用场景如何选择这是发散题。对数组快排通常更快因为它的partition操作可以在原数组上交换元素缓存命中率优于归并排序。对链表归并排序完胜因为链表无法高效随机访问快排的partition需要反复遍历链接而链表归并只需调整指针即可。如果业务要求稳定排序数组也只能选归并。6.2 面试考场上的代码书写习惯建议我一直建议同行在写归并排序时控制每个函数的长度在20到30行以内超过这个范围出错概率直线上升。还有一个小习惯先写主调用函数和辅助函数签名再写递归函数主体最后写合并逻辑从宏观到微观思路会清晰得多。如果你在考场上卡住了可以把mergeSort递归过程和merge合并过程分开写能显著降低压力。很多面试官其实也在通过这道题观察你的代码组织能力混乱的人通常在这道题上撑不过三个追问。7. 延伸思考归并排序思想如何迁移到更大规模场景7.1 外部排序内存放不下时的归并应用主流的归并排序延伸场景我都建议刷题者了解一下至少能说出基本思路因为面试官特别容易从排序题延伸到这里。假设你有一个10GB的文件需要排序但计算机内存只有1GB这时候归并排序的思路就是做“外部排序”。先把10GB文件切成10个1GB的小块把每个小块读入内存排序后写回磁盘此时每个小块内部有序。然后使用一个10路的归并排序同时打开这10个文件流用一个小顶堆维护每个文件当前的头部元素每次弹出一个最小值写入最终文件然后从那个文件流补一个数据进堆里。整体思路就是归并排序“多路归并”的自然延伸核心还是那句老话把大问题拆小逐个击破后合并。7.2 TimSort真实工业界里的归并改进版如果把归并排序直接用在生产环境会遇到一个性能问题对一个基本有序的数组做全量归并会白白浪费掉很多能量。于是就有了TimSort这样的归并排序改进版它在底层扫描出天然有序的子序列学术名叫“块”run然后只对块和块之间做归并。Python和Java的标准库排序用的正是TimSort。这就是归类排在实际工程中的一个可怕缩影。下次你要是再用Python写sorted()函数心里应该清楚那背后就有一棵归并树的影子在运行。我个人刷题时曾经有一个困惑为什么工程里最后没有用纯归并排序替代快排后来想明白了常数项和局部性差异才是工程世界的真相。力扣上比的是渐近复杂度工程上线比的是真实延时两者都需要懂但它们不是一回事。你能把这个差异讲给面试官听就已经超越了大量只会背诵时间复杂度的同行了。8. 最终实操总结我建议采用的刷题顺序与复盘方法如果你想把这个知识点彻底钉死在脑子里我的建议是严格按照下面的顺序操作。第一遍看懂模板逐行背诵并能说出每一行是在做什么为什么这么做。 第二遍不看参考代码自己在编辑器里闭卷写出第912题保证一次通过。 第三遍立刻做第315题做完对照模板思考变体到底多了哪些统计逻辑。 第四遍做第148题链表排序和493题翻转对感受归并排序在不同数据结构上的适应能力。 第五遍隔一周后再回来完整手写一遍自查是否还会出现边界、稳定性和辅助数组的问题。每一遍刷完都花十分钟把这次写的代码和上次做个对比看看自己是否在收敛到更简洁的实现。如果一次比一次简单说明真的理解了如果一次比一次复杂说明还在用记忆硬撑需要回炉重造。这五遍走完归并排序在你的代码库里就不是一道题了而是一个随时能调用的思维工具不管是面试的算法轮次还是未来工程里做大规模数据合并你都能随手拿出一个清晰高效的方案。说到底算法刷题的目标不是通关数而是真正理解每一个经典模式背后的逻辑。归并排序正是那个能帮你把“分治”两个字刻进脑子的最佳起点吃透了它后面很多难题的钥匙也就自动配好了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑