👋 欢迎阅读
🔥愿旖旎· 个人主页
📘学习专栏:《算法专栏》《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. 算法策略(归并排序 · 递归分治 + 双指针合并)
核心步骤:
- 开辅助数组:成员
tmp一次性resize(n),供所有合并轮次复用。 - 递归终止:
if (left >= right) return;(区间只剩 0/1 个元素,天然有序)。 - 递归拆分:取中点
mid = left + (right - left) / 2,分别排序[left, mid]与[mid+1, right]。 - 双指针合并:
cur1从左段起点、cur2从右段起点,每次取较小者写入tmp(<=保证稳定)。 - 收尾搬运:某一段先耗尽后,把另一段剩余元素依次搬入
tmp。 - 回填原数组:
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]✅ |
两次小合并产出[2,5]与[1,4],最后一次大合并把两段按序交错取出,得到[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]。请问**<=与<对排序结果有何影响**?本题需要稳定吗?
如果你觉得本文对你有所帮助,欢迎:
👍 点赞 / 收藏 👤 关注作者,获取更多题解 💬 留言交流你的疑问或优化思路
📌 深入思考答案
- "单元素天然有序"是递归的终止条件——它把"排序整个数组"分解为"排序单元素"这一不需要任何操作的最小单位,再靠合并逐层回溯,这是分治能够收敛的根本。
tmp本轮从下标 0 写起,而nums的目标区间起点是left,两者起点错开left个位置;不减left会读到tmp中不属于本轮的数据(或上一轮旧值),回填错乱。- 当某一子段先耗尽时触发收尾(另一段还有剩余)。省略后,主循环只把"能比较的部分"写入
tmp,剩余元素没写,回填时只覆盖了部分位置,后半段仍是旧数据——错误表现为部分有序而非全乱。 <=在相等时先取左段,保持相等元素的相对顺序(稳定);<会先取右段,稳定性被破坏。本题只要求升序,不需要稳定,但写<=是归并排序的标准做法,为多关键字排序等扩展留余地。
祝你在算法之路上越走越稳,早日攻克每一道难题!下次见 🚀✨