文章目录


题目描述
题目链接:力扣 912. 排序数组
题目描述:

示例 1:
输入:nums = [5,2,3,1]
输出:[1,2,3,5]
解释:数组排序后,某些数字的位置没有改变(例如,2 和 3),而其他数字的位置发生了改变(例如,1 和 5)。
示例 2:输入:nums = [5,1,1,2,0,0]
输出:[0,0,1,1,2,5]
解释:请注意,nums 的值不一定唯一。
提示:1 <= nums.length <= 5 * 104
-5 * 104 <= nums[i] <= 5 * 104
为什么这道题值得咱们二刷?
在之前的博客 力扣 912. 排序数组 中我们用快排解决这道题时,我们掌握了"原地分区、空间高效"的排序思路,我们今天二刷这道题的目的是通过这道题来学习归并排序的思路,并且比较二者优劣,通过理解两种解法进而实现互补,让我们构建更完整的排序算法认知------快排和归并各有不可替代的价值。
1. 掌握两种"O(nlogn)"算法的场景适配能力
快排和归并的核心差异,决定了它们在不同场景下的适用性,二刷能帮我们建立"按需选算法"的意识:
- 若场景要求"空间尽可能小"(如内存紧张的嵌入式开发),快排的平均O(logn)空间开销更有优势;
- 若场景不能接受"时间退化风险"(如金融数据排序,需稳定高效),归并排序无论数据分布如何,都能稳定保持O(nlogn),更符合需求;
- 若场景需要"稳定排序"(如按成绩排序后,保留同分数学生的报名顺序),归并的稳定性是快排无法替代的关键特性。
只有同时掌握二者,才能在不同需求下做出最优选择,而不是只会一种解法"硬套"。
2. 补全"算法优化"的不同维度认知
快排和归并的优化方向各有侧重,二刷能帮你拓宽优化思路:
- 快排的优化集中在"避免分区失衡":比如随机选基准、三指针处理重复元素,核心是解决"极端场景下的时间退化"问题;
- 归并的优化集中在"减少空间开销":比如全局复用临时数组,避免递归中频繁创建销毁数组,核心是解决"内存操作效率"问题。
两种优化思路覆盖了"时间稳定性"和"空间效率"两个关键维度,学会后能迁移到其他算法的优化中(如动态规划的空间压缩、搜索算法的剪枝)。
3. 为后续复杂问题铺垫互补的知识基础
快排和归并的应用场景各有延伸,二者都学能降低后续学习成本:
- 快排的"分区思想"是解决Top K问题(如第K个最大元素)的核心,能实现O(n)的平均时间复杂度;
- 归并的"拆分-合并逻辑"是解决链表排序(如排序链表)的最优选择------链表的指针特性可避免归并的数组空间开销,实现O(logn)的空间复杂度。
现在通过同一道题掌握两种算法,后续遇到这些延伸问题时,能直接复用已学思路,无需从零开始理解。
二刷不是"重复做题",而是通过归并排序这个"补充视角",把快排未覆盖的"稳定性、场景适配、合并型分治"等知识点补全。快排和归并就像排序算法里的"左右手",单独会一只不够,两只都会才能应对更多复杂需求------这才是二刷的核心价值。
算法原理
我们回归这道题在"不依赖内置函数、O(nlogn) 时间、空间尽可能小"的要求下,归并排序是除快排外的另一重要选择。它虽在空间开销上略高于快排,但胜在时间性能稳定、实现逻辑直观,且具备"稳定性"这一关键特性。
归并排序基本思路
归并排序的核心是"先拆分、再合并",通过将大问题拆解为小问题,逐一解决后再整合,具体分为"拆分"和"合并"两步。
1. 拆分(Divide):将数组拆分为最小子问题
先找到数组的中间位置 mid,将数组分为左子数组(left ~ mid)和右子数组(mid+1 ~ right)。之后进行递归拆分左子数组和右子数组,直到子数组长度为 0 或 1(长度为 1 的数组天然有序,无需再拆分)。
如下图👇:

2. 合并(Merge):将有序子数组合并为大数组
准备一个临时数组mark,用于存储合并后的有序结果,用两个指针l,r分别指向左子数组和右子数组的起始位置,逐元素比较大小,将更小的元素通过指针i放入临时数组,当其中一个子数组遍历完后,将另一个子数组的剩余元素直接追加到临时数组末尾,将临时数组的有序结果复制回原数组的对应位置,完成合并。
如下图👇:

归并排序 vs 快速排序:核心差异对比
快排和归并虽然思路很相似并且还同属"分治法"(时间长不复习经常弄混),但是我们可以通过其递归顺序很容易的记忆区分二者,二者落地逻辑完全不同,我们进行对比记忆能让我们直观理解"分治"的灵活性:
1.快排是"先局部有序,再拆分处理":
- 通过基准值先将数组分成"左小右大"的区间(分块处理)
- 再递归处理子区间(递归)
类似二叉树前序遍历,如下图(二叉树没画好见谅👇)。

2.归并是"先拆分到最小,再合并有序":
- 先把数组拆到长度为1的子数组(递归)
- 再逐层合并成更大的有序数组(和并)
类似二叉树后序遍历,如下图👇:

两种路径没有好坏之分,学会后遇到"拆分-解决"类问题(如Top K、链表处理),能根据问题特性灵活选择思路,我们通过表格进行二者对比:
| 对比维度 | 归并排序(Merge Sort) | 快速排序(Quick Sort) |
|---|---|---|
| 分治顺序 | 先分后治:先递归拆分数组为子数组,再合并子数组得到有序结果 | 先治后分:先通过基准值分区(得到局部有序),再递归处理子分区 |
| 遍历顺序 | 类似二叉树后序遍历:左子数组处理 → 右子数组处理 → 合并左右 | 类似二叉树前序遍历:分区(处理当前) → 左子分区递归 → 右子分区递归 |
| 空间复杂度 | 平均/最坏均为 O(n)(需临时数组存合并结果) | 平均 O(logn)、最坏 O(n)(递归栈开销) |
| 稳定性 | 稳定(相等元素相对位置不变) | 不稳定(分区交换可能打乱相等元素顺序) |
| 极端场景性能 | 始终 O(nlogn)(不受数据分布影响) | 最坏 O(n²)(如有序数组、大量重复元素) |
代码实现
1.递归内创建临时数组
cpp
class Solution {
public:
// 归并排序主函数:拆分+合并
void Msort(vector<int>& nums, int left, int right) {
// 递归终止条件:子数组长度为0或1,无需排序
if (left >= right)
return;
// 1. 拆分:找到中间位置,递归处理左右子数组
int mid = left + (right - left) / 2; // 避免(left+right)溢出
Msort(nums, left, mid); // 处理左子数组
Msort(nums, mid + 1, right); // 处理右子数组
// 2. 合并:用临时数组存储合并后的有序结果
vector<int> mark(right - left + 1); // 临时数组,长度=当前子数组长度
int l = left, r = mid + 1; // l:左子数组指针,r:右子数组指针
int i = 0; // 临时数组的索引
// 比较左右子数组元素,按从小到大放入临时数组
while (l <= mid && r <= right) {
mark[i++] = nums[l] <= nums[r] ? nums[l++] : nums[r++];
}
// 处理左子数组剩余元素
while (l <= mid) {
mark[i++] = nums[l++];
}
// 处理右子数组剩余元素
while (r <= right) {
mark[i++] = nums[r++];
}
// 将临时数组的有序结果复制回原数组
for (int k = left; k <= right; ++k) {
nums[k] = mark[k - left]; // mark的索引从0开始,需对应原数组的left位置
}
}
vector<int> sortArray(vector<int>& nums) {
Msort(nums, 0, nums.size() - 1);
return nums;
}
};
2.全局复用临时数组
cpp
class Solution {
public:
// 全局临时数组:仅初始化一次,避免递归中频繁创建
vector<int> mark;
vector<int> sortArray(vector<int>& nums) {
// 初始化临时数组,长度与原数组一致
mark.resize(nums.size());
Msort(nums, 0, nums.size() - 1);
return nums;
}
// 归并排序函数:拆分+合并(复用全局mark数组)
void Msort(vector<int>& nums, int left, int right) {
if (left >= right)
return;
// 1. 拆分:递归处理左右子数组
int mid = left + (right - left) / 2;
Msort(nums, left, mid);
Msort(nums, mid + 1, right);
// 2. 合并:复用全局mark数组,无需重新创建
int l = left, r = mid + 1;
int i = left; // 临时数组的索引直接对应原数组的left位置,避免后续偏移计算
// 比较并放入临时数组
while (l <= mid && r <= right) {
mark[i++] = nums[l] <= nums[r] ? nums[l++] : nums[r++];
}
// 处理剩余元素
while (l <= mid) {
mark[i++] = nums[l++];
}
while (r <= right) {
mark[i++] = nums[r++];
}
// 将临时数组结果复制回原数组(从left到right区间)
for (int k = left; k <= right; ++k) {
nums[k] = mark[k]; // 索引直接对应,无需偏移
}
}
};
临时数组的复用策略
归并排序的空间开销主要来自"合并"步骤的临时数组,若每次递归都创建新的临时数组,会产生频繁的内存分配与释放,降低执行效率。因此,我们提供两种实现方案,对应不同的优化思路:
-
方案一:递归内创建临时数组
每次合并时,在递归函数内部创建与当前子数组长度匹配的临时数组。
- 优点:代码简洁,无需考虑全局变量的作用域问题,新手易理解;
- 缺点:频繁创建/销毁数组,内存操作开销大,在数组长度较大(如 5*10^4)时,执行效率会明显下降。
-
方案二:全局复用临时数组
在主函数中创建一个与原数组长度相同的全局(或类成员)临时数组,合并时直接复用该数组存储结果。
- 优点:仅一次内存分配,避免频繁内存操作,执行效率更高,更适合本题"5*10^4"的数组规模;
- 缺点:需额外维护全局变量,代码逻辑需注意临时数组的索引对应关系。
两种方案效率对比
| 对比维度 | 方案一(递归内创建临时数组) | 方案二(全局复用临时数组) |
|---|---|---|
| 内存操作 | 每次合并都创建/销毁临时数组,内存分配释放频繁 | 仅一次内存分配,全程复用,开销低 |
| 执行速度 | 较慢(尤其数组长度大时,内存操作耗时占比高) | 较快(减少内存操作,专注元素比较与复制) |
| 代码复杂度 | 低(无需维护全局变量,逻辑独立) | 中(需注意全局数组的索引对应,避免越界) |
| 空间峰值 | 较高(递归栈+多个临时数组同时存在) | 较低(仅递归栈+一个全局临时数组) |
通过力扣的执行用时分布我们能更加直观的感受到二者的效率差距:
1.递归内创建临时数组

