ARTICLE DETAIL

资讯详情

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

LeetCode 430:深度优先遍历与指针操作,扁平化多级双向链表详解

LeetCode 430:深度优先遍历与指针操作,扁平化多级双向链表详解 1. 问题引入当链表有了“孩子”如果你刷过一些链表题可能会觉得无非就是单链表、双向链表操作不外乎增删改查。但LeetCode 430这道“扁平化多级双向链表”会给你一个全新的视角它处理的是一种带“层级”结构的链表每个节点除了常规的next和prev指针还有一个额外的child指针可能指向另一个独立链表的头节点。这就形成了一个类似“树”或“多级列表”的结构。想象一下你在处理一个文档的大纲或者一个文件系统的目录树。一级标题根目录下可能有二级标题子目录二级标题下又可能有内容文件。这种层级关系用普通的链表无法直接表示而这道题的数据结构恰好能模拟这种场景。题目的要求就是把这个“多级”的、有深度的链表按照深度优先的顺序“拍扁”成一个标准的、只有next和prev的双向链表。这听起来有点像树的深度优先遍历DFS事实也确实如此。但难点在于你是在操作一个链表需要在原地修改并且要处理好双向指针的重新连接不能破坏结构也不能使用额外的数据结构题目进阶要求。我第一次看到这个结构时觉得它既熟悉又陌生——熟悉的是DFS的思想陌生的是在链表上实现DFS的指针操作细节。很多朋友在这里容易把指针指乱最后得到一个环或者丢失节点。今天我们就来彻底拆解这个问题从理解结构开始到写出清晰且高效的原地解法。2. 彻底理解数据结构多级双向链表的“三维”视图在动手写代码之前我们必须像熟悉自己的手掌一样熟悉这个数据结构。题目给出的Node定义如下以常见语言为例class Node: def __init__(self, val, prevNone, nextNone, childNone): self.val val self.prev prev self.next next self.child child这短短几行定义却构建了一个可以无限延伸的复杂结构。我们可以这样可视化它水平维度next代表同一层级下的兄弟节点形成一个双向链表。垂直维度child代表当前节点拥有的子链表头节点指向下一层级的开始。回溯维度prev由于是双向链表子链表的尾节点可以通过prev指针反向找到父节点吗不child指针是单向的子节点并不知道自己的父节点是谁。回溯需要靠我们遍历时的逻辑来维护。一个具体的例子链表1 - 2 - 3 - 4 - 5 - 6其中节点3有一个子链表7 - 8 - 9 - 10而节点8又有一个子链表11 - 12。图形化表示如下1---2---3---4---5---6 | 7---8---9---10 | 11--12扁平化的目标是将其变成1-2-3-7-8-11-12-9-10-4-5-6。你会发现这完全就是深度优先遍历DFS的顺序从1开始走到3发现它有孩子7于是深入孩子链表走到8又发现它有孩子11继续深入... 当一条子链走到头如12则返回上一层8继续遍历该层的下一个节点9以此类推。注意这里有一个极其关键的细节也是容易出错的地方。当我们把子链表“插入”到当前层时比如把7-8-11-12-9-10插入到节点3和节点4之间我们不仅要设置3.next 7和7.prev 3还必须找到子链表的尾节点这里是10并将其与原来3的下一个节点即4连接起来10.next 4和4.prev 10。如果漏掉这一步链表就会在3这里断开后面的节点4,5,6就丢失了。理解了这个数据结构像一棵“横着的树”以及DFS的遍历顺序我们的解题思路就清晰了大半。接下来我们探讨如何实现这个“扁平化”过程。3. 递归解法最符合直觉的深度优先策略递归是解决树形结构问题的天然利器。对于这道题递归函数的设计可以非常直观flatten(node)的功能是扁平化以node为头节点的多级链表并返回扁平化后的尾节点。为什么返回尾节点这是递归连接的关键。因为当我们在父层处理到一个有child的节点curr时我们需要递归地扁平化它的子链表得到子链表的尾节点tail。将子链表插入到curr和curr.next之间。为了能继续处理curr原来后面的节点我们需要知道子链表结束后新的当前节点是谁其实就是子链表的尾节点tail。下面是递归法的详细步骤和代码实现以Python为例class Solution: def flatten(self, head: Node) - Node: # 递归函数扁平化以node为头的链表返回尾节点 def dfs(node: Node) - Node: curr node last None # 用于记录当前链的最后一个节点 while curr: nxt curr.next # 必须先保存下一个节点因为curr.next可能会被改变 if curr.child: # 递归扁平化子链表得到其尾节点 child_tail dfs(curr.child) # 将子链表插入当前节点curr之后 curr.next curr.child curr.child.prev curr # 如果当前节点原来有后续节点需要将子链表的尾与之连接 if nxt: child_tail.next nxt nxt.prev child_tail # 当前节点的child指针必须置空题目要求 curr.child None # 更新last为子链表的尾因为子链表已经接上 last child_tail else: # 如果没有孩子当前节点就是当前段的最后一个节点 last curr # 移动到下一个待处理节点 # 注意如果curr有child经过上面处理curr.next已经指向子链表头所以下一步会自然遍历子链表 # 如果curr没有child那么curr.next就是原来保存的nxt curr nxt # 返回本层链表的尾节点 return last if not head: return None dfs(head) return head递归解法的核心逻辑拆解遍历与保存使用while循环遍历当前层链表。在循环开始时立即保存curr.next到nxt。这是一个非常重要的技巧因为curr.next可能在处理child时被修改提前保存可以保证我们之后能正确回到主链的后续节点。处理子链表当遇到curr.child不为空时递归调用dfs(curr.child)。这个递归调用会完成整个子链表的扁平化并返回子链表的尾节点child_tail。链表拼接这是指针操作的核心区。curr.next curr.child和curr.child.prev curr将子链表头接在curr后面。如果curr原本后面还有节点nxt不为空则需要将子链表的尾child_tail与nxt连接起来child_tail.next nxt; nxt.prev child_tail。这一步确保了链表不会断裂。将curr.child置为None满足题目要求。更新尾指针与移动更新last指针为当前已知的链表尾可能是子链表尾也可能是当前节点本身。然后将curr指向之前保存的nxt。这里很精妙如果curr有孩子nxt是curr原来的下一个节点它现在被接在了子链表尾的后面所以循环会从子链表处理完后自然地继续处理nxt节点。如果curr没有孩子nxt就是下一个兄弟节点。返回尾节点函数返回本层链表遍历结束后的最后一个节点last供上一层用于连接。递归法的优点是思路清晰代码相对容易理解深度优先的逻辑与递归调用栈完美契合。它的时间复杂度是 O(N)因为每个节点只被访问一次。空间复杂度是 O(M)其中 M 是链表的深度递归调用栈的深度在最坏情况下链表退化成一条竖线可能达到 O(N)。4. 迭代解法模拟递归栈避免递归开销虽然递归直观但有时我们想用迭代的方式来实现或者担心递归深度过深导致栈溢出。迭代法的核心思想是显式地使用一个栈Stack来模拟递归的调用过程实现深度优先遍历。我们沿着next指针遍历一旦遇到有child的节点我们未来的任务是在处理完这个child链表后回来继续处理当前节点原来的next节点。这不正是“后进先出”的栈结构吗迭代法的步骤用一个栈stack来保存“待返回的后续节点”。用一个指针curr遍历链表。如果curr有child如果curr.next存在即当前节点后面还有兄弟节点将curr.next压入栈中。这是我们“未来要回来处理的任务”。将child链表接在curr之后curr.next curr.child; curr.child.prev curr。将curr.child置为None。如果curr没有child则简单移动到curr.next。当curr移动到None即当前路径走到头时检查栈是否为空。如果不为空则从栈中弹出一个节点这就是之前某层未处理的后续节点我们将curr指向它并将其与当前链表的尾部连接起来然后继续遍历。class Solution: def flatten(self, head: Node) - Node: if not head: return None stack [] curr head while curr: # 情况1当前节点有子链表 if curr.child: # 如果当前节点有后继节点将其入栈待后续处理 if curr.next: stack.append(curr.next) # 将子链表接入主链 curr.next curr.child curr.child.prev curr # 别忘记清空child指针 curr.child None # 情况2当前节点没有子链表且没有后继节点但栈中还有待处理的节点 if not curr.next and stack: # 弹出栈顶节点某个未处理的后继节点连接到当前链表尾部 next_node stack.pop() curr.next next_node next_node.prev curr # 移动到下一个节点 curr curr.next return head迭代解法的精妙之处与注意事项栈的作用栈stack严格保存了那些因为深入子链表而暂时被“搁置”的next节点。这完美模拟了递归函数调用时的“返回地址”。连接时机在curr.next为None且栈非空时进行连接。这表示我们沿着一条路径可能是经过多层child的路径已经走到了尽头需要回溯到之前某个岔路口继续处理另一条分支。指针操作顺序与递归法一样必须先保存需要的信息将curr.next压栈再进行连接和修改操作否则信息会丢失。复杂度分析时间复杂度依然是 O(N)每个节点被访问一次。空间复杂度是 O(K)K 是同时被暂存的分支节点数量通常比递归的深度M要小但在最坏情况下也是 O(N)。迭代法在思维上稍微绕一点但避免了递归的系统开销是工程中更常用的稳健做法。它让你对DFS的过程有了更底层的控制。5. 原地解法类DFS最优雅的指针舞递归和迭代法都需要额外的空间调用栈或显式栈。题目进阶要求尝试使用 O(1) 额外空间的原地算法。这听起来很有挑战性因为DFS似乎天然需要栈。但我们可以利用链表本身的结构玩一场精彩的“指针舞”。思路的核心在于我们并不需要显式地保存所有未访问的next节点而是利用遍历过程中的child和next指针在访问子链表前先把当前层的“后事”安排好。具体来说当我们遍历到某个节点curr时如果curr有child我们的目标是把child链表插入到curr和curr.next之间。为了在插入后能继续扁平化整个链表我们需要在插入操作之前找到child链表的最后一个节点尾节点。找到尾节点后执行插入操作。这个操作本身会改变指针但关键在于插入完成后curr.child这个子链表的头已经变成了curr.next。而原来curr.next节点现在被接在了子链表的尾部之后。那么接下来应该遍历哪个节点是curr.next即原子链表头吗是的。因为插入操作后curr的下一个节点就是原子链表的头我们需要继续扁平化它它可能自己也有child。我们不需要栈因为curr原来的next节点已经被正确地连接到了子链表的尾部它会在我们遍历完子链表的所有节点后被自然地访问到。这个过程有点像把子链表“旋转”到当前层。实现的关键在于如何在不使用递归或栈的情况下找到子链表的尾节点答案就是在处理curr时如果它有child我们就先沿着这个child的next指针一直走到底找到尾节点。class Solution: def flatten(self, head: Node) - Node: curr head while curr: # 如果当前节点有子链表 if curr.child: # 第一步找到子链表的尾节点 tail curr.child while tail.next: tail tail.next # 第二步将子链表插入当前节点与后继节点之间 # 连接 tail 和 curr.next if curr.next: tail.next curr.next curr.next.prev tail # 连接 curr 和 curr.child curr.next curr.child curr.child.prev curr # 清空 child 指针 curr.child None # 无论是否有child都移动到下一个节点继续处理 # 如果有child此时curr.next已经是原子链表头从而继续深入 curr curr.next return head原地解法的再剖析与对比为什么是O(1)空间整个算法只使用了curr,tail等几个固定数量的指针变量没有使用任何与链表规模N相关的额外数据结构。时间复杂度分析最坏情况下每个节点都可能被访问多次例如在寻找子链表尾时会遍历子链表的所有节点。对于链表1 - 2 - 3 - ... - N且每个节点都有一个子链表只有一个节点那么为节点1找尾要遍历N-1个节点为节点2找尾要遍历N-2个节点... 总的时间复杂度是 O(N^2)。但在实际情况或平均情况下子链表不会那么极端性能通常可以接受。这是一种用时间换空间的策略。与递归/迭代法的本质区别递归和迭代是“先深入再回溯”而原地解法是“先铺平再前进”。它不是在遇到子链表时立即跳进去而是先把子链表“拉”到当前层排好队然后再按顺序继续处理。这样就不需要记住回溯点栈了。这种方法非常巧妙但需要仔细理解指针的变换。它牺牲了部分时间效率换取了极致的空间效率是面试中展示你对指针操作深刻理解的利器。6. 调试与常见“坑点”实录无论采用哪种解法在实现过程中以下几个“坑”几乎每个人都会遇到坑点一丢失后继节点这是最常见的错误。在把curr.child设置为curr.next时如果之前没有保存curr原本的next节点或者没有像迭代法那样压栈那么这个节点就永远丢失了链表会在curr处断开。务必在修改curr.next之前保存好它的原值。坑点二忘记处理双向指针的prev我们习惯于操作单链表只设置next指针。但在双向链表中每一个next关系的建立都必须配套地设置对应的prev关系。例如设置了curr.next child就必须有child.prev curr设置了tail.next old_next就必须有old_next.prev tail。漏掉任何一个prev双向链表的结构就不完整可能导致某些操作如反向遍历出错。坑点三child指针未置空题目明确要求扁平化后所有child指针必须为None。这是一个容易忽略的细节但却是输出正确与否的检查点之一。坑点四边界条件处理空链表输入head为None时应直接返回None。单个节点无论是否有child都需要正确处理。子链表为空curr.child存在但指向None根据定义child要么指向一个节点要么为None。所以curr.child为None就是没有子链表按正常无孩子情况处理即可。当前节点是尾节点即curr.next为None时在连接子链表尾部时不需要连接一个不存在的next节点。在代码中体现为if curr.next:或if nxt:的判断。调试建议画图在纸上画出原始链表结构然后一步步模拟你的算法画出指针每一步的变化。这是理解链表问题最有效的方法。使用小规模测试用例例如1 - null - 2 - 3节点1有孩子22有孩子3。手动推导出正确结果应为1-2-3。检查循环扁平化后遍历链表打印每个节点的val,next.val(如果存在),prev.val(如果存在),child。确保next和prev互相指向正确且所有child为None。特别要检查尾部节点的next是否为None头部节点的prev是否为None。7. 从解题到应用多级链表的现实映射解完这道题我们不妨思考一下它的实际意义。这种“多级双向链表”结构在哪些场景下有用图形用户界面GUI的组件树一个窗口父节点包含多个面板子节点面板里又有按钮、文本框孙节点。某些渲染或布局算法可能需要将这种层级结构扁平化成一个列表来进行批量操作。文档大纲与目录Word或Markdown文档的多级标题。扁平化操作可能对应于生成一个线性的、包含所有标题的目录列表。文件系统遍历以深度优先方式DFS遍历目录列出所有文件这正是扁平化的过程。浏览器历史记录某些浏览器标签组或会话恢复功能可能会用到类似的结构。理解如何扁平化这种结构本质上就是掌握了对一种特定树形结构进行深度优先遍历并原地重组为线性结构的算法。这种“遍历重组”的思想在处理嵌套数据如JSON、XML时也经常遇到。所以LeetCode 430不仅仅是一道链表题更是一个经典的、将树形操作映射到线性数据结构上的练习题。它考察了你对指针的精确控制、对递归/迭代的理解以及对复杂数据结构进行变换的能力。下次当你看到嵌套的、有层级的数据时也许就会想起这场与多级链表的“较量”。
返回列表