题目:排序算法之:合并两个有序数组的“尾插法”与原地合并优化
字数 2711 2025-12-19 09:41:37

好的,我注意到之前已讲过的题目非常详尽,涵盖了从基础到复杂的各种排序算法及其变体、应用、证明和优化。为了避免重复,我将为你讲解一个虽然本身不是新算法,但涉及到排序算法核心应用且思路非常巧妙的经典问题。

题目:排序算法之:合并两个有序数组的“尾插法”与原地合并优化

题目描述:
给定两个非递减顺序排列的整数数组 nums1nums2。请你将 nums2 合并到 nums1 中,使合并后的数组同样保持非递减顺序

约束条件:

  • nums1 的长度为 m + n,其中前 m 个元素为有效元素(需要合并的),后 n 个元素被初始化为 0,用于容纳来自 nums2 的元素。
  • nums2 的长度为 n

要求: 不要返回任何结果,而是原地修改 nums1。换句话说,不能使用额外的 O(m+n) 大小的数组来存储中间结果。

示例:
输入:

nums1 = [1, 2, 3, 0, 0, 0], m = 3
nums2 = [2, 5, 6], n = 3

输出:

nums1 = [1, 2, 2, 3, 5, 6]

解题过程循序渐进讲解

这个问题可以看作是归并排序(Merge Sort) 中“合并(Merge)”步骤的一个特例。在标准归并中,我们需要一个同等大小的辅助数组。但这里的特殊结构(nums1 后面有预留空间)要求我们进行原地(In-Place)合并,这是一个非常经典的技巧。

第一步:理解问题的核心难点与常见错误思路

  1. 错误思路(覆盖前移): 如果从 nums1 的开头开始比较和插入,当我们把 nums2[0] 插入到 nums1 中时,为了腾出位置,需要将 nums1 中该位置及之后的元素全部向后移动一位。这会导致 O(n*m) 的极高时间复杂度,并且频繁移动元素效率极低。
  2. 核心难点: 插入操作(尤其是向数组头部方向插入)会导致元素移动。我们需要避免这种昂贵的移动。
  3. 关键观察: nums1尾部有预留空间!这个空间目前是“空的”或无效的。如果我们从数组的尾部开始填充,也就是从最大值开始归并,那么我们就可以利用这些空位,而不会覆盖任何尚未处理的、有效的 nums1 元素。

第二步:最优解法——“尾插法”(逆向双指针法)思路详解

这是一种 “从后往前”的填充策略

  1. 初始化三个指针:

    • p1: 指向 nums1 有效部分的最后一个元素,即索引 m - 1
    • p2: 指向 nums2 的最后一个元素,即索引 n - 1
    • p: 指向 nums1 整个数组的最后一个位置,即索引 m + n - 1。这个位置是我们当前要填充的位置。
  2. 比较与填充的逻辑循环:

    • 比较 nums1[p1]nums2[p2]
    • 较大的那个值放入 nums1[p]
    • 然后,将指向较大值的指针向前移动一位(p1--p2--),同时将填充指针 p 也向前移动一位(p--)。
    • 重复此过程,直到 p1p2 中有一个“用完”(即小于0)。
  3. 处理剩余元素:

    • 如果 p1 先用完(即 p1 < 0),说明 nums1 的有效元素已经全部归并完毕,但 nums2 中还有一些较小的元素剩余。我们需要把 nums2 中剩余的元素(从 p2 开始往前)依次复制到 nums1 的前端(即 p 指向的位置及其之前)。
    • 如果 p2 先用完(即 p2 < 0),说明 nums2 的所有元素都已归并完毕,而 nums1 中剩余的有效元素本身就已经在它们正确的位置上了(因为它们一开始就在 nums1 的前部,并且是有序的),所以不需要做任何操作。

这个过程的正确性保证:
因为我们是从后往前填充,每次都取当前两个数组中剩余元素的最大值,并且 nums1 尾部有足够的空位,所以永远不会发生有效数据被覆盖的情况p 指针走过的位置,要么是预留的空位,要么是已经处理过的、可以覆盖的旧值。

第三步:分步骤演算与代码实现

让我们用示例来手动演算:

初始状态:

nums1 = [1, 2, 3, 0, 0, 0]
m=3, n=3
p1 = 2 (指向3), p2 = 2 (指向6), p = 5

步骤1:
比较 nums1[2]=3nums2[2]=6,6更大。nums1[5] = 6
p2-- = 1, p-- = 4
状态:nums1 = [1, 2, 3, 0, 0, 6]

