愿旖旎头像
LeetCode 912: 排序数组 (快速排序) —— 题解封面图

LeetCode 912: 排序数组 (快速排序) —— 题解

  👋 欢迎阅读

 🔥 愿旖旎 · 个人主页

📘 学习专栏: 《算法专栏》《LangChain学习》《贪心算法》

🌄 钱塘江上潮信来,今日方知我是我

✨当前学习内容:《快速排序》


一.题目

912. 排序数组 - 力扣(LeetCode)

🎯 欢迎来到「排序数组」题解之旅! 本文将带你从"把两摞已排好的牌合并成一摞"这一直观场景出发,深入理解归并排序(分治)的巧妙运用,并掌握如何递归二分 + 双指针合并来在 O(n log n) 内完成排序。

在开始之前,建议你先:

  • 了解题目背景:这是 LeetCode 912 题,给定整数数组 nums,要求按升序排序并返回,不能使用内置排序函数,数组长度可达 5×10⁴。本质上,排序可以分而治之——把数组拆到只剩单个元素(天然有序),再两两合并成有序序列。

  • 明确学习目标:掌握归并排序的递归划分与合并技术,理解辅助数组 tmp 的偏移回填与收尾 while 的作用,并熟练处理递归终止与稳定性(<=)等边界情况。

  • 准备好环境:建议在本地 IDE 或 LeetCode 在线编辑器中打开代码,边看边运行,亲手验证示例(如 nums = [5,2,3,1] 输出 [1,2,3,5])。

本文将从问题转化、递归分治、双指针合并、边界防护到代码实现,层层递进。即使你对归并排序还不熟悉,我们也会从"先拆成一个个,再两两拼回有序"这一直觉出发,让你轻松抓住核心思想——分到最细,合而有序。现在,让我们一起递归拆分、双指针合并,把数组排好序吧! 🔀🎯

 ​

二、算法分析

1、问题分析(前置分析)

  • 题目要求:对 nums 升序排序并返回,禁止调用内置排序,长度可达 5×10⁴。
  • 关键约束:不能直接用 sort;数据量大,O(n²) 算法会超时;要求最坏情况也稳定在 O(n log n)。
  • 核心思路:归并排序——递归把数组对半拆分到单元素(天然有序),再在回溯时把相邻两段双指针合并;最坏、平均都是 O(n log n),且天然稳定,满足题目对"手写排序 + 大数据量"的要求。

📌 例子:为什么是归并而不是快排/冒泡

nums = [5,2,4,1]:冒泡需约 n²/2 次比较,n = 5×10⁴ 时约 1.25×10⁹ 次,必超时;快速排序平均 O(n log n),但最坏 O(n²)(如已排序数组取末端为基准),需随机化才能避免。归并排序无论输入如何都是 O(n log n),且合并时用 <= 还能保持稳定——在"禁用内置排序 + 大数据量"的约束下,它是最稳妥的选择。

2、算法策略(归并排序 · 递归分治 + 双指针合并)

核心步骤:

  1. 开辅助数组:成员 tmp 一次性 resize(n),供所有合并轮次复用。
  2. 递归终止:if (left >= right) return;(区间只剩 0/1 个元素,天然有序)。
  3. 递归拆分:取中点 mid = left + (right - left) / 2,分别排序 [left, mid] 与 [mid+1, right]。
  4. 双指针合并:cur1 从左段起点、cur2 从右段起点,每次取较小者写入 tmp(<= 保证稳定)。
  5. 收尾搬运:某一段先耗尽后,把另一段剩余元素依次搬入 tmp。
  6. 回填原数组:nums[i] = tmp[i - left],把本轮合并结果写回。

📊 示例(nums = [5,2,4,1]):

阶段区间操作结果
拆分[0,3]中点 1,拆成 [0,1] 与 [2,3]—
拆分[0,1]中点 0,拆成 [0,0] 与 [1,1]单元素天然有序
合并[0,1]合并 5 与 2:取 2,再取 5[2,5]
拆分[2,3]中点 2,拆成 [2,2] 与 [3,3]单元素天然有序
合并[2,3]合并 4 与 1:取 1,再取 4[1,4]
合并[0,3]双指针合并 [2,5] 与 [1,4]:1、2、4、5[1,2,4,5] ✅

3、正确性说明(简单版本)

  • 拆分正确:每次取中点把区间一分为二([left, mid] 与 [mid+1, right]),两段不重不漏,递归直到单元素终止,最终所有子区间都落到"天然有序"的最小单位。
  • 合并正确:双指针从两段起点推进,每次取较小者写入 tmp,保证合并结果的单调性;<= 保证相等时先取左段,维持稳定性。
  • 收尾完备:某个指针先越界后,另一段的剩余元素必然都更大(或相等),直接依次搬运即可,不会遗漏。
  • 回填正确:tmp 本轮从下标 0 写起,回填时用 tmp[i - left] 精确偏移,nums 的 [left, right] 段被完整覆盖为本轮合并结果。

