好的,我注意到之前已讲过的题目非常详尽,涵盖了从基础到复杂的各种排序算法及其变体、应用、证明和优化。为了避免重复,我将为你讲解一个虽然本身不是新算法,但涉及到排序算法核心应用且思路非常巧妙的经典问题。
题目:排序算法之:合并两个有序数组的“尾插法”与原地合并优化
题目描述:
给定两个非递减顺序排列的整数数组 nums1 和 nums2。请你将 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)合并,这是一个非常经典的技巧。
第一步:理解问题的核心难点与常见错误思路
- 错误思路(覆盖前移): 如果从
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 指针走过的位置,要么是预留的空位,要么是已经处理过的、可以覆盖的旧值。
第三步:分步骤演算与代码实现
让我们用示例来手动演算:
初始状态:
nums1 = [1, 2, 3, 0, 0, 0]
m=3, n=3
p1 = 2 (指向3), p2 = 2 (指向6), p = 5
步骤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):
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)。 每个元素(来自
nums1或nums2)都恰好被比较和赋值一次。 - 空间复杂度:O(1)。 只使用了几个指针变量,是常数级别的额外空间,完美满足了题目的原地要求。
第五步:总结与扩展思考
这个 “尾插法” 或 “逆向双指针法” 是原地合并两个有序数组的标准且最优解法。它巧妙地利用了目标数组尾部预留空间这一条件,将归并排序中“合并”操作的空间复杂度从 O(n) 降低到了 O(1)。
扩展思考:
如果题目没有 nums1 后部的预留空间,要求真正完全原地的合并(比如 nums1 和 nums2 是紧挨着的两个内存块),这个问题会变得非常困难,通常需要借助更复杂的算法,如 “原地归并排序(In-Place Merge Sort)” 中的块旋转等技巧,其时间复杂度会上升,实现也更复杂。而本题的约束条件恰好为我们提供了一个优雅而高效的解决路径。