最近在力扣上反复刷分治相关的题目,发现归并排序这组题特别适合用来理解"分而治之"的核心思想。今天把排序数组和交易逆序对的总数这两道题放在一起写,是因为它们本质上共享同一套底层框架:归并排序。一道题是直接考察归并排序本身的实现,另一道题则是利用归并过程中的有序性做文章。把这组题吃透,不光是学会两个题解,更重要的是建立起"分治问题怎么拆、怎么合、在哪里做手脚"的思维习惯。这篇笔记给准备面试、正在刷力扣热题100的同学一个参考,也是我自己踩坑之后的复盘总结。
1. 整体思路拆解:为什么归并排序能同时解决这两道题
1.1 归并排序的分治折叠
要理解这两道题,先得把归并排序的骨架打扎实。归并排序的思路归纳起来就两句话:先把数组不断对半分,分到不能再分,然后两两合并,在合并过程中把元素按顺序排好。这个"先拆后合"的过程,就是分治法的标准模板。
拆的过程比较简单,递归调用的每一层都取中点 mid,把左半部分和右半部分分别排序。合的过程是核心,需要借助一个临时数组,用双指针分别指向两个子数组的起始位置,比较大小后依次放入临时数组,最后拷贝回原数组。
关键点在合并阶段:左半部分和右半部分在各自内部已经有序了,所以合并时只需要 O(n) 的扫描就能完成整体排序。整个算法的时间复杂度稳定在 O(nlogn),不管输入数据原本是什么状态,都是这个量级,这正是归并排序区别于快排的重要特性。
很多人第一次写归并排序时,容易被递归过程绕晕。我的经验是:把递归当作"黑盒"。你只需要清楚两点:第一,递归调用结束后,左半部分和右半部分各自已经有序;第二,你要负责把这两个有序部分合并起来。至于递归内部怎么排序,不用在当前这一层操心。能把这个抽象思维建立起来,分治的代码就不会写得纠缠不清。
1.2 从"排序"到"逆序对"的思维跳跃
先抛出一个观察:如果数组是有序的,那么逆序对的数量就是 0;如果一个数组完全是倒序的,逆序对数量就是最大值。换句话说,排序的过程本身就天然和逆序对有关联。
那么逆序对的定义是什么?对于数组中的两个位置 i < j,如果 nums[i] > nums[j],那就构成一对逆序对。暴力解法是双重循环,O(n^2) 的时间复杂度,在大数组上完全不可行。
归并排序给了我们一个巧妙的视角:在合并左右两个有序子数组时,如果从右边取了一个元素放入临时数组,这个元素和左边剩余的所有元素都构成了逆序对。因为左边剩余的元素全部比它大,且位置都在它前面。这样每取一次右边元素,就能一次统计出一批逆序对,把所有合并过程中的统计结果累加,就是整个数组的逆序对总数。
这个转化是整个题目的精华所在,也是分治思想最漂亮的应用场景之一。排序本身和业务统计在一个框架里同时完成,时间复杂度不变,仍然是 O(nlogn),空间复杂度也同样是 O(n)。
1.3 两道题的异同与刷题顺序
把这两道题放在一起刷的原因很简单:排序数组是第一层,考察基本实现;交易逆序对的总数是第二层,在排序数组的基础上加了一层统计逻辑。我建议先写一道,再写第二道,因为第二道题可以看作是在第一道题的合并逻辑里"加一行代码"。
当然有一个很重要的区别需要注意。排序数组这道题要求我们对数组本身进行原地排序,输出排好序的结果。而逆序对题目要求的是返回一个计数结果,并不需要真的把数组排好序,但排序过程是统计的工具。理解这个差异之后,你就不会被"明明排序了却没用到排序结果"这件事困扰。
还有一个容易被忽视的点:交易逆序对的总数这道题通常要求的返回值是 int 类型,但极端情况下逆序对数量可能超过 int 范围。写代码时要根据题目要求调整返回类型,否则提交时会卡在数据范围上,这个细节在后面的常见问题里还会展开说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:排序数组的实现要点
2.1 递归版本的完整代码
先上排序数组这道题的经典递归实现。题目本身要求对整数数组排序,我用 Java 来写:
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++];
}
}
}
核心点在 merge 函数里。首先用临时数组承接合并结果,通过指针 i 和 j 分别指向左右子数组,比较后按序写入。归并排序要求合并时保持稳定性,所以当左边元素等于右边元素时,优先取左边的。这也是合并时用 <= 而不是 < 的原因。
2.2 递归边界与中点计算
递归终止条件是 left >= right,说明当前区间只有一个元素或者为空,天然有序,不用再拆分。中点计算使用 left + (right - left) / 2 而不是 (left + right) / 2,是为了避免 left 和 right 都很大时整数相加溢出,这是一个习惯问题,建议从一开始就养成这个写法。
merge 函数里的拷贝环节也要注意。很多新手在最后拷贝时用临时数组的下标 t 做遍历,但正确的做法是用原数组 left 到 right 的区域同步拷贝。我见过不少人在这个环节出错,导致合并结果回到原数组时位置错乱。
还要想清楚一个问题:为什么需要额外的临时数组?因为合并过程中,如果直接在原数组上交换元素,会覆盖掉还没参与比较的元素,导致数据丢失。临时数组在这里起到了缓冲的作用。这也是归并排序空间复杂度为 O(n) 的原因。
2.3 迭代版本与递归版本怎么选
递归版本写起来直观,但面试官偶尔会追问非递归实现。迭代版本的思路是:先从长度为 1 的子数组开始合并,然后长度翻倍,直到整个数组合并完成。这相当于用一个外层循环模拟递归过程。
java复制class Solution {
public int[] sortArray(int[] nums) {
int n = nums.length;
int[] temp = new int[n];
for (int size = 1; size < n; size *= 2) {
for (int left = 0; left < n - size; left += 2 * size) {
int mid = left + size - 1;
int right = Math.min(left + 2 * size - 1, n - 1);
merge(nums, left, mid, right, temp);
}
}
return nums;
}
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++];
}
}
}
迭代版本有一个容易错的地方:右边界 right 不能超过数组末尾,所以要用 Math.min(left + 2 * size - 1, n - 1)。左边界 left 的循环条件也要注意,如果 left >= n - size,说明剩下的元素不足一组,已经不需要再合并了。这个版本对理解归并过程很有帮助,建议两个版本都写一遍,能加深对分治边界的理解。
3. 交易逆序对的总数:归并过程中的统计逻辑
3.1 暴力解法不可行的原因
先看一眼暴力解法为什么不行。LeetCode 剑指 Offer 51 的题目,数组长度可能达到 50000 以上,暴力双重循环的 O(n^2) 复杂度在最坏情况下需要执行 25 亿次比较,这个时间在在线评测系统里妥妥超时。
但暴力解法并不是没有价值,它至少帮我们验证了逆序对的定义。写一个双重循环,枚举所有 i < j 并且 nums[i] > nums[j] 的组合,累加计数。这个版本的代码逻辑简单,适合用来验算后面归并解法的正确性,比如拿小数组做对比测试。
3.2 基于归并排序的统计代码
核心思路是在合并左右两个有序数组时,每当从右侧取元素,就意味着左侧剩余元素都大于当前右侧元素,这些元素都能和当前右侧元素组成逆序对。统计数量就是左侧剩余的个数:mid - i + 1。
java复制class Solution {
public int reversePairs(int[] nums) {
if (nums == null || nums.length < 2) {
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 {
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;
}
}
对比排序数组的代码,你会发现只是在 merge 函数的 else 分支里加了一行:count += mid - i + 1。这一行就是逆序对统计的全部秘密。
3.3 统计时机的底层逻辑
为什么在 nums[i] > nums[j] 时,左侧剩余的所有元素都能与当前 nums[j] 构成逆序对?仔细想想,因为左子数组已经有序,从 i 到 mid 的所有元素都大于等于 nums[i](等于的情况下由于稳定性优先取了左边,不会进入 else 分支),既然 nums[i] > nums[j],那么 i 后面的元素也必然大于 nums[j]。同时左边所有元素在原始数组中的位置都在右边当前元素之前,这完全符合逆序对的条件。
所以每当发生一次"从右边取数",我们就一次性统计了 mid - i + 1 个逆序对。这也是归并排序统计逆序对的精髓:一次合并可以统计多条信息,而不需要逐一枚举。
有一种更容易记忆的方式:从右边取数,意味着右边这个元素"跨过"了左边所有剩余元素,跨越的数量就是新增的逆序对数。想象成两个人排队,后面的人比前面的人矮,这个矮个子每往前"跨"一个人,就产生一对逆序。
3.4 关于排序完成却没有使用排序结果的困惑
很多人在写完逆序对代码后会有个疑惑:我们统计完逆序对,数组也被排序了,但题目没有要求排序,这个排序操作是不是多余的?
不是多余的。回到归并排序的运行机制:只有通过排序让左右两边分别有序,才能在合并时用"左侧剩余数量"这种方式快速统计逆序对。如果左右不是有序的,那么在合并时从右边取一个元素,根本不能确定左边有多少个元素比它大。排序是这个统计方法的前提条件,而不是副产品。理解这一点,才算真正理解了为什么逆序对问题和归并排序绑定得这么紧密。
4. 归并排序还有哪些花活:从两道题到一类问题
4.1 三路归并与外部排序
归并排序不只是二路归并这一种实现,还有三路归并的思想。所谓三路归并,就是每次把数组分成三段,分别排序后再合并。理论上分成 k 路可以把合并的层数变为 log_k(n),但每层合并的比较次数会增加,所以实际性能并不是分得越多越快。三路归并在外部排序场景里比较常见,比如处理大规模数据时无法全部载入内存,会利用多路归并减少磁盘读写次数。
面试中如果被问到相关扩展,能说出二路归并到多路归并的演变逻辑、时间复杂度的变化,会是很加分的表现。但日常刷题,二路归并已经完全够用,不需要过度设计。
4.2 什么情况下归并排序比快排更合适
归并排序的稳定性和稳定复杂度是它区别于快排的核心优势。快排的平均复杂度也是 O(nlogn),但最坏情况下会退化到 O(n^2)。归并排序不管数据分布如何,都是严格的 O(nlogn)。
代价是空间复杂度。归并排序需要额外的 O(n) 空间,快排的空间复杂度是 O(logn)(递归栈)。所以在内存敏感的场景,快排通常胜出;在稳定性要求高、或者数据无法随机访问的场景(比如链表排序),归并排序更合适。
力扣 148 题排序链表,就是归并排序的典型应用场景。链表的归并排序不需要额外空间完成合并,因为链表节点可以通过指针重新连接,不需要临时数组。
4.3 分治思维的延伸
归并排序是分治思想最标准、最友好的入门载体。分治法的通用模式是:分解原问题为子问题、解决子问题、合并子问题的解得到原问题的解。归并排序完美体现了这三个步骤。
掌握这个模式后,你会发现在很多问题上都能找到类似的影子。求数组前缀和的线段树、求最近点对的分治算法、求逆序对的各种变体,核心都是分解和合并的逻辑。刷这组题的时候,不要只满足于把代码写对,多想一步:合并这一步到底在做什么、我能不能在合并时额外做一些事?
这是把一道题的价值放大到十道的关键习惯。
5. 实操过程记录:我如何从超时到一次通过
5.1 第一次提交:暴力解法超时
我刚开始做交易逆序对的总数时,第一反应是双重循环,代码 5 分钟就写完了,信心满满提交,结果直接超时。回头看,数组长度为 50000 时,双重循环的上限是 25 亿次比较,这个量级在大多数 OJ 上都无法通过。
超时之后,我意识到这道题显然在考察更优的算法。再看到题目分类里的"归并排序",才想起来可以用归并过程中统计逆序对的经典做法。
5.2 第二次提交:统计逻辑写错位置
按照记忆写完了归并排序的统计版本,一开始我是在合并完成之后,再单独遍历临时数组找逆序对。这个版本虽然基于归并排序,但统计逻辑仍然等价于暴力枚举,因为单独遍历并没有利用左右有序的性质,时间复杂度依旧很高。
后来调整思路,把统计逻辑放在合并循环的 else 分支里,每次从右侧取数时立即累加逆序对数,这样才能利用有序性达到 O(nlogn) 的整体复杂度。
5.3 第三次提交:边界条件修正
统计逻辑对了之后,又遇到一个小问题:当数组只有一个元素或为空时,mergeSort 函数最开始就返回 0,这个没问题。但边界条件 left >= right 的处理,我在最初版本写成了 left > right,导致某些情况下数组只有一个元素时继续递归,左右边界会出现 left > mid 的情况,最终代码出错。
修正为 left >= right 后逻辑就清晰了:只要区间内元素个数少于等于 1,就不需要再排序,天然有序,逆序对数量为 0。
6. 常见问题与排查技巧实录
6.1 区间边界搞混导致崩溃
问:合并时 while 循环的条件为什么是 i <= mid && j <= right?答:i 和 j 分别是左右子数组的当前遍历指针,mid 是左子数组的右边界,right 是右子数组的右边界。只要还有一方没有遍历完,就要继续比较。如果一方已经遍历完,就把另一方剩余的元素全部放入临时数组。
常见错误是把 i <= mid 写成 i < mid,这样会漏掉左子数组的最后一个元素,导致排序结果错误。建议写完后用一个简单用例手动模拟,比如 [2, 1, 3, 5, 4],一行行走一遍,立刻能发现问题。
6.2 逆序对数量溢出 int
剑指 Offer 51 的题目在较新版本中返回类型是 int,但某些变体或类似题目,数组长度更大时逆序对数量可能超出 int 范围。最坏情况是数组完全倒序,逆序对数量为 n*(n-1)/2,当 n 约为 10^5 时,这个值就超过了 int 的 21 亿上限。
解决办法是:如果题目返回类型允许 long,就把计数变量定义为 long,并在递归返回值中也使用 long。如果题目明确要求 int,那就需要确认测试数据范围是否安全。一般力扣官方题解中,只要函数签名是 int,说明测试用例不会溢出,但写代码时养成用 long 的习惯更稳妥,可以根据情况调整。
6.3 临时数组的复用与重复创建
一个性能优化的细节:不要在递归函数里每次创建临时数组,应该在主函数里创建一个长度等于原数组的大临时数组,然后在递归过程中复用它。如果每次合并都新建临时数组,会产生大量不必要的空间分配和垃圾回收开销。
临时数组的下标 t 在每个 merge 中从 0 开始使用,合并结束后拷贝回原数组的对应区间。这里有个小坑:拷贝时注意原数组的下标是从 left 开始的,不是从 0 开始。很多人用临时数组的 t 遍历时没有把原数组的 left 加到下标上,导致拷贝错位。
6.4 递归深度过大怎么办
递归实现归并排序,递归深度是 log2(n),对于 n = 50000 来说,深度大约为 16,完全不用担心栈溢出。但如果数据量级达到百万、千万级别,递归深度也只是 20 左右,依旧没问题。真正会导致栈溢出的是像快排在最坏情况下递归深度等于 n 的情况。
如果面试官问能不能不用递归实现归并排序,可以使用迭代版本,也就是前面提到的自底向上的归并过程。这个实现虽然写起来稍微绕一些,但能展示你对归并排序原理的深入理解。
6.5 数据错误定位的调试技巧
当你写完归并排序,结果不对时,推荐一个调试技巧:先用小规模测试用例验证核心逻辑。我常用的测试用例是:
- 空数组 []
- 单元素数组 [5]
- 两个元素 [2, 1]
- 逆序数组 [5, 4, 3, 2, 1]
- 包含重复元素的数组 [4, 2, 2, 1]
对于逆序对统计题,还要额外验证正序数组 [1, 2, 3, 4] 的输出应该为 0,逆序数组 [5, 4, 3, 2, 1] 的输出应该为 10(5*4/2)。把这些用例跑通,代码基本就没有大问题了。
7. 经验总结:从这两道题得到的四件事
7.1 模板代码要写到肌肉记忆
归并排序的模板代码是刷题的基础功,建议做到闭着眼能写出来。不只是代码本身,还要能清楚地讲出每个参数、每个边界条件的含义。面试时如果连归并排序都无法一次写对,后续的深入拓展就无从谈起。
练习方法是:连续几天每天手写一遍归并排序的递归版本和迭代版本,不参考任何资料,写完之后用测试用例验证。坚持一周,这段代码就成为条件反射级别的记忆了。
7.2 在分治合并处"做手脚"是加分项
逆序对统计告诉我们一个很重要的思路:分治问题的合并阶段,是很多业务统计的天然切入点。只要你理解了归并排序的合并过程,就可以在合并时统计各种信息,比如逆序对数量、区间和、最大子段和等。
刷题时遇到类似问题,可以想想能不能在归并的合并阶段做文章,往往会有意想不到的效果。
7.3 不要忽略空间复杂度的讨论
面试中写归并排序时,面试官大概率会追问空间复杂度,以及能不能优化。归并排序的空间复杂度是 O(n),对于大多数场景这是可以接受的,但链表的归并排序可以做到 O(1) 的额外空间。
把空间复杂度的讨论当成一个加分环节。能讲清楚为什么需要额外空间、哪些场景可以省掉额外空间,通常比背代码更能体现算法功底。
7.4 使用稳定性优势回答问题
归并排序是稳定的排序算法,这是它在某些场景下替代快排的关键原因。比如在数据库排序中,如果先按照某个字段排序,再按照另一个字段排序,稳定排序可以保证相同关键字之间的原始相对位置不改变。
在面试中,如果能主动提出"归并排序是稳定的,所以适用于多关键字排序的场景",通常会给面试官留下不错的印象。
最后说一点我自己刷这类题的感受。这道题的代码量不大,但每次重新写一遍,总能多理解一点其中的细节。第一次实现时只求能通过用例,第二次开始思考边界条件背后的逻辑,第三次再写才真正感觉到归并排序的分治框架和逆序对统计之间的关系是环环相扣的。
如果在数组完全逆序时,自己手动模拟一遍归并排序的过程,把每次统计到的逆序对数画出来,你会发现整个统计过程就像在"数"每一次跨越产生的数量,非常直观。这个手动模拟的过程虽然花时间,但对理解分治思想帮助极大,建议你也试一次。