📌 例子:为什么"收尾 while"不能省略

合并 [1,2] 与 [3,4]:主循环依次取 1、2 后,左段立即耗尽(cur1 > mid),此时右段的 3、4 还没写入——若只有主循环,tmp 只写入了 [1,2],回填后 nums 的后两位就是旧数据,排序错误。两个收尾 while 专门搬运"另一段的剩余元素",缺一不可。

4、实现细节(边界防护)

  • 初始化:tmp 作为成员变量,在 sortArray 中 resize(n) 一次分配;mergeSort(nums, 0, n-1),区间为闭区间 [left, right]。
  • 边界防护:if (left >= right) return;(用 >= 比 == 更防御);mid = left + (right - left) / 2(防溢出的写法);tmp[i - left] 回填时偏移必须减 left。
  • 复杂度:时间 O(n log n)(共 log n 层,每层合并总代价 O(n)),空间 O(n)(辅助数组 tmp)+ O(log n)(递归栈)。
  • 关键操作:mid = left + (right - left) / 2;(安全取中点)、if (nums[cur1] <= nums[cur2]) tmp[i++] = nums[cur1++];(双指针取小)、nums[i] = tmp[i - left];(偏移回填)。

📌 例子:tmp 为什么要"一次分配、反复复用"

若把 tmp 声明成局部数组、每次合并都新建 vector<int>(len):n = 5×10⁴ 时递归会产生约 n-1 次合并,即 5 万次堆分配,累计分配/释放的数据量约 O(n log n),且频繁申请释放会拖慢程序、加剧内存碎片。作为成员变量只 resize 一次,所有轮次复用同一块空间,分配次数从 O(n) 降到 1,是归并排序的常见优化。

5、返回值(目标映射)

  • 返回 nums:原地排序完成后的原数组(mergeSort 已把结果回填进去),对应题目"返回排序后的数组"。

三.代码

class Solution
{
    vector<int> tmp;    // 辅助数组:所有合并轮次复用(只分配一次)

public:
    vector<int> sortArray(vector<int>& nums)
    {
        int n = nums.size();
        tmp.resize(n);                  // 一次性开好,避免每次递归都分配
        mergeSort(nums, 0, n - 1);      // 对闭区间 [0, n-1] 归并排序
        return nums;                    // 结果已回填到 nums 中
    }

    void mergeSort(vector<int>& nums, int left, int right)
    {
        // 1. 递归终止:区间只剩 0 或 1 个元素,天然有序
        if (left >= right)
        {
            return;
        }

        // 2. 取中点(防溢出写法),对半拆分
        int mid = left + (right - left) / 2;

        // 3. 分别排序左右两段:[left, mid] 与 [mid+1, right]
        mergeSort(nums, left, mid);
        mergeSort(nums, mid + 1, right);

        // 4. 双指针合并:从两段起点出发,每次取较小者写入 tmp
        int cur1 = left;        // 左段指针
        int cur2 = mid + 1;     // 右段指针
        int i = 0;              // tmp 的写入位置(从 0 开始)

        while (cur1 <= mid && cur2 <= right)
        {
            if (nums[cur1] <= nums[cur2])   // 用 <= 保证稳定性
            {
                tmp[i++] = nums[cur1++];
            }
            else
            {
                tmp[i++] = nums[cur2++];
            }
        }

        // 5. 收尾:把尚未耗尽的另一段剩余元素直接搬入 tmp
        while (cur1 <= mid)
        {
            tmp[i++] = nums[cur1++];
        }
        while (cur2 <= right)
        {
            tmp[i++] = nums[cur2++];
        }

        // 6. 回填:tmp 从 0 写起,故 nums[i] 对应 tmp[i - left]
        for (int k = left; k <= right; k++)
        {
            nums[k] = tmp[k - left];
        }
    }
};

四、易错点分析

难点1:回填时的偏移 tmp[i - left]

for (int i = left; i <= right; i++)
{
    nums[i] = tmp[i - left];
}

tmp 每轮合并都从下标 0 开始写(i 初始为 0),而 nums 的目标区间是 [left, right],两者起点不同,因此回填必须减去 left 偏移。若写成 nums[i] = tmp[i],当 left > 0 时会读到 tmp 中不属于本段的数据(本轮只写了 tmp[0 .. right-left]),导致元素错乱;同时 i 可能超出本轮写入范围,读到上一轮的旧值。"tmp 从 0 写、回填减 left"是一套配套约定,也可改成"从 left 写、回填直接对应",但两者不能混用。

