LeetCode 430:扁平化多级双向链表的递归与迭代解法详解

发布时间:2026/8/1 18:52:46
LeetCode 430:扁平化多级双向链表的递归与迭代解法详解 1. 项目概述当链表有了“子节点”如果你刷过一些链表题可能会觉得链表无非就是val和next顶多再加个prev变成双向链表。但 Leetcode 430 这道“扁平化多级双向链表”的题目直接把复杂度提升了一个维度它引入了一个叫child的指针。这不再是简单的“一条线”而是一棵可能在任何节点分叉的“树”只不过这棵树是用链表节点连起来的。想象一下你手头有一份文档的大纲主标题一级节点下可能有子标题二级节点子标题下还有更细的说明三级节点。这份大纲在内存里就是用这种带child指针的链表存储的。现在老板要求你把这份“层级式”的大纲转换成一份“扁平化”的、所有内容按深度优先顺序依次排列的纯文本列表。Leetcode 430 要解决的就是这个问题遍历这棵“链表树”按照深度优先的顺序将所有的child链表“拉平”并插入到当前节点和它的下一个节点之间最终形成一个单一、绵长的双向链表。这题之所以被频繁讨论从相关热词如“链表遍历”、“链表插入”的高频出现可见一斑是因为它完美融合了链表的基础操作遍历、插入、指针修改和树的深度优先搜索DFS思想。它考察的不仅仅是你会不会写while循环移动指针更是考察你能否在复杂的指针关系变化中保持清晰的逻辑处理好prev、next和child这三个指针的“牵一发而动全身”。很多朋友在初次尝试时很容易被绕晕导致链表断裂或者形成环。接下来我就结合自己多次调试和教学的经验把这道题的“里子”和“面子”都拆解清楚。2. 核心思路拆解递归与迭代的抉择面对这种具有“层级”或“嵌套”结构的问题我们的第一反应往往是递归。因为递归的思想天然契合“深度优先”处理当前节点如果它有孩子就一头扎进去处理孩子链表处理完了再回来继续。这题用递归实现确实非常直观。2.1 递归解法清晰但需注意细节递归函数dfs(node)的核心职责是扁平化以node为头节点的链表及其所有子链表并返回扁平化后的尾节点。返回尾节点是关键因为父节点需要知道子链表处理完后应该接回哪里。递归过程可以分解为以下几步初始化定义current指针指向当前节点tail指针用于记录当前链表的最后一个节点初始化为current。循环处理只要current不为空就持续处理。保存后继这是极易出错的一步在处理current的child之前必须先用一个临时变量next_node保存current.next。因为一旦我们开始处理childcurrent.next会被修改指向子链表的头节点原来的后继关系就丢失了。处理子链表如果current.child不为空递归调用dfs(current.child)得到子链表扁平化后的尾节点child_tail。重新接线将current.next指向current.child。将current.child.prev指向current。这是将子链表“拉出来”接到主链上的关键操作。清空 child 指针题目要求扁平化后所有child指针都应置为null。连接子链表尾部将child_tail.next指向我们之前保存的next_node。如果next_node不为空即当前节点不是原链表的最后一个节点需要将next_node.prev指向child_tail。更新 tail此时整个链表包含刚接入的子链表的尾节点变成了child_tail或者如果child_tail后面还有next_node则 tail 会在后续循环中更新但这里tail应更新为child_tail以确保递归返回正确的尾节点。移动指针无论是否有child都将current移动到它的next节点注意此时的next可能已经是子链表的头节点了并更新tail为current当current不为空时。返回尾节点当current为空循环结束返回tail。注意递归解法在逻辑上清晰但需要警惕栈溢出风险。虽然本题的测试数据通常不会让递归深度达到溢出程度但在工业级代码或层级极深的情况下迭代解法是更安全的选择。2.2 迭代解法模拟栈的深度优先遍历迭代解法的核心思想是显式地使用一个栈Stack来模拟递归的调用过程。我们沿着next指针一路向前当遇到有child的节点时我们并不立即深入而是把当前节点的“未来”即next节点先压入栈中保存起来然后转向处理child。等child这条分支处理完了再从栈里把之前保存的“未来”弹出来继续处理。具体步骤创建一个栈stack初始化当前指针curr head。while循环只要curr不为空或者栈不为空还有未处理的分支就继续。如果curr.child不为空如果curr.next不为空将curr.next压入栈。这是保存主链的后续部分。执行扁平化操作curr.next curr.childcurr.child.prev currcurr.child null// 清空 child如果curr.next为空但栈不为空说明当前分支已经走到头需要回溯到之前保存的节点从栈顶弹出一个节点这就是之前保存的某个next节点。curr.next popped_nodepopped_node.prev curr移动curr到curr.next。迭代解法的优势在于完全避免了递归的调用开销和栈溢出风险空间复杂度上栈的大小取决于链表的“宽度”而非“深度”在某些情况下更优。它更像是在手动管理一个“待办事项列表”。2.3 方案选择与对比对于面试或日常刷题我通常推荐先掌握递归解法因为它思路直接代码简洁易于阐述。在解释时一定要强调“保存后继节点”和“返回尾节点”这两个关键点。如果面试官追问优化或大数据量处理再引出迭代的栈解法。为了更直观我们用一个简单的例子对比两种思路。假设链表为1 - 2 - 3 - 4 - 5其中节点3有一个child链表7 - 8 - 9。递归走到节点3保存next4递归处理child(7)。递归内部将7-8-9扁平化并返回尾节点9。然后执行3.next7,7.prev3,9.next4,4.prev9最后清空3.child。迭代栈走到节点3发现它有child且next4不为空将4压栈。然后处理3和child的连接。之后沿着7-8-9走。走到9时9.next为空但栈不为空栈里有4于是弹出4连接9.next4,4.prev9然后继续从4往下处理。两种方法最终都得到1 - 2 - 3 - 7 - 8 - 9 - 4 - 5。3. 递归解法深度剖析与代码实现让我们把递归解法掰开揉碎看看每一个指针变动背后的意图并给出健壮的代码。这里我以 Python 语言为例因为其语法清晰但逻辑完全适用于 Java、C 等。首先我们定义节点类这也是题目给出的基础结构。class Node: def __init__(self, val, prevNone, nextNone, childNone): self.val val self.prev prev self.next next self.child child接下来是递归函数flatten_dfs(head)的实现。这个函数输入是多级链表的头节点返回扁平化后的新链表头节点其实就是输入的头节点但结构已改变。class Solution: def flatten(self, head: Node) - Node: if not head: return head # 哑节点简化边界条件处理 dummy Node(0, None, head, None) def dfs(prev, curr): 递归扁平化链表。 Args: prev: 当前节点 curr 的前驱节点。 curr: 当前要处理的节点。 Returns: 当前子树扁平化后的尾节点。 if not curr: return prev # 1. 连接前驱和当前节点 curr.prev prev prev.next curr # 2. 保存当前节点的原始后继节点非常重要 next_node curr.next # 3. 优先处理 child 链表深度优先 tail curr # 初始尾节点为当前节点 if curr.child: # 递归处理 child返回 child 链表的尾节点 tail dfs(curr, curr.child) # 处理完后必须将 child 指针置空 curr.child None # 4. 继续处理原始后继节点 # 如果 next_node 为空则上一行得到的 tail 就是整个链表的尾节点 # 如果 next_node 不为空则继续递归并将 tail 更新为递归返回的尾节点 if next_node: tail dfs(tail, next_node) return tail # 从哑节点开始递归 dfs(dummy, head) # 断开哑节点与真实头节点的连接 dummy.next.prev None return dummy.next代码关键点解析与实操心得使用哑节点Dummy Node这是一个在处理链表问题时极其有用的技巧。它位于真实头节点之前可以避免对头节点prev指针为None的特殊判断让prev和curr的连接操作逻辑统一。在递归结束后记得将真实头节点的prev重新置为None并返回dummy.next。递归函数的参数设计我设计的dfs(prev, curr)同时传入前驱和当前节点。这样在每一层递归里连接prev和curr的操作就变得非常自然。另一种常见设计是dfs(curr)只传入当前节点返回尾节点然后在主函数里处理连接。两种方式都可以但我觉得传入prev让逻辑更清晰。next_node的保存时机必须在处理curr.child之前保存curr.next。因为一旦进入child分支curr.next就被修改了。这个临时变量是连接子链表和原主链后继的桥梁。尾节点tail的更新这是递归正确工作的核心。tail始终表示“当前已处理完的部分的最后一个节点”。处理完child后tail更新为子链表的尾节点然后如果还有next_node就继续递归处理并将tail更新为那次递归返回的尾节点。这样每一层递归都能向上返回正确的、最新的尾节点。清空child指针这是一个易漏点但必须做的操作。题目要求输出一个标准的双向链表所有child指针都应设为null。在递归处理完一个节点的child后立即将其置空是好习惯。4. 迭代解法详解与避坑指南对于更喜欢显式控制流程或者担心递归深度的朋友迭代解法是必须掌握的。它的核心是深度优先搜索DFS的栈实现。class Solution: def flatten(self, head: Node) - Node: if not head: return head dummy Node(0, None, head, None) prev dummy stack [head] # 初始化栈放入头节点 while stack: curr stack.pop() # 连接当前节点与前驱节点 prev.next curr curr.prev prev # 关键下一步该处理谁DFS要求先深入child # 所以如果当前节点有next先压栈稍后处理 # 如果当前节点有child下一步就处理child if curr.next: stack.append(curr.next) if curr.child: stack.append(curr.child) curr.child None # 清空child指针 # 移动prev指针为下一个节点做准备 prev curr # 断开哑节点连接 dummy.next.prev None return dummy.next等等上面的代码有一个经典的、不易察觉的错误你能看出来吗错误在于栈的压入顺序和while循环的弹出顺序。DFS 是后进先出LIFO。我们希望先处理child再处理原来的next。所以应该先把next压栈再把child压栈。这样弹出时child会在next之前被处理符合深度优先。上面的代码压栈顺序是next先于child但因为是pop()所以后压入的child会先弹出顺序是对的不仔细看if curr.next:和if curr.child:是顺序执行的。假设curr既有next又有child那么执行顺序是stack.append(curr.next)// 栈底[next]stack.append(curr.child)// 栈变为[next, child]stack.pop()取出的是最后压入的child。正确所以这段代码的压栈顺序是正确的。但它依然有另一个问题它没有在连接节点时正确处理prev和next的覆盖吗我们来看一个更清晰、不易出错的迭代写法它模拟了“主指针”前进遇到分支则保存现场的过程class Solution: def flatten(self, head: Node) - Node: if not head: return head dummy Node(0, None, head, None) prev dummy curr head stack [] # 栈里保存的是“主链上”被中断的后续节点 while curr: # 1. 连接节点 prev.next curr curr.prev prev # 2. 如果当前节点有孩子则处理孩子链表 if curr.child: # 如果当前节点还有后继则把后继节点保存到栈里以后处理 if curr.next: stack.append(curr.next) # 转向孩子节点 curr.next curr.child curr.child None # 清空child # 注意这里不修改curr.child的prev因为在下一次循环时prev.next curr会设置 # 移动curr到孩子节点prev在循环末尾更新 curr curr.next prev prev.next continue # 跳过本次循环剩余的移动操作直接进入下一轮处理新的curr # 3. 如果当前节点没有孩子则检查是否有保存的后续节点需要处理 if not curr.next and stack: # 当前分支走到头从栈中取出之前保存的节点继续 curr.next stack.pop() # 注意弹出节点的prev将在下一轮循环中被正确设置 # 移动curr到取出的节点prev在循环末尾更新 curr curr.next prev prev.next continue # 4. 常规情况既没有child栈也为空或还有next就沿着next走 prev curr curr curr.next # 断开哑节点 dummy.next.prev None return dummy.next迭代解法避坑指南栈的用途栈里保存的是什么是当前节点curr的原始next节点当curr有child时。它代表一条“待探索”的路径。只有当当前路径child链走到尽头curr.next为None时我们才需要从栈里取出之前保存的路径继续。continue的使用在迭代中当我们因为处理child或从栈中取出新节点而改变了curr的指向时应该使用continue立即开始下一次循环。这是因为curr已经更新本次循环后续的prev curr; curr curr.next操作会基于错误的curr进行。连接时机在每次循环开始时就执行prev.next curr和curr.prev prev。这保证了每个新被访问的节点都能正确地链接到链表中。prev指针像一个“缝纫针”始终指向已构建好的扁平链表的最后一个节点。清空child指针和递归一样在将curr.next指向curr.child之后要立即将curr.child设为None。边界条件循环的继续条件是curr is not None。当curr为空且栈也为空时循环结束。注意处理curr没有next但栈不为空的情况即从一个分支回溯。5. 常见问题与调试技巧实录即便理解了算法在实现时依然会遇到各种指针错误。下面是我在刷题和教学中总结的几个高频问题及解决方法。5.1 链表成环或断裂这是最常见的问题根本原因是指针修改顺序错误或遗漏。症状程序陷入死循环或遍历链表时提前结束/访问到空指针。排查画图画图画图对于链表问题尤其是在修改指针时在纸上画出节点和prev、next、child指针的变化过程是无敌的调试方法。针对一个简单例子如1 - 2 - 3其中2有child 4 - 5一步步模拟你的代码。检查next_node的保存在递归解法中是否在处理child前保存了curr.next这个临时变量是否用于后续连接检查双向连接修改了A.next B后是否记得设置B.prev A双向链表必须维护两个方向的指针。检查child指针清空忘记清空child不会导致功能错误但不符合题目要求且在后续操作中可能引起混淆。修复严格按照“保存后继 - 处理子链 - 连接子链尾部和原始后继”的顺序操作并确保每一步都完成了双向连接。5.2 返回的链表头节点 prev 不为 None症状扁平化后的链表头节点的prev属性不是None这不符合双向链表的定义头节点无前驱。原因通常是因为在递归或迭代开始时没有正确处理头节点与前驱的连接。头节点在扁平化后它的prev应该指向None。解决使用哑节点Dummy Node技巧。创建一个临时节点dummy让dummy.next head。然后以dummy作为初始的prev开始算法。最后在返回结果前执行head.prev None或dummy.next.prev None并返回dummy.next。这样可以统一所有节点的连接逻辑避免对头节点的特殊判断。5.3 递归深度过大导致栈溢出症状对于层级非常深的多级链表例如每个节点只有一个child形成一条长链递归解法可能引发RecursionError。分析递归的深度等于链表树的深度。虽然 Leetcode 的测试用例通常不会这么极端但这是一个理论上的风险点。应对首选迭代解法迭代的栈模拟解法其栈空间消耗取决于树的宽度通常远小于递归深度。尾递归优化了解理论上如果递归调用是函数体最后一步操作某些语言编译器会进行尾递归优化将其转化为循环避免栈增长。但 Python 默认不支持尾递归优化且本题的递归并非严格的尾递归形式因为处理完child后还要处理next所以此路不通。结论在工程实践中面对未知深度的嵌套结构迭代解法是更稳健的选择。5.4 多级链表的构造与测试如何快速构造一个测试用例这里提供一个简单的工具函数用于构建题目示例中的链表1-2-3-4-5-6其中3有子链表7-8-9-10而8又有子链表11-12。def build_multi_level_list(): # 第一层 n1 Node(1) n2 Node(2); n1.next n2; n2.prev n1 n3 Node(3); n2.next n3; n3.prev n2 n4 Node(4); n3.next n4; n4.prev n3 n5 Node(5); n4.next n5; n5.prev n4 n6 Node(6); n5.next n6; n6.prev n5 # 第二层 (3的孩子) n7 Node(7) n8 Node(8); n7.next n8; n8.prev n7 n9 Node(9); n8.next n9; n9.prev n8 n10 Node(10); n9.next n10; n10.prev n9 n3.child n7 # 第三层 (8的孩子) n11 Node(11) n12 Node(12); n11.next n12; n12.prev n11 n8.child n11 return n1 # 测试 head build_multi_level_list() s Solution() flattened s.flatten(head) # 打印结果 curr flattened result [] while curr: result.append(str(curr.val)) # 可选检查prev指针 # if curr.prev: # print(f{curr.val}.prev {curr.prev.val}) # else: # print(f{curr.val}.prev None) curr curr.next print(-.join(result)) # 应输出: 1-2-3-7-8-11-12-9-10-4-5-6通过自己编写测试用例可以非常直观地验证算法的正确性尤其是对于边界情况如头节点有child、尾节点有child、child链表为空等。6. 性能分析与扩展思考6.1 时间复杂度与空间复杂度时间复杂度O(N)。其中 N 是多级链表中的节点总数。无论是递归还是迭代每个节点都只被访问一次。对于每个节点的操作连接指针、压栈/弹栈都是常数时间。空间复杂度递归解法O(N)。在最坏情况下链表退化成一条竖直的链每个节点只有child递归调用栈的深度将达到 N。迭代解法O(N)。同样在最坏情况下如果每个节点都有next和child且我们总是先遇到child那么栈中最多可能保存 N/2 个节点例如每个节点的next都被压栈。平均情况下的空间消耗通常小于递归。从复杂度上看两种方法都是线性的。迭代法在空间上通常更可控。6.2 与相似题目的对比联想刷题时善于联想对比能加深理解。这道题可以和以下题目结合起来看Leetcode 114. 二叉树展开为链表这是本题的“二叉树版本”。核心思想也是深度优先遍历前序遍历将左子树插入到根节点和右子树之间。解题思路惊人地相似递归处理左子树返回尾节点然后重新接线。掌握了本题那道题就迎刃而解。Leetcode 21. 合并两个有序链表基础的双指针链表操作题。是理解链表指针移动的基础。Leetcode 138. 复制带随机指针的链表同样涉及在复杂指针结构中遍历和构建新关系使用了哈希表或交错链表的方法。锻炼了在复杂指针关系下保持逻辑清晰的能力。6.3 从解题到理解数据结构这道题不仅仅是一道算法题它揭示了链表作为一种基础数据结构如何通过增加一个指针child来模拟树或图的邻接关系。这种“多级链表”在实际系统中也有应用比如文件系统目录结构一个文件夹节点包含文件next和子文件夹child。文档大纲或菜单如前所述多级标题和嵌套菜单。浏览器DOM树的一种简化表示当然实际DOM是树但遍历思想相通。理解如何将这种嵌套结构“扁平化”本质上是在练习对复杂数据结构的遍历和重构能力。在解决这类问题时培养“指针安全意识”和“分步绘图分析”的习惯比单纯记住代码模板重要得多。下次当你遇到复杂的指针操作时不妨停下来找张纸画一画每一步操作后指针指向哪里思路自然会清晰起来。