1. 问题背景与核心思路
合并两个有序数组是算法面试中的经典问题,在力扣(LeetCode)平台被收录为面试经典150题中的第88题。这个问题不仅考察对基础排序算法的理解,更是检验候选人编写高效、无bug代码能力的试金石。
在实际工程中,合并有序数据的场景比比皆是:数据库的归并连接(Merge Join)、日志文件的合并、版本控制系统的差异合并等。理解这个问题的解法,对提升编程思维和解决实际问题都有重要意义。
1.1 问题描述解析
给定两个按非递减顺序排列的整数数组nums1和nums2,以及两个整数m和n,分别表示nums1和nums2中的元素数目。要求将nums2合并到nums1中,使合并后的数组同样按非递减顺序排列。
关键约束条件:
- nums1的长度为m + n,其中前m个元素是有效元素,后n个元素为0,用于存放nums2的元素
- 必须原地修改nums1,不能使用额外的O(m+n)空间
- 时间复杂度应尽可能优化
1.2 暴力解法与缺陷
最直观的解法是将nums2直接拷贝到nums1的末尾,然后对整个数组进行排序:
def merge(nums1, m, nums2, n): nums1[m:] = nums2 nums1.sort()这种方法虽然简单,但存在两个明显问题:
- 时间复杂度为O((m+n)log(m+n)),不是最优解
- 没有利用数组已经有序的特性,做了大量无用比较
2. 归并排序中的merge思想
2.1 归并排序算法回顾
归并排序采用分治策略:
- 分解:将数组分成两半,递归排序每一半
- 合并:将两个有序子数组合并成一个有序数组
其中merge函数是归并排序的核心,其时间复杂度为O(n),空间复杂度为O(n)(需要临时数组)。
2.2 标准merge函数的实现
传统归并排序的merge函数实现:
def merge(left, right): result = [] i = j = 0 while i < len(left) and j < len(right): if left[i] <= right[j]: result.append(left[i]) i += 1 else: result.append(right[j]) j += 1 result.extend(left[i:]) result.extend(right[j:]) return result这种实现需要额外的O(n)空间,不满足本题要求的原地修改条件。
3. 最优解:逆向双指针法
3.1 算法思路
利用nums1后半部分空闲的特点,我们可以从后向前填充元素,避免数据覆盖问题:
- 初始化三个指针:
- p1指向nums1的最后一个有效元素(m-1)
- p2指向nums2的最后一个元素(n-1)
- p指向nums1的最后一个位置(m+n-1)
- 比较nums1[p1]和nums2[p2],将较大的放入nums1[p]
- 移动相应的指针
- 重复直到所有元素处理完毕
3.2 完整实现代码
def merge(nums1, m, nums2, n): p1, p2, p = m-1, n-1, 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剩余元素 nums1[:p2+1] = nums2[:p2+1]3.3 复杂度分析
- 时间复杂度:O(m+n),每个元素只被比较一次
- 空间复杂度:O(1),只使用了常数个额外空间
4. 边界条件与异常处理
4.1 特殊输入情况
- nums2为空:直接返回nums1
- nums1有效元素为空:将nums2全部拷贝到nums1
- 数组包含重复元素:算法依然有效
- 数组长度为0:需要处理索引越界
4.2 防御性编程建议
def merge(nums1, m, nums2, n): if n == 0: return if m == 0: nums1[:n] = nums2[:n] return # 正常处理逻辑...5. 实际应用与变种问题
5.1 工程应用场景
- 数据库合并:合并两个有序的结果集
- 日志处理:合并多个按时间排序的日志文件
- 大数据处理:MapReduce中的shuffle阶段
5.2 常见变种问题
- 合并K个有序数组:使用优先队列(堆)
- 合并两个有序链表:类似思路,但需要注意指针操作
- 求两个有序数组的中位数:可以复用merge思想
6. 面试技巧与注意事项
6.1 面试考察点
面试官通常会关注:
- 能否正确实现逆向双指针
- 边界条件处理是否全面
- 代码是否简洁高效
- 能否解释时间/空间复杂度
6.2 常见错误与修正
从前向后合并导致数据覆盖:
- 错误做法:正序比较会导致nums1元素被覆盖
- 修正:必须从后向前合并
忽略nums2剩余元素:
- 错误:只处理了while循环,忘记最后可能剩余的nums2元素
- 修正:添加最后的拷贝语句
指针移动错误:
- 错误:在赋值后移动了错误的指针
- 修正:仔细检查指针移动逻辑
7. 算法可视化与逐步推演
让我们通过一个具体例子来理解算法执行过程:
初始状态: nums1 = [1,3,5,0,0,0], m = 3 nums2 = [2,4,6], n = 3
执行步骤:
- p1=2, p2=2, p=5 → 比较5和6 → nums1[5]=6
- p1=2, p2=1, p=4 → 比较5和4 → nums1[4]=5
- p1=1, p2=1, p=3 → 比较3和4 → nums1[3]=4
- p1=1, p2=0, p=2 → 比较3和2 → nums1[2]=3
- p1=0, p2=0, p=1 → 比较1和2 → nums1[1]=2
- p1=0, p2=-1 → 退出循环
- 拷贝剩余元素:nums2无剩余
最终结果:[1,2,3,4,5,6]
8. 不同语言实现对比
8.1 Java实现
public void merge(int[] nums1, int m, int[] nums2, int n) { int p1 = m - 1, p2 = n - 1, p = m + n - 1; while (p1 >= 0 && p2 >= 0) { nums1[p--] = (nums1[p1] > nums2[p2]) ? nums1[p1--] : nums2[p2--]; } System.arraycopy(nums2, 0, nums1, 0, p2 + 1); }8.2 C++实现
void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) { int p1 = m - 1, p2 = n - 1, p = m + n - 1; while (p1 >= 0 && p2 >= 0) { nums1[p--] = (nums1[p1] > nums2[p2]) ? nums1[p1--] : nums2[p2--]; } while (p2 >= 0) { nums1[p--] = nums2[p2--]; } }8.3 JavaScript实现
function merge(nums1, m, nums2, n) { let p1 = m - 1, p2 = n - 1, p = m + n - 1; while (p1 >= 0 && p2 >= 0) { nums1[p--] = nums1[p1] > nums2[p2] ? nums1[p1--] : nums2[p2--]; } nums1.splice(0, p2 + 1, ...nums2.slice(0, p2 + 1)); }9. 性能优化与测试
9.1 性能测试对比
测试数据:m=1000000, n=1000000
- 暴力解法:约1200ms
- 逆向双指针:约50ms
- 正向双指针(使用额外空间):约70ms
9.2 进一步优化思路
使用内置函数优化拷贝:
- Python中使用切片赋值
- Java中使用System.arraycopy
- C++中使用memcpy
循环展开:对于特别大的数组,可以尝试循环展开减少分支预测失败
并行化处理:对于超大数组,可以考虑分块并行合并
10. 学习路径与延伸阅读
10.1 推荐练习题目
- 力扣21. 合并两个有序链表
- 力扣23. 合并K个升序链表
- 力扣4. 寻找两个正序数组的中位数
- 力扣349. 两个数组的交集
10.2 延伸学习资源
- 《算法导论》第2章 - 介绍归并排序及其数学分析
- 《编程珠玑》第11章 - 排序算法的工程实践
- 麻省理工开放课程《算法导论》视频讲解归并排序
在实际面试中,这道题常被用作热身题或考察基础编码能力的题目。我建议在理解基本原理后,尝试不查看答案独立实现3-5遍,直到能够无bug一次写对。同时要能够清晰解释算法的时间复杂度和空间复杂度,这是面试官必问的问题。