尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

合并两个有序数组:双指针原地归并思路与边界处理详解

合并两个有序数组:双指针原地归并思路与边界处理详解 先给结论LeetCode 88题“合并两个有序数组”是面试里出现频率极高的一道题但真正值得聊的并不是“会写代码”而是它背后那一整套“为什么这么做”的逻辑——为什么不能从头往后排、为什么必须原地操作、为什么双指针要反着走。这篇博文我就从题目本身开始把思路推导、代码实现、边界处理到工程里的同类场景整个过一遍目标是你看完不只是能背下答案而是真正理解这一类“合并有序序列”问题的套路。适合看这篇内容的人我简单分三类一是准备面试、想把基础扎实的候选人二是工作中经常要处理有序数据合并的开发者三是带团队做code review时想给别人讲清楚这道题的人。前两类解决“怎么写出正确代码”第三类解决“怎么解释清楚每一步取舍”。1. 读完题目先别急着写代码1.1 题面到底在说什么题目本身很短给你两个按非递减顺序排列的整数数组nums1和nums2另外给两个整数m和n分别代表nums1和nums2中实际有效的元素个数。nums1的长度是m n后面的n个位置默认是 0最终要求把合并后的结果存到nums1里。这句话里有几个容易读漏的关键点“非递减顺序”意味着允许重复元素不是说严格递增排序时相等元素放哪边都行。nums1的长度是m n你不需要额外开辟一个完整的新数组必须在nums1内完成合并这就是常说的“原地合并”。给定的m 0或者n 0是合法输入不是异常情况代码必须能处理空数组的边界。面试时考官看重的往往不是你能不能写出来而是能不能把空间复杂度控制在 O(1) 的前提下完成合并。如果能顺手想到“从后往前”的思路基本就踩中了考点。1.2 三个初步思路为什么全都被否决了大多数人拿到题的第一个念头是把nums2直接追加到nums1的有效元素后面然后整个数组排序。这个方案代码量最少两三行就能写完但有两个问题一是时间复杂度是 O((mn)log(mn))排序本身的成本远高于必要开销二是这个做法完全浪费了“两个数组各自已经有序”这一关键条件。面试官如果看到你写这版基本会追问一句“能不能在不用排序的情况下合并”所以这个方案只适合作为暴力解不适合作为终点。第二个想法是开一个新数组temp大小设为m n用双指针分别扫描两个数组把较小的元素依次放进temp最后再把temp拷回nums1。这个思路的时间复杂度 O(mn) 没问题正确性也容易保证唯一的软肋是空间复杂度 O(mn)。很多题目答案是“不限制额外空间”但在 88 题里题目给的nums1已经预留了空间相当于明示你要原地操作。如果用了temp等于白拿了预留空间和题意相悖面试评价会被拉低一档。第三个想法是不是要合并吗那我先把nums2里的元素覆盖到nums1后面空的位置然后做局部插入排序。这个方案省了空间但最坏情况下的时间复杂度 O(m×n)数据量一大就明显变慢而且代码写起来远没有想象中简单。我见过不少人想用“插入”的方式处理有序合并结果边界条件越写越多最后反而连正确性都保不住。这三个方案被我同时否决后剩下的最优解就是利用“尾部空位”从后往前合并。这样既不需要额外空间也不需要排序一次扫描 O(mn) 就能搞定而且代码只有十来行。2. 双指针不是最聪明的方案却是最稳的方案2.1 归并思维是合并类问题的根在介绍最终解法前先把“归并”这个基础概念说透因为它不只是这一道题的核心也是后面聊到 git 分支合并、多路日志归并时都会用到的底层思路。有序数组合并的本质是两个有序序列每次比较当前两个序列的头部元素把更小的那个取出来放进结果集然后移动对应指针当一个序列取完了剩下的直接整体接到结果末尾。这个过程叫“二路归并”对应的时间复杂度是线性的因为每个元素只会被比较和移动常数次。生活化的类比假设你有两摞按身高排好的照片一摞是小学同学一摞是初中同学。合并成一摞时你不会把两摞混在一起再重新排一遍你只需要每次同时看两摞最上面的那张照片抽出身高更矮的放到新的一摞上。这就是双指针的核心动作两个指针各指一摞的顶部谁小谁走。88 题和传统归并唯一的区别是它要求把结果写回nums1而nums1的前m个位置已经占用。这就逼着你思考一个问题正向归并时指针从头往后比较较小的元素会优先被放到nums1的前面这时你可能会覆盖掉nums1还没比较到的元素丢失原始数据。2.2 从前往后会覆盖数据那从后往前呢我把“从前往后”和“从后往前”的差别用一张表列出来这样理解起来更直观比较方向指针初始位置取出元素放哪覆盖风险空间开销从前往后nums1 头部、nums2 头部nums1 前部空位高可能覆盖 nums1 未处理元素需要额外数组保存 nums1或导致错误从后往前nums1 有效部分末尾、nums2 末尾nums1 后部空位低因为 nums1 尾部本来就是预留给合并的空白完全原地 O(1)一边复制一边从后往前nums1 有效部分末尾、nums2 末尾写入指针在最末端从结果末尾往前填无完全原地 O(1)从前往后的方案代码初看起来对称清晰但如果你真正在纸上跑一遍会发现nums1的第一个元素可能还在第二个就没了具体覆盖发生在哪一次比较完全取决于两个数组的大小关系。这也是我为什么建议宁可先理解“从后往前”的本质也别急着写“从前往后”的代码。从后往前的本质是反正nums1的尾部有n个空闲槽位这些槽位现在空着之后也不会被任何人用到那就从结果数组的最后一个位置开始倒着填。比较两个数组的末尾元素谁更大谁放到尾部空位的最后面然后把指针往前挪。每次填入的都是“当前所有剩余元素里最大”的那一个所以结果依然有序。你可能会疑惑从前往后取的是最小的从后往前取的是最大的怎么结果都一样有序答案是两个方向是镜像对称的。正向归并相当于从小到大构建结果反向归并相当于从大到小构建结果。只要每次选择的都是剩余元素中正确的最值顺序就必然正确。2.3 逆序双指针为什么好用逆序双指针写起来需要三个变量一个指向nums1有效部分的末尾即m - 1一个指向nums2的末尾即n - 1一个指向nums1整个数组的最末尾即m n - 1。每次比较前两个指针指的值把较大值写入第三个位置然后对应指针和写入指针各自前移。这个方案最大的优点是“一次到位”没有辅助数组不需要最后整体拷贝也没有元素移动的二次开销。数据规模大时比如nums1长度几百万、nums2长度几百万这种写法在内存访问上也比较友好写入位置是连续的CPU 缓存命中率不错。另一个隐藏优势是代码短、易验证。你不需要在纸上模拟大量用例只要稍微明白指针移动规则就能通过简单推演确认正确性。面试写这种代码口头解释成本很低考官也容易跟你的思路走。我后来做评委时遇到候选人写这种解法的我会觉得至少基础是扎实的不是单纯背题。3. 完整实现与每个细节的来历3.1 三指针写法的 Java 实现直接上代码我用的是 Java这也是面试中最常见的语言之一class Solution { public void merge(int[] nums1, int m, int[] nums2, int n) { int i m - 1; // nums1 有效元素最后一个位置 int j n - 1; // nums2 最后一个位置 int k m n - 1; // 合并后数组的最后一个位置 while (j 0) { if (i 0 nums1[i] nums2[j]) { nums1[k--] nums1[i--]; } else { nums1[k--] nums2[j--]; } } } }这里的循环条件是j 0而不是i 0这个细节值得停下来解释一下当nums2的所有元素都处理完了nums1里剩余的有效元素本来就已经在正确位置上不需要任何额外操作但如果nums1先处理完nums2还没处理完那就要继续把nums2的剩余元素逐个拷贝到前面去。所以这个小小的不对称恰好对应了“谁先空谁不用管”的归并规则。i 0的判断为什么必须放在nums1[i] nums2[j]前面想想i变成负数之后的情景如果i已经小于 0你还去访问nums1[i]结果就是数组越界异常。当i小于 0 时说明nums1的有效元素已经全部处理完此时无论nums2[j]是什么值都应该直接放入nums1[k]这就是 else 分支做的事情。顺序反过来的话程序会在越界异常前崩溃调试成本瞬间升高。用一组数据实际模拟一下nums1 [1, 2, 3, 0, 0, 0]m 3nums2 [2, 5, 6]n 3。指针初始位置i 2指向 3j 2指向 6k 5。第一轮比较 3 和 66 更大于是nums1[5] 6j变成 1k变成 4。第二轮比较 3 和 55 更大nums1[4] 5j变成 0k变成 3。第三轮比较 3 和 23 更大nums1[3] 3i变成 1k变成 2。后面继续依次填入 2 和 1最后结果是[1, 2, 2, 3, 5, 6]完全正确。3.2 边界条件逐个过一遍很多题目不是难在正常情况而是难在边界条件。这道题的边界看似简单实际坑点不少我一一列举m 0n 0nums1根本没有有效元素i初始为 -1循环会直接把nums2的所有元素从后往前填充到nums1结果就是nums2的副本。n 0m 0j初始为 -1循环条件j 0不成立直接跳过nums1保持原样也是正确结果。m 0n 0两个数组都空直接返回完全不进入循环。所有nums2元素都比nums1元素大每次比较都会走 else 分支nums2从后往前逐个填入尾部空闲位置nums1的原元素最终整体后移。实测效果像“把 nums1 向后推”。所有nums1元素都比nums2元素大每次比较走 if 分支nums1的元素不断被挪到尾部nums2的元素占满空位最终nums2的元素位于前面nums1的元素位于后面。两个数组含有大量重复元素比较时用而不是当相等时走 else 分支优先放入nums2的元素。这样处理的好处是如果nums2先放完循环可以直接结束从稳定性角度讲相等元素的相对顺序并不会产生可感知的差异但这种写法能让循环更早结束。有些资料会建议用优先放nums1的元素结果也不会错但循环结束得更晚因为nums2会一直剩着。这些边界情况刷下来你会发现自己对“谁先耗尽”的理解会更深。下次面试官随便改改条件你也能立刻反应出对应逻辑。3.3 其他语言的对照写法用 Python 写同样逻辑时要注意 Python 没有 Java 那种显式的数组预留概念但依然可以用同样的逻辑操作列表。Python 的写法稍微需要适应一下因为列表的“长度”概念更灵活def merge(nums1: list[int], m: int, nums2: list[int], n: int) - None: i, j, k m - 1, n - 1, m n - 1 while j 0: if i 0 and nums1[i] nums2[j]: nums1[k] nums1[i] i - 1 else: nums1[k] nums2[j] j - 1 k - 1JavaScript 的版本在思路上完全一致只是数组定义和函数签名有些差异function merge(nums1, m, nums2, n) { let i m - 1; let j n - 1; let k m n - 1; while (j 0) { if (i 0 nums1[i] nums2[j]) { nums1[k--] nums1[i--]; } else { nums1[k--] nums2[j--]; } } }这几个版本都保持 O(1) 额外空间和 O(mn) 时间。语言不同只是语法层面的微调核心逻辑全在双指针的移动方向上。4. 边界条件与高频坑位排查4.1 常见的错和怎么快速定位我在实际协助他人验证这段代码时发现最常见的错误有三种每种都有对应的高频触发条件和排查思路。第一种是数组越界典型场景是循环条件写成了while (i 0 || j 0)却没有在 if 判断里处理i 0或j 0的情况。这种写法思路本身没有错但你要在循环体内部增加两个额外的判断分支否则必然在某个时刻访问负索引。要避免越界最简单的方式就是像我这样把循环条件限制为j 0把i的状态放进内部判断这样nums1[i]的访问被i 0保护nums2[j]的访问被j 0保护。第二种是结果数组前段出现未处理的零。这个现象通常出现在把循环条件写成while (i 0)的情况下程序把所有nums1的元素搬完就直接退出nums2还有大量元素没归并进去它们在nums1中的对应位置仍然是初始化的 0。定位这种问题很快的办法就是用几个直观用例跑一下比如nums1 [4,5,6,0,0,0]、nums2 [1,2,3]一看输出就知道有没有漏。第三种是元素覆盖导致数据错乱。这种情况一般出现在从前往后的实现中nums1前面的元素在还没有被比较时就被更大的数字覆盖掉了。我见过不少人写从前往后的版本后用[1,2,3,0,0,0]和[2,5,6]去测结果发现第一个位置的 1 被提前覆盖输出变成一个错乱的大数。这里强烈建议如果面试环境允许先在草稿纸上模拟两步再写代码否则写完后再调试反而更费时间。4.2 我怎么自测这个函数这道题的函数要自测我一般会准备一组涵盖所有类型边界的用例而不是只测一两个正常数据。以下是我常用的测试组用例mnums2n预期结果[1,2,3,0,0,0]3[2,5,6]3[1,2,2,3,5,6][1]1[]0[1][0]0[1]1[1][4,5,6,0,0,0]3[1,2,3]3[1,2,3,4,5,6][1,1,1,0,0,0]3[1,1,1]3[1,1,1,1,1,1][2,4,6,0,0,0]3[1,3,5]3[1,2,3,4,5,6]这组用例包含“nums2 全小”“nums2 全大”“全相同”“一数组为空”“nums1 为空”等情况基本能覆盖所有分支路径。我在执行自测时会重点检查三个东西最终数组长度是否等于 mn合并后数组是否非递减nums1原有元素和nums2元素是否全部在结果中保留且没有丢失。这个“未合并检查”在工程里的价值远比在算法题里大。比如你写一个配置中心服务需要把两个来源的配置列表合并成一份如果有一个数据源没有完整合并进去线上就会出现配置缺失的故障。我通常的做法是在合并前先记录两个数组元素的总和合并完成后用一组哈希集合或者计数器验证元素种类和数量完全一致。这个习惯可以直接迁移到任何“合并”类功能上极其有用。4.3 十种和多场景对照这里有一个速查表刷题过程中我还整理了一份“别人容易犯的错”速查表这里一并送出错误现象根因修复方式nums1[i]越界未判断i 0就访问nums1[i]加前置判空或调整循环条件结果前段残留大量 0循环条件错误导致nums2未完全归并循环改为while (j 0)从头到尾元素乱序从前往后覆盖丢失原数据换用从后往前遍历最后一个元素仍是 0k指针初始化或移动时机错误模拟一次完整流程核对k的移动多余元素未被复制忘记把剩余数组整体拼接确保循环结束条件覆盖两种“一方耗尽”的情况普通解法超时使用排序导致 O(nlogn)改用线性归并额外空间被扣分使用了temp数组改成原地尾插输出数组比预期长没有考虑nums1的尾部预置空间用循环变量控制写入次数大数用例内存溢出使用了很多不必要的临时结构精简为指针操作合并结果不稳定相等元素优先放入了nums1或nums2面试时按需求选择稳定或不稳定策略这份表是通用的不限于这一道题。比如在ffmpeg多文件合并时需要确保轨道完整无遗漏在Excel合并单元格时要防止文本截断这些操作背后的“检查完整性”思想都有共通之处。5. 从算法题到真实世界的“合并”5.1 git 分支合并和这道题为什么是同一种思维很多人做题时会想这种数组题在真实工程里到底有什么用其实“合并有序数组”的思维在 git 分支合并场景里就有非常直接的对应。git 合并分支时如果被合并的两个分支都基于同一个 commit 点做了修改git 会尝试自动合并。它需要把两个分支各自的提交记录按时间线合并成一份“新历史”。如果两个分支各改的是不同文件git 会直接自动合并如果改了同一个文件git 会做三路合并并对比内容差异相当于同时扫描两个有序的差异序列把冲突部分标记出来交给人处理。我曾在实际工作中遇到过一个很有意思的场景一份配置文件在两个分支上分别新增了不同的配置项git 自动合并后居然产生了完全顺序错乱的结果其中一个分支的配置项被另一个分支的配平项覆盖掉了。原因就在于配置项的顺序对系统行为有影响git 那种“基于内容归并”的策略不会考虑业务顺序。后来我们在项目中引入了一个自定义脚本先把两个分支的配置文件解析成有序键值对数组然后调用一个类似本题的归并函数按 key 顺序合并问题彻底解决。你看这道题的思想不是只有在 LeetCode 上才有意义真实合并场景里到处都是它的影子。5.2 ffmpeg 合并视频、Excel 合并单元格里的“归并”影子继续说两个更贴近日常的例子。用ffmpeg合并多个视频文件时很多新手会直接用concat协议把文件顺序拼起来。这种方式本质上是“机械拼接”只保证文件首尾相接不保证时间戳和音视频轨道的对齐。更稳妥的做法是先把所有输入文件转成统一的编码参数再使用concat demuxer让ffmpeg内部按照时间线做有序合并。这就像两个有序数组的归并你得保证两个输入序列各自有序并且你有一个明确的比较规则否则合并结果就是乱的。Excel合并单元格又是另一种“合并”。表面上看把多个单元格合并是用一个格子来存放多个原始区域的值但如果你想把多张表格按照某一列对应用户、日期之类的键合并成一张总表就需要做类似两路归并的操作。我经常用Python的pandas做这种跨表合并它的merge方法本质上就是基于键的有序匹配底层逻辑和双指针归并是一致的。理解 88 题之后你会更容易理解pandas里howinner、howouter这些合并方式分别对应归并过程中的哪些分支内连接相当于只保留两个数组中都存在的元素外连接相当于保留全部元素且另一方缺失的值填 NaN。5.3 工程里我常用的变体在实际工程里裸数组的有序合并很少见更多是一种变体。我在工作中遇到比较多的几种变体在这里列一下第一种是合并多个有序链表或有序流。比如日志系统里有多台机器各自按时间产出日志需要汇总到查询端时不再是合并两个数组而是做 K 路归并普通双指针会退化成优先队列堆实现每次从 K 个输入流中取最小元素。这个变体本质相同只是数量从 2 变成 K。第二种是磁盘上的大数据文件合并。两块大文件各自有序如果整个加载到内存根本放不下就需要采用“外部归并排序”的思路从每个文件分块读取维护有限大小的缓冲再把缓冲中的有序片段归并写回新文件。双指针的思想依然适用只是指针变成了文件读取偏移量。第三种是数据库索引合并。MySQL 的多索引合并Index Merge在某些场景下会同时扫描两个索引的有序记录再按主键或 rowid 归并成最终结果集。很多数据库工程师一开始不理解为什么OR条件有时会触发index merge其实就是因为优化器判断这样可以按有序数组的方式快速归并出最终结果跟这道题如出一辙。6. 我的一些实操心得和最后的建议6.1 一条容易忽略的细节代码写完之后别急着提交多花十秒钟检查一个关键细节当nums1的有效元素全部消费完但nums2还没空时k指针应该正好落在nums1的前端空白区。如果你在循环末尾手动让k多减了一次或者k的初始值设成了m n而不是m n - 1就会在数组末尾留下一个永远填不上的空洞最终那个位置全是 0。我熟悉的几个同事刷这道题时十个人里至少有三个会在k的初始值上翻车。原因很朴素nums1的长度是m n所以最后一个有效下标是m n - 1容易顺手多写一个。这种错误运行起来不一定报异常但结果一定不对。检查方法很简单找一个最简单的用例比如nums1 [1, 0]、nums2 [2]用纸笔走一遍如果k在循环结束后不是 -1就说明初始值或移动逻辑有问题。6.2 从一道题到一种解题习惯刷完这道题最大的收获不是记住了一个“从后往前”的结论而是意识到“合并有序序列”这个动作有一套通用的解法骨架两个指针指向各自序列的当前候选位一个写入指针指向结果区的待写入位每次从候选位中选出满足目标顺序的元素写入后移动对应指针处理完一个序列后剩余序列整体续接。这套骨架在链表合并、字符串归并、日志归并、外部排序、数据库索引合并里全部有效。我自己后续设计代码方案时遇到“两个输入都有序、需要一个有序输出”的场景第一反应基本都是套这个骨架。这能让代码本来就更清晰也能让审查的人一眼看懂你的方案意图。我把这道题当作面试候选人的基础分题如果这题讲不清楚“为什么从后往前”“为什么是j 0”那后面的难题我基本就不再追问技术细节了因为基础分析能力已经在这里暴露得差不多了。6.3 最后一件事自己读一遍你的代码代码在所有用例上通过不代表你可以直接提交。我有个习惯性的最后一步把代码当成要给别人讲一遍的故事逐行朗读对照数组的状态变化验证一下指针的语义是否和自己脑中的设想完全一致。这步操作看起来浪费时间但对查漏补缺非常有效很多时候我就是这么发现一些“逻辑上明明对但表达上就别扭”的地方。比如你看到if (i 0 nums1[i] nums2[j])这一行可以读成“如果 nums1 还有元素而且它的当前元素比 nums2 的当前元素大把 nums1 当前元素放到结果尾部两个指针都往前走。”读一遍之后你会自然地问一个问题如果nums1没有元素了怎么办答案就在 else 分支里读到这里整个循环的意义就完全闭合了。最后给出一个独立于本题的小建议无论你是在准备面试还是日常写业务尽量在小题目上多花一点时间做“为什么”的分析而不是急着刷题量。理解双指针、合并类问题的通用骨架以后你再看其他需要维护多个指针的题目比如三数之和、颜色分类、滑动窗口都会有一种豁然开朗的感觉。88 题只是这扇门里很小的一道口推开它就够了。
返回列表