C++ 分治 归并排序 归并排序VS快速排序 力扣 912. 排序数组 题解 每日一题

文章目录


题目描述

题目链接:力扣 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]; // 索引直接对应,无需偏移
        }
    }
};

临时数组的复用策略

归并排序的空间开销主要来自"合并"步骤的临时数组,若每次递归都创建新的临时数组,会产生频繁的内存分配与释放,降低执行效率。因此,我们提供两种实现方案,对应不同的优化思路:

  1. 方案一:递归内创建临时数组

    每次合并时,在递归函数内部创建与当前子数组长度匹配的临时数组。

    • 优点:代码简洁,无需考虑全局变量的作用域问题,新手易理解;
    • 缺点:频繁创建/销毁数组,内存操作开销大,在数组长度较大(如 5*10^4)时,执行效率会明显下降。
  2. 方案二:全局复用临时数组

    在主函数中创建一个与原数组长度相同的全局(或类成员)临时数组,合并时直接复用该数组存储结果。

    • 优点:仅一次内存分配,避免频繁内存操作,执行效率更高,更适合本题"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) 空间,归并排序的空间开销更高,但胜在时间性能稳定且具备稳定性,适合对排序稳定性有要求的场景。

总结

  1. 掌握"分治"的不同落地逻辑:归并排序"先拆分后合并"的思路,与快排"先分区后递归"形成互补,理解这种差异能帮你在不同问题中灵活选择算法------需要稳定时间性能选归并,需要极致空间优化选快排。
  2. 优化空间开销的关键思路:归并排序的空间痛点是临时数组,通过"全局复用"而非"递归内重复创建",可大幅减少内存操作开销,这一思路也适用于其他需要频繁使用临时存储的算法。
  3. 明确稳定性的应用价值:归并排序的"稳定性"是快排无法替代的优势,当排序场景需保留相等元素的原始相对位置时(如多关键字排序),归并排序是唯一的 O(nlogn) 选择,现在掌握可应对后续复杂需求。
  4. 权衡代码简洁与执行效率:方案一的"递归内创建数组"适合快速编码与调试,方案二的"全局复用数组"适合追求执行效率,实际开发中需根据数组规模、性能要求灵活选择,避免一刀切。

下题预告

下一篇我们将聚焦 力扣 LCR 170. 交易逆序对的总数 ------ 这道题可是归并排序的 "进阶实战场"!刚吃透归并排序的 "拆分 - 合并" 逻辑,正好用这道题检验成果:它不仅能帮你深化对 "分治思想" 的理解,还能教会你如何在排序过程中 "顺带" 统计逆序对,打通 "排序算法" 到 "实际统计问题" 的应用链路。

Doro 又又又带着小花🌸来啦!🌸奖励🌸看到这里的你!如果这篇归并排序的博客帮你理清了 "拆分合并" 的细节,或是搞懂了临时数组的优化技巧,别忘了点赞支持呀!把它收藏起来,以后复习归并排序时翻出来,就能快速回忆起核心逻辑~关注我,我会持续更新算法系列的博客,有什么解题思路或疑问我们随时讨论,对算法感兴趣的朋友也可以去我的算法专辑看看,里面还有更多有意思的算法题等着你解锁!

相关推荐
三体世界3 小时前
Qt从入门到放弃学习之路(1)
开发语言·c++·git·qt·学习·前端框架·编辑器
victory04313 小时前
K8S 安装 部署 文档
算法·贪心算法·kubernetes
月疯3 小时前
样本熵和泊松指数的计算流程!!!
算法·机器学习·概率论
机器学习之心3 小时前
MATLAB基于自适应动态特征加权的K-means算法
算法·matlab·kmeans
minji...3 小时前
算法题 逆波兰表达式/计算器
数据结构·c++·算法·1024程序员节
ZhiqianXia3 小时前
C++ 常见代码异味(Code Smells)
c++
编码追梦人4 小时前
基于 STM32 的智能语音唤醒与关键词识别系统设计 —— 从硬件集成到算法实现
stm32·算法·struts
循着风6 小时前
二叉树的多种遍历方式
数据结构·算法
老猿讲编程10 小时前
C++中的奇异递归模板模式CRTP
开发语言·c++