刷力扣的人对 88. 合并两个有序数组 应该都不陌生,它在“力扣热题100”里挂着,也是不少公司面试的第一道热身题。可我一直觉得,这道题被严重低估了:你随手写一个能 Accepted 的版本不难,但要在面试里把“从后往前合并”讲清楚、把边界条件写稳、再扛住面试官的一串追问,就没那么简单了。这篇文章就从我做这道题的完整思路出发,拆开讲讲它的考点、三种写法、易错点和常见变体,希望能帮你从“会过”变成“会讲”。
1. 为什么一道Easy题值得单独写一篇
1.1 题目里暗藏的三个约束
LeetCode 88 的原题描述很短:给你两个按非递减顺序排列的整数数组 nums1 和 nums2,请将 nums2 合并到 nums1 中,使合并后的数组同样是按照非递减顺序排列。注意,合并结果不要用函数返回,而是直接存储在 nums1 里。为了这一点,nums1 的长度被特意设成了 m + n,其中前 m 个位置是真正需要合并的元素,后面 n 个位置用 0 占位。
很多第一次刷的人会觉得这题“过于简单”——两个有序数组合并,不是基础到不能再基础的问题吗?但实际上题目里藏了三个约束:
- 第一,不能返回新数组,必须原地修改
nums1; - 第二,题目已经帮你预留好了尾部空间,这本身就是一个强烈暗示;
- 第三,两个数组全部有序,不利用这个条件就等于浪费了题目信息。
这三个点任何一个没有被照顾到,你写出来的解法都只能算“能过”,不能算“切题”。我之前在整理力扣刷题攻略时也反复提醒自己:Easy 题往往不是难在代码,而是难在能不能把题目给出的每个条件都用上。
1.2 面试中它为什么总被拿来当开场题
在“力扣热题100”里,88 的位置非常靠前,无论是暑期实习还是校招正式批,它经常被面试官拿来当作第一题。它作为开场题有天然优势:代码短、思路清晰、复杂度分析一目了然,可以快速筛掉完全不会写代码的人。但有意思的是,它又留给面试官很大的追问空间——合并有序数组背后站着的是一整棵知识树,往上是归并排序的 merge 步骤、合并 K 个有序链表,往旁边是寻找两个有序数组的中位数。如果热身题都讲不利索,后面更难的题基本没戏。
我去参加模拟面试和实际面试时观察到一个现象:能 AC 这道题的人不少,但很多人写的是“额外开一个数组,双指针归并完再拷贝回去”,或者干脆用 sort 一把梭。这两种写法在 OJ 上都能过,但面试官想要的通常不是它们。原因很简单——题目已经给了你 m + n 的数组长度,这就是在明示“原地合并”。你没用上这个暗示,说明你对空间复杂度的敏感度还不够。这也是我认为这道题值得单独写一篇的原因:它考的不是你会不会归并,而是你能不能读懂一个看似不起眼的数组长度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种解法的完整对比:从能过到最优
2.1 最偷懒的合并后排序:只配当暖场思路
先说说最直觉的方案。Python 里一行就能写完:
python复制nums1[:] = sorted(nums1[:m] + nums2)
思路是:把 nums1 的有效部分和 nums2 拼成一个新列表,整体排序,再原地写回 nums1。代码没有问题,力扣上也能过,但它的复杂度是 O((m+n)log(m+n)),额外空间是 O(m+n)。最大的问题是,这个写法完全没有用到“两个数组已经有序”这个天然条件,等于把题目给的有效信息全扔了,再去重新排序一遍。
面试时我一般建议把它当作暖场思路提一句:“我第一反应是把两个数组合起来排序,但这样复杂度偏高,没有利用有序性。我们再想想能不能做到线性时间。”这样一说,面试官会觉得你有思路层次,而不是一上来就闷头写最优解。直接写出 sort 版本也没什么不好,但如果这就是你的最终答案,八成会被追问一句“你觉得这个方案哪里不好”。
2.2 标准的双指针新数组:归并排序的 merge 原型
第二种方案是大多数人在学归并排序时就会写的标准双指针归并:新建一个长度为 m + n 的结果数组,然后用 i、j 两个指针分别指向 nums1 和 nums2 的开头,每次比较两个指针指向的元素,把更小的那个放进结果数组,对应指针右移。当一个数组遍历完后,把另一个数组剩余的片段全部拷进去。
这个思路的时间复杂度是 O(m+n),额外空间是 O(m+n),已经比排序方案优秀得多。而且这版代码本身就是归并排序中 merge 阶段的标准写法,非常有价值。很多人一上来就跳过这个方案直接背“从后往前”的代码,我反而觉得不太对——因为新数组双指针是更通用、更符合直觉的写法,你先把这一步写熟了,才能更清楚地从后往前版到底优化掉了什么东西。
面试时如果被问到“能不能不开新数组”,就从这一版自然地过渡到第三种方案。开新数组本身没有错,错的是你没有根据题目约束做调整。
2.3 从后往前的原地合并:这道题的正解
真正切中题意的解法是“三指针从后往前”。初始化三个指针:
i = m - 1,指向nums1有效区的最后一个元素;j = n - 1,指向nums2的最后一个元素;k = m + n - 1,指向nums1数组的最后一个位置,也就是预留给合并结果的末尾。
循环规则很简单:只要 i >= 0 且 j >= 0,就比较 nums1[i] 和 nums2[j],谁更大,就放到 nums1[k],然后对应的指针左移,k 也左移。循环结束后,如果 j 还大于等于 0,说明 nums2 有剩余元素,把 nums2 的前段直接拷到 nums1 开头;如果 i 还大于等于 0,则什么都不用做,因为 nums1 这一段本来就待在正确的位置上。
用一张复杂度对照表来描述三种方案的区别:
| 方案 | 时间复杂度 | 额外空间 | 核心缺陷 |
|---|---|---|---|
| 合并后排序 | O((m+n)log(m+n)) | O(m+n) | 没有利用有序性 |
| 双指针 + 新数组 | O(m+n) | O(m+n) | 多开数组,不是原地 |
| 三指针从后往前 | O(m+n) | O(1) | 需要理解覆盖逻辑 |
从后往前版达到了最优时空复杂度:时间 O(m+n),额外空间 O(1)。这才是题目真正想考的答案,也是我在各种力扣刷题攻略里反复见到的标准解。
3. 核心考点:为什么必须从后往前合并
3.1 从前往后的覆盖事故,用一个例子讲透
很多人能背下代码,但被问“为什么不能从前往后”就卡住了。我们用一个小例子把覆盖问题演示出来。
假设 nums1 = [1, 3, 5, 0, 0, 0],m = 3,有效数据是 [1, 3, 5];nums2 = [2, 4, 6],n = 3。如果按从前往后的方式原地合并,i = 0 指向 1,j = 0 指向 2,k = 0 指向放置位置:
- 第一轮:1 小于 2,把 1 放到
nums1[0],原值不变,没问题; - 第二轮:
i = 1(指向 3),j = 0(指向 2),2 小于 3,于是把 2 放到nums1[1]。问题来了:nums1[1]原本存的是 3,这个 3 还没来得及被比较,就被覆盖了; - 再往后推一轮,
i仍然指向下标 1,但读到的值已经不是原来的 3,而是刚写入的 2,整个归并顺序彻底乱掉。
这就是从前往后的致命伤:写入位置 k 会赶在读取位置 i 之前,把还没有参与比较的元素干掉。数据一丢,后面所有的比较都建立在错误的数据上。
3.2 从后往前的正确性:写入位置永远不会追上读取位置
倒过来想就通了。nums1 的后 n 个位置本来就是空的,专门用来放结果,所以我们可以先把当前两堆元素中的最大值放到最后一个槽位,再依次向前放置。因为每次放置的位置 k 一定严格大于当前两个指针 i 和 j 的位置,所以我们永远只会写入“已经被处理过的位置”或者“预留的空位”,不会碰到尚未参与比较的元素。这是整个算法正确性的基石。
走一遍官方示例:nums1 = [1, 2, 3, 0, 0, 0],nums2 = [2, 5, 6]。
- 初始:
i = 2(指向 3),j = 2(指向 6),k = 5; - 3 小于 6,把 6 放到
nums1[5],j变成 1,k变成 4; - 3 小于 5,把 5 放到
nums1[4],j变成 0,k变成 3; - 3 大于 2,把 3 放到
nums1[3],i变成 1,k变成 2; - 2 等于 2,走 else 分支,把
nums2的 2 放到nums1[2],j变成 -1,循环结束; - 最终结果是
[1, 2, 2, 3, 5, 6]。
相等时选 nums1 的 2 也一样,这道题对重复元素的顺序没有额外要求。核心逻辑就是每次把“当前最大的元素”放到最靠右的空位上,从后向前逐步铺满。
3.3 用货架类比建立肌肉记忆
如果指针逻辑一时转不过弯,我通常会用一个生活类比:想象一个货架,前段已经摆满商品,后段是空的。现在要把第二批商品按大小合并摆上去,最稳妥的办法当然不是从左边硬塞——那样必须频繁挪动已经摆好的东西,很容易碰倒。正确的做法是从两堆商品里每次都拿最大的一件,放到货架最靠右的空位,然后逐步往左填满。整个过程完全不用碰已经整理好的部分。
刷题时,我更愿意把这道题的类别叫作“逆向双指针”,而不是笼统的“双指针”。和它同类的还有 27. 移除元素、283. 移动零。这些题有一个共同特点:允许你原地修改数组,但正着处理有可能会破坏后面还没处理的数据。遇到这种信号,第一反应就应该是“要不要反着来”。
4. 参考实现与边界Case打磨
4.1 Python 和 Java 两种参考代码
先给一份 Python 的标准实现:
python复制class Solution:
def merge(self, nums1: List[int], m: int, nums2: List[int], n: int) -> None:
i, j, k = m - 1, n - 1, m + n - 1
while i >= 0 and j >= 0:
if nums1[i] > nums2[j]:
nums1[k] = nums1[i]
i -= 1
else:
nums1[k] = nums2[j]
j -= 1
k -= 1
# nums2 还有剩余,直接拷到 nums1 前面
while j >= 0:
nums1[k] = nums2[j]
j -= 1
k -= 1
再给一份 Java 版本,刷字节、阿里这类偏 Java 技术栈的岗位时用得上:
java复制class Solution {
public void merge(int[] nums1, int m, int[] nums2, int n) {
int i = m - 1, j = n - 1, k = m + n - 1;
while (i >= 0 && j >= 0) {
if (nums1[i] > nums2[j]) {
nums1[k--] = nums1[i--];
} else {
nums1[k--] = nums2[j--];
}
}
while (j >= 0) {
nums1[k--] = nums2[j--];
}
}
}
核心代码就这么多,已经算得上这道题的最短形态。刷题时没必要再加多余的变量或分支,保持这个结构最容易记忆和讲清。
4.2 边界条件逐一验证
一个看着简单的题,边界一多照样容易翻车。我把典型的边界情况整理成一张表:
| 情况 | 示例 | 预期行为 |
|---|---|---|
n = 0 |
nums1 = [1, 2, 3],nums2 = [] |
主循环进不去,拷贝循环也进不去,直接返回原数组 |
m = 0 |
nums1 = [0, 0, 0],nums2 = [1, 2, 3] |
主循环进不去,但 while j >= 0 会把 nums2 全体拷入,正确 |
| 两个数组全相等 | [1, 1, 1, 0, 0, 0] 与 [1, 1, 1] |
每次都走 else 分支,结果为 [1, 1, 1, 1, 1, 1] |
| 包含负数 | nums1 = [-3, -1, 0, 0],nums2 = [-5, -4] |
比较逻辑只比大小,与正负无关,正确 |
| 单个元素 | m = 1, n = 1 |
一轮比较加一轮收尾即可完成 |
还有一个小细节:nums1 的长度在题目里被保证等于 m + n,所以用 m + n - 1 作为 k 的初始值永远不会越界。我自己写代码时会顺手检查一下这个前提,因为它决定了 k 的合法性。
4.3 我踩过的三个坑与对应症状
第一个坑是忘了拷贝 nums2 的剩余部分。当 nums2 的最小元素比 nums1 的最小元素还小时,主循环结束后 j 还没有走到 -1,如果不处理 nums2 剩余元素,输出数组的前段会残留占位的 0。例如 nums1 = [4, 5, 6, 0, 0, 0],nums2 = [1, 2, 3],漏掉最后的 while j >= 0 拷贝,结果是 [4, 5, 6, 0, 2, 3] 这种看起来“半完成”的错误输出。
第二个坑是 k 递减的位置写错。有人习惯在每一轮循环开头就 k -= 1,结果放元素的槽位整体偏左一位,最后要么多出一个 0,要么直接下标越界。正确的做法是只在完成比较和赋值之后才让 k 递减。记住:k 是“这轮放完再往前走”,不是“我先让位再放东西”。
第三个坑是用 m 和 n 本身当作循环指针。比如写成 while (m > 0 && n > 0),在循环里不断 --m、--n,这样虽然可能凑出正确答案,但会把原始变量破坏掉,后面再用 m 时语义就乱了。我更建议单独声明 i、j、k,让 m 和 n 保持原样。面试官看你代码的时候,也会更愿意看到这种“参数不动、指针独立”的写法。
5. 面试追问与变体:这道题的“周边生态”
5.1 没有预留空间时该怎么合并
题目一变,解法跟着变。如果 nums1 没有尾部那 n 个空位,原地合并就无法进行(很多语言数组长度固定,想扩都扩不了),这时只能开一个新数组来接收结果。这个版本正是归并排序中经典的 merge 函数:新建一个长度为 m + n 的数组,双指针从头比较,小的放入结果,剩余元素整体拷贝。时间 O(m+n),空间 O(m+n)。
面试官问这个变体,实际上是想确认你有没有把“原地”和“非原地”两种写法都掌握。我先给出新数组版本,然后主动解释为什么原题更优——原题能省掉 O(m+n) 的额外空间,就是利用了 nums1 尾部的预留区。这样回答才显得有层次。
5.2 合并后找中位数:进阶的二分思路
顺着这道题往上走,最自然的变体是 LeetCode 4. 寻找两个正序数组的中位数。如果你先把两个数组合并,再取中位数,时间 O(m+n),这在笔试里其实已经能过。但面试官往往追问一句“能不能做到 O(log(m+n))”。答案是可以——因为两个数组都有序,可以用二分法在其中一个数组中找划分点,另一个数组的划分位置由总数推导出来,从而实现对数复杂度。
88 的 merge 在 4 题里只能算基础实现。能答出从后往前合并只是第一步,能联想到中位数题里的二分划分,说明你对同一数据结构的多个套路有全局认识。
5.3 从两个数组合并到 K 路归并
两个有序数组合并是特殊场景,推广到 K 个有序数组(或者合并 K 个升序链表,即力扣 23 题)才是更常见的大题。K 个序列最简单的合并策略是连续调用两个序列的 merge,做 K-1 次,总复杂度 O(K·N)。更优的做法是用一个最小堆,每次从 K 个序列的头部取最小值放入结果,复杂度能压到 O(N·logK),这里的 N 是元素总数。
面试时如果能从 88 直接聊到堆优化的 K 路归并,面试官会明显觉得你的知识是成体系的。哪怕最后不让你手写堆,你也证明了“简单题的背后我有延伸思考”。
5.4 这道题就是归并排序的 merge 步骤
我在刷题笔记里专门标过一条关联:88 题的从后往前合并,和归并排序的“治”阶段几乎同源。归并排序分两步——递归地把数组对半切,然后在回溯阶段把两个有序子数组按序合并。很多人背归并排序只记得递归框架,一写 merge 的指针就出问题,归根结底是对“合并两个有序数组”这个原子操作不够熟。
我的建议是:花半小时先把 88 的三指针版本练到闭眼能写,再去写归并排序,你会发现整个递归回溯过程变得非常顺。这也是为什么很多刷题攻略会把 88 放到链表合并、数组排序题的前面作为地基题。
6. 刷题方法论:从 88 提炼出的通用套路
6.1 三个识别信号
回头看这道题,它其实是一个很典型的算法模板。我总结了三个识别信号,遇到类似题目可以直接套用:
- 看到“两个有序序列合并”,第一反应就是双指针,时间成本可以压到线性;
- 看到“原地修改”或“尾部预留”,立刻想是否需要逆向填充;
- 看到“剩余元素拷贝”,一定不要漏,谁剩拷谁,但如果剩下的是
nums1则不需要动。
这三个信号不仅适用于 88,也适用于 27. 移除元素、283. 移动零、977. 有序数组的平方。尤其是 977,它也是用两个指针从两端往中间填平方后的最大值,和 88 的从后往前是同一个思维模型。
6.2 三遍刷题法,把它焊死在脑子里
我刷 88 用的方法是“三遍刷题法”,对这类高频题特别有效:
第一遍,不看任何题解,先写一个能 AC 的版本,哪怕是合并后排序也行。写完算一下复杂度,自己问一句“哪里浪费了”。
第二遍,理解从后往前的原理之后,关掉题解重新写一遍,同时准备好回答“为什么不能从前往后”。
第三遍,过两天再刷一次。这一次要求自己边写边说,把每一步的理由讲出口,并且能准确预判边界条件下会发生什么。
如果三遍都能做到,这道题基本就不会忘了。面试前我还会快速过一遍 88,它经常是技术面第一题,答得干脆利落能给后面的题目攒下印象分。
6.3 面试前快速过这道题的建议
我自己面试前有一个固定动作:用一分钟在白板上写出 88 的 Java 版本,然后模拟回答“为什么从后往前”这个问题。听起来很基础,但这个动作能帮我快速进入状态,保持指针和边界条件的手感。
给别人讲题的时候,我也反复强调:这道题真正的考点不是“能不能合并”,而是“能不能在 O(1) 空间下完成”。所以拿到任何一道题,先别急着写代码,先判断三件事——能不能用额外空间、时间复杂度有没有要求、原始数据允不允许覆盖。这三个问题的答案,直接决定了你该从哪个方向思考:能开新数组,就规规矩矩双指针;不能开,就想想从后往前;既不能开又要最优复杂度,那就大概率是二分或者堆。这套判断习惯,远比背会一道题更有价值。