2.全局复用临时数组

结论:对于本题"5*10^4"的数组规模,方案二的执行效率明显优于方案一,更能满足"空间尽可能小"的隐含需求;若数组规模较小(如小于 1000),方案一的简洁性更有优势,可根据实际场景选择。
时间复杂度与空间复杂度分析
时间复杂度:
所有场景均为 O(nlogn) :归并排序的时间消耗主要在"合并"步骤,每次合并需遍历当前子数组的所有元素(O(n))。
而数组拆分的深度为 logn(如长度为 n 的数组需拆 log2n 层),因此总时间复杂度为 O(nlogn)。
空间复杂度
核心开销为临时数组 :无论哪种方案,都需一个长度为 n 的临时数组存储合并结果,因此空间复杂度为 O(n);
额外开销为递归栈:递归深度为 logn,栈空间开销为 O(logn),远小于临时数组的 O(n),因此总空间复杂度由临时数组决定,为 O(n)。
对比快排的 O(logn) 空间,归并排序的空间开销更高,但胜在时间性能稳定且具备稳定性,适合对排序稳定性有要求的场景。
总结
- 掌握"分治"的不同落地逻辑:归并排序"先拆分后合并"的思路,与快排"先分区后递归"形成互补,理解这种差异能帮你在不同问题中灵活选择算法------需要稳定时间性能选归并,需要极致空间优化选快排。
- 优化空间开销的关键思路:归并排序的空间痛点是临时数组,通过"全局复用"而非"递归内重复创建",可大幅减少内存操作开销,这一思路也适用于其他需要频繁使用临时存储的算法。
- 明确稳定性的应用价值:归并排序的"稳定性"是快排无法替代的优势,当排序场景需保留相等元素的原始相对位置时(如多关键字排序),归并排序是唯一的 O(nlogn) 选择,现在掌握可应对后续复杂需求。
- 权衡代码简洁与执行效率:方案一的"递归内创建数组"适合快速编码与调试,方案二的"全局复用数组"适合追求执行效率,实际开发中需根据数组规模、性能要求灵活选择,避免一刀切。
下题预告
下一篇我们将聚焦 力扣 LCR 170. 交易逆序对的总数 ------ 这道题可是归并排序的 "进阶实战场"!刚吃透归并排序的 "拆分 - 合并" 逻辑,正好用这道题检验成果:它不仅能帮你深化对 "分治思想" 的理解,还能教会你如何在排序过程中 "顺带" 统计逆序对,打通 "排序算法" 到 "实际统计问题" 的应用链路。
Doro 又又又带着小花🌸来啦!🌸奖励🌸看到这里的你!如果这篇归并排序的博客帮你理清了 "拆分合并" 的细节,或是搞懂了临时数组的优化技巧,别忘了点赞支持呀!把它收藏起来,以后复习归并排序时翻出来,就能快速回忆起核心逻辑~关注我,我会持续更新算法系列的博客,有什么解题思路或疑问我们随时讨论,对算法感兴趣的朋友也可以去我的算法专辑看看,里面还有更多有意思的算法题等着你解锁!
