别小看 LeetCode 88 这道“简单题”,我见过不少人在面试里栽在它手上。明明思路说得很顺,一写代码就漏掉某个边界条件;或者代码能跑通,但面试官追问一句“为什么从后往前填”就答不上来。这道题表面上是“合并两个有序数组”,实际上考察的是对数组原地操作的理解、对边界条件的敏感度,以及能不能把算法思路解释得清清楚楚。
这篇文章我会从题目拆解开始,把三类主流解法讲透,再重点分析那些大多数人意识不到的“工程陷阱”——这些陷阱才会真正影响你在面试中的表现,以及在日常业务代码里写类似逻辑时的正确性。不管你是准备面试的求职者,还是想加深数组操作功底的开发者,都值得花十分钟看完。
1. 题目到底在问什么——先别急着写代码
1.1 还原题目原貌,别忽略“隐藏条件”
原题描述很简短:给定两个有序整数数组nums1和nums2,将nums2合并到nums1中,使nums1成为一个有序数组。但关键在后面这句:
假设
nums1的空间大小等于m + n,其中m是nums1中实际元素的数量,n是nums2中实际元素的数量。
注意这个假设。它意味着nums1的尾部提前预留了n个空位,而这些空位的值是什么无所谓(通常实现里是 0,但不应该依赖这个)。很多人在 LeetCode 上做题时,直接把nums1声明成[1, 2, 3, 0, 0, 0],习惯性地以为后面的 0 就是“空位”,但在真实项目中,数组尾部的未使用空间完全可能是脏数据。这个认知差别,恰恰是后面要讲的第一个工程陷阱的来源。
还有一个隐藏条件容易被忽略:题目要求把结果直接写回nums1,也就是原地合并。如果允许新建一个数组,这道题就变成最简单的归并流程,一点难度都没有。原地修改才是真正要考验的看点,也是面试官追问的重点。
1.2 为什么这道题常考:三个层面的考察点
一道题能被反复用来面试,一定是因为它能同时考察几个层次的功力。
第一层是“会不会”:能不能写出能跑通的代码,最基本的双指针归并解法,属于数据结构课程的入门练习。
第二层是“好不好”:能不能写出 O(m+n) 时间、O(1) 空间的解法。很多初学者第一反应是先把nums2追加到nums1尾部,再调用一次全局排序。这在 LeetCode 上也能通过,因为总时间复杂度是 O((m+n) log(m+n)),对于题目给定的数据规模来说足够快。但面试官看到这种解法,心里会打一个问号:你是真的理解了有序数组的归并本质,还是仅仅靠一个排序函数蒙混过关?在真实的大规模数据场景里,这种多余的对数复杂度很可能成为性能瓶颈。
第三层是“稳不稳”:边界条件能不能一次覆盖完整。m = 0时怎么办?n = 0时怎么办?nums1的有效元素已经全部搬完但nums2还剩很多时,怎么办?这些分支处理得干不干净,直接反映出平时写代码有没有养成严谨的习惯。
2. 三种解法的演进——从低效暴力到最优解
2.1 暴力解法:先拼接再排序,为什么仅限实验室
先看一下最直观的解法:把nums2的所有元素复制到nums1的尾部,然后对nums1做一次整体排序。用 Java 写大概是这样:
public void merge(int[] nums1, int m, int[] nums2, int n) { for (int i = 0; i < n; i++) { nums1[m + i] = nums2[i]; } Arrays.sort(nums1); }这段代码在 LeetCode 上能通过所有测试用例,因为题目数据规模不大,m + n最多也就几百。但它的致命问题在于复杂度:排序阶段消耗 O((m+n) log(m+n)) 的时间。如果m和n都接近百万量级,这种写法会明显慢于线性解法。
更重要的是,这种解法完全没有利用两个数组“已经分别有序”这个前提条件。你等于把有效信息全部丢掉,重新做了一次无序排序。就好比手上有两副已经排好序的扑克牌,你偏要把它们混在一起重新理一遍,而不是用归并的方式一次抽牌搞定。面试官会认为你缺乏对数据性质的敏感度。
2.2 开辟新数组的正向双指针:思路清晰但违背题意
既然两个数组都是有序的,最自然的归并方法就是:另开一个长度为m + n的新数组,用两个指针分别从nums1和nums2头部开始,谁小就取谁,最后把新数组内容拷贝回nums1。
public void merge(int[] nums1, int m, int[] nums2, int n) { int[] temp = new int[m + n]; int p1 = 0, p2 = 0, p = 0; while (p1 < m && p2 < n) { if (nums1[p1] <= nums2[p2]) { temp[p++] = nums1[p1++]; } else { temp[p++] = nums2[p2++]; } } while (p1 < m) { temp[p++] = nums1[p1++]; } while (p2 < n) { temp[p++] = nums2[p2++]; } System.arraycopy(temp, 0, nums1, 0, m + n); }这段代码在逻辑上完全没有问题,作为一种“热身写法”或笔试草稿,它能帮你快速梳理归并流程。但注意题目要求原地修改nums1,你额外申请了O(n)空间,严格来说并不是最优解。有些面试官会容忍这种写法,但更多面试官会追问:“能不能不申请额外空间?”如果你答不上来,印象分会打折扣。
这个解法的最大价值在于帮助理解归并的本质,但它在真实工程里也有一个意义:当原数组空间不足时,你没有办法原地合并,只能另开空间。这就引出了下面最优解的思考角度——既然题目保证了nums1有足够空间,那为什么我们不好好利用它呢?
2.3 最优解:从后往前的双指针,时间和空间的双重最优
真正的最优解决定了思考方向:不从头开始比,而从尾部开始比。nums1的有效元素集中在前面m个位置,后面n个位置是空的。如果我们从尾部往前填,就不会覆盖nums1中还未来得及处理的元素。
代码非常简洁:
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 (p2 >= 0) { if (p1 >= 0 && nums1[p1] > nums2[p2]) { nums1[p--] = nums1[p1--]; } else { nums1[p--] = nums2[p2--]; } } }核心逻辑只有七行。p1指向nums1有效部分的最后一个元素,p2指向nums2的最后一个元素,p指向合并后数组的最后一个位置。每一次循环都从两个数组的尾部挑一个更大的放到nums1的尾部。
外层循环条件写成p2 >= 0而不是p1 >= 0 || p2 >= 0是有讲究的。如果nums2已经全部搬完,那么nums1剩余的前半部分本来就处于正确位置,无需再动。这比常规的“两个 while 收尾”写法更简洁,也少处理一个分支。但要注意,这个写法依赖一个前提:p1有可能变成负数,所以if条件里必须显式检查p1 >= 0。这是我见过很多人在七行写法里最容易漏掉的点,漏掉之后要么数组越界,要么结果错误,现场调试会很狼狈。
让我用一个简单例子走一遍流程。假设nums1 = [1, 3, 5, 0, 0, 0],m = 3;nums2 = [2, 4, 6],n = 3。初始p1 = 2,p2 = 2,p = 5。第一次比较nums1[2] = 5和nums2[2] = 6,6 更大,所以nums1[5] = 6,p2变为 1,p变为 4。第二次比较 5 和 4,5 更大,nums1[4] = 5,p1变为 1。第三次比较 3 和 4,4 更大,nums1[3] = 4。之后比较 3 和 2,3 更大,nums1[2] = 3。接着p1变为 0,比较 1 和 2,2 更大,nums1[1] = 2。最后p2变为 0,比较 1 和 2 的流程已经结束,p2变为 -1,循环退出。最终数组为[1, 2, 3, 4, 5, 6],完全正确。
三种解法的复杂度和适用场景对比:
| 解法 | 时间复杂度 | 额外空间 | 是否原地 | 工程建议 |
|---|---|---|---|---|
| 拼接后排序 | O((m+n) log(m+n)) | O(1)(依赖排序实现) | 是 | 只适合数据量极小或一次性脚本 |
| 新数组双指针 | O(m+n) | O(m+n) | 否 | 适合理解归并思路,或允许额外空间时 |
| 从后往前双指针 | O(m+n) | O(1) | 是 | 面试和工程首选 |
3. 代码逐行解析与边界条件控制
3.1 指针的含义和为什么外层条件只用p2 >= 0
很多初学者看到p = m + n - 1会疑惑:为什么不是nums1.length - 1?原因是m和n代表“有效元素数量”,而nums1.length是数组的物理长度。题目保证两者相等,但真实工程里可能不相等。使用m + n - 1能让你明确表达“我只关心有效数据区域”,而不是默认整个数组都装满。
p1 = m - 1指向nums1有效尾部,p2 = n - 1指向nums2尾部。p指向合并后数组的最后一个写入位置。从语义上看,p的位置恰好是p1和p2位置的并集减去起点。当p1或p2往前移动时,p也会往前移动,始终保持“下一个要写入的位置”是正确的。
外层循环为什么只判断p2 >= 0?因为当nums2的元素全部搬完时,nums1中剩下的元素已经在正确的位置上,不需要再做任何操作。举个例子:nums1 = [4, 5, 6, 0, 0],nums2 = [1, 2]。合并过程中,nums2的两个元素会被依次放到数组最前面,nums1原有的 4、5、6 依次被搬到后面,最后nums1前半段是[1, 2],后半段是[4, 5, 6],整体有序。如果这时候继续循环处理p1剩下的元素,反而会把已经放好的元素打乱。
3.2 循环里的那个if为什么必须加p1 >= 0
这是整道题里最容易出错、也最容易被面试官抓住的一点。当p2还没搬完而p1已经变成 -1 时,说明nums1的有效元素已经全部被搬走了,剩下的位置应该全部填入nums2剩余的元素。但如果你不加p1 >= 0判断,直接访问nums1[p1],就会触发数组越界异常。
我们来看一个触发场景:nums1 = [1, 2, 3, 0, 0, 0],nums2 = [4, 5, 6],m = 3,n = 3。前三次循环中,nums2的三个元素都比nums1当前尾部的元素大,所以每次都是nums2元素被填入尾部,p2从 2 减到 -1,p1始终保持 2 不变。这种情况下p1永远不会变成 -1,安全。但如果反过来:nums1 = [10, 11, 12, 0, 0, 0],nums2 = [1, 2, 3],那么前三次循环每次都是nums1的元素被搬到尾部,p1从 2 一路减到 -1。此时p2还有剩余,下一次进入循环时,p1 = -1,nums1[p1]就越界了。正确的行为是直接走else分支,把nums2剩余元素依次填入。
简化记忆法:只要p1还没用完而且当前nums1元素更大,就优先搬nums1;一旦p1用完,剩下的所有位置无脑从nums2补。加不加p1 >= 0,就差这一行,结果可能是完全跑不起来的代码和一次通过的区别。
3.3 三个特殊输入场景的测试清单
写完之后,至少用下面这些场景自测一遍:
m = 0, n = 3:nums1全是空位,应该把nums2原样搬进去。此时p1 = -1,循环完全依赖p2,代码应该直接走else分支填完所有元素。测试时确保没有数组越界,最终结果等于nums2。n = 0, m = 3:nums2为空,外层循环一次都不执行,nums1保持不变。这种场景在 LeetCode 里可能不会专门出现,但真实业务的入参校验阶段用得上。- 两个数组存在大量重复元素,比如
nums1 = [1, 1, 2],nums2 = [1, 2, 3]。注意比较逻辑用>还是>=会对稳定性和结果顺序产生细微影响。使用>时,相同元素优先取nums2的;使用>=时,相同元素优先取nums1的。由于两个数组合并后只要求有序,不要求区分来源,相等时取哪一个都不影响有序性。但从稳定性的角度看,如果nums1表示旧数据、nums2表示新数据,有时候你会希望相同元素保留nums1的原始顺序——这时候应该用>=,让nums1的元素优先被搬运。
4. 简单题背后的工程陷阱——从 LeetCode 到真实业务
4.1 陷阱一:把有效长度和数组容量混为一谈
LeetCode 的题目输入已经把m和n明确传递给你了,但真实工程里经常没有这种好心。你可能拿到的是一个大数组,里面只有前m个位置是有效数据,后面全是未初始化或被置零的垃圾值。如果直接把nums1.length当成有效长度去合并,结果会出现大量不该存在的 0 或脏数据。
我处理过一个日志合并场景:系统 A 导出当天日志到一个固定大小的缓冲区,后面没有填满的部分用\0填充。合并时因为代码里写的是nums1.length而不是实际日志条数,导致把一整片空字节当成真实数据参与归并,最后产出的文件里到处是乱码和空行。排查花了大半天,根因就是有效长度和物理容量混淆。
工程上的建议是:合并函数永远显式接收有效长度参数,不依赖数组自带的length属性。如果语言支持切片或子数组视图,优先用视图对象传递。
4.2 陷阱二:“原地合并”到底意味着什么
看题时很多人不以为意,但真正写代码时,原地操作引发的问题很多。最常见的是覆盖正在使用的数据。如果你从前往后填,nums1的前几个元素很容易被nums2的较小元素覆盖,而nums1尾部还没有被搬走的元素会丢失。这就是为什么最优解必须从后往前填。
从更广义的工程视角来看,“原地操作”意味着你不一定能依赖语言标准库的便捷功能。比如在 Python 里,如果你把nums1当作 list,直接在中间插入元素,默认行为是移动后续元素,可能带来 O(m+n) 的额外复制开销,表现看起来很正确,实际上隐藏了成本。在 C/C++ 中,原地操作还涉及到内存布局、指针移动等底层的正确性。很多人只会在刷题时想到这些问题,但真实项目里对大数据结构做原地归并、原地去重、原地重排的需求并不少见,搞不好就会造成隐性数据丢失。
4.3 陷阱三:归并过程中的稳定性和优先级
归并排序在很多语言里都是稳定排序,这意味着相等元素的相对顺序会保持不变。LeetCode 88 这道题没有明确要求稳定性,但在某些业务场景里,稳定性是硬需求。
举一个真实案例:某个交易系统的账单合并模块,需要把“线上支付记录”和“线下支付记录”按时间排序,合并成一条时间线。线上记录和线下记录可能在同一秒发生,这时候产品希望线上记录排在前面。如果归并时用>而不是>=,在时间相等的情况下会优先保留线下记录,顺序就反了。这就是比较运算符的选择带来的行为差异,在算法题里无伤大雅,在真实产品里却会影响最终展示顺序甚至后续对账逻辑。
4.4 陷阱四:极端规模下的空间与性能权衡
从后往前的双指针解法在时空上都最优,但工程上有一个隐含假设:nums1的实际容量确实足够容纳m + n个元素。如果容量不够,原地合并就无从谈起,必须申请新数组。这时候你可能需要写一个类似“按需扩容”的逻辑。很多程序员在刷题时完全不会考虑扩容问题,但真实工程中数组的初始容量往往是估算出来的,比如按历史数据量的 1.5 倍预留。如果估算偏少,合并时会遇到空间不足,需要触发扩容和搬移。
假设nums1扩容策略是倍增,那么合并时如果触发一次扩容,时间开销会额外增加 O(m+n) 的拷贝成本,虽然均摊下来仍是线性,但峰值延迟会明显变高。对于高并发接口来说,这种偶发抖动可能会触发超时告警。更好的做法是在合并之前预先精确计算所需容量,一次分配完毕,避免中途扩容。这就是算法题和工程实现之间最常见的“最后一公里”差异。
5. 常见问题与排查技巧实录
5.1 高频错误对照表
| 错误类型 | 典型表现 | 根本原因 | 解决方式 |
|---|---|---|---|
| 数组越界异常 | 运行时报ArrayIndexOutOfBoundsException | p1或p2指针移到 -1 后继续访问 | 访问前先检查指针是否 >= 0 |
| 合并后包含多余 0 | 结果数组尾部出现不该有的 0 | 把nums1.length当有效长度 | 只用m和n控制循环和写入位置 |
| 从前往后填写导致覆盖 | 结果数组中部分原始数据丢失 | 没有利用尾部空位,正向覆盖了未处理元素 | 改成从后往前写入 |
| 空间不足异常 | 程序崩溃或数据截断 | 未确认nums1容量是否满足m + n | 合并前强制校验容量,必要时扩容 |
| 排序后结果错乱 | 结果前半段正确、后半段无序 | 尾部残留未覆盖的旧数据混入结果 | 确保填充新元素时覆盖所有尾部位置 |
| 相等元素顺序不一致 | 合并顺序偶发不稳定 | 使用>和>=的选择影响同等值优先级 | 根据业务稳定性需求选择合适的比较符号 |
| 无符号整型下溢 | C/C++ 中p1--变成极大值 | 无符号整型在 0 减 1 时回绕 | 改用有符号整型,或把循环条件改为先判断再移动 |
5.2 现场排查经验:三步定位错误
如果合并结果有问题,我建议按这个顺序排查:第一步,打印三个指针的初始值和每次循环后的变化。不要小看这条,很多时候问题就出在指针更新位置上。我曾经调试一个类似逻辑,调了半天发现是因为p的更新放在continue之后跳过了,导致每次循环写入同一个位置。第二步,构造最小复现用例。把m和n都调成 1,比如nums1 = [2, 0],nums2 = [1],手动模拟一遍循环,很容易发现覆盖问题。第三步,检查循环退出条件。很多人把条件写成p1 >= 0 && p2 >= 0,这种情况下会漏掉nums2剩余元素,属于典型的“看起来对但实际不对”的写法。
5.3 变体题与延伸思考:刷一道题吃透一个知识块
LeetCode 88 最常见的变体是“合并两个有序链表”,解法思路类似,但链表没有被动覆盖的问题,只需要调整指针指向。处理方式从“数组尾部逆向写入”变成“头节点往前拼接”。另一个高频变体是“合并 K 个有序链表或数组”,可以用优先级队列,或者两两归并(分治策略)。如果能理解 88 题的归并本质,再去看这些变体,会发现核心都是“比较两端最小值/最大值,按序取出”。还有一个经典的延伸是“寻找两个有序数组的中位数”,虽然它披着二分的马甲,但同样基于有序数组的归并思想,只是要求更高效的时间复杂度。
从刷题策略的角度,一道题不是做出来就完事,而是要整理出它的“母题属性”:凡是涉及“有序结构合并”的题目,基本都能和 88 题的归并思路对应上。多花十分钟做这种归纳,远比闷头刷二十道孤立题目有效。
我个人在做这道题时还有一个小习惯:不管用什么语言写,都会在本地 IDE 里跑一遍丑陋的打印调试。刷题平台上的测试用例往往比较友好,不会刻意刁难边界。真实工程里的数据才不管你有没有边界条件,所以每道数组题我至少会自测空数组、单元素数组、全反序数组、全部相等数组四种形态。LeetCode 88 这种“简单题”在这些极端输入下暴露的问题,往往比友好用例下暴露的更多更深。
最后再分享一个思路:这道题从前往后改从后往前,本质上是“能否利用已有空间的尾部空闲”。这个思路不止用于数组归并,在处理缓存淘汰、内存池分配策略时也经常出现。理解它,你收获的不仅是一道题的答案,而是一种工程直觉。 ## 1. 思路被卡住时,一定是某个环节的理解出了问题
很多人在 LeetCode 上做到“合并两个有序数组”这道题时,第一反应是“这有什么难的”。但真正上手写代码,尤其是要求不能使用额外数组空间、必须在原数组上完成合并的时候,最容易卡住。我自己第一次做这道题的时候,也想当然地认为用一个小技巧从前往后一个个比较就完事了,结果调了半天才发现:如果你从前往后覆盖,nums1里还没被比较过的元素会被提前覆盖掉,数据直接丢了。
这道题的核心考点其实有两个:
- 数组是连续内存空间,覆盖写入是常态,但怎么做到既覆盖又不丢数据。
- 如何把两个已经有序的数组合并成一个有序数组,同时时间复杂度和空间复杂度都控制在最优。
如果你也在这道题上栽过跟头,或者正愁怎么给面试官讲清楚思路,这篇文章把我的完整思考过程、正确解法的推导、边界条件的处理,以及我实际调试中踩过的坑,一次说清楚。不管你是刚开始刷题的新手,还是准备面试想巩固基本功的开发者,应该都能从中得到一些启发。
2. 正确解法的推导过程——从暴力法怎么一步步优化到双指针
2.1 先想清楚最朴素的方案:合并后排序
最简单的思路:直接把nums2的元素追加到nums1的末尾,然后对nums1整体排序。逻辑上完全正确,代码也短,但问题在于时间复杂度。
把nums2的n个元素搬进nums1,需要 O(n)。再对长度为 m+n 的数组排序,如果用快速排序或归并排序,时间复杂度是 O((m+n) log(m+n))。看起来也不差,但面试官想要的显然不是在已经有序的两个数组上还用全量排序。
这里要意识到一个关键点:数组局部有序这个信息你完全没有利用起来。如果你只用排序解决,等于无视了题目里“两个数组分别有序”这个前提条件,面试官很容易追一句“能不能做到 O(m+n)”。到这一步,如果你答不上来,这道题基本就减分了。
2.2 利用有序性:开辟额外数组的双指针归并
既然两个数组都是有序的,经典的归并思路就出现了:开一个新的数组temp,长度 m+n,然后用两个指针p1和p2分别指向nums1和nums2的起始位置,比较两个指针所指元素的大小,把较小的放入temp,然后移动对应指针。哪边先遍历完,就把另一边剩下的元素全部拷贝进temp。最后再把temp拷贝回nums1。
时间复杂度是 O(m+n),空间复杂度是 O(m+n)。这个方案逻辑清晰,正确性容易验证,很多教科书上的归并排序合并步骤就是这样的。
但问题又来了:题目要求不使用额外数组空间,最好是原地修改nums1。你开了一个temp,严格来说不符合题目的原意。有些面试官会允许你这样做,但既然题目特意强调了空间复杂度,最优解就应该追求 O(1) 额外空间。
2.3 关键转折:既然要从头开始覆盖会丢数据,那就从尾开始
从头开始比较并写入nums1的前面位置,会覆盖掉nums1中还没参与比较的元素。那如果我们反过来,从两个数组的末尾开始比较,把较大的元素放到nums1的末尾呢?
这就是整个解法推导中最重要的一步:倒序遍历。nums1的末尾是预留出来的空位,长度为 n,刚好够放nums2的全部元素。所以从后往前填,不会覆盖任何还没处理的元素。每次从nums1的有效尾部和nums2的尾部各取一个元素,比较大的那个放在nums1当前从后往前数的下一个位置,直到nums2的元素全部放完。
为什么不是等到nums1的元素全部放完?因为nums1的前 m 个元素本身就在正确的位置上,如果nums2已经全部搬进nums1,剩下的nums1原有元素就不需要再动了。用大白话说:nums2搬完了,合并就完成了,剩下的元素本来就是有序的,留在原地即可。
这就是双指针 + 倒序遍历的解法闭环,时间复杂度 O(m+n),额外空间 O(1)。
3. 可落地方案:代码实现、边界条件与复杂度分析
3.1 一份可以直接用的标准实现
我用 Java 写一版最清晰、最容易向面试官解释的实现,你本地跑 LeetCode 88 可以直接用:
public void merge(int[] nums1, int m, int[] nums2, int n) { // p1 指向 nums1 有效元素的最后一个位置 int p1 = m - 1; // p2 指向 nums2 的最后一个位置 int p2 = n - 1; // p 指向 nums1 的最后一个位置(整个数组尾部) int p = m + n - 1; // 从后往前比较,把较大的元素放到 nums1 的尾部 while (p2 >= 0) { if (p1 >= 0 && nums1[p1] > nums2[p2]) { nums1[p] = nums1[p1]; p1--; } else { nums1[p] = nums2[p2]; p2--; } p--; } }这段代码最核心的退出条件只有一个:while (p2 >= 0)。你可能会问,为什么不用同时判断p1 >= 0?因为正如前面分析的,nums1前 m 个元素本身就在最终位置上,只要nums2全部搬完,合并就已经完成了,剩下没动过的nums1头部元素不需要再处理。这个写法比同时判断两个指针的写法更精炼,也更好解释。
3.2 逐行讲解,面试时你要这样解释
先看三个指针的初始化。p1 = m - 1,表示nums1中最后一个有效元素的位置。注意这里不是nums1.length - 1,因为nums1的长度是 m+n,后面 n 个位置是空的。p2 = n - 1同理。p = m + n - 1,是整个nums1数组的最后一个位置,也是合并后最大元素应该放的位置。
进入循环后,比较nums1[p1]和nums2[p2]。如果nums1[p1]更大,就把它放到nums1[p],然后p1左移。否则把nums2[p2]放到nums1[p],p2左移。每次放置完,p也要左移,为下一个较大元素腾位置。
这里有个关键细节:当p1 < 0时,意味着nums1的有效元素已经全部被处理过了,剩下要处理的只有nums2里的元素。此时会走else分支,把nums2剩下的元素依次放入nums1的空位。这就是为什么if条件里必须写p1 >= 0,不写的话,p1 = -1时会直接访问nums1[-1],数组越界。
3.3 边界条件逐项验证
我刷题时养成了习惯:写完代码先把边界条件在纸上列一遍,再上机跑。
- 情况一:
n = 0,即nums2为空。while (p2 >= 0)一次都不会执行,直接返回。正确,合并结果就是nums1本身。 - 情况二:
m = 0,即nums1没有有效元素。此时p1 = -1,进入循环后只走else分支,把nums2从后往前一个个搬进nums1。最后nums1刚好等于nums2的完整内容。正确。 - 情况三:
nums1的最大元素小于等于nums2的所有元素。循环里每次都会先把nums2的元素放到末尾,等p2 < 0后退出,nums1原封不动保留在前面。正确。 - 情况四:两个数组都只有一个元素,且
nums2[0] < nums1[0]。p1 = 0,p2 = 0,p = 1。比较后发现nums2[0]更小,放到nums1[1],然后p2 = -1,退出循环,nums1[0]保留原值。最终nums1 = [nums1[0], nums2[0]],是有序的。正确。
这些边界条件在面试中都是追问点,你能主动列出来并给出正确答案,会明显加分。
3.4 复杂度分析
时间复杂度:循环最多执行 m+n 次,因为每次迭代都会把一个元素放到最终位置。所以是 O(m+n)。
空间复杂度:只用了三个整型变量,没有额外数组,因此是 O(1)。
这里有一点值得多说,很多人刷题时会忽略“原地操作”的具体含义。如果你开了一个temp = new int[m + n],表面看起来代码很简单,但空间复杂度是 O(m+n),在面试场景下不是最优解。如果能用 O(1) 空间完成,就不要用 O(m+n)。
4. 实际调试中避坑经验:从错误解法到正确解法的真实复盘
4.1 坑一:从前往后比较导致数据覆盖
我第一次写的时候用的是从前往后的正序双指针。逻辑看起来天衣无缝:两个指针指向两个数组的起始位置,比较后把较小的放入nums1当前位置。但运行后结果完全不对。
举个例子:nums1 = [1, 2, 3, 0, 0, 0],nums2 = [2, 5, 6],m = 3,n = 3。如果从前往后比较,第一步比较 1 和 2,把 1 放到nums1[0],没毛病。第二步比较 2 和 2,假设取nums2的 2 放到nums1[1],nums1[1]原来的元素 2 就被覆盖了。而这个 2 是nums1还没参与比较的元素,丢了。所以正序比较时,你放进nums1前面的元素,很可能会覆盖掉后面还没被处理的nums1原元素。
这就是为什么必须用倒序。想明白这个“覆盖”的本质,你就理解这道题的精髓了。
4.2 坑二:退出条件写成p1 >= 0 && p2 >= 0
这个错误很隐蔽。如果退出条件写成两个指针都必须大于等于 0,那么当p1 < 0但p2 >= 0时,循环就退出了,导致nums2剩下的元素没有被搬进nums1。
我当时就是这样,测试用例偏偏是nums2的元素普遍比nums1小,导致p1先变成 -1,结果nums2还剩了一堆元素没放进去。输出结果乱七八糟,而且不好调试,因为数组前面的部分看起来是对的,只有中后段乱掉。
正确的退出条件只需要p2 >= 0。原因很简单:nums2是必须全部搬进nums1的;nums1原有的元素则不需要搬完,因为剩下的自然有序留在原地即可。
4.3 坑三:忽略了nums1扩容后长度和 m 的关系
以前我还见过有人这么写:
int p = nums1.length - 1;这在 LeetCode 上是对的,因为nums1的长度恰好等于 m+n。但在真实工程里可不一定:你拿到的nums1可能是一个预分配的数组,容量比 m+n 还大。这时用nums1.length - 1做指针初始化,会在数组尾部留下多余的空位,结果最后输出数组末尾有无效的 0 或垃圾值。
正确写法是int p = m + n - 1,只关注有效数据区域,不依赖数组的物理长度。这也是我在实际开发里踩过的一个坑——不是 LeetCode 上会暴露的,但真实业务代码里非常常见。
4.4 一段我调试用的本地测试代码
在 LeetCode 上调试不方便看中间状态,我习惯在本地写一个小的测试方法,打印每次循环结束后的数组内容:
public static void main(String[] args) { int[] nums1 = new int[]{1, 2, 3, 0, 0, 0}; int m = 3; int[] nums2 = new int[]{2, 5, 6}; int n = 3; merge(nums1, m, nums2, n); System.out.println(Arrays.toString(nums1)); }你可以在merge方法里的循环里加一行输出,比如:
System.out.println("p1=" + p1 + ", p2=" + p2 + ", p=" + p + ", nums1=" + Arrays.toString(nums1));这样可以看到每一次放置后数组的变化。我调试的时候靠这个输出来确认倒序有没有放错位置,很快就能发现问题在哪。
5. 常见问题速查表:如果你也在这些地方卡住
| 症状 | 可能原因 | 解决方式 |
|---|---|---|
运行结果中nums1后半段出现垃圾值 | 用了nums1.length - 1而不是m + n - 1 | 初始化指针时严格用m + n - 1 |
结果中nums2的部分元素丢失 | 退出条件写成了p1 >= 0 && p2 >= 0 | 改为while (p2 >= 0) |
| 抛出数组越界异常 | nums1[p1]在p1为负数时被访问 | if条件里必须先判断p1 >= 0 |
| 时间复杂度超时 | 用的是先合并再排序的 O((m+n) log(m+n)) 方案 | 改成双指针倒序,O(m+n) |
| 正序覆盖导致元素丢失 | 从前往后放元素覆盖了未处理的nums1 | 改成从后往前放元素 |
边界条件m=0时出错 | 忘记处理nums1为空的情况 | 让循环只依赖p2,m=0 时自动进入 else 分支 |
6. 写在最后:这道题给我的真实启示
说实话,LeetCode 88 是我见过“最简单也是最容易翻车”的数组题之一。它不像动态规划那样需要大量思维铺垫,也不像图论那样需要记忆复杂的模板,但恰恰因为“简单”,很多人反而最容易在细节上翻车。覆盖写、指针初始化、退出条件,这三个小地方任何一处出问题,整体结果就错得离谱。
我个人做完这道题后,最大的体会是:很多算法题的正确解法和错误解法之间的差距,不是智力差距,而是“是否意识到数据覆盖问题”的经验差距。一旦你想明白“从后往前可以有效规避覆盖”,这道题就彻底拿下了。
对正在刷题的朋友给个建议:不要只满足于通过 LeetCode 的测试用例。你可以试着把这道题的标准实现改成等价的 C、Python 版本,或者自己构造几个边界测试,比如所有元素相等、nums1为空、nums2只有一个元素等,跑一遍看看结果是否符合预期。这种动手验证的过程,比刷十道新的简单题都有用。