十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

LeetCode 88题解析:逆向双指针法合并有序数组的算法精讲

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(mn)大小的辅助数组。在面试或一些严格的空间限制场景下这通常不是期望的答案。题目将nums1的长度预设为mn并在尾部预留了空间这本身就是一个强烈的暗示希望我们能在不占用额外线性空间的情况下完成操作。那么如果坚持原地修改直接从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], m3nums2 [2, 4, 6], n3。如果从前往后合并当比较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指向下一个待写入位置的前一个所以是p1个元素) # 更直观的写法是直接指定范围复制 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(mn)需要额外数组时间复杂度O(mn)O(mn)代码复杂度中等需注意边界和剩余元素处理简单逻辑清晰适用场景内存敏感要求原地操作无空间限制求代码简洁显然逆向双指针法在空间效率上完胜这也是本题被设计出来的主要考察点。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]; m3; nums2[2,5,6]; n3 # 预期结果: [1,2,2,3,5,6]nums2 全部大于 nums1nums1 [1,2,3,0,0]; m3; nums2[4,5]; n2 # 预期: [1,2,3,4,5] # 验证主循环后 nums2 剩余元素处理nums2 全部小于 nums1nums1 [4,5,6,0,0]; m3; nums2[1,2]; n2 # 预期: [1,2,4,5,6] # 验证 nums1 元素向后移动nums2 元素填充前端存在重复元素nums1 [1,2,2,0,0,0]; m3; nums2[2,3,4]; n3 # 预期: [1,2,2,2,3,4] # 验证相等时的处理逻辑空数组用例# nums1 为空 nums1 [0,0]; m0; nums2[1,2]; n2 # 预期: [1,2] # nums2 为空 nums1 [1,2]; m2; nums2[]; n0 # 预期: [1,2] (不变)单元素数组nums1 [0]; m0; nums2[1]; n1 # 预期: [1] nums1 [2,0]; m1; nums2[1]; n1 # 预期: [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”。它教会了我几个重要的编程和算法思维操作方向的选择取决于数据布局当需要在原有数据空间内进行重组时必须仔细分析数据移动的方向避免有用的数据被覆盖。从后往前操作利用预留的空位是一种非常经典的“空间复用”技巧。双指针是处理有序序列的利器无论是同向双指针快慢指针还是相向双指针抑或是本题这种分别指向两个序列的指针它们都能将看似需要多层循环的问题简化到线性时间。关键在于定义清楚每个指针的含义和移动条件。边界条件就是生命线m0或n0的情况、指针变为负数的情况、循环结束后剩余元素的处理这些边界情况往往比主逻辑更能体现代码的健壮性。写完代码后主动构造极端用例进行测试是一个优秀程序员必备的习惯。理解“为什么”比记住代码更重要我见过有人死记硬背这个题的代码但稍微变一下形式比如要求合并到nums2里或者数组是递减的就不知所措。只有真正理解了“从后往前是为了防止覆盖”这个核心原因才能灵活应对各种变体。最后一个小技巧在面试中讲解这道题时不要急于写代码。可以先在白板上画两个数组用不同的颜色或标记标出有效数据和空闲区域然后一步步模拟从后往前合并的过程并解释每一步为什么是安全的。这种可视化演示比干巴巴的代码更能体现你的沟通能力和对问题的深刻理解。这道题就像一把钥匙打开的是对数组操作和双指针技巧深入理解的大门。
返回列表