在算法与数据结构的世界中,有序数组的合并是一个基础却极具教学意义的问题。它不仅考察了对数组操作的理解,还引导我们思考如何在时间和空间复杂度之间取得平衡。本文将围绕 LeetCode 第88题《合并两个有序数组》(题目链接),从问题描述出发,逐步深入讲解双指针法和空间优化的三指针法,并结合代码实现、原理剖析与边界处理,全面掌握这一经典问题。
📌 问题背景
给定两个非递减顺序排列的整数数组 nums1 和 nums2,以及两个整数 m 和 n,分别表示 nums1 和 nums2 中的有效元素个数。
nums1的长度为m + n,其中前m个元素是有效数据,后n个位置为预留空间(初始值通常为0或任意值)。- 要求将
nums2合并到nums1中,使得nums1成为一个包含m + n个元素的非递减有序数组。 - 关键限制:必须在
nums1上原地修改,不能返回新数组。
💡 注意:由于
nums1已经预留了足够空间,因此无需额外分配内存来存储结果。
🔍 初步思路:双指针从前向后(需额外空间)
最直观的想法是使用双指针,分别指向两个数组的开头:
- 指针
i指向nums1[0] - 指针
j指向nums2[0] - 创建一个临时数组
temp,长度为m + n - 比较
nums1[i]和nums2[j],将较小者放入temp,并移动对应指针 - 最后将
temp复制回nums1
// 双指针 + 额外空间(不满足原地要求)
function mergeWithExtraSpace(nums1, m, nums2, n) {
let i = 0, j = 0;
const temp = [];
while (i < m && j < n) {
if (nums1[i] <= nums2[j]) {
temp.push(nums1[i++]);
} else {
temp.push(nums2[j++]);
}
}
// 处理剩余元素
while (i < m) temp.push(nums1[i++]);
while (j < n) temp.push(nums2[j++]);
// 复制回 nums1
for (let k = 0; k < m + n; k++) {
nums1[k] = temp[k];
}
}
✅ 优点:逻辑清晰,易于理解
❌ 缺点:需要 O(m + n) 的额外空间,不符合题目“原地修改”的要求
🚀 优化方案:三指针从后向前(原地合并)
为了避免覆盖尚未处理的元素,我们可以逆向思维:从两个数组的末尾开始比较,并将较大值填入 nums1 的末尾预留空间。
🧩 为什么可以从后往前?
nums1的后n个位置是空的(或可被覆盖的)- 两个数组都是非递减的 → 它们的最大值一定在末尾
- 因此,全局最大值一定是
nums1[m-1]或nums2[n-1]中的较大者 - 将其放入
nums1[m+n-1]后,问题规模缩小 1,可递归/迭代处理
🧮 三指针定义
i = m - 1:指向nums1有效部分的最后一个元素j = n - 1:指向nums2的最后一个元素k = m + n - 1:指向nums1的最后一个位置(待填充)
🔁 合并过程
-
当
i >= 0且j >= 0时:- 若
nums1[i] > nums2[j],则nums1[k] = nums1[i],i-- - 否则
nums1[k] = nums2[j],j-- k--
- 若
-
若
nums2还有剩余(即j >= 0),说明nums1的所有元素已处理完,直接将nums2剩余部分复制到nums1前部 -
若
nums1有剩余(i >= 0),无需操作,因为它们已经在正确位置
✅ 关键洞察:不需要处理
nums1剩余的情况,因为它们本就在nums1中,且位置正确!
💻 完整代码实现(JavaScript)
function merge(nums1, m, nums2, n) {
// 数组是连续的存储空间,所以可以从后往前合并
let i = m - 1; // nums1 有效部分的末尾
let j = n - 1; // nums2 的末尾
let k = m + n - 1; // nums1 整体的末尾(待填充位置)
// 两个数组都还有元素时,比较并填充
while (i >= 0 && j >= 0) {
if (nums1[i] > nums2[j]) {
nums1[k] = nums1[i];
i--;
} else {
nums1[k] = nums2[j];
j--;
}
k--;
}
// 如果 nums2 还有剩余元素(nums1 已耗尽)
while (j >= 0) {
nums1[k] = nums2[j];
j--;
k--;
}
// 注意:如果 nums1 有剩余,无需处理,已在正确位置
}
📌 重要说明:该函数不返回任何值,因为 JavaScript 中数组是引用类型,直接修改
nums1即可影响调用者。
🧪 示例演示
假设:
nums1 = [1, 2, 3, 0, 0, 0], m = 3
nums2 = [2, 5, 6], n = 3
执行过程:
| 步骤 | i | j | k | 比较 | nums1 状态(k位置更新) |
|---|---|---|---|---|---|
| 初始 | 2 | 2 | 5 | 3 vs 6 → 6 | [..., 6] |
| 1 | 2 | 1 | 4 | 3 vs 5 → 5 | [..., 5, 6] |
| 2 | 2 | 0 | 3 | 3 vs 2 → 3 | [..., 3, 5, 6] |
| 3 | 1 | 0 | 2 | 2 vs 2 → 2 | [..., 2, 3, 5, 6] |
| 4 | 0 | 0 | 1 | 1 vs 2 → 2 | [..., 2, 2, 3, 5, 6] |
| 5 | 0 | -1 | 0 | j < 0,结束 | [1, 2, 2, 3, 5, 6] ✅ |
最终 nums1 = [1, 2, 2, 3, 5, 6],完全有序。
⏱️ 复杂度分析
- 时间复杂度:O(m + n)
每个元素最多被访问一次,总共m + n次操作。 - 空间复杂度:O(1)
仅使用三个指针变量,原地修改,无额外数组。
✅ 完美满足题目对“原地”和“高效”的双重要求!
❗ 边界情况处理
-
m = 0:nums1无有效元素,直接将nums2全部复制到nums1- 此时
i = -1,第一个while不执行,进入第二个while,正确处理
- 此时
-
n = 0:nums2为空,无需操作j = -1,两个while都不执行,nums1保持不变
-
全相等元素:如
nums1 = [1,1,0,0],nums2 = [1,1]- 算法仍能正确合并,保持稳定性(虽然题目不要求稳定)
🧠 为什么这个方法巧妙?
- 利用了数组的有序性:最大值在末尾,天然适合从后往前填
- 避免了元素覆盖:传统从前向后会覆盖
nums1中未处理的元素,而从后向前写入的是“空位” - 极致的空间优化:将“预留空间”转化为“操作缓冲区”
📚 延伸思考
- 如果两个数组都没有预留空间,该如何合并?→ 必须使用额外 O(m+n) 空间
- 如果要求稳定合并(相等元素保持原相对顺序)?→ 本算法在
nums1[i] <= nums2[j]时优先取nums2,不稳定;若改为<时取nums1,>=时取nums2,则可稳定(但本题不要求) - 此技巧可推广到多个有序数组合并、归并排序的原地优化等场景
✅ 总结
🌟 有序数组合并看似简单,实则蕴含深刻的算法思想。
通过从双指针+额外空间的朴素解法,到三指针+从后向前的原地优化,我们不仅解决了具体问题,更掌握了逆向思维、空间复用和边界控制等核心编程技巧。
记住这个模式:当需要原地合并有序数组,且目标数组尾部有空闲空间时,优先考虑从后往前的三指针法!
这不仅是 LeetCode 的一道题,更是通向高效算法设计的一把钥匙 🔑。