每个刷力扣的人应该都经历过这种卡壳:明明题目看懂了,暴力法也写出来了,但一提交就是超时,看题解又觉得别人思路太绕。尤其是分治和归并排序这类题目,代码量不大,但递归的展开过程、区间的划分、合并时的细节,每一步都可能让你怀疑自己适不适合写代码。这篇内容我想用两道经典的力扣题——排序数组和交易逆序对的总数——把归并排序这条线彻底讲透,从原理到代码,从易错点到优化思路,一次说清楚。
先交代一下这篇文章适合谁看:如果你正在刷力扣热题100,或者准备面试需要系统过一遍分治/排序类题目,又或者你其实已经能写出归并排序但不太清楚它到底能解决什么进阶问题,那么这篇内容值得你花二十分钟认真读一遍。我会尽量用现场写代码时的真实思路来讲,而不是给你一份冷冰冰的参考答案。
1. 整体设计与思路拆解:两道题为什么放在一起讲
1.1 分治与归并排序的本质
先理解归并排序的本质。它做的事情只有三步:把数组从中间切成两半,分别把左半边和右半边排好序,然后把两个有序的子数组合并成一个整体有序的数组。这三步对应到代码上就是两个函数:一个负责递归切分,一个负责合并。
很多初学者会把注意力放在“切分”上,觉得递归入口和退出条件是难点。但实际上,切分只是把问题规模不断缩小,真正决定归并排序性能和正确性的,是合并那一步。合并的过程也很简单:维护两个指针分别指向左子数组和右子数组的头部,每次取较小的那个放入结果数组,然后移动对应的指针。听起来简单,但当你真正动手写的时候会发现,边界条件、指针移动、临时数组的分配,任何一个地方出错,跑出来的结果就会很离谱。
我每次思考分治题目的时候都会提醒自己一句话:分治只是手段,合并才是核心。后面的逆序对题目就是这句话最好的证明。
1.2 从“排序数组”到“交易逆序对”的进阶逻辑
这两道题放在一起,因为它们共用同一套分治框架,但难度和技术含量完全不在一个级别。
排序数组(LeetCode 912题)是纯正的归并排序裸题,没有任何变体,只需要你把一个数组排好序就行。它适合用来打基础,验证你对归并排序递归框架的掌握程度。
交易逆序对的总数(剑指Offer 51题)就不一样了。它要求在排序的过程中额外计算一个统计量:数组中满足 i < j 且 nums[i] > nums[j] 的数对个数。直观的暴力解法是双重循环,时间复杂度 O(n^2),数据量一大必然超时。而用归并排序恰好能在合并的过程中自然统计出逆序对数量,时间复杂度直接降到 O(n log n)。
这里有一个很关键的思维跳跃:传统的归并排序只关心“合并后数组是否有序”,而逆序对题目要求你在合并的同时关注“左子数组中哪些元素比右子数组的大”。如果你理解了这个跳跃,就理解了归并排序作为分治算法为什么这么强大——它不仅仅是排序工具,还是一个天然的计数框架。
1.3 这两种解法适合什么场景
我自己的经验是,如果你能把这两道题吃透,你就掌握了分治思想最典型的应用模板。之后你再遇到类似的题目,比如计算重要逆序对、区间和的个数、翻转对等,会发现它们的核心都是“在分治的合并阶段统计某种跨区间的性质”。
所以,这篇内容不会只给你贴代码,我会把每一行代码背后的思考过程、为什么这样写、踩过什么坑都讲清楚。你照着这个思路自己推一遍,比背十道题解都管用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点:排序数组这道题的正确打开方式
2.1 分治模板的三种常见写法
先说明一下,归并排序的实现方式其实有好几种,面试和刷题中常见的分三类:自上而下的递归、自下而上的迭代,以及原地归并。我一个个说。
递归写法是最符合分治思想的写法,代码结构清晰:先处理递归基(数组长度小于等于1直接返回),然后找中点,递归排序左右,最后合并。这种写法的时间复杂度稳定在 O(n log n),空间复杂度 O(n)(因为要用额外的临时数组)。面试中如果你要用归并排序,推荐写这种,因为思路直观,不容易出错。
迭代写法是另一种选择。它从长度为1的子数组开始,两两合并,然后长度翻倍继续,直到整个数组有序。这种写法避免了递归调用带来的栈开销,但在实现时要注意每一轮合并的区间边界,代码写起来比递归更容易出Bug。
原地归并听起来很理想,不需要额外空间,但在数组上实现原地合并的时间开销会变大,因为需要在数组中间反复移动元素,实际生产中很少用,刷题也不推荐。
2.2 排序数组的完整代码拆解
下面这段是我在实际刷题时用的递归版本,注释标注了每一段的关键逻辑:
java复制class Solution {
public int[] sortArray(int[] nums) {
if (nums == null || nums.length < 2) {
return nums;
}
int[] temp = new int[nums.length];
mergeSort(nums, 0, nums.length - 1, temp);
return nums;
}
private void mergeSort(int[] nums, int left, int right, int[] 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);
}
private void merge(int[] nums, int left, int mid, int right, int[] temp) {
int i = left;
int j = mid + 1;
int t = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
temp[t++] = nums[i++];
} else {
temp[t++] = nums[j++];
}
}
while (i <= mid) {
temp[t++] = nums[i++];
}
while (j <= right) {
temp[t++] = nums[j++];
}
t = 0;
while (left <= right) {
nums[left++] = temp[t++];
}
}
}
这几个细节,写的时候要特别留意:
第一,中点计算用 left + (right - left) / 2 而不是 (left + right) / 2。虽然两种写法在大多数情况下结果一样,但后者在 left 和 right 都很大的时候可能溢出。虽然面试时数据规模不一定到这个程度,但养成好习惯总没错。
第二,递归基是 left >= right 就返回。当区间只有一个元素或者空区间时,自然有序,不需要再切分。
第三,合并时临时数组的索引 t 从 0 开始,最后把临时数组拷贝回原数组时,是从 nums[left] 开始覆盖到 nums[right]。这里要注意拷贝的循环条件:while (left <= right),每次自增 left。我见过很多人写完合并之后忘了拷贝回原数组,结果排完等于没排。
2.3 为什么要用额外数组,空间复杂度怎么算
归并排序的空间复杂度是 O(n),这是它的硬伤,也是面试官喜欢追问的点。如果你每次合并都新建一个临时数组,那空间复杂度会变成 O(n log n),因为每一层递归都要分配新数组。更好的做法是在 sortArray 里一次性分配一个和原数组等长的临时数组,然后递归过程中反复使用这块空间,这样总空间就是 O(n)。
从工程实践的角度来看,额外数组带来的内存开销是不能忽略的。但对于力扣题目来说,n 基本在 10^5 量级,一个 int 数组只占几百 KB,完全没问题。所以刷题时不需要纠结空间优化,先保证算法正确且跑得快,再考虑优化。
3. 实操过程与核心环节实现:交易逆序对的总数如何用归并排序统计
3.1 逆序对计数的核心思路:从“排序”到“计数”
剑指Offer 51题,题目名很直白:在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求逆序对的总数。
暴力双重循环很好写,但遇到 n = 50000 就会超时。归并排序的思路是把统计逆序对这件事融进合并过程中。
我先解释一个关键事实:假设在归并排序的某个阶段,左子数组和右子数组都已经各自有序。现在要合并它们。在合并过程中,如果右子数组的当前元素 nums[j] 小于左子数组的当前元素 nums[i],那么左子数组中从 i 到 mid 的所有元素,都应该排在 nums[j] 前面,且它们都大于 nums[j]。也就是说,nums[j] 与左子数组中 mid - i + 1 个元素构成了逆序对。
这个事实是统计逆序对的基础,也是这道题最精妙的地方。你想想看,如果没有归并排序帮你把左右子数组排好序,你根本没法一下子知道左子数组里有多少个元素比当前右子数组元素大。正是因为左右子数组有序,你才能用 mid - i + 1 这个式子批量计数。
3.2 代码实现:在合并阶段插入计数逻辑
直接看我写的完整代码:
java复制class Solution {
public int reversePairs(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int[] temp = new int[nums.length];
return mergeSort(nums, 0, nums.length - 1, temp);
}
private int mergeSort(int[] nums, int left, int right, int[] temp) {
if (left >= right) {
return 0;
}
int mid = left + (right - left) / 2;
int count = mergeSort(nums, left, mid, temp);
count += mergeSort(nums, mid + 1, right, temp);
count += merge(nums, left, mid, right, temp);
return count;
}
private int merge(int[] nums, int left, int mid, int right, int[] temp) {
int i = left;
int j = mid + 1;
int t = 0;
int count = 0;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
temp[t++] = nums[i++];
} else {
// 此时 nums[j] 小于 nums[i],左子数组中还有 mid - i + 1 个元素更大
count += (mid - i + 1);
temp[t++] = nums[j++];
}
}
while (i <= mid) {
temp[t++] = nums[i++];
}
while (j <= right) {
temp[t++] = nums[j++];
}
t = 0;
while (left <= right) {
nums[left++] = temp[t++];
}
return count;
}
}
这段代码与排序数组唯一显著的区别就在合并的 else 分支中多了一行 count += (mid - i + 1)。其他结构完全一样。这说明什么?说明一旦你掌握了模板,很多进阶题只是在这个模板上做小改动。
3.3 为什么计数不会重复或漏掉
这是很多人会纠结的问题:在合并过程中统计的逆序对,会不会和之前递归层统计的重复?会不会漏掉?
答案是不会。原因在于,每一层递归只统计“跨越左右两个子数组边界”的逆序对。也就是说,被统计的逆序对一定是左子数组中的元素和右子数组中的元素构成的。至于完全在左子数组内部的逆序对,以及完全在右子数组内部的逆序对,会在更深的递归层中被统计。递归逐层向上,最终覆盖所有情况。
这个论证逻辑我用一个具体例子走一遍。假设数组是 [7, 5, 6, 4]。
第一次递归,左子数组是 [7, 5],右子数组是 [6, 4]。进入左子数组的递归时,统计出 7 和 5 构成一组逆序对,count加1。右子数组递归时,统计出 6 和 4 构成一组逆序对,count再加1。然后在当前层合并 [7,5] 和 [6,4] 时,会统计跨左右边界的逆序对:7 大于 6 和 4,5 大于 4,一共三组。总数为 1+1+3 = 5。
手动验算一下,原数组 [7,5,6,4] 中所有逆序对为 (7,5)、(7,6)、(7,4)、(5,4)、(6,4),正好5组。分层的计数逻辑没有重复也没有遗漏。
提示: 当你怀疑这类计数算法漏算的时候,最好的方法不是空想,而是拿一个规模很小的数组,比如4个元素,把递归树完整展开,对照每一层的归并过程,数一数每一层统计了几个逆序对。
3.4 边界条件和特殊用例的处理
写这道题时,有几个边界情况我建议你单独测一测:
- 空数组:
nums为 null 或长度为0,返回0。代码开头就已经处理。 - 数组已经有序:比如
[1, 2, 3, 4]。这种情况下,每次合并的nums[i] <= nums[j]分支都会先走,不会进入else,因此 count 始终为0。这验证了算法在最优情况下的计数逻辑。 - 数组完全逆序:比如
[4, 3, 2, 1]。逆序对总数是n * (n - 1) / 2。你可以用这个公式验证算法的输出。这是最直接的自测方法,我每次写完都会用这个用例跑一遍,确认没有算错。 - 数组中含有重复元素:逆序对要求
nums[i] > nums[j],严格大于。代码中用nums[i] <= nums[j]作为不构成逆序对的条件,等于的情况不计数,保证了统计结果的正确性。
4. 常见问题与排查技巧实录:递归和归并的易错点
4.1 递归栈溢出与时间优化
力扣的测试用例中,数组规模有时候会到 10^5 甚至 10^6,递归深度为 log2(n),大概十几层到二十几层,完全不会触发栈溢出。但如果你用迭代版本的归并排序,就不会有递归调用的栈开销,不过代码逻辑更绕一些。我自己刷题阶段更推荐递归版,重要的是先把思想用对。
有人可能会问,既然两种版本时间复杂度一样,面试时用哪个更好?我的建议是:优先递归版本,因为面试官更容易看懂你的思路,追问起来你也能讲清楚。如果面试官额外问你“递归会不会栈溢出”,你再说出迭代版本的思路也不迟。
4.2 排序数组踩过的典型坑
我在这道题上踩过几个典型的坑,都是真实写代码时容易犯的:
第一,临时数组的长度写错。有些人喜欢在 merge 方法里写 int[] temp = new int[right - left + 1],然后重新分配。这样做虽然能通过,但每次合并都申请内存,性能差。更严重的是,如果把 temp 作为局部变量,最后还要把它拷贝回原数组,很容易搞错拷贝起点。
第二,合并后的数据忘了覆盖回原数组。如果不把 temp 中的数据写回 nums,那么后续的归并操作拿到的还是未排序的数据,整个排序就失败了。
第三,拷贝时的索引错乱。很多人会用 for (int k = 0; k < temp.length; k++) nums[left + k] = temp[k],这种写法没问题,但一旦 left 没有在循环里自增,就会出现问题。我看到有些实现会在循环里同时自增 left 和 t,这样写也可以,关键是不要混用两种写法。
4.3 逆序对计数错误的分析思路
如果你在写逆序对这道题时发现输出比预期小,最常见的错误就是把 count += (mid - i + 1) 写成了 count += 1。
我解释一下为什么不能写 count += 1。当 nums[j] < nums[i] 时,不仅 nums[i] 和 nums[j] 构成逆序对,左子数组中从 i 到 mid 的所有元素都大于 nums[j]。因为左子数组是有序的,nums[i] 是当前元素,而它后面的元素都大于等于它。所以如果你只加1,就会漏掉同一轮中后面的若干组逆序对。
另一个容易出错的地方是等号的处理。当 nums[i] == nums[j] 时,不应该计数。因为在逆序对的定义里,要求 nums[i] > nums[j]。所以合并时条件要写成 if (nums[i] <= nums[j]) 先取左边的,这样相等的情况下不会进入计数分支。如果你写成小于号,那么相等时就会进入 else,多统计出一批本不该存在的逆序对。
4.4 针对这类问题的自查清单
我把自己的排查经验整理成一个速查表,你可以对照着自己调试:
| 症状 | 可能原因 | 检查方法 |
|---|---|---|
| 排序结果混乱 | 合并后没覆盖回原数组 | 在 merge 最后打印 nums 看看是否有序 |
| 逆序对数量偏大 | 相等元素被计数 | 检查 if (nums[i] <= nums[j]) 是否用了等号 |
| 逆序对数量偏小 | 计数只加1而不是加 mid - i + 1 |
用完全逆序数组验证总数是否为 n*(n-1)/2 |
| 栈溢出 | 递归基写错导致无限递归 | 检查 left >= right 是否在入口处执行 |
| 运行超时 | 每次合并新建数组 | 改为外部统一分配 temp 数组 |
5. 进阶思考:归并排序之外,这两道题还能怎么考
5.1 从逆序对衍生出来的同类型题目
力扣上有很多与“逆序对”同源的题,核心思路都是在分治合并阶段维护某种统计量。比如 翻转对(LeetCode 493题),它要求统计 i < j 且 nums[i] > 2 * nums[j] 的逆序对数量。解题思路和标准逆序对几乎一样,区别在于合并时不能直接用 nums[i] > nums[j] 判断,而是要在合并前先找到“大于两倍”的分界点。
再比如 计算右侧小于当前元素的个数(LeetCode 315题),它要求返回一个数组,每个位置的值是它右边有多少个元素小于它。这个题也可以用归并排序的计数框架,但需要在排序过程中保留每个元素的原始索引,因为最后要按原始位置填充结果。
还有 区间和的个数(LeetCode 327题),它把数组前缀和算出来之后,统计某个区间内的前缀和落在指定范围内的数量。这个问题的核心变成了对前缀和数组做归并排序,并在合并阶段统计跨区间满足条件的下标对。本质上还是分治的套路。
这些题你不需要都刷完,但理解了归并排序的进阶用法之后,看到“统计某种配对数量”的题目,第一反应不应该是暴力两重循环,而应该是想一下能不能在分治过程中批量统计。
5.2 三路归并和其他排序算法的思考
有些文章会提到三路归并排序,思路是把数组切成三段而不是两段,递归排序后再三路合并。理论上三路归并可以减少递归深度,但每一层的合并复杂度会增加,实际性能并不总是优于二路归并。工程中更常见的优化方案是:当子数组规模小于某个阈值(比如 16 或 32)时,改用插入排序,因为小规模数组上插入排序的常数因子更小。
不过这些优化对于力扣刷题来说不是重点。你可以了解,但不要在题目上过度优化。面试时如果被问到归并排序的优化,能说出“小数组改用插入排序”就比大部分人强了。
5.3 刷题时的复习方法建议
我自己的刷题习惯是,同类型题目放在一个周期里集中刷,每道题刷完后,尝试默写一遍核心模板。比如今天这篇内容里的归并排序模板,你可以先看懂了,然后关掉页面,自己在编译器里写出来。写不出来就再看一遍,直到能一口气默写完整为止。这种方法的记忆效果比反复看代码好十倍。
而且你要注意,默写模板和默写题解是两回事。模板是要掌握了能灵活用的,题解是针对某道题的。模板掌握之后,你会发现上面的翻转对、右侧小于当前元素的个数等题目,你只需要在模板的合并阶段做小小的逻辑改动,就能解出来。
6. 写在最后的排错心得
归并排序这类题目第一次写的时候容易懵,不是因为它有多难,而是因为递归思维需要刻意训练。我想分享一下我个人的体会:每次写完归并排序相关代码,一定要用最小用例走一遍完整流程。这个习惯救了我很多次。比如输入 [2, 1],手动走一下递归:左子数组 [2] 返回,右子数组 [1] 返回,合并时 nums[i]=2,nums[j]=1,进入 else 分支,count 加 mid - i + 1 = 1,排序后数组变成 [1, 2]。整个过程清晰明了,任何一步都能和代码对应上,这样调试起来非常快。
还有一个建议是,当你觉得代码看起来没问题但结果不对的时候,不要急着看题解。先在代码里加几个打印语句,把每次合并前后的数组、左右子数组的值、计数的变化都打出来,基本上一眼就能看出问题。
最后再分享一个小技巧:遇到需要统计“跨区间配对”的题目,先把归并排序的框架写好,再在合并阶段思考统计逻辑。这样思维负担会小很多,因为你不用每次从零搭建一套分治结构,只需要在已经验证过的模板上修改合并逻辑。这个习惯是我刷了几十道分治题之后总结出来的,对你应该也有用。
