排序算法之:合并两个已排序数组的高效算法——双指针法与空间优化策略
1. 问题描述
我们面临一个经典的排序相关任务:给定两个 已排序的非递减整数数组 nums1 和 nums2,以及两个整数 m 和 n,分别表示 nums1 和 nums2 中的元素数量。
已知:
nums1的长度为m + n,其中前m个元素是已经排好序的有效元素,后n个位置(初始值通常是0或任意值)被预留出来,用于容纳合并后的所有元素。nums2的长度为n,包含了n个已排序的有效元素。
目标:将 nums2 合并到 nums1 中,使合并后的 nums1 数组整体保持非递减顺序。最终结果就存储在 nums1 中,不允许使用额外的、与问题规模成比例的空间(即要求原地修改 nums1)。
示例:
输入:
nums1 = [1, 2, 3, 0, 0, 0], m = 3
nums2 = [2, 5, 6], n = 3
输出:
nums1 = [1, 2, 2, 3, 5, 6]
核心约束:必须在不使用另一个大小为 m+n 的数组的前提下,完成合并。
2. 解题思路演进
第一步:直观但低效的方法
最直接的想法可能是:将 nums2 的所有元素先直接放入 nums1 末尾预留的 n 个位置,然后对整个 nums1(长度为 m+n)调用标准排序算法(如快速排序)。
- 缺点:这种方法没有利用
nums1和nums2已经各自有序 这个关键信息。排序整个数组的时间复杂度为 O((m+n) log(m+n)),不够高效。空间复杂度虽然也是 O(1)(原地排序),但存在更优解。
第二步:利用额外空间的简单归并
标准的归并排序(Merge)操作思路是:创建一个大小为 m+n 的临时数组 temp。使用三个指针 i、j、k:
i初始指向nums1的第一个有效元素(索引 0)。j初始指向nums2的第一个元素(索引 0)。k初始指向temp的第一个位置(索引 0)。
然后,比较nums1[i]和nums2[j],将较小的那个放入temp[k],并移动相应的指针。重复此过程直到其中一个数组遍历完,再将另一个数组剩余的部分直接复制到temp末尾。最后将temp的内容复制回nums1。- 优点:时间复杂度是完美的 O(m+n),因为我们只遍历了每个数组一次。
- 缺点:空间复杂度是 O(m+n),因为需要额外的
temp数组。这不符合题目对“原地”的隐含要求(尽管题目可能没有明确禁止,但追求最优解时这是需要优化的)。
第三步:关键洞察——逆向双指针法(从后向前填充)
我们注意到 nums1 的后面有 n 个预留的、可以覆盖的位置。如果我们还是从前往后比较和填充,在将 nums1 的元素放入正确位置时,可能会覆盖掉后面尚未比较的 nums1 元素,导致信息丢失。
核心策略逆转:既然后面有空位,且两个数组的最大值最终一定在最后的位置,我们何不从后往前(从大到小)进行归并呢?
算法步骤详解:
-
指针初始化:
p1:指向nums1的最后一个有效元素,即索引m-1处。p2:指向nums2的最后一个元素,即索引n-1处。p:指向nums1数组的最后一个位置,即索引m+n-1处。这个位置是我们当前要填充的目标位置。
-
逆向归并循环:
- 只要
p1 >= 0且p2 >= 0,说明两个数组都还有元素未处理,就执行循环:- 比较
nums1[p1]和nums2[p2]。 - 将两者中 较大 的值,放入
nums1[p]。 - 将较大值所属数组的指针(
p1或p2)向前移动一位(减1)。 - 将目标位置指针
p向前移动一位(减1)。
- 比较
- 只要
-
处理剩余元素:
- 循环结束后,有两种情况:
- 情况 A:
p2 >= 0,这意味着nums2中还剩一些元素(这些元素是所有剩余元素中最小的),而nums1中的有效元素已经全部正确归位。此时,我们只需要把nums2中剩下的这些元素(从索引0到p2)按顺序复制到nums1的前p2+1个位置即可。 - 情况 B:
p1 >= 0,这意味着nums2的所有元素已经处理完,而nums1中剩余的元素本来就在正确的位置(因为我们是原地从后往前放),所以什么都不用做。
- 情况 A:
- 循环结束后,有两种情况:
为什么这个方法正确且高效?
- 正确性:我们总是比较两个数组中当前未处理的、各自最大的元素,将其中最大的放到当前未填充的最末尾位置。这保证了从后往前,
nums1被逐步填充为正确的非递减序列。 - 时间复杂度:O(m+n)。每个元素只被比较和移动一次。
- 空间复杂度:O(1)。只使用了几个指针变量,没有使用与
m或n成比例的额外空间。
3. 算法流程示例
以示例说明:
初始状态:
nums1 = [1, 2, 3, 0, 0, 0], p1=2 (指向3), p2=2 (指向6), p=5
循环步骤:
Step1: nums1[p1]=3, nums2[p2]=6, 6更大 -> nums1[5]=6, p2=1, p=4 -> [1,2,3,0,0,6]
Step2: nums1[p1]=3, nums2[p2]=5, 5更大 -> nums1[4]=5, p2=0, p=3 -> [1,2,3,0,5,6]
Step3: nums1[p1]=3, nums2[p2]=2, 3更大 -> nums1[3]=3, p1=1, p=2 -> [1,2,3,3,5,6]
Step4: nums1[p1]=2, nums2[p2]=2, 相等 (取nums2的也行) -> nums1[2]=2, p2=-1, p=1 -> [1,2,2,3,5,6]
循环结束,因为 p2 变为 -1 (p2 < 0),所以 nums2 已处理完,算法结束。
最终结果为 [1, 2, 2, 3, 5, 6]。
4. 边界条件与实现细节
- 空数组处理:如果
m=0,则nums1的有效部分为空。算法中p1初始为 -1,进入循环后第一个判断p1>=0为假,循环直接跳过。随后判断p2>=0为真,会将整个nums2复制到nums1开头,完全正确。 - 指针范围:需要确保指针在操作数组时不越界。我们的初始化
p1=m-1,p2=n-1,p=m+n-1在逻辑上是严谨的。在复制剩余元素时,源和目标索引的计算要小心。 - 稳定性:对于相等元素 (
nums1[p1] == nums2[p2]),优先放哪一个(移动p1还是p2)在排序结果上是等价的,不影响非递减顺序。但从“稳定排序”的角度(如果元素附带其他信息),通常优先移动nums1的指针(即先放nums1的元素)可以保持nums1内部原有相等元素的相对顺序,但题目未要求,此处可自由选择。
这个逆向双指针归并法是解决该问题的标准且最优解,它巧妙地利用了输入数组的特性,实现了时间复杂度和空间复杂度的最优平衡。