1. 从一道简单题看双指针的实战价值
LeetCode 88 题“合并两个有序数组”,在题库里被标记为简单难度,但我在过去几年带新人、做代码评审的过程中发现,这道题能暴露出的问题一点都不简单。它表面上考的是数组合并,实际上考的是你对原地操作、双指针从后往前遍历、边界条件处理这三个核心技能的理解深度。很多人第一次写这道题,五分钟就能提交一个能通过的版本,但如果你问他“为什么必须从后往前”“为什么不能用额外数组”“时间复杂度到底怎么算”,能完整答上来的人不到三成。
这篇文章适合三类人看:第一类是刚刷 LeetCode 不久,想通过一道题把双指针思想吃透的初学者;第二类是刷了不少题但遇到原地合并类问题还是容易写挂的进阶者;第三类是在准备面试,需要把这道高频题讲清楚、写干净的求职者。我会从题目本身出发,把思路拆解、代码实现、边界处理、常见错误、面试追问全部讲一遍,让你做完这一道题就能举一反三,拿下所有“两个有序序列合并”的变种题。
先明确一下题目:给你两个按非递减顺序排列的整数数组nums1和nums2,以及两个整数m和n,分别表示nums1和nums2中实际元素的个数。要求把nums2合并到nums1中,使合并后的数组同样按非递减顺序排列。关键约束是:最终结果不能通过返回新数组来实现,必须原地修改nums1。nums1的长度是m + n,后n个位置预留给nums2的元素。
这道题的核心关键词就是:双指针、原地合并、从后往前、时间复杂度 O(m+n)、空间复杂度 O(1)。下面我逐层拆开讲。
2. 题目整体设计与思路拆解
2.1 为什么不能从前往后直接合并
最直觉的想法是这样的:既然两个数组都是有序的,那我从前往后比较,谁小就把谁放到nums1的前面不就行了?这个思路本身没错,但问题在于nums1的前面已经有元素了。你从前往后遍历时,一旦把nums2的元素插入到nums1的前面,就会覆盖掉nums1中还没处理的元素。
举个例子:nums1 = [1, 3, 5, 0, 0, 0],m = 3;nums2 = [2, 4, 6],n = 3。如果你从前往后比较,第一步发现1 < 2,1不用动;第二步发现2 < 3,于是把2放到nums1[1]的位置,结果原来的3就被覆盖了。你可能会说“那我先把nums1的前m个元素复制一份出来不就行了”——可以,但那样空间复杂度就变成了 O(m),不是最优解。
所以从前往后的核心矛盾是:写入位置在读取位置之前,会造成数据覆盖。要解决这个问题,要么额外开空间,要么换一个方向遍历。
2.2 从后往前:一个方向上的巧妙反转
既然从前往后会覆盖未处理的数据,那我们从后往前呢?nums1的后n个位置是空的(值为 0 的占位符),我们可以把比较大的元素先放到数组末尾。这样一来,写入位置始终在读取位置之后,不会覆盖任何还没处理的元素。
具体做法是:用两个指针p1和p2分别指向nums1的第m-1个位置和nums2的第n-1个位置,再用一个指针p指向nums1的最后一个位置m+n-1。每次比较nums1[p1]和nums2[p2],把较大的那个放到nums1[p],然后对应的指针往前移动一位,p也往前移动一位。重复这个过程直到其中一个数组处理完。
这个思路的精妙之处在于:它利用了nums1末尾的空闲空间,把“合并”这个操作变成了“从大到小填充”。你不需要额外空间,也不需要移动已有元素,每个元素只被访问一次,时间复杂度 O(m+n),空间复杂度 O(1)。
2.3 为什么这个思路是最优的
从信息论的角度看,合并两个有序数组至少需要访问每个元素一次,所以时间复杂度下界就是 O(m+n),从后往前的双指针正好达到了这个下界。空间方面,题目已经给了nums1足够的空间,如果我们还额外开数组,那就是浪费。从后往前填充的做法完美利用了题目给的约束条件,是真正意义上的最优解。
我在面试中经常问的一个追问是:“如果nums1的末尾没有预留空间,你怎么办?”这时候从后往前就不适用了,你得先扩容或者开新数组。所以理解这个解法的适用前提很重要——它依赖于nums1有足够的尾部空间。
3. 核心细节解析与实操要点
3.1 三个指针的初始位置与移动逻辑
三个指针的初始化非常关键,写错一个就容易数组越界:
p1 = m - 1:指向nums1中最后一个有效元素p2 = n - 1:指向nums2中最后一个有效元素p = m + n - 1:指向nums1的最后一个位置,也就是合并后数组的末尾
循环的条件是p1 >= 0 && p2 >= 0,也就是说两个数组都还有元素没处理完。在循环体内,比较nums1[p1]和nums2[p2]:
- 如果
nums1[p1] >= nums2[p2],把nums1[p1]放到nums1[p],然后p1--、p-- - 否则,把
nums2[p2]放到nums1[p],然后p2--、p--
这里有一个细节:当两个元素相等时,我们选择先放nums1的元素。这不会影响结果的正确性,因为相等的元素谁先谁后都一样。但从稳定性角度考虑(虽然这道题不要求稳定),先放nums1的元素可以保证nums1中原本靠后的相等元素仍然靠后。
3.2 循环结束后的收尾处理
循环结束后,有两种情况:
第一种是p1 < 0,说明nums1的元素已经全部处理完,但nums2还有剩余。这时候需要把nums2中剩下的元素依次复制到nums1的前面。为什么?因为nums2剩下的元素都是比已经放置的元素更小的,它们应该排在nums1的最前面。
第二种是p2 < 0,说明nums2的元素已经全部处理完,但nums1还有剩余。这时候不需要做任何操作,因为nums1剩下的元素本来就在正确的位置上——它们比已经放置的所有元素都小,而且已经在nums1的前面了。
很多初学者会忘记处理第一种情况,导致nums2中较小的元素丢失。我建议在写代码时,循环结束后统一加一个while (p2 >= 0)的收尾循环,把nums2剩余元素搬过去。这样写虽然多了一个循环,但逻辑更清晰,不容易出错。
3.3 边界条件的全面梳理
这道题的边界条件不少,我整理了一个表格,方便你对照检查:
| 边界情况 | 具体场景 | 处理要点 |
|---|---|---|
m = 0 | nums1没有有效元素 | 直接把nums2全部复制到nums1 |
n = 0 | nums2没有元素 | 不需要任何操作,直接返回 |
m = 0且n = 0 | 两个数组都为空 | 直接返回,不做任何处理 |
nums1元素全部大于nums2 | 如nums1=[4,5,6],nums2=[1,2,3] | 从后往前比较时,nums2的元素会先被放置,最后nums1的元素留在原位 |
nums2元素全部大于nums1 | 如nums1=[1,2,3],nums2=[4,5,6] | nums1的元素先被放置到末尾,nums2的元素最后通过收尾循环复制到前面 |
| 存在重复元素 | 如nums1=[1,2,2],nums2=[2,3] | 相等时先放nums1的元素,保证结果正确 |
提示:在本地测试时,建议至少覆盖上面表格中的每一种情况。尤其是
m = 0和n = 0这两种极端情况,很多人写代码时只考虑了“两个数组都有元素”的常规场景,一提交就挂在这些边界上。
4. 实操过程与核心环节实现
4.1 完整代码实现与逐行注释
下面给出 Python 和 Java 两个版本的实现,都是可以直接复制到 LeetCode 上提交的。
Python 版本:
class Solution: def merge(self, nums1: List[int], m: int, nums2: List[int], n: int) -> None: """ Do not return anything, modify nums1 in-place instead. """ # p1 指向 nums1 最后一个有效元素 p1 = m - 1 # p2 指向 nums2 最后一个元素 p2 = n - 1 # p 指向 nums1 最后一个位置,即合并后的末尾 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 还有剩余,复制到 nums1 前面 # 如果 nums1 还有剩余,不需要处理,它们已经在正确位置 while p2 >= 0: nums1[p] = nums2[p2] p2 -= 1 p -= 1Java 版本:
class Solution { public void merge(int[] nums1, int m, int[] nums2, int n) { int p1 = m - 1; int p2 = n - 1; int p = m + n - 1; while (p1 >= 0 && p2 >= 0) { if (nums1[p1] >= nums2[p2]) { nums1[p] = nums1[p1]; p1--; } else { nums1[p] = nums2[p2]; p2--; } p--; } // 只需要处理 nums2 剩余的情况 while (p2 >= 0) { nums1[p] = nums2[p2]; p2--; p--; } } }4.2 执行过程逐步推演
拿一个具体的例子走一遍,你就明白每一步在干什么了。
输入:nums1 = [1, 3, 5, 0, 0, 0],m = 3;nums2 = [2, 4, 6],n = 3
初始状态:p1 = 2(指向 5),p2 = 2(指向 6),p = 5(指向最后一个 0)
| 步骤 | 比较 | 操作 | 数组状态 | 指针变化 |
|---|---|---|---|---|
| 1 | 5 vs 6 | 6 更大,放到nums1[5] | [1,3,5,0,0,6] | p2=1, p=4 |
| 2 | 5 vs 4 | 5 更大,放到nums1[4] | [1,3,5,0,5,6] | p1=1, p=3 |
| 3 | 3 vs 4 | 4 更大,放到nums1[3] | [1,3,5,4,5,6] | p2=0, p=2 |
| 4 | 3 vs 2 | 3 更大,放到nums1[2] | [1,3,3,4,5,6] | p1=0, p=1 |
| 5 | 1 vs 2 | 2 更大,放到nums1[1] | [1,2,3,4,5,6] | p2=-1, p=0 |
循环结束,p2 = -1,收尾循环不执行。最终结果[1,2,3,4,5,6],正确。
再看一个需要收尾的例子:nums1 = [4, 5, 6, 0, 0, 0],m = 3;nums2 = [1, 2, 3],n = 3
| 步骤 | 比较 | 操作 | 数组状态 | 指针变化 |
|---|---|---|---|---|
| 1 | 6 vs 3 | 6 更大,放到nums1[5] | [4,5,6,0,0,6] | p1=1, p=4 |
| 2 | 5 vs 3 | 5 更大,放到nums1[4] | [4,5,6,0,5,6] | p1=0, p=3 |
| 3 | 4 vs 3 | 4 更大,放到nums1[3] | [4,5,6,4,5,6] | p1=-1, p=2 |
循环结束,p1 = -1,但p2 = 2还有元素。收尾循环把nums2[0..2]依次复制到nums1[0..2],最终得到[1,2,3,4,5,6]。
4.3 复杂度分析与参数选择
时间复杂度:每个元素最多被访问一次,循环总共执行m + n次,所以是O(m+n)。这个复杂度已经是最优的,因为任何合并算法都必须至少读取每个元素一次。
空间复杂度:只用了三个指针变量,没有开辟任何与输入规模相关的额外空间,所以是O(1)。
有读者可能会问:“Python 里nums1[p] = nums1[p1]这种赋值算不算额外空间?”不算,这只是常数级别的临时变量,不随输入规模变化。空间复杂度的核心是看有没有开辟与m或n成正比的内存。
注意:有些语言(比如 Python)的列表切片操作
nums1[:] = sorted(nums1[:m] + nums2)虽然写起来短,但空间复杂度是 O(m+n),不符合题目的原地要求。面试时如果这么写,面试官大概率会追问“能不能优化到 O(1)”。
5. 常见问题与排查技巧实录
5.1 高频错误速查表
我在代码评审和面试中见过太多人在这道题上翻车,下面这张表整理了最常见的错误和对应的排查方法:
| 错误现象 | 根本原因 | 排查方法 | 修复方案 |
|---|---|---|---|
| 数组越界 | 指针初始值写错,比如p1 = m而不是m-1 | 打印指针初始值,检查是否在[0, m-1]范围内 | 统一用m-1和n-1初始化 |
结果中nums2的小元素丢失 | 忘记处理循环结束后的收尾 | 检查循环结束后p2是否大于等于 0 | 加while p2 >= 0收尾循环 |
| 结果顺序不对 | 比较时用了>而不是>=,或者方向搞反 | 用[1,2,2]和[2,3]这种有重复的用例测试 | 相等时先放nums1的元素 |
| 覆盖了未处理的元素 | 从前往后遍历 | 检查遍历方向 | 改为从后往前 |
m=0时结果为空 | 没有单独处理m=0的情况 | 用nums1=[0],m=0,nums2=[1]测试 | 收尾循环会自动处理,确认循环条件正确 |
| 提交后提示“修改了输入数组但结果不对” | 在函数内重新赋值了nums1而不是原地修改 | 检查是否有nums1 = ...这样的语句 | 用索引赋值nums1[p] = ... |
5.2 三个我踩过的坑
第一个坑:指针初始值写成m和n。刚开始刷题时,我总觉得“第 m 个元素”就是索引 m,结果一提交就数组越界。后来养成了一个习惯:凡是涉及数组索引的,先在纸上画一遍,标清楚每个指针指向哪个元素,确认无误再写代码。
第二个坑:以为循环结束后不需要收尾。我最早写的版本只处理了两个数组都有元素的情况,测试用例nums1=[4,5,6,0,0,0]、nums2=[1,2,3]直接挂了。后来我总结了一个规律:从后往前合并时,只需要检查第二个数组是否还有剩余,因为第一个数组的剩余元素天然就在正确位置。这个规律帮我省了很多调试时间。
第三个坑:在 Python 里用nums1 = nums1[:m] + nums2然后sort。这样写确实能过,但完全违背了题目的原地要求。面试时如果这么写,面试官会认为你没有理解题目的核心考点。后来我强制自己只用索引赋值,再也不走捷径。
5.3 面试追问与应对策略
这道题在面试中经常被用作“热身题”,但面试官往往会在此基础上追问。我整理了几个常见的追问和应对思路:
追问一:“如果nums1的末尾没有预留空间,你怎么合并?”这时候从后往前就不适用了。你可以先计算合并后的长度,扩容nums1,然后再从后往前填充。或者如果允许返回新数组,直接开一个新数组,用从前往后的双指针合并。
追问二:“如果两个数组是降序的,怎么改?”降序的话,从后往前比较时应该放较小的元素到末尾,或者改成从前往后比较、从后往前填充。核心思想不变,只是比较逻辑反过来。
追问三:“这个算法稳定吗?”稳定排序的定义是相等元素的相对顺序不变。在这道题中,当nums1[p1] == nums2[p2]时,我们先放nums1的元素,所以nums1中的相等元素会排在nums2中相等元素的后面。如果要求稳定,应该先放nums2的元素。不过这道题不要求稳定,所以两种写法都可以。
追问四:“能不能用二分查找优化?”对于两个有序数组的合并,二分查找并不能降低时间复杂度,因为最终还是要移动所有元素。二分查找适合在单个有序数组中查找,不适合合并场景。如果题目改成“在一个有序数组中插入另一个数组的元素”,二分查找才有用武之地。
6. 从这道题延伸出的通用解题框架
6.1 双指针从后往前的适用场景
这道题的核心技巧是“从后往前双指针”,它适用于所有原地合并两个有序序列的场景。判断能不能用这个技巧,看两个条件:第一,目标数组有足够的尾部空间;第二,两个源序列都是有序的。满足这两个条件,就可以放心用从后往前的双指针。
类似的题目还有“合并两个有序链表”,不过链表没有“从后往前”的概念,所以用的是从前往后的双指针。数组和链表的区别在于:数组可以随机访问,所以能从后往前;链表只能顺序访问,所以只能从前往后。理解这个区别,你就能根据数据结构的特点选择合适的遍历方向。
6.2 原地操作的通用原则
原地操作的核心原则是:写入位置不能覆盖还没读取的数据。从前往后写入会覆盖后面的数据,从后往前写入会覆盖前面的数据。所以选择遍历方向时,要看哪边的数据是“安全”的。在这道题中,nums1的后半部分是空的,所以从后往前写入是安全的。
这个原则可以推广到很多原地操作场景,比如“原地删除数组中的重复元素”“原地移除指定元素”等。这些题目通常用从前往后的双指针,因为写入位置在读取位置之前,不会覆盖未处理的数据。
6.3 举一反三:三道变种题
变种一:合并两个有序数组,但要求去重。在比较时,如果nums1[p1] == nums2[p2],只放一个,另一个跳过。收尾时也要注意去重。
变种二:合并 K 个有序数组。可以用最小堆(优先队列)来做,每次从 K 个数组中取出最小的元素。时间复杂度 O(N log K),其中 N 是所有元素的总数。
变种三:两个有序数组求交集。用双指针从前往后遍历,相等就加入结果,不相等就移动较小的那个指针。时间复杂度 O(m+n),空间复杂度 O(min(m,n))。
这三道变种题都可以在 LeetCode 上找到对应的题目,做完 88 题之后可以顺手练一下,把双指针的套路彻底吃透。
6.4 刷题方法论的几点体会
最后分享几点我在刷题过程中总结的方法论。第一,不要追求刷题数量,要追求刷题质量。一道 88 题吃透,比刷十道同类型的题但每道都一知半解要强。第二,每道题至少写两遍。第一遍看题解理解思路,第二遍关掉题解独立写出来,第三遍尝试优化和变种。第三,建立自己的错题本。把每次踩的坑记下来,定期回顾,避免重复犯错。
这道 88 题我前前后后写了不下十遍,每次都有新的体会。从最开始只会从前往后硬写,到后来理解从后往前的精妙,再到能给别人讲清楚每一步的原理,这个过程本身就是一种成长。刷题不是为了背答案,而是为了训练思维方式。当你能把一道简单题讲出深度,你就真正掌握了它。