ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LeetCode 88题深度解析:原地合并有序数组的双指针技巧与边界处理

LeetCode 88题深度解析:原地合并有序数组的双指针技巧与边界处理 1. 项目概述一次关于“原地合并”的深度剖析今天想和大家深入聊聊一个看似基础实则暗藏玄机的算法问题——LeetCode第88题“合并两个有序数组”。这道题在面试中的出场率极高我敢说但凡你面过技术岗十有八九都遇到过它。题目要求很简单给你两个按非递减顺序排列的整数数组nums1和nums2以及两个整数m和n分别表示nums1和nums2中的元素数目。你需要将nums2合并到nums1中使合并后的数组同样按非递减顺序排列。最终排序后的数组不应由函数返回而是存储在数组nums1中。为了应对这种情况nums1的初始长度为m n其中前m个元素表示应合并的元素后n个元素为 0应忽略。很多新手朋友拿到题目第一反应可能是“这还不简单直接把nums2拼接到nums1后面然后调用sort()排序不就完了” 从结果上看这确实能得到正确的排序数组。但如果你在面试中给出这个答案面试官大概率会皱起眉头因为这完全忽略了题目设计的精妙之处和考察点。这道题的核心约束在于“原地”合并即要求我们在nums1这一个数组空间内完成所有操作并且通常期望达到O(m n)的时间复杂度。这背后考察的是对数组操作、双指针技巧以及从后向前遍历以避免覆盖的深刻理解。今天我就以一个老码农的视角带大家从头到尾拆解这道题不仅给出标准解法更要讲清楚每一步背后的“为什么”并分享一些我踩过的坑和实战中的优化技巧。2. 核心思路拆解为什么不能从前往后在动手写代码之前我们必须先想清楚算法的大方向。这是区分“背题”和“真懂”的关键。2.1 暴力法的陷阱与局限性最直观的“暴力”想法正如开头所说是合并后排序。具体操作是先将nums2的所有元素拷贝到nums1从索引m开始的位置然后对整个nums1数组进行排序。# 一种直观但低效的做法仅用于说明问题不推荐 def merge_naive(nums1, m, nums2, n): for i in range(n): nums1[m i] nums2[i] nums1.sort()这种方法的时间复杂度是O((mn) log(mn))主要消耗在排序上。空间复杂度是O(1)或者O(log(mn))取决于排序算法的实现如Timsort需要额外空间。虽然题目没有明确禁止排序但这显然不是出题人的本意。它没有利用“两个数组已经有序”这个至关重要的前置条件相当于把一道中等题降维成了简单的API调用题在面试中毫无竞争力。2.2 双指针法的必然选择既然两个数组都有序我们很自然地会想到使用“双指针”或“归并”的思想。想象一下我们有两个已经排好队的队伍nums1的前m个元素和整个nums2现在要把他们合并成一个新队伍。最直接的方法是创建一个新的空数组merged然后同时从两个队伍的队首即数组开头开始比较每次将较小的那个人放入新队伍直到所有元素都进入新队伍。最后再把新队伍复制回nums1。# 使用额外空间的归并标准解法之一但不是最优 def merge_with_extra_space(nums1, m, nums2, n): merged [0] * (m n) p1, p2, p 0, 0, 0 while p1 m and p2 n: if nums1[p1] nums2[p2]: merged[p] nums1[p1] p1 1 else: merged[p] nums2[p2] p2 1 p 1 # 拷贝剩余元素 while p1 m: merged[p] nums1[p1] p1 1 p 1 while p2 n: merged[p] nums2[p2] p2 1 p 1 # 将结果复制回nums1 for i in range(m n): nums1[i] merged[i]这个方法的时间复杂度是完美的O(mn)因为我们只遍历了每个数组一次。但它的空间复杂度也是O(mn)因为我们用了一个同等大小的新数组。题目虽然没说不能用额外空间但nums1后面明明预留了足够的空间n个0这就强烈暗示我们可以在nums1内部完成所有操作达到O(1)的额外空间复杂度如果不算输出空间的话。2.3 关键突破从后向前遍历那么如何在不使用额外数组的情况下在nums1内部完成归并呢这里最大的障碍是如果我们从数组的前面索引0开始比较和填充当我们想把nums2的一个较小值放到nums1前面时会覆盖掉nums1中尚未比较的原始有效元素。举个例子nums1 [1, 3, 5, 0, 0, 0], m3nums2 [2, 4, 6], n3。如果从前往后比较nums1[0]1和nums2[0]21小放在nums1[0]还是它自己没问题。下一步比较nums1[1]3和nums2[0]22小本应放在nums1[1]。但nums1[1]当前是3是有效数据如果直接放入2就把3覆盖了而这个3后续还需要参与比较。这就产生了冲突。解决这个冲突的绝妙方法就是从后向前遍历。既然nums1的尾部是预留的空白区域0我们就从这些空白位置开始填充。每次比较nums1和nums2当前剩余部分的最大值将更大的那个数放到nums1的尾部。这样填充的位置尾部空白永远不会覆盖到nums1前面还未参与比较的有效数据因为那些有效数据的位置都在当前填充位置的“前面”。注意这个“从后向前”的思路是本题最核心的考点。它完美利用了nums1尾部预留空间的特点将覆盖冲突的风险化解于无形。面试时如果能清晰阐述这个思路就已经赢了一半。3. 标准解法实现与逐行解析理解了从后向前的精髓我们就可以动手实现标准的“三指针”解法了。这里说的三指针分别是p1指向nums1有效部分的末尾初始为m-1。p2指向nums2的末尾初始为n-1。p指向nums1整个数组的末尾即最终下一个元素应该放置的位置初始为mn-1。算法的过程就像一场“擂台赛”裁判指针p站在最后面每次请nums1和nums2各自队伍里当前最强的人最大的元素出来比一比赢的人更大的数就去占领裁判身后的位置然后裁判和赢家所在队伍都向前移动一位。直到某一队的人全部上场完毕再把另一队剩下的人按顺序安排到前面的位置。下面我们用Python来实现这个算法并加上详细的注释。def merge(nums1, m, nums2, n): 将nums2合并到nums1中使其成为非递减顺序数组。 原地修改nums1。 Args: nums1: List[int], 长度为 mn前m个元素有效。 m: int, nums1中初始有效元素个数。 nums2: List[int], 长度为 n。 n: int, nums2中元素个数。 # 初始化三个指针 p1 m - 1 # nums1有效部分的最后一个元素索引 p2 n - 1 # nums2的最后一个元素索引 p m n - 1 # nums1整个数组的最后一个位置索引 # 从后向前遍历比较并填充 while p1 0 and p2 0: if nums1[p1] nums2[p2]: # 如果nums1当前元素更大把它放到p的位置 nums1[p] nums1[p1] p1 - 1 else: # 如果nums2当前元素更大或相等把nums2的元素放过去 # 注意这里处理了相等的情况先放nums2的也可以保证稳定性或非递减性 nums1[p] nums2[p2] p2 - 1 p - 1 # 填充位置向前移动 # 如果nums2还有剩余元素意味着nums1的有效元素已经全部处理完 # 需要把nums2剩余的元素它们已经是最小的那部分拷贝到nums1的前面 # 如果nums1有剩余元素它们本来就在正确的位置无需移动。 while p2 0: nums1[p] nums2[p2] p2 - 1 p - 1 # 循环结束后nums1即为合并后的有序数组让我们用一个具体的例子来走一遍流程加深理解 假设nums1 [1, 3, 5, 0, 0, 0], m3,nums2 [2, 4, 6], n3。 初始状态p12(指向nums1[2]5),p22(指向nums2[2]6),p5(指向最后一个0)。第一轮nums1[p1]5vsnums2[p2]66更大。nums1[5] 6。p2变为1p变为4。nums1变为[1, 3, 5, 0, 0, 6]第二轮nums1[p1]5vsnums2[p2]45更大。nums1[4] 5。p1变为1p变为3。nums1变为[1, 3, 5, 0, 5, 6](注意原来的5被复制到了后面前面的位置之后会被覆盖或保留)第三轮nums1[p1]3vsnums2[p2]44更大。nums1[3] 4。p2变为0p变为2。nums1变为[1, 3, 5, 4, 5, 6]第四轮nums1[p1]3vsnums2[p2]23更大。nums1[2] 3。p1变为0p变为1。nums1变为[1, 3, 3, 4, 5, 6](注意原来的3被复制到了索引2)第五轮nums1[p1]1vsnums2[p2]22更大。nums1[1] 2。p2变为-1p变为0。nums1变为[1, 2, 3, 4, 5, 6]此时p2 0第一个while循环结束。由于nums2已全部处理完p2-1第二个while循环不会执行。最终结果[1, 2, 3, 4, 5, 6]。可以看到nums1中原有的元素1,3,5在过程中被复制到了更靠后的位置但最终它们和nums2的元素一起构成了完整的有序数组。整个过程中没有任何一个有效数据因为被覆盖而丢失。4. 边界条件与易错点深度剖析一个健壮的算法必须能处理各种边界情况。这道题看似简单但边界条件没处理好很容易翻车。下面我结合自己调试和面试别人的经验总结几个最常见的“坑”。4.1 当n0或m0时这是最容易被忽略的边界条件。n0即nums2为空数组。此时nums1已经是有序的不需要做任何操作。我们的算法中p2初始为-1第一个while循环因p20为False而直接跳过第二个while循环也会跳过。函数什么都不做nums1保持不变这是正确的。m0即nums1的有效部分为空但nums1容器长度是n里面全是0。此时我们只需要把nums2的全部元素按序拷贝到nums1中。在我们的算法里p1初始为-1第一个while循环因p10为False而跳过然后进入第二个while循环将nums2的所有元素从后向前实际上顺序拷贝放入nums1最终得到正确的有序数组。实操心得在写代码时要养成先考虑极端情况的好习惯。对于这道题在脑子过一遍m0或n0时指针的初始值和循环条件能帮你快速发现逻辑漏洞。很多同学的代码在m0时出错就是因为没处理好p1为负索引的情况。4.2 指针移动与比较逻辑的细节在while p1 0 and p2 0:这个主循环中比较条件是nums1[p1] nums2[p2]。这里有一个细节当两者相等时我们走else分支放置nums2[p2]。你也可以选择放置nums1[p1]对于这道题非递减排序来说结果都是正确的。但这涉及到排序的“稳定性”概念。如果希望保持原有序列的某些特性但这道题没有这个要求就需要明确。通常选择放哪个都可以但要在注释里说明或者统一用一种。另一个细节是第二个while循环while p2 0:。为什么只需要检查p2因为如果p1先耗尽p10那么nums2剩余的元素一定都比已经放置好的所有元素小因为我们是挑大的往后放所以需要把它们拷贝到nums1的前部。反之如果p2先耗尽nums1剩余的元素本来就位于当前p指针之前的位置并且它们已经是有序的所以不需要做任何操作。这个逻辑确保了算法的正确性。4.3 关于“原地”操作的理解误区有些同学会纠结我们不是用了p1,p2,p三个变量吗这算不算额外空间在算法分析中我们通常只考虑随着输入数据规模增长而增长的额外空间。像这种固定数量的指针变量通常是常数个比如3个其空间消耗是O(1)即常数空间复杂度。因此这个算法是符合“原地”合并的要求的。千万不要去钻牛角尖认为用了变量就不是原地了。5. 复杂度分析与变种思考5.1 时间与空间复杂度时间复杂度O(mn)。我们最多会遍历nums1的有效部分一次通过p1遍历nums2一次通过p2每个元素都被比较和赋值一次。没有嵌套循环是线性的时间复杂度。空间复杂度O(1)。除了几个固定的指针变量我们没有使用任何与m或n成比例的额外存储空间。这是相对于使用额外数组的解法最大的优势。5.2 如果题目要求稳定排序怎么办原题只要求“非递减顺序”没有要求稳定排序。稳定排序是指如果两个元素相等排序后它们的相对位置保持不变。假设nums1和nums2中的元素还附带其他信息比如是对象用某个键排序我们需要保持稳定性。 我们之前的写法相等时先放nums2的元素可能破坏稳定性。为了稳定当nums1[p1] nums2[p2]时应该优先放置nums1[p1]因为它在原nums1中位置更靠前。只需将比较条件从改为即可if nums1[p1] nums2[p2]: # 改为大于等于优先保留nums1的元素 nums1[p] nums1[p1] p1 - 1 else: nums1[p] nums2[p2] p2 - 15.3 从前往后真的无法实现原地合并吗理论上如果允许使用O(m)的额外空间可以先备份nums1的前m个元素然后使用从前往后的双指针归并到nums1中。但这不符合本题最优解的要求。如果严格限制O(1)空间且必须从前往后对于数组这种数据结构在没有预留足够“空隙”的情况下是无法做到的。这体现了数组和链表在插入操作上的根本区别链表可以轻松地在任意位置插入而不影响其他元素而数组的插入往往需要移动后续所有元素。6. 实战扩展与技巧总结6.1 如何在其他语言中实现思路是完全一致的只是语法不同。例如在C中要注意使用向量vector的size()和索引访问在Java中数组长度是固定的但我们可以直接操作传入的nums1数组。核心的三指针逻辑和从后向前的遍历顺序是跨语言通用的。6.2 调试技巧可视化指针移动对于双指针问题尤其是像这样从后向前操作的在纸上画图或者用调试器一步步跟踪指针和数组值的变化是理解算法最有效的方法。你可以画两个数组用不同颜色的笔标注p1,p2,p然后手动模拟每一步。我强烈建议初学者不要只看代码一定要动手模拟一遍这能帮你建立牢固的直觉。6.3 关联算法题掌握这道题的双指针和从后向前思想对解决其他问题大有裨益LeetCode 21. 合并两个有序链表更简单因为链表插入不需要移动元素可以直接从前往后合并。LeetCode 977. 有序数组的平方同样可以利用双指针从两端向中间遍历将平方后的较大值从结果数组的末尾开始放置。归并排序中的合并步骤这是归并排序的核心子过程本题目可以看作一个特化的、原地版本的二路归并。最后我个人的体会是算法题的价值不在于死记硬背多少个解法而在于通过每一道题深入理解其背后的数据结构和算法思想并锻炼将复杂问题分解、抽象、最终用简洁代码实现的能力。像“合并两个有序数组”这样的题目就是培养这种能力的绝佳素材。它用简单的场景考察了你对数组特性、指针操作和贪心思想的掌握程度。下次遇到类似问题不妨先想想有没有已经排好序的部分能不能用指针来避免不必要的移动或拷贝从哪个方向遍历可以避免冲突多问自己几个为什么你的算法功力自然会稳步提升。
返回列表