【 算法-7 /Lesson72(2025-12-17)】有序数组合并:从双指针到三指针的深度解析🧠

2 阅读6分钟

在算法与数据结构的世界中,有序数组的合并是一个基础却极具教学意义的问题。它不仅考察了对数组操作的理解,还引导我们思考如何在时间和空间复杂度之间取得平衡。本文将围绕 LeetCode 第88题《合并两个有序数组》(题目链接),从问题描述出发,逐步深入讲解双指针法空间优化的三指针法,并结合代码实现、原理剖析与边界处理,全面掌握这一经典问题。


📌 问题背景

给定两个非递减顺序排列的整数数组 nums1nums2,以及两个整数 mn,分别表示 nums1nums2 中的有效元素个数。

  • 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 的最后一个位置(待填充)

🔁 合并过程

  1. i >= 0j >= 0 时:

    • nums1[i] > nums2[j],则 nums1[k] = nums1[i]i--
    • 否则 nums1[k] = nums2[j]j--
    • k--
  2. nums2 还有剩余(即 j >= 0),说明 nums1 的所有元素已处理完,直接将 nums2 剩余部分复制到 nums1 前部

  3. 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

执行过程:

步骤ijk比较nums1 状态(k位置更新)
初始2253 vs 6 → 6[..., 6]
12143 vs 5 → 5[..., 5, 6]
22033 vs 2 → 3[..., 3, 5, 6]
31022 vs 2 → 2[..., 2, 3, 5, 6]
40011 vs 2 → 2[..., 2, 2, 3, 5, 6]
50-10j < 0,结束[1, 2, 2, 3, 5, 6] ✅

最终 nums1 = [1, 2, 2, 3, 5, 6],完全有序。


⏱️ 复杂度分析

  • 时间复杂度:O(m + n)
    每个元素最多被访问一次,总共 m + n 次操作。
  • 空间复杂度:O(1)
    仅使用三个指针变量,原地修改,无额外数组。

✅ 完美满足题目对“原地”和“高效”的双重要求!


❗ 边界情况处理

  1. m = 0nums1 无有效元素,直接将 nums2 全部复制到 nums1

    • 此时 i = -1,第一个 while 不执行,进入第二个 while,正确处理
  2. n = 0nums2 为空,无需操作

    • j = -1,两个 while 都不执行,nums1 保持不变
  3. 全相等元素:如 nums1 = [1,1,0,0], nums2 = [1,1]

    • 算法仍能正确合并,保持稳定性(虽然题目不要求稳定)

🧠 为什么这个方法巧妙?

  • 利用了数组的有序性:最大值在末尾,天然适合从后往前填
  • 避免了元素覆盖:传统从前向后会覆盖 nums1 中未处理的元素,而从后向前写入的是“空位”
  • 极致的空间优化:将“预留空间”转化为“操作缓冲区”

📚 延伸思考

  • 如果两个数组都没有预留空间,该如何合并?→ 必须使用额外 O(m+n) 空间
  • 如果要求稳定合并(相等元素保持原相对顺序)?→ 本算法在 nums1[i] <= nums2[j] 时优先取 nums2不稳定;若改为 < 时取 nums1>= 时取 nums2,则可稳定(但本题不要求)
  • 此技巧可推广到多个有序数组合并归并排序的原地优化等场景

✅ 总结

🌟 有序数组合并看似简单,实则蕴含深刻的算法思想。

通过从双指针+额外空间的朴素解法,到三指针+从后向前的原地优化,我们不仅解决了具体问题,更掌握了逆向思维空间复用边界控制等核心编程技巧。

记住这个模式:当需要原地合并有序数组,且目标数组尾部有空闲空间时,优先考虑从后往前的三指针法!

这不仅是 LeetCode 的一道题,更是通向高效算法设计的一把钥匙 🔑。