别小看 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(mn) 时间、O(1) 空间的解法。很多初学者第一反应是先把nums2追加到nums1尾部再调用一次全局排序。这在 LeetCode 上也能通过因为总时间复杂度是 O((mn) log(mn))对于题目给定的数据规模来说足够快。但面试官看到这种解法心里会打一个问号你是真的理解了有序数组的归并本质还是仅仅靠一个排序函数蒙混过关在真实的大规模数据场景里这种多余的对数复杂度很可能成为性能瓶颈。第三层是“稳不稳”边界条件能不能一次覆盖完整。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((mn) log(mn)) 的时间。如果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 3nums2 [2, 4, 6]n 3。初始p1 2p2 2p 5。第一次比较nums1[2] 5和nums2[2] 66 更大所以nums1[5] 6p2变为 1p变为 4。第二次比较 5 和 45 更大nums1[4] 5p1变为 1。第三次比较 3 和 44 更大nums1[3] 4。之后比较 3 和 23 更大nums1[2] 3。接着p1变为 0比较 1 和 22 更大nums1[1] 2。最后p2变为 0比较 1 和 2 的流程已经结束p2变为 -1循环退出。最终数组为[1, 2, 3, 4, 5, 6]完全正确。三种解法的复杂度和适用场景对比解法时间复杂度额外空间是否原地工程建议拼接后排序O((mn) log(mn))O(1)依赖排序实现是只适合数据量极小或一次性脚本新数组双指针O(mn)O(mn)否适合理解归并思路或允许额外空间时从后往前双指针O(mn)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 3n 3。前三次循环中nums2的三个元素都比nums1当前尾部的元素大所以每次都是nums2元素被填入尾部p2从 2 减到 -1p1始终保持 2 不变。这种情况下p1永远不会变成 -1安全。但如果反过来nums1 [10, 11, 12, 0, 0, 0]nums2 [1, 2, 3]那么前三次循环每次都是nums1的元素被搬到尾部p1从 2 一路减到 -1。此时p2还有剩余下一次进入循环时p1 -1nums1[p1]就越界了。正确的行为是直接走else分支把nums2剩余元素依次填入。简化记忆法只要p1还没用完而且当前nums1元素更大就优先搬nums1一旦p1用完剩下的所有位置无脑从nums2补。加不加p1 0就差这一行结果可能是完全跑不起来的代码和一次通过的区别。3.3 三个特殊输入场景的测试清单写完之后至少用下面这些场景自测一遍m 0, n 3nums1全是空位应该把nums2原样搬进去。此时p1 -1循环完全依赖p2代码应该直接走else分支填完所有元素。测试时确保没有数组越界最终结果等于nums2。n 0, m 3nums2为空外层循环一次都不执行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(mn) 的额外复制开销表现看起来很正确实际上隐藏了成本。在 C/C 中原地操作还涉及到内存布局、指针移动等底层的正确性。很多人只会在刷题时想到这些问题但真实项目里对大数据结构做原地归并、原地去重、原地重排的需求并不少见搞不好就会造成隐性数据丢失。4.3 陷阱三归并过程中的稳定性和优先级归并排序在很多语言里都是稳定排序这意味着相等元素的相对顺序会保持不变。LeetCode 88 这道题没有明确要求稳定性但在某些业务场景里稳定性是硬需求。举一个真实案例某个交易系统的账单合并模块需要把“线上支付记录”和“线下支付记录”按时间排序合并成一条时间线。线上记录和线下记录可能在同一秒发生这时候产品希望线上记录排在前面。如果归并时用而不是在时间相等的情况下会优先保留线下记录顺序就反了。这就是比较运算符的选择带来的行为差异在算法题里无伤大雅在真实产品里却会影响最终展示顺序甚至后续对账逻辑。4.4 陷阱四极端规模下的空间与性能权衡从后往前的双指针解法在时空上都最优但工程上有一个隐含假设nums1的实际容量确实足够容纳m n个元素。如果容量不够原地合并就无从谈起必须申请新数组。这时候你可能需要写一个类似“按需扩容”的逻辑。很多程序员在刷题时完全不会考虑扩容问题但真实工程中数组的初始容量往往是估算出来的比如按历史数据量的 1.5 倍预留。如果估算偏少合并时会遇到空间不足需要触发扩容和搬移。假设nums1扩容策略是倍增那么合并时如果触发一次扩容时间开销会额外增加 O(mn) 的拷贝成本虽然均摊下来仍是线性但峰值延迟会明显变高。对于高并发接口来说这种偶发抖动可能会触发超时告警。更好的做法是在合并之前预先精确计算所需容量一次分配完毕避免中途扩容。这就是算法题和工程实现之间最常见的“最后一公里”差异。5. 常见问题与排查技巧实录5.1 高频错误对照表错误类型典型表现根本原因解决方式数组越界异常运行时报ArrayIndexOutOfBoundsExceptionp1或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)。再对长度为 mn 的数组排序如果用快速排序或归并排序时间复杂度是 O((mn) log(mn))。看起来也不差但面试官想要的显然不是在已经有序的两个数组上还用全量排序。这里要意识到一个关键点数组局部有序这个信息你完全没有利用起来。如果你只用排序解决等于无视了题目里“两个数组分别有序”这个前提条件面试官很容易追一句“能不能做到 O(mn)”。到这一步如果你答不上来这道题基本就减分了。2.2 利用有序性开辟额外数组的双指针归并既然两个数组都是有序的经典的归并思路就出现了开一个新的数组temp长度 mn然后用两个指针p1和p2分别指向nums1和nums2的起始位置比较两个指针所指元素的大小把较小的放入temp然后移动对应指针。哪边先遍历完就把另一边剩下的元素全部拷贝进temp。最后再把temp拷贝回nums1。时间复杂度是 O(mn)空间复杂度是 O(mn)。这个方案逻辑清晰正确性容易验证很多教科书上的归并排序合并步骤就是这样的。但问题又来了题目要求不使用额外数组空间最好是原地修改nums1。你开了一个temp严格来说不符合题目的原意。有些面试官会允许你这样做但既然题目特意强调了空间复杂度最优解就应该追求 O(1) 额外空间。2.3 关键转折既然要从头开始覆盖会丢数据那就从尾开始从头开始比较并写入nums1的前面位置会覆盖掉nums1中还没参与比较的元素。那如果我们反过来从两个数组的末尾开始比较把较大的元素放到nums1的末尾呢这就是整个解法推导中最重要的一步倒序遍历。nums1的末尾是预留出来的空位长度为 n刚好够放nums2的全部元素。所以从后往前填不会覆盖任何还没处理的元素。每次从nums1的有效尾部和nums2的尾部各取一个元素比较大的那个放在nums1当前从后往前数的下一个位置直到nums2的元素全部放完。为什么不是等到nums1的元素全部放完因为nums1的前 m 个元素本身就在正确的位置上如果nums2已经全部搬进nums1剩下的nums1原有元素就不需要再动了。用大白话说nums2搬完了合并就完成了剩下的元素本来就是有序的留在原地即可。这就是双指针 倒序遍历的解法闭环时间复杂度 O(mn)额外空间 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的长度是 mn后面 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 0p2 0p 1。比较后发现nums2[0]更小放到nums1[1]然后p2 -1退出循环nums1[0]保留原值。最终nums1 [nums1[0], nums2[0]]是有序的。正确。这些边界条件在面试中都是追问点你能主动列出来并给出正确答案会明显加分。3.4 复杂度分析时间复杂度循环最多执行 mn 次因为每次迭代都会把一个元素放到最终位置。所以是 O(mn)。空间复杂度只用了三个整型变量没有额外数组因此是 O(1)。这里有一点值得多说很多人刷题时会忽略“原地操作”的具体含义。如果你开了一个temp new int[m n]表面看起来代码很简单但空间复杂度是 O(mn)在面试场景下不是最优解。如果能用 O(1) 空间完成就不要用 O(mn)。4. 实际调试中避坑经验从错误解法到正确解法的真实复盘4.1 坑一从前往后比较导致数据覆盖我第一次写的时候用的是从前往后的正序双指针。逻辑看起来天衣无缝两个指针指向两个数组的起始位置比较后把较小的放入nums1当前位置。但运行后结果完全不对。举个例子nums1 [1, 2, 3, 0, 0, 0]nums2 [2, 5, 6]m 3n 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的长度恰好等于 mn。但在真实工程里可不一定你拿到的nums1可能是一个预分配的数组容量比 mn 还大。这时用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((mn) log(mn)) 方案改成双指针倒序O(mn)正序覆盖导致元素丢失从前往后放元素覆盖了未处理的nums1改成从后往前放元素边界条件m0时出错忘记处理nums1为空的情况让循环只依赖p2m0 时自动进入 else 分支6. 写在最后这道题给我的真实启示说实话LeetCode 88 是我见过“最简单也是最容易翻车”的数组题之一。它不像动态规划那样需要大量思维铺垫也不像图论那样需要记忆复杂的模板但恰恰因为“简单”很多人反而最容易在细节上翻车。覆盖写、指针初始化、退出条件这三个小地方任何一处出问题整体结果就错得离谱。我个人做完这道题后最大的体会是很多算法题的正确解法和错误解法之间的差距不是智力差距而是“是否意识到数据覆盖问题”的经验差距。一旦你想明白“从后往前可以有效规避覆盖”这道题就彻底拿下了。对正在刷题的朋友给个建议不要只满足于通过 LeetCode 的测试用例。你可以试着把这道题的标准实现改成等价的 C、Python 版本或者自己构造几个边界测试比如所有元素相等、nums1为空、nums2只有一个元素等跑一遍看看结果是否符合预期。这种动手验证的过程比刷十道新的简单题都有用。