合并两个有序链表:双指针、哨兵节点与递归写法详解
刷到这个题的时候我正在过力扣hot100清单合并两个有序链表这题排在比较靠前的位置。说实话第一眼看到题目我觉得挺简单的不就是双指针扫一遍嘛但等真正动手写的时候才发现里面值得抠的细节远比想象中多。链表的指针操作、边界情况的处理、递归和迭代的取舍这些基本功全都能在这道题里体现出来。不管你是刚准备面试的应届生还是写了好几年业务代码想补一补算法底子这题都值得认真做一遍。这篇文章我把自己刷题过程中的完整思路、两种主流解法的代码逐行解释、踩过的坑以及面试现场怎么回答更加分全部整理出来应该能帮你把这道题吃透。1. 题目拆解与考点分析1.1 先读懂题目到底在问什么题目描述本身很短给定两个升序排列的链表要求把这两个链表合并成一个新的升序链表并返回。所谓升序就是链表从头到尾每个节点的值逐步变大比如1 - 2 - 4。合并的意思不是新建一个数组做排序而是要把两条链表的节点重新串联起来最终返回的头节点是值较小的那一个。这里有一个关键信息很多人会粗心忽略输入的两个链表本身就是有序的。既然是有序的就完全没有必要把所有节点摘下来重新排序那是最笨也是最容易被面试官追问的做法。正确思路是利用两个链表各自有序这个前提用类似于归并排序里合并过程的双指针方式每次只取两个链表当前节点中较小的那一个接到结果链上。还有一个容易忽略的细节是题目对空链表的处理。一个链表为空另一个不为空甚至两个都为空这三种情况在代码里都必须有对应的分支处理。如果两个链表都为空返回的应该也是空链表也就是null。很多初学者在写完核心逻辑之后会漏掉这一层判断导致空指针异常这类低级错误在面试现场扣分很严重。1.2 这道题的高频考点和隐藏要求这道题虽然代码量不大但至少覆盖了四个核心考察点。链表基础操作是最直接的考察你对ListNode节点的理解对指针或者引用的运用以及是否清楚next指向的本质是一个内存地址。边界处理是第二个考点包括空链表、只有一个节点、两个链表长度差异很大、所有节点值都相同等情况。递归思维是第三个考点。用递归法解决链表问题时递归的终止条件和递推关系是怎么确定的栈空间怎么变化这些都是面试官喜欢追问的。最后还有一个容易被忽略的考察点空间复杂度。用迭代法时你是否能做到原地合并不新建任何额外的节点把空间复杂度控制在O(1)。如果写出的代码每次都new一个节点虽然功能上没错但面试官马上就会追问你空间复杂度是多少这时候就露怯了。我刷题时习惯先把题目想透彻再动手。这道题本质上是归并排序中的合并环节被单独拎出来考只不过数据结构从数组换成了链表。理解了这一层后续遇到合并K个有序链表、排序链表这些进阶题思路是可以直接迁移的。2. 核心解法一迭代法加哨兵节点2.1 双指针移动的完整流程迭代法的核心思想是同时维护两个指针l1和l2分别指向两条链表的当前节点再维护一个结果链表的尾部指针tail。每一轮循环里比较l1.val和l2.val把值较小的那个节点接到tail的后面然后移动对应链表的指针。当某一条链表先走到尽头就把另一条链表剩下的部分直接接在尾部。这里有一个非常实用的技巧创建一个哨兵节点dummy node也叫虚拟头节点。因为最终合并后的链表头节点是不确定的有可能是原来l1的头节点也有可能是l2的头节点。如果不用哨兵节点你就需要额外写一堆分支判断来确认结果链表的头到底是谁。有了哨兵节点头节点问题就消失了最后只需要返回dummy.next即可。哨兵节点的值无所谓一般设为-1就行它的唯一作用是充当结果链表的起点让代码逻辑统一不用单独处理头节点为空的特殊情况。我在实际写代码的时候几乎所有涉及链表拼接的题目都会先建一个 dummy 节点这个习惯可以帮你减少大量边界判断。2.2 迭代法完整代码与逐行解释class ListNode: def __init__(self, val0, nextNone): self.val val self.next next class Solution: def mergeTwoLists(self, l1: ListNode, l2: ListNode) - ListNode: dummy ListNode(-1) tail dummy while l1 and l2: if l1.val l2.val: tail.next l1 l1 l1.next else: tail.next l2 l2 l2.next tail tail.next tail.next l1 if l1 else l2 return dummy.next逐行解释一下。dummy ListNode(-1)创建哨兵节点tail一开始指向它。进入while l1 and l2循环后只要两条链表都还没遍历完就执行比较。注意我用了也就是说当两个节点的值相等时优先取l1的节点。这样写可以保证合并后的链表是稳定排序也就是原链表中靠前的相同值节点在合并后的链表中依然靠前。虽然题目没有明确要求稳定性但很多面试官会注意到这个细节。tail.next l1这行是把较小的节点接到结果链上然后l1 l1.next把指针往前移一位。这里最容易犯的错是忘了移动tail如果不移动下一次循环会把新节点覆盖掉链表就断开了。循环结束之后最多只有一条链表还有剩余节点直接用tail.next l1 if l1 else l2接上剩余部分即可。这行代码看似简单其实同时处理了三种情况l1剩节点、l2剩节点、两个都为空。最后return dummy.next跳过头节点返回真正的链表头。2.3 为什么说哨兵节点是链表题的万能钥匙很多刚开始刷链表题的读者可能会有疑问我不建哨兵节点行不行当然也行但代码会复杂很多。你需要在循环之前先比较l1和l2的头节点确定整个合并后链表的头节点是谁再进入循环。这样写虽然能过但代码多了好几行分支判断每多一个分支就多一个出错的可能。哨兵节点的另一个好处是它天然处理了结果链表为空的情况。假如两条链表都为空循环不会执行tail.next也不会被赋值最终返回dummy.next就是null完全正确。打个比方哨兵节点就像你去银行办事的时候在门口取的排队号它本身不参与业务但它保证了整个流程有序进行。你在窗口处理完业务之后排队号就被丢掉了存款流水照常进行。链表题里很多人头疼的头节点不确定性问题用哨兵节点直接化解所以我愿意把它称为链表题里的万能钥匙。3. 核心解法二递归法及其本质3.1 递归三要素在链表合并中的落地递归法写起来更短看起来也更优美但理解起来对新手稍微有一点门槛。写递归必须先明确三件事递归函数的返回值是什么、递归的终止条件是什么、每一层递归要做什么。对于合并两个有序链表这个场景mergeTwoLists(l1, l2)递归函数的语义是返回合并l1和l2这两个链表后得到的新链表头节点。终止条件自然就是l1或l2为空如果l1为空那合并结果就是l2如果l2为空结果就是l1如果两个都为空返回空。每一层递归要做的核心事情是比较当前两个节点的值值较小的节点就是合并后链表的头节点然后让这个节点的next指向对剩余部分继续合并的结果。用生活化的方式理解递归就像是排队烙饼。你站在队伍里负责比较自己和你前面那个人谁先烙你只需要解决当前这一步剩下的事交给后面的人。每一层递归都在解决当前最小的是谁这个问题然后大家把答案一层层往回传最终拼成完整的链表。3.2 递归代码实现与调用过程模拟class Solution: def mergeTwoLists(self, l1: ListNode, l2: ListNode) - ListNode: if not l1: return l2 if not l2: return l1 if l1.val l2.val: l1.next self.mergeTwoLists(l1.next, l2) return l1 else: l2.next self.mergeTwoLists(l1, l2.next) return l2我们用l1 1 - 3l2 2 - 4来模拟一下递归过程。第一次调用两个头节点分别是 1 和 2因为 1 小于 2所以进入第一个分支l1.next被赋值为合并3这个链表和2 - 4这个链表的结果。此时函数返回的是节点 1但这个节点的next还没确定它等着递归结果回来。第二次调用传入的是3和2 - 4比较 3 和 22 更小所以l2.next被赋值为合并3和4的结果。函数这次返回节点 2它同样等着下层递归结果。第三次调用传入3和43 更小进入第一个分支l1.next被赋值为合并空链表和4 -这个链表的结果。返回节点 3。第四次调用传入null和4 -第一个终止条件命中直接返回4 -。然后递归开始逐层返回。第四次的结果接在节点 3 后面变成3 - 4这个结果接在节点 2 后面变成2 - 3 - 4最终接在节点 1 后面得到1 - 2 - 3 - 4。整个过程像是先把问题拆到底再按原路把答案拼起来。3.3 递归和迭代怎么选面试时怎么说面试现场你不需要两种解法都写但至少要能说出两种解法的差异。迭代法是原地修改指针空间复杂度是O(1)不依赖调用栈递归法代码更简洁逻辑更清晰但会占用O(n)的递归栈空间。n在这里是两条链表总长度因为极端情况下比如l1 1 - 2 - 3 - 4 - 5l2 6 - 7递归会一直顺着l1往下走直到l1为空压栈深度等于l1的长度。我在面试中一般这样回答如果链表长度未知或可能非常长我会优先选择迭代法因为递归可能导致栈溢出如果题目明确指出链表长度有限两种解法都可以递归写法更优雅并且在后续需要反向思考链表问题时有启发意义。这个回答既展示了你理解两种方法的本质区别又体现了工程意识面试官对你的印象分通常不会差。4. 边界情况与错误排查实录4.1 我刷题时踩过的坑第一坑忘接剩余链表。我第一次写迭代法的时候while循环结束后我以为就完事了直接return dummy.next结果发现输出只包含了较短链表的所有节点较长链表的剩余节点全部丢失。原因很简单循环只会执行到某一条链表为空此时另一条链表可能还有一串节点这串节点必须靠循环外的一行tail.next ...接上。这个坑只要跑一次测试用例就能立刻发现但在面试现场如果没有提前用测试用例检验就很尴尬。第二坑循环里忘了移动tail。我见过不少同学写循环时只顾着移动l1和l2指针却忘了把tail也往后移一位。结果就是每一轮循环都把新节点接到同一个位置前一个节点的next指向被覆盖链表在这里断裂。写代码时记住一个原则tail永远指向结果链表的最后一个节点每接入一个新节点tail就必须同步前进。第三坑值相等的死循环隐患。假设两个链表当前节点的值相等如果你在if和else之间处理不当比如两种情况都写了相同的逻辑就可能出现死循环或者节点重复输出。我的建议是明确约定相等时取l1的节点然后在代码里写出if l1.val l2.val的分支这样逻辑不会模棱两可。第四坑空指针异常。很多人拿到题目直接写while (l1.val l2.val)完全不检查l1或l2是否为空。这个错误在链表题里几乎一抓一个准。循环条件必须写成l1 and l2也就是只有两个都非空才能进入循环任一为空就退出。4.2 常见问题速查表问题现象可能原因解决方案输出链表缺了后半部分循环结束后没接剩余链表加上tail.next l1 if l1 else l2运行时空指针异常没判断链表为空就读取val循环条件用while l1 and l2链表在中间断开忘了移动tail指针每接入一个节点tail tail.next同步前进出现了死循环相等值处理分支不完整明确用让相等情况走入指定分支输出结果不符合有序比较用了或方向写反每次取较小值别被变量名绕晕内存占用过高每接入节点时new了新节点直接复用原链表的节点引用不创建新节点这里要特别提醒一点面试的时候哪怕你的代码一次性通过也一定要主动说出我已经考虑到了空链表和长度不等的情况。面试官不会只看你最终的输出是否正确更会关注你在写代码的过程中是否显式处理了这些边界分支。把边界处理讲清楚比把代码背下来重要得多。4.3 测试用例设计技巧很多同学刷题只看官方给的那一个示例跑通了就以为完事了。这样远远不够。设计测试用例要覆盖正常情况、极端情况和边界情况。我用这组用例测试自己的代码基本可以覆盖全部场景第一条用例是题目的标准示例[1,2,4]和[1,3,4]期望输出[1,1,2,3,4,4]。第二条是双方都是空链表期望输出空。第三条是一个为空另一个不为空比如[]和[0]期望输出[0]。第四条是长度差异很大的情况比如[1]和[2,3,4,5,6]重点检查剩余链表是否正确接上。第五条是所有节点值都相同的链表比如[5]和[5,5]检查相等值分支是否稳定。我建议大家在本地环境里把这五条用例都跑一遍再提交。5. 复杂度分析与更好的理解方式5.1 时间复杂度的推导过程迭代法中两个指针分别从两条链表的头部出发每次循环确定一个节点的最终位置。总节点数是m n其中m和n分别是两条链表的长度。每一轮循环时间复杂度是O(1)也就是常数时间内的比较和指针修改所以整体时间复杂度是O(m n)。递归法同样是每个节点只被访问一次每一层递归做O(1)的工作因此时间复杂度同样是O(m n)。不会因为用了递归就变得更快也不会更慢。时间复杂度说的都是最坏情况下的渐进趋势在这个题里两条链表越长需要访问的节点总数就越多呈线性关系。5.2 空间复杂度的计算和取舍迭代法的空间复杂度是O(1)。整个合并过程只创建了一个哨兵节点其余全部通过修改现有节点的next指针完成不额外分配与链表长度相关的内存。这也是我认为迭代法更适合工程场景的原因之一。递归法的空间复杂度是O(m n)因为递归调用会使用系统栈每一层递归保存的是当前函数的局部状态这个栈的深度最多等于两条链表的总长度。如果链表很长极端情况下递归栈可能撑爆所以生产环境的代码一般不用递归实现链表合并原因就在这。很多人混淆了递归代码看起来短和效率高这两件事。在算法题里代码短不等于效率高递归通常以空间换代码简洁性。这道题两种解法的时间复杂度完全一样但空间复杂度差了O(m n)这是面试时最值得展开讲的一个比较点。6. 从合并两个到合并K个进阶变体6.1 变体问题的思路迁移这道题刷完之后我强烈建议顺势做一道进阶题合并K个升序链表。合并两个链表的解法可以直接扩展基本思路是每轮从K个链表的头节点中选出值最小的那一个接在结果链上。最直观的做法是每轮遍历一遍K个头节点找最小这样做的时间复杂度是O(K * N)其中N是所有链表的总节点数。问题在于每选一个节点都要重新比较K个头当K很大时效率很差。更进一步的做法是用优先级队列最小堆来优化找最小值的过程每次从堆顶取最小节点然后把它所在链表的下一个节点放入堆中。写优先级队列解法的时候有一个容易翻车的细节堆中存储的元素不能直接放ListNode因为堆在比较元素时会尝试比较整个对象而ListNode默认没有实现可比较接口。我踩过这个坑运行时报类型错误。正确做法是在元组里同时存(val, index, node)因为index的存在可以保证即使val相同也能通过index区分两个节点不会触发对node本身的比较。6.2 优先级队列的参考代码import heapq class Solution: def mergeKLists(self, lists): heap [] for i, node in enumerate(lists): if node: heapq.heappush(heap, (node.val, i, node)) dummy ListNode(0) tail dummy while heap: val, i, node heapq.heappop(heap) tail.next node tail tail.next if node.next: heapq.heappush(heap, (node.next.val, i, node.next)) return dummy.nextenumerate(lists)为每个链表编号这个编号就是元组里的index。当两个节点的val相同时堆会继续比较index来区分它们从而避免了直接比较ListNode对象。这个解法的总时间复杂度是O(N * logK)空间复杂度是O(K)。如果你不想用堆还可以用分治的思路先把K个链表两两配对分别用合并两个链表的方法合并合并的结果再继续两两配对直到最终只剩一个链表。这个方案的时间复杂度同样是O(N * logK)空间复杂度是O(logK)是另一种很有价值的解法。面试时能把堆解法和分治解法都讲清楚基本可以证明你对链表合并类问题有系统性的理解。7. 面试现场如何回答才能加分7.1 先沟通再动手拿到这道题不要立刻开始写代码。先用一两句话跟面试官确认假设两个链表是否都是升序排列是否可以修改原始链表的结构如果链表中有重复值怎么处理这些提问会让面试官觉得你是一个考虑周全的工程师而不是一个只会背题的人。明确可以修改原始链表这一点很重要因为这决定了空间复杂度能不能做到O(1)。如果面试官要求你不能改变原链表的结构那你可能需要先复制节点再合并空间复杂度相应变为O(m n)。大多数情况下面试官都会允许原地修改不过你要主动确认。7.2 边写边说的节奏建议写代码时不要闷头敲完再解释而是边写边说思路。我先说我打算用一个哨兵节点统一处理头节点的不确定性然后把 dummy 节点建出来。写到while循环时说当两个链表都不为空时比较当前节点的值。写到tail.next ...这行时说明把较小节点接到结果链表末尾。最后提到边界处理循环结束后把剩余链表直接接上因为剩下的链表本身已经有序。这个节奏的好处是面试官能实时跟上你的思路即使代码有小瑕疵他也会理解你的整体方向是正确的。很多候选人代码写得对但全程不说话面试官没办法评估思路过程评分往往会打折。7.3 追问环节的准备面试官常见的追问包括时间复杂度是多少空间复杂度是多少能不能用递归实现递归的空间复杂度是多少两个链表的值有重复怎么办如果链表是逆序的要怎么处理合并K个链表怎么做其中递归的空间复杂度是多少这个问题最容易被忽略。学生时代很多人只关心递归代码怎么背没有想过每一层递归都在占用栈空间。提前想清楚这个问题面试时就能从容回答迭代法O(1)递归法O(m n)。如果要答链表是逆序的这个变体处理方式其实大同小异你需要先把两条链表分别反转再进行合并反转链表本身又是一道经典题可以提前准备一下。8. 个人心得与一个小技巧这道题我前前后后刷了三遍每一遍都有新的收获。第一遍只会照着题解抄对哨兵节点的理解很浅第二遍开始自己推导边界条件理解了为什么循环结束后还要接剩余链表第三遍之后我开始主动总结链表题型的通用套路。现在我刷到一个新的链表题第一反应永远是能不能用哨兵节点简化头节点处理这个习惯让我在绝大多数链表题上都能快速写出简洁的解法。最后分享一个小技巧合并完链表之后千万不要在本地用打印链表的方式验证结果就完事。我习惯顺手检查一下会不会有成环的问题操作方法是把链表走一遍用一个指针记录头节点如果走的过程中某个节点的next回到了头节点说明代码在某个位置造成了环。链表题成环是很隐蔽的错误代码跑起来可能不报错但一旦被面试官用特殊用例测试就露馅了。养成写完后自己构造几个边界用例的习惯对你后续刷任何数据结构题都会有帮助。