LeetCode 88题解析:逆向双指针法合并有序数组的算法精讲
1. 从一道“简单”题开始的思考
今天想聊的题目是LeetCode上的第88题“合并两个有序数组”。乍一看,这题简单得有点“侮辱智商”:给你两个按非递减顺序排列的整数数组nums1和nums2,以及两个整数m和n,分别表示nums1和nums2中的元素数目。你需要将nums2合并到nums1中,使合并后的数组同样按非递减顺序排列。nums1的初始长度为m + n,其中前m个元素表示应合并的元素,后n个元素为 0,应忽略。nums2的长度为n。要求是原地修改nums1,不能返回一个新数组。
很多刚接触算法的朋友,甚至一些有经验的同学,看到这个描述的第一反应可能是:“这不就是归并排序的合并步骤吗?太基础了。” 然后随手写出一个从前往后合并的版本,提交,结果发现要么是结果不对,要么是虽然通过了但总觉得哪里有点别扭。这道题真正的“坑”和“价值”恰恰就藏在这份看似简单的描述里。它考察的不是你会不会合并,而是你能不能在一个特定的约束条件下(原地修改,且nums1前端有有效数据,后端有预留空间)高效、正确地完成合并。这背后涉及的是对数组操作、指针(或索引)移动、以及算法时空复杂度权衡的深刻理解。今天,我们就来彻底拆解这道题,不止于AC,更要弄明白每一种解法背后的“为什么”,以及在实际编码中那些容易忽略的细节。
2. 误区警示:为什么不能简单地从前往后合并
我们先来看看最直观、也最容易出错的解法思路:开辟一个新数组,然后用两个指针分别指向nums1和nums2的开头,比较大小,依次放入新数组,最后再把新数组拷贝回nums1。
def merge(nums1, m, nums2, n): merged = [0] * (m + n) i, j, k = 0, 0, 0 while i < m and j < n: if nums1[i] <= nums2[j]: merged[k] = nums1[i] i += 1 else: merged[k] = nums2[j] j += 1 k += 1 while i < m: merged[k] = nums1[i] i += 1 k += 1 while j < n: merged[k] = nums2[j] j += 1 k += 1 # 拷贝回 nums1 for idx in range(m + n): nums1[idx] = merged[idx]这个解法逻辑完全正确,但它违背了题目的一个关键要求:原地修改。虽然最终nums1的内容被更新了,但过程中我们额外使用了一个O(m+n)大小的辅助数组。在面试或一些严格的空间限制场景下,这通常不是期望的答案。题目将nums1的长度预设为m+n,并在尾部预留了空间,这本身就是一个强烈的暗示:希望我们能在不占用额外线性空间的情况下完成操作。
那么,如果坚持原地修改,直接从nums1的开头开始覆盖写入行不行呢?我们来模拟一下:设i指向nums1待比较元素,j指向nums2待比较元素,k指向nums1中当前需要写入的位置(从0开始)。当nums1[i] <= nums2[j]时,似乎可以直接把nums1[i]放到nums1[k](即原位置或更前的位置)。但这里有一个致命问题:nums1前半部分的元素是后续比较还需要用到的“原材料”。如果我们从nums1[0]开始覆盖,那么当nums1[i] > nums2[j],需要将nums2[j]写入nums1[k]时,这个位置可能原本存放着一个尚未被比较的nums1元素,这个元素会被直接覆盖掉,导致数据丢失,后续比较无法进行。
例如:nums1 = [1, 3, 5, 0, 0, 0], m=3,nums2 = [2, 4, 6], n=3。如果从前往后合并,当比较1和2时,1写入原位;比较3和2时,2需要写入nums1[1],这会覆盖掉尚未参与比较的3,从此这个3就永远丢失了,最终结果必然错误。
所以,从前往后合并,在需要原地修改且源数组前半部分有数据的情况下,是不可行的,因为会产生数据覆盖冲突。这个误区是理解本题最优解法的第一个关键。
3. 核心解法:逆向双指针法(从后往前合并)
既然从前往后会覆盖未处理的数据,一个很自然的逆向思维就是:从后往前处理。因为nums1的尾部是预留的空白区域(初始为0),这片空间就是我们的“临时操作区”。我们可以从两个数组有效元素的末尾开始比较,将较大的那个放到nums1整个数组的末尾。这样,每次写入的位置都是当前空闲的,永远不会覆盖掉那些还需要用来比较的“有效数据”。
3.1 算法步骤与原理拆解
我们定义三个指针(或索引):
p1:指向nums1有效部分的最后一个元素,初始值为m - 1。p2:指向nums2的最后一个元素,初始值为n - 1。p:指向nums1中当前需要填充的位置,初始值为m + n - 1(即整个nums1的最后一个位置)。
然后,我们进行一个循环,只要p1和p2都还大于等于0(即两个数组都还有元素未处理):
- 比较
nums1[p1]和nums2[p2]。 - 将较大的那个值赋值给
nums1[p]。 - 将较大的值所属数组的指针(
p1或p2)向前移动一位。 - 将写入指针
p也向前移动一位。
当上述循环结束后,有两种情况:
p1先小于0:这意味着nums1的有效元素已经全部处理并归位完毕,但nums2中还有剩余元素。由于这些剩余元素本身就是有序的,并且它们都应该小于等于已经放置在nums1后半部分的元素(如果存在的话,实际上此时nums1前半部分已空),所以我们需要将nums2中剩余的元素(从0到p2)按顺序拷贝到nums1前端剩余的位置(从0到p)。p2先小于0:这意味着nums2的所有元素都已并入nums1,且nums1自身剩余的有效元素已经处在正确的位置上(因为它们是在和nums2元素的比较中被移动的),此时工作已经完成,无需额外操作。
为什么
p2先耗尽时不需要额外操作?因为我们的操作始终是把较大的数往nums1的尾部塞。如果nums2先耗尽,说明nums1剩余的有效元素都是相对较小的,它们在与nums2元素的比较中“胜出”,被移动到了更靠前的位置。当nums2耗尽时,这些nums1的剩余元素已经处于它们最终排序位置更靠前的地方,并且它们的相对顺序没有改变,所以整个数组已经有序。
3.2 代码实现与逐行分析
下面给出Python版本的实现,并加上详细注释:
def merge(nums1, m, nums2, n): """ 原地合并两个有序数组到 nums1 中。 :type nums1: List[int] :type m: int :type n: int :type nums2: List[int] :rtype: None Do not return anything, modify nums1 in-place instead. """ # 初始化三个指针 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当前元素更大,将其放到当前写入位置 nums1[p] = nums1[p1] p1 -= 1 else: # nums2当前元素更大或相等,将nums2的元素放入 # 注意:这里处理了相等的情况,将nums2的元素放入,保证了稳定性(如果考虑的话) nums1[p] = nums2[p2] p2 -= 1 p -= 1 # 写入位置前移 # 循环结束后,如果nums2还有剩余元素(p2 >= 0) # 需要将这些剩余元素(它们一定是当前最小的)复制到nums1的前端 # 如果nums1有剩余(p1 >= 0),它们已经在正确的位置上,无需操作 if p2 >= 0: # 将nums2[0..p2]的内容复制到nums1[0..p] (注意:此时p指向下一个待写入位置的前一个,所以是p+1个元素) # 更直观的写法是直接指定范围复制 nums1[:p2 + 1] = nums2[:p2 + 1]关键点解析:
- 循环条件
while p1 >= 0 and p2 >= 0:确保只在两个数组都还有未处理的元素时进行比较。任何一个指针走到-1,就意味着该数组的元素已全部安置妥当。 - 比较逻辑
if nums1[p1] > nums2[p2]:这里使用>而不是>=,当相等时,我们会执行else分支,将nums2[p2]放入。这个选择在本题中不影响最终排序结果(都是非递减),但它隐含了一种处理习惯。有时我们讨论算法的“稳定性”,即相等元素的原始相对顺序是否保持不变。这里若nums1的元素在前,nums2的相等元素在后,这样处理可以看作是一种约定。 - 最后的
if p2 >= 0分支:这是整个算法最容易遗漏的一步。当nums1的有效元素全部处理完(p1先变为-1),而nums2还有元素时,这些剩余的nums2元素一定小于等于任何已经放置在nums1后半部分的元素(实际上此时后半部分就是最终位置),并且它们自身是有序的。因此,只需要将它们整体拷贝到nums1最前端尚未被覆盖的位置(即从索引0开始)。nums1[:p2 + 1] = nums2[:p2 + 1]这行代码利用Python切片的高效性,一次性完成了这个拷贝操作。注意,此时p可能不等于p2,但p2 + 1正好是剩余待拷贝的元素数量,而nums1前端同样数量的位置是空的(因为p1已耗尽,这些位置的原数据已经被安全地移动到后面去了)。
3.3 复杂度分析与对比
- 时间复杂度:O(m + n)。我们最多会遍历
nums1的有效元素一次(移动),遍历nums2的所有元素一次(比较并移动或直接拷贝),因此总操作次数与两个数组的总长度成线性关系。 - 空间复杂度:O(1)。我们只使用了几个固定的指针变量,没有使用任何与输入规模相关的额外空间,完美符合原地修改的要求。
与开篇提到的“辅助数组法”对比:
| 特性 | 逆向双指针法 | 辅助数组法 |
|---|---|---|
| 空间复杂度 | O(1),原地修改 | O(m+n),需要额外数组 |
| 时间复杂度 | O(m+n) | O(m+n) |
| 代码复杂度 | 中等,需注意边界和剩余元素处理 | 简单,逻辑清晰 |
| 适用场景 | 内存敏感,要求原地操作 | 无空间限制,求代码简洁 |
显然,逆向双指针法在空间效率上完胜,这也是本题被设计出来的主要考察点。
4. 边界条件与常见“坑点”实战
即使理解了算法,在实现时依然有几个细节容易出错,这些往往是面试时面试官关注的重点,也是自己调试时的常见痛点。
4.1 空数组处理
题目中m和n可能为0。
- 如果
m == 0,则nums1的有效部分为空。此时p1 = -1。我们的主循环while p1 >= 0 and p2 >= 0会直接跳过,因为p1 >= 0为假。然后进入if p2 >= 0分支,将整个nums2拷贝到nums1的前n位。这正是我们期望的行为:将nums2合并到一个空的nums1中。 - 如果
n == 0,则nums2为空。p2 = -1。主循环同样跳过,if p2 >= 0条件为假,函数直接结束。nums1保持不变,这也是正确的。 - 如果
m != 0但n == 0,算法也能正确处理。我们的实现已经涵盖了这些情况。
4.2 指针越界与循环终止条件
确保指针在移动前是有效的至关重要。在while循环中,我们确保p1和p2都有效时才进行比较。在循环体内,每次赋值后移动指针时,也要确保不会对负数索引进行访问(在我们的逻辑中,循环条件保证了访问是安全的)。最后的nums1[:p2 + 1] = nums2[:p2 + 1],当p2为 -1 时,切片[:0]是空切片,操作是安全的,什么也不会做。
4.3 剩余元素处理的另一种写法
有些实现喜欢在循环结束后,再用两个独立的while循环来处理nums1或nums2的剩余元素,而不是用切片拷贝。代码如下:
# 主循环同上... while p1 >= 0 and p2 >= 0: # ... 比较和赋值 # 处理 nums1 剩余元素 (实际上不需要,因为已经在正确位置) # while p1 >= 0: # nums1[p] = nums1[p1] # p1 -= 1 # p -= 1 # 处理 nums2 剩余元素 while p2 >= 0: nums1[p] = nums2[p2] p2 -= 1 p -= 1这种写法逻辑上更清晰,一步步地把剩余元素搬过去。它同样正确,并且更直观地展示了“无论哪个数组有剩余,都要继续放置”的过程。但需要注意的是,对于nums1剩余的情况,正如之前分析的,这些元素其实已经在正确的位置(它们是被从后往前“挤”过去的),所以第一个while p1 >= 0循环是多余的。而while p2 >= 0循环是必要的。我个人更喜欢切片拷贝的简洁性,但显式的while循环在理解算法流程上更有教学意义。
4.4 关于“稳定性”的思考
虽然题目没有要求保持稳定性(即相等元素的原始顺序),但我们可以思考一下当前算法的稳定性。假设nums1 = [x1, x2],nums2 = [y1, y2],且x1 == y1。在我们的代码中,当nums1[p1] == nums2[p2]时,我们执行else分支,将nums2[p2](即y1)放入。这意味着在合并后的数组中,y1会出现在x1的后面。如果nums1和nums2各自内部有序,且我们将nums2视为需要被合并进来的“新”序列,那么这种处理可以理解为保持了nums2元素相对于nums1中间等元素的“后置性”,但这并非传统归并排序中定义的稳定性。在纯粹的归并排序合并中,通常当元素相等时,我们会优先放置前一个数组的元素以保持稳定。对于本题而言,排序正确性是唯一要求,稳定性不是考点,但了解自己代码在这方面的行为是有益的。
5. 测试用例设计与验证
写出代码后,必须用多种情况的测试用例来验证其正确性和健壮性。以下是一些关键的测试场景:
常规用例:
nums1 = [1,2,3,0,0,0]; m=3; nums2=[2,5,6]; n=3 # 预期结果: [1,2,2,3,5,6]nums2 全部大于 nums1:
nums1 = [1,2,3,0,0]; m=3; nums2=[4,5]; n=2 # 预期: [1,2,3,4,5] # 验证主循环后 nums2 剩余元素处理nums2 全部小于 nums1:
nums1 = [4,5,6,0,0]; m=3; nums2=[1,2]; n=2 # 预期: [1,2,4,5,6] # 验证 nums1 元素向后移动,nums2 元素填充前端存在重复元素:
nums1 = [1,2,2,0,0,0]; m=3; nums2=[2,3,4]; n=3 # 预期: [1,2,2,2,3,4] # 验证相等时的处理逻辑空数组用例:
# nums1 为空 nums1 = [0,0]; m=0; nums2=[1,2]; n=2 # 预期: [1,2] # nums2 为空 nums1 = [1,2]; m=2; nums2=[]; n=0 # 预期: [1,2] (不变)单元素数组:
nums1 = [0]; m=0; nums2=[1]; n=1 # 预期: [1] nums1 = [2,0]; m=1; nums2=[1]; n=1 # 预期: [1,2]
在IDE或LeetCode调试器中逐一运行这些用例,观察指针变化和数组状态,能极大地加深对算法流程的理解。特别是用例2和3,它们分别对应了p1先耗尽和p2先耗尽的情况,是检验剩余元素处理逻辑是否正确的试金石。
6. 举一反三:相关变种与扩展思考
掌握了“合并两个有序数组”的核心——逆向双指针法,我们可以解决一系列类似问题,其本质都是在有限空间内进行有序数据的重排。
6.1 变种一:合并并去重
如果要求合并后的数组不能有重复元素呢?思路依然是从后往前,但在比较和放置时加入去重逻辑。由于数组已有序,重复元素必然相邻。我们可以维护一个指向当前合并结果数组最后一个有效元素的位置last_unique_pos。当从nums1或nums2取出一个候选值val准备放入时,先与nums1[last_unique_pos]比较,如果相等,则跳过该值(不放入),只移动源数组的指针;如果不相等,则放入,并更新last_unique_pos。需要注意处理初始时last_unique_pos的设定以及两个源数组各自内部的重复。
6.2 变种二:合并K个有序数组
这是LeetCode第23题“合并K个升序链表”的数组版本。最直接的方法是两两合并,但时间复杂度会达到 O(k^2 * n),其中n是平均长度。更高效的方法是使用最小堆(优先队列)。初始化时,将每个数组的第一个元素及其所属数组索引、元素索引放入最小堆。每次从堆中弹出最小元素,放入结果数组,然后将该元素所在数组的下一个元素(如果存在)推入堆中。时间复杂度为 O(N log k),其中N是总元素数。对于数组,我们可能需要一个额外的索引来跟踪每个数组当前取到了第几个元素。
6.3 变种三:原地合并的链表版本
如果数据结构是链表,例如“合并两个有序链表”(LeetCode 21),问题会变得更简单,因为链表节点的插入不需要移动大量元素,只需要改变指针。通常我们使用一个虚拟头节点,然后用两个指针遍历两个链表,将较小的节点接在结果链表后面即可。这属于“从前往后”合并,但因为是链表,不存在数组那样的覆盖问题。
6.4 工程实践中的考量
在实际的软件开发中,我们很少会像这道题一样,预先在一个大数组里留好空位。更常见的场景可能是:我们有两个有序的列表或数组,需要合并成一个新的有序列表。此时,使用一个额外的辅助空间(新列表)是最清晰、最不易出错的选择,代码可读性更高。不要为了“炫技”而刻意追求原地修改,除非有明确的内存限制要求。这道题的训练价值在于锻炼我们在特定约束下优化空间复杂度的思维,而不是鼓励在所有合并场景中都使用逆向双指针。
7. 从解题到掌握:我的复盘心得
回顾这道题,我认为它的价值远远超过一个简单的“Accept”。它教会了我几个重要的编程和算法思维:
- 操作方向的选择取决于数据布局:当需要在原有数据空间内进行重组时,必须仔细分析数据移动的方向,避免有用的数据被覆盖。从后往前操作,利用预留的空位,是一种非常经典的“空间复用”技巧。
- 双指针是处理有序序列的利器:无论是同向双指针(快慢指针)还是相向双指针,抑或是本题这种分别指向两个序列的指针,它们都能将看似需要多层循环的问题简化到线性时间。关键在于定义清楚每个指针的含义和移动条件。
- 边界条件就是生命线:
m=0或n=0的情况、指针变为负数的情况、循环结束后剩余元素的处理,这些边界情况往往比主逻辑更能体现代码的健壮性。写完代码后,主动构造极端用例进行测试,是一个优秀程序员必备的习惯。 - 理解“为什么”比记住代码更重要:我见过有人死记硬背这个题的代码,但稍微变一下形式(比如要求合并到
nums2里,或者数组是递减的)就不知所措。只有真正理解了“从后往前是为了防止覆盖”这个核心原因,才能灵活应对各种变体。
最后,一个小技巧:在面试中讲解这道题时,不要急于写代码。可以先在白板上画两个数组,用不同的颜色或标记标出有效数据和空闲区域,然后一步步模拟从后往前合并的过程,并解释每一步为什么是安全的。这种可视化演示比干巴巴的代码更能体现你的沟通能力和对问题的深刻理解。这道题就像一把钥匙,打开的是对数组操作和双指针技巧深入理解的大门。
