88. 合并两个有序数组
给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。
请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。
注意: 最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。
示例 1:
输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]
解释:需要合并 [1,2,3] 和 [2,5,6] 。
合并结果是 [1,2,2,3,5,6] ,其中斜体加粗标注的为 nums1 中的元素。
示例 2:
输入:nums1 = [1], m = 1, nums2 = [], n = 0
输出:[1]
解释:需要合并 [1] 和 [] 。
合并结果是 [1] 。
示例 3:
输入:nums1 = [0], m = 0, nums2 = [1], n = 1
输出:[1]
解释:需要合并的数组是 [] 和 [1] 。
合并结果是 [1] 。
注意,因为 m = 0 ,所以 nums1 中没有元素。nums1 中仅存的 0 仅仅是为了确保合并结果可以顺利存放到 nums1 中。
提示:
nums1.length == m + nnums2.length == n0 <= m, n <= 2001 <= m + n <= 200-10^9 <= nums1[i], nums2[j] <= 10^9
进阶: 你可以设计实现一个时间复杂度为 O(m + n) 的算法解决此问题吗?
第一阶段:最直观(但低效)的方法:合并后排序
思考过程
当我们刚看到这个问题时,最直接的想法就是“先不管顺序,再整体排序”。
- 第一步: 题目要求把
nums2合并到nums1里。nums1的末尾正好有n个空位(都是0)。那我们就先把nums2的所有元素直接复制到nums1的末尾空位上。- 例如:
nums1 = [1,2,3,0,0,0],nums2 = [2,5,6] - 复制后:
nums1变为[1,2,3,2,5,6]。
- 例如:
- 第二步: 现在
nums1包含了所有需要的元素,但是是无序的。题目要求最终的数组是有序的,所以我们调用一个排序函数,比如 Java 的Arrays.sort(),对整个nums1数组进行排序。- 排序后:
nums1变为[1,2,2,3,5,6]。
- 排序后:
- 完成: 得到了正确的结果。
评估与反思
- 正确性: 这个方法是完全正确的。
- 效率: 复制需要 O(n) 时间。对一个长度为
m+n的数组进行排序,时间复杂度是 O((m+n) log(m+n))。所以总时间复杂度是 O((m+n) log(m+n))。 - 问题: 这个方法完全没有利用题目给的一个关键信息:“
nums1和nums2本身已经是排好序的”。这通常意味着存在比通用排序更高效的解法。题目的“进阶”要求也暗示了 O(m+n) 的可能性。
第二阶段:进一步的思考:为什么从前往后合并行不通?
既然两个数组都有序,我们自然会想到类似“归并排序”中的合并步骤。
思考过程
- 想法: 我们用两个指针
p1和p2,分别指向nums1的开头和nums2的开头。比较nums1[p1]和nums2[p2],把较小的那个放到nums1的开头,然后移动相应的指针。 - 遇到障碍:
- 假设
nums1 = [4,5,6,0,0,0],nums2 = [1,2,3]。 - 比较
nums1[0](4) 和nums2[0](1)。1更小,所以我们希望最终数组的第一位是1。 - 我们必须把
1放到nums1[0]的位置。但这样做会覆盖掉原来的4!在覆盖4之前,我们需要把它保存下来,并且还要把5和6向后移动,为1腾出空间。 - 这个“向后移动”的操作非常耗时。每从
nums2插入一个数,都可能需要移动nums1中大量的元素。最坏情况下(nums2的所有元素都比nums1的小),时间复杂度会变成 O(m * n),效率非常低。
- 假设
结论: 从前往后合并,在原地(in-place)操作 nums1 会导致大量的元素移动,效率很差。
第三阶段:关键突破——从后往前合并
思考过程
- 重新审视问题: 从前往后合并的问题在于我们会覆盖掉后面需要用到的数据。那么,
nums1数组中哪里有“安全”的、可以随便覆盖的空闲空间呢?答案是数组的末尾。 - 逆向思维: 既然最终排好序的数组,其最大的元素也应该在末尾,我们为什么不从后往前填充
nums1呢? - 指针设置:
- 设置一个指针
p1指向nums1有效元素部分的末尾(即索引m-1)。 - 设置一个指针
p2指向nums2的末尾(即索引n-1)。 - 设置一个“写入指针”
p指向nums1数组的最末尾(即索引m+n-1)。
- 设置一个指针
- 比较与填充:
- 比较
nums1[p1]和nums2[p2]的大小。 - 将两者中较大的那个数,放到
nums1[p]的位置。 - 移动被选中的那个数的指针(如果选了
nums1[p1],则p1--;如果选了nums2[p2],则p2--)。 - 将写入指针
p向前移动一位 (p--)。
- 比较
- 循环: 重复这个过程,直到
p1或p2有一个越界(即处理完了其中一个数组的所有元素)。 - 处理剩余元素: 循环结束后,可能有一个数组还有剩余元素。
- 如果
nums1还有剩余(p1 >= 0),我们不需要做任何事。因为它们本来就在nums1的开头,并且它们比所有已经放置的元素都要小,所以它们的位置是正确的。 - 如果
nums2还有剩余(p2 >= 0),说明这些是最小的一批元素。我们需要将它们全部复制到nums1的开头空余位置(从p指针当前位置往前)。
- 如果
这个方法完美地解决了问题:
- 它从后往前填充,利用了末尾的空闲空间。
- 它绝不会覆盖掉尚未比较的有效数据。
- 每个元素只被访问一次,时间复杂度是 O(m+n)。
- 它是在原地完成的,空间复杂度是 O(1)。
第四阶段:最终的 Java 代码实现
import java.util.Arrays; // 仅用于 main 方法的测试输出
import java.util.Scanner; // 用于 ACM 模式
public class Main { // ACM 模式类名通常为 Main
public static void main(String[] args) {
// ... (此处省略 ACM 输入处理部分,专注于核心算法) ...
// 示例调用:
int[] nums1 = {1, 2, 3, 0, 0, 0};
int m = 3;
int[] nums2 = {2, 5, 6};
int n = 3;
merge(nums1, m, nums2, n);
System.out.println(Arrays.toString(nums1)); // 输出: [1, 2, 2, 3, 5, 6]
}
/**
* 将两个有序整数数组 nums1 和 nums2 合并到一个数组 nums1 中。
* 采用从后往前的双指针法,实现 O(m+n) 时间复杂度和 O(1) 空间复杂度。
*
* @param nums1 第一个数组,有足够的空间 (m+n)
* @param m nums1 中有效元素的数量
* @param nums2 第二个数组
* @param n nums2 中有效元素的数量
*/
public static void merge(int[] nums1, int m, int[] nums2, int n) {
// --- 1. 初始化指针 ---
// p1 指向 nums1 有效元素的末尾
int p1 = m - 1;
// p2 指向 nums2 的末尾
int p2 = n - 1;
// p 指向 nums1 数组的最末尾,这是写入位置
int p = m + n - 1;
// --- 2. 从后往前比较和填充 ---
// 当 nums2 的所有元素都被处理完时 (p2 < 0),循环就可以结束
// 因为如果 nums2 处理完了,nums1 剩余的元素本就在正确的位置上
while (p2 >= 0) {
// 比较 nums1 和 nums2 的尾部元素
// 同时需要确保 nums1 的指针 p1 没有越界
if (p1 >= 0 && nums1[p1] > nums2[p2]) {
// 如果 nums1 的当前元素更大,则将其放置在 p 位置
nums1[p] = nums1[p1];
p1--; // 将 p1 指针左移
} else {
// 否则(nums2 的元素更大或相等,或者 nums1 已处理完),
// 将 nums2 的元素放置在 p 位置
nums1[p] = nums2[p2];
p2--; // 将 p2 指针左移
}
// 无论放置哪个元素,写入指针 p 都需要左移
p--;
}
// 当 p2 < 0 时,循环结束,所有 nums2 的元素都已合并到 nums1 中。
// nums1 中可能剩余的元素(如果它们是最小的)已经在正确的位置,无需移动。
}
}