步骤2:
比较 nums1[2]=3nums2[1]=5,5更大。nums1[4] = 5
p2-- = 0, p-- = 3
状态:nums1 = [1, 2, 3, 0, 5, 6]

步骤3:
比较 nums1[2]=3nums2[0]=2,3更大。nums1[3] = 3
p1-- = 1, p-- = 2
状态:nums1 = [1, 2, 3, 3, 5, 6]

步骤4:
比较 nums1[1]=2nums2[0]=2,相等,我们可以取任意一个(比如取 nums1 的)。nums1[2] = 2
p1-- = 0, p-- = 1
状态:nums1 = [1, 2, 2, 3, 5, 6]

步骤5:
比较 nums1[0]=1nums2[0]=2,2更大。nums1[1] = 2
p2-- = -1, p-- = 0
此时 p2 已用完(p2 < 0),循环终止。
最终状态:nums1 = [1, 2, 2, 3, 5, 6]

代码实现(Python):

def merge(nums1, m, nums2, n):
    p1 = m - 1  # nums1有效元素末尾
    p2 = n - 1  # nums2末尾
    p = m + n - 1  # 合并数组的末尾

    # 从后往前填充,直到其中一个数组的元素用完
    while p1 >= 0 and p2 >= 0:
        if nums1[p1] > nums2[p2]:
            nums1[p] = nums1[p1]
            p1 -= 1
        else:
            nums1[p] = nums2[p2]
            p2 -= 1
        p -= 1

    # 如果nums2还有剩余元素(p2>=0),需要全部复制到nums1的前部
    # 如果nums1有剩余元素,它们已经在其正确位置上,无需操作
    while p2 >= 0:
        nums1[p] = nums2[p2]
        p2 -= 1
        p -= 1
    # 函数结束,nums1已被原地修改

第四步:复杂度分析

  • 时间复杂度:O(m + n)。 每个元素(来自 nums1nums2)都恰好被比较和赋值一次。
  • 空间复杂度:O(1)。 只使用了几个指针变量,是常数级别的额外空间,完美满足了题目的原地要求。

第五步:总结与扩展思考

这个 “尾插法” 或 “逆向双指针法” 是原地合并两个有序数组的标准且最优解法。它巧妙地利用了目标数组尾部预留空间这一条件,将归并排序中“合并”操作的空间复杂度从 O(n) 降低到了 O(1)。

扩展思考:
如果题目没有 nums1 后部的预留空间,要求真正完全原地的合并(比如 nums1nums2 是紧挨着的两个内存块),这个问题会变得非常困难,通常需要借助更复杂的算法,如 “原地归并排序(In-Place Merge Sort)” 中的块旋转等技巧,其时间复杂度会上升,实现也更复杂。而本题的约束条件恰好为我们提供了一个优雅而高效的解决路径。

