LeetCode 88题深度解析:原地合并有序数组的双指针技巧与边界处理
2026/9/1 18:51:17 网站建设 项目流程

1. 项目概述:一次关于“原地合并”的深度剖析

今天想和大家深入聊聊一个看似基础,实则暗藏玄机的算法问题——LeetCode第88题“合并两个有序数组”。这道题在面试中的出场率极高,我敢说,但凡你面过技术岗,十有八九都遇到过它。题目要求很简单:给你两个按非递减顺序排列的整数数组nums1nums2,以及两个整数mn,分别表示nums1nums2中的元素数目。你需要将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((m+n) log(m+n)),主要消耗在排序上。空间复杂度是O(1)或者O(log(m+n))(取决于排序算法的实现,如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(m+n),因为我们只遍历了每个数组一次。但它的空间复杂度也是O(m+n),因为我们用了一个同等大小的新数组。题目虽然没说不能用额外空间,但nums1后面明明预留了足够的空间(n个0),这就强烈暗示我们可以在nums1内部完成所有操作,达到O(1)的额外空间复杂度(如果不算输出空间的话)。

2.3 关键突破:从后向前遍历

那么,如何在不使用额外数组的情况下,在nums1内部完成归并呢?这里最大的障碍是:如果我们从数组的前面(索引0)开始比较和填充,当我们想把nums2的一个较小值放到nums1前面时,会覆盖掉nums1中尚未比较的原始有效元素。

举个例子:nums1 = [1, 3, 5, 0, 0, 0], m=3nums2 = [2, 4, 6], n=3。如果从前往后,比较nums1[0]=1nums2[0]=2,1小,放在nums1[0](还是它自己,没问题)。下一步比较nums1[1]=3nums2[0]=2,2小,本应放在nums1[1]。但nums1[1]当前是3,是有效数据,如果直接放入2,就把3覆盖了,而这个3后续还需要参与比较。这就产生了冲突。

解决这个冲突的绝妙方法,就是从后向前遍历。既然nums1的尾部是预留的空白区域(0),我们就从这些空白位置开始填充。每次比较nums1nums2当前剩余部分的最大值,将更大的那个数放到nums1的尾部。这样,填充的位置(尾部空白)永远不会覆盖到nums1前面还未参与比较的有效数据,因为那些有效数据的位置都在当前填充位置的“前面”。

注意:这个“从后向前”的思路是本题最核心的考点。它完美利用了nums1尾部预留空间的特点,将覆盖冲突的风险化解于无形。面试时如果能清晰阐述这个思路,就已经赢了一半。

3. 标准解法实现与逐行解析

理解了从后向前的精髓,我们就可以动手实现标准的“三指针”解法了。这里说的三指针分别是:

  • p1:指向nums1有效部分的末尾(初始为m-1)。
  • p2:指向nums2的末尾(初始为n-1)。
  • p:指向nums1整个数组的末尾,即最终下一个元素应该放置的位置(初始为m+n-1)。

算法的过程就像一场“擂台赛”,裁判(指针p)站在最后面,每次请nums1nums2各自队伍里当前最强的人(最大的元素)出来比一比,赢的人(更大的数)就去占领裁判身后的位置,然后裁判和赢家所在队伍都向前移动一位。直到某一队的人全部上场完毕,再把另一队剩下的人按顺序安排到前面的位置。

下面我们用Python来实现这个算法,并加上详细的注释。

def merge(nums1, m, nums2, n): """ 将nums2合并到nums1中,使其成为非递减顺序数组。 原地修改nums1。 Args: nums1: List[int], 长度为 m+n,前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], m=3,nums2 = [2, 4, 6], n=3。 初始状态:p1=2(指向nums1[2]=5),p2=2(指向nums2[2]=6),p=5(指向最后一个0)。

  1. 第一轮:nums1[p1]=5vsnums2[p2]=6,6更大。nums1[5] = 6p2变为1,p变为4。nums1变为[1, 3, 5, 0, 0, 6]
  2. 第二轮:nums1[p1]=5vsnums2[p2]=4,5更大。nums1[4] = 5p1变为1,p变为3。nums1变为[1, 3, 5, 0, 5, 6](注意,原来的5被复制到了后面,前面的位置之后会被覆盖或保留)
  3. 第三轮:nums1[p1]=3vsnums2[p2]=4,4更大。nums1[3] = 4p2变为0,p变为2。nums1变为[1, 3, 5, 4, 5, 6]
  4. 第四轮:nums1[p1]=3vsnums2[p2]=2,3更大。nums1[2] = 3p1变为0,p变为1。nums1变为[1, 3, 3, 4, 5, 6](注意,原来的3被复制到了索引2)
  5. 第五轮:nums1[p1]=1vsnums2[p2]=2,2更大。nums1[1] = 2p2变为-1,p变为0。nums1变为[1, 2, 3, 4, 5, 6]
  6. 此时p2 < 0,第一个while循环结束。由于nums2已全部处理完(p2=-1),第二个while循环不会执行。
  7. 最终结果:[1, 2, 3, 4, 5, 6]

可以看到,nums1中原有的元素(1,3,5)在过程中被复制到了更靠后的位置,但最终它们和nums2的元素一起,构成了完整的有序数组。整个过程中,没有任何一个有效数据因为被覆盖而丢失。

4. 边界条件与易错点深度剖析

一个健壮的算法必须能处理各种边界情况。这道题看似简单,但边界条件没处理好,很容易翻车。下面我结合自己调试和面试别人的经验,总结几个最常见的“坑”。

4.1 当n=0m=0

这是最容易被忽略的边界条件。

  • n=0:即nums2为空数组。此时,nums1已经是有序的,不需要做任何操作。我们的算法中,p2初始为-1,第一个while循环因p2>=0False而直接跳过,第二个while循环也会跳过。函数什么都不做,nums1保持不变,这是正确的。
  • m=0:即nums1的有效部分为空(但nums1容器长度是n,里面全是0)。此时,我们只需要把nums2的全部元素按序拷贝到nums1中。在我们的算法里,p1初始为-1,第一个while循环因p1>=0False而跳过,然后进入第二个while循环,将nums2的所有元素从后向前(实际上顺序拷贝)放入nums1,最终得到正确的有序数组。

实操心得:在写代码时,要养成先考虑极端情况的好习惯。对于这道题,在脑子过一遍m=0n=0时指针的初始值和循环条件,能帮你快速发现逻辑漏洞。很多同学的代码在m=0时出错,就是因为没处理好p1为负索引的情况。

4.2 指针移动与比较逻辑的细节

while p1 >= 0 and p2 >= 0:这个主循环中,比较条件是nums1[p1] > nums2[p2]。这里有一个细节:当两者相等时,我们走else分支,放置nums2[p2]。你也可以选择放置nums1[p1],对于这道题(非递减排序)来说,结果都是正确的。但这涉及到排序的“稳定性”概念。如果希望保持原有序列的某些特性(但这道题没有这个要求),就需要明确。通常,选择放哪个都可以,但要在注释里说明,或者统一用一种。

另一个细节是第二个while循环:while p2 >= 0:。为什么只需要检查p2?因为如果p1先耗尽(p1<0),那么nums2剩余的元素一定都比已经放置好的所有元素小(因为我们是挑大的往后放),所以需要把它们拷贝到nums1的前部。反之,如果p2先耗尽,nums1剩余的元素本来就位于当前p指针之前的位置,并且它们已经是有序的,所以不需要做任何操作。这个逻辑确保了算法的正确性。

4.3 关于“原地”操作的理解误区

有些同学会纠结,我们不是用了p1,p2,p三个变量吗?这算不算额外空间?在算法分析中,我们通常只考虑随着输入数据规模增长而增长的额外空间。像这种固定数量的指针变量(通常是常数个,比如3个),其空间消耗是O(1),即常数空间复杂度。因此,这个算法是符合“原地”合并的要求的。千万不要去钻牛角尖,认为用了变量就不是原地了。

5. 复杂度分析与变种思考

5.1 时间与空间复杂度

  • 时间复杂度:O(m+n)。我们最多会遍历nums1的有效部分一次(通过p1),遍历nums2一次(通过p2),每个元素都被比较和赋值一次。没有嵌套循环,是线性的时间复杂度。
  • 空间复杂度:O(1)。除了几个固定的指针变量,我们没有使用任何与mn成比例的额外存储空间。这是相对于使用额外数组的解法最大的优势。

5.2 如果题目要求稳定排序怎么办?

原题只要求“非递减顺序”,没有要求稳定排序。稳定排序是指如果两个元素相等,排序后它们的相对位置保持不变。假设nums1nums2中的元素还附带其他信息(比如是对象,用某个键排序),我们需要保持稳定性。 我们之前的写法(相等时先放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 -= 1

5.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. 有序数组的平方:同样可以利用双指针,从两端向中间遍历,将平方后的较大值从结果数组的末尾开始放置。
  • 归并排序中的合并步骤:这是归并排序的核心子过程,本题目可以看作一个特化的、原地版本的二路归并。

最后,我个人的体会是,算法题的价值不在于死记硬背多少个解法,而在于通过每一道题,深入理解其背后的数据结构和算法思想,并锻炼将复杂问题分解、抽象、最终用简洁代码实现的能力。像“合并两个有序数组”这样的题目,就是培养这种能力的绝佳素材。它用简单的场景,考察了你对数组特性、指针操作和贪心思想的掌握程度。下次遇到类似问题,不妨先想想:有没有已经排好序的部分?能不能用指针来避免不必要的移动或拷贝?从哪个方向遍历可以避免冲突?多问自己几个为什么,你的算法功力自然会稳步提升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询