难点2:两个收尾 while 缺一不可

while (cur1 <= mid) tmp[i++] = nums[cur1++];
while (cur2 <= right) tmp[i++] = nums[cur2++];

主循环在任一段耗尽时停止,此时另一段可能还有剩余元素未被写入 tmp。合并 [1,2] 与 [3,4] 时,主循环取完 1、2 就退出,3、4 必须靠第二个收尾 while 搬入;省略后 tmp 后半段是旧数据,回填即出错。两个 while 分别对应"左段剩余"与"右段剩余",一个都不能少。

难点3:mid 取整与区间划分必须配套

int mid = left + (right - left) / 2;
mergeSort(nums, left, mid);        // 左段 [left, mid]
mergeSort(nums, mid + 1, right);   // 右段 [mid+1, right]

mid 下取整,故左段是 [left, mid]、右段是 [mid+1, right],两段不重不漏。若划分写成 [left, mid-1] 与 [mid, right],当 left == mid(区间长 2)时左段为空、右段仍是原区间,递归永远不缩小 → 无限递归(栈溢出)。另外 left + (right - left) / 2 避免 left + right 溢出,是安全取中点的标准写法。

难点4:<= 与 < 决定排序是否稳定

if (nums[cur1] <= nums[cur2])
    tmp[i++] = nums[cur1++];

相等时用 <= 会优先取左段元素,而左段元素在原数组中本就位于右段之前,因此相等元素的相对顺序得以保持——这就是稳定性。若写成 <,相等时会先取右段,相对顺序被颠倒,变为不稳定排序。本题只要求升序,稳定性不影响正确性,但归并排序的稳定性是它的重要优势(多关键字排序时必需),写 <= 是良好习惯。

难点5:递归终止条件 left >= right 不能漏

if (left >= right)
{
    return;
}

递归的"分到最细"终点是区间只剩 1 个元素(此时天然有序,无需合并)。若漏掉这个终止条件,mergeSort 会无限自我调用(空区间不收敛),直接栈溢出崩溃。用 >= 而非 == 是防御性写法:即使区间意外变空(如错误划分),也能正确返回而不越界。

五、流程图

 

   🎯 闭幕

🎉 恭喜你完成了「排序数组」问题的学习!

为了巩固知识并进一步拓展,建议你:

🚀 动手实践 在 LeetCode 上提交代码,尝试不同的测试用例。

💡 深入思考

  • 本题采用归并排序,把数组拆到单元素再合并。请问**"单元素天然有序"这一性质**在算法中扮演什么角色?它是递归的什么?
  • tmp 每轮合并从下标 0 开始写,回填用 tmp[i - left]。请问为什么要减 left?不减会发生什么?
  • 两个收尾 while 缺一不可。请问在什么情况下会触发收尾?省略后为什么只丢"后半段"而不是全部?
  • 代码用 nums[cur1] <= nums[cur2]。请问**<= 与 < 对排序结果有何影响**?本题需要稳定吗?
  • mid 是下取整,区间划分为 [left, mid] 与 [mid+1, right]。请问若把划分写成 [left, mid-1] 与 [mid, right] 会发生什么?

如果你觉得本文对你有所帮助,欢迎:

👍 点赞 / 收藏 👤 关注作者,获取更多题解 💬 留言交流你的疑问或优化思路

📌 深入思考答案

  • "单元素天然有序"是递归的终止条件——它把"排序整个数组"分解为"排序单元素"这一不需要任何操作的最小单位,再靠合并逐层回溯,这是分治能够收敛的根本。
  • tmp 本轮从下标 0 写起,而 nums 的目标区间起点是 left,两者起点错开 left 个位置;不减 left 会读到 tmp 中不属于本轮的数据(或上一轮旧值),回填错乱。
  • 当某一子段先耗尽时触发收尾(另一段还有剩余)。省略后,主循环只把"能比较的部分"写入 tmp,剩余元素没写,回填时只覆盖了部分位置,后半段仍是旧数据——错误表现为部分有序而非全乱。
  • <= 在相等时先取左段,保持相等元素的相对顺序(稳定);< 会先取右段,稳定性被破坏。本题只要求升序,不需要稳定,但写 <= 是归并排序的标准做法,为多关键字排序等扩展留余地。
  • mid 下取整时,[left, mid-1] 在 left == mid 时为空、[mid, right] 仍是原区间,递归区间不缩小,导致无限递归直至栈溢出;必须配套 [left, mid] 与 [mid+1, right]。

祝你在 算法之路 上越走越稳,早日攻克每一道难题!下次见 🚀✨

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2601_96587588/article/details/168236015

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--