好的,我注意到之前已讲过的题目非常详尽,涵盖了从基础到复杂的各种排序算法及其变体、应用、证明和优化。为了避免重复,我将为你讲解一个虽然本身不是新算法,但涉及到排序算法核心应用且思路非常巧妙的经典问题。 题目:排序算法之:合并两个有序数组的“尾插法”与原地合并优化 题目描述: 给定两个 非递减顺序 排列的整数数组 nums1 和 nums2 。请你将 nums2 合并到 nums1 中,使合并后的数组同样保持 非递减顺序 。 约束条件: nums1 的长度为 m + n ,其中前 m 个元素为有效元素(需要合并的),后 n 个元素被初始化为 0,用于容纳来自 nums2 的元素。 nums2 的长度为 n 。 要求: 不要返回任何结果,而是 原地 修改 nums1 。换句话说,不能使用额外的 O(m+n) 大小的数组来存储中间结果。 示例: 输入: 输出: 解题过程循序渐进讲解 这个问题可以看作是 归并排序(Merge Sort) 中“合并(Merge)”步骤的一个特例。在标准归并中,我们需要一个同等大小的辅助数组。但这里的特殊结构( nums1 后面有预留空间)要求我们进行 原地(In-Place)合并 ,这是一个非常经典的技巧。 第一步:理解问题的核心难点与常见错误思路 错误思路(覆盖前移): 如果从 nums1 的开头开始比较和插入,当我们把 nums2[0] 插入到 nums1 中时,为了腾出位置,需要将 nums1 中该位置及之后的元素全部向后移动一位。这会导致 O(n* m) 的极高时间复杂度,并且频繁移动元素效率极低。 核心难点: 插入操作(尤其是向数组头部方向插入)会导致元素移动。我们需要避免这种昂贵的移动。 关键观察: nums1 的 尾部有预留空间 !这个空间目前是“空的”或无效的。如果我们从 数组的尾部开始填充 ,也就是从 最大值 开始归并,那么我们就可以利用这些空位,而不会覆盖任何尚未处理的、有效的 nums1 元素。 第二步:最优解法——“尾插法”(逆向双指针法)思路详解 这是一种 “从后往前”的填充策略 。 初始化三个指针: p1 : 指向 nums1 有效部分的最后一个元素,即索引 m - 1 。 p2 : 指向 nums2 的最后一个元素,即索引 n - 1 。 p : 指向 nums1 整个数组的最后一个位置,即索引 m + n - 1 。这个位置是我们当前要填充的位置。 比较与填充的逻辑循环: 比较 nums1[p1] 和 nums2[p2] 。 将 较大的那个值 放入 nums1[p] 。 然后,将指向较大值的指针向前移动一位( p1-- 或 p2-- ),同时将填充指针 p 也向前移动一位( p-- )。 重复此过程,直到 p1 或 p2 中有一个“用完”(即小于0)。 处理剩余元素: 如果 p1 先用完(即 p1 < 0 ),说明 nums1 的有效元素已经全部归并完毕,但 nums2 中还有一些较小的元素剩余。我们需要把 nums2 中剩余的元素(从 p2 开始往前)依次复制到 nums1 的前端(即 p 指向的位置及其之前)。 如果 p2 先用完(即 p2 < 0 ),说明 nums2 的所有元素都已归并完毕,而 nums1 中剩余的有效元素 本身就已经在它们正确的位置上了 (因为它们一开始就在 nums1 的前部,并且是有序的),所以不需要做任何操作。 这个过程的正确性保证: 因为我们是 从后往前 填充,每次都取当前两个数组中剩余元素的 最大值 ,并且 nums1 尾部有足够的空位,所以 永远不会发生有效数据被覆盖的情况 。 p 指针走过的位置,要么是预留的空位,要么是已经处理过的、可以覆盖的旧值。 第三步:分步骤演算与代码实现 让我们用示例来手动演算: 初始状态: 步骤1: 比较 nums1[2]=3 和 nums2[2]=6 ,6更大。 nums1[5] = 6 。 p2-- = 1 , p-- = 4 。 状态: nums1 = [1, 2, 3, 0, 0, 6] 步骤2: 比较 nums1[2]=3 和 nums2[1]=5 ,5更大。 nums1[4] = 5 。 p2-- = 0 , p-- = 3 。 状态: nums1 = [1, 2, 3, 0, 5, 6] 步骤3: 比较 nums1[2]=3 和 nums2[0]=2 ,3更大。 nums1[3] = 3 。 p1-- = 1 , p-- = 2 。 状态: nums1 = [1, 2, 3, 3, 5, 6] 步骤4: 比较 nums1[1]=2 和 nums2[0]=2 ,相等,我们可以取任意一个(比如取 nums1 的)。 nums1[2] = 2 。 p1-- = 0 , p-- = 1 。 状态: nums1 = [1, 2, 2, 3, 5, 6] 步骤5: 比较 nums1[0]=1 和 nums2[0]=2 ,2更大。 nums1[1] = 2 。 p2-- = -1 , p-- = 0 。 此时 p2 已用完( p2 < 0 ),循环终止。 最终状态: nums1 = [1, 2, 2, 3, 5, 6] 代码实现(Python): 第四步:复杂度分析 时间复杂度:O(m + n)。 每个元素(来自 nums1 或 nums2 )都恰好被比较和赋值一次。 空间复杂度:O(1)。 只使用了几个指针变量,是常数级别的额外空间,完美满足了题目的原地要求。 第五步:总结与扩展思考 这个 “尾插法” 或 “逆向双指针法” 是 原地合并两个有序数组 的标准且最优解法。它巧妙地利用了目标数组尾部预留空间这一条件,将归并排序中“合并”操作的空间复杂度从 O(n) 降低到了 O(1)。 扩展思考: 如果题目没有 nums1 后部的预留空间,要求 真正完全原地 的合并(比如 nums1 和 nums2 是紧挨着的两个内存块),这个问题会变得非常困难,通常需要借助更复杂的算法,如 “原地归并排序(In-Place Merge Sort)” 中的块旋转等技巧,其时间复杂度会上升,实现也更复杂。而本题的约束条件恰好为我们提供了一个优雅而高效的解决路径。