ARTICLE DETAIL

资讯详情

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

从前序与中序遍历构造二叉树:递归与迭代详解

从前序与中序遍历构造二叉树:递归与迭代详解 作为一个刷了上百道二叉树题的人我可以负责任地说LeetCode 105这道“从前序与中序遍历序列构造二叉树”才是真正检验递归功底的试金石。前序遍历、中序遍历单独拿出来都不难理解可一旦把两个序列摆在一起要求你还原出一整棵树很多人的思路就开始绕了——尤其是那几个区间边界看着不复杂一写就乱。这篇文章想把这题从头到尾讲透两条遍历序列为什么能组合起来唯一确定一棵树、手推一遍完整构造过程、递归和迭代两种写法各自怎么落地以及我在反复调试这类题目时踩过的边界坑。无论你是刚开始刷二叉树的新手还是准备面试想让自己代码细节更稳一点这篇文章都应该能给你一些参考。1. 核心原理前序给根、中序给边界信息互补怎么成立1.1 前序遍历到底给了什么信息前序遍历的顺序是根节点、左子树、右子树。换句话说它按照“先访问当前节点再递归访问左子树最后访问右子树”的顺序输出节点值。这意味着一个非常重要的信息前序序列的第一个元素一定是整棵树的根节点。这个结论看起来很朴素但它就是整道题的钥匙。你拿到[3, 9, 20, 15, 7]这个前序序列不需要看任何其他信息就能确定根节点的值是3。问题是知道根节点之后你没办法直接知道左子树有哪些节点、右子树有哪些节点。前序序列里第2个到第几个是左子树不知道。因为左子树的节点数量和右子树的节点数量是未知的而前序遍历只是把这些节点按顺序串了起来并没有明确划出左右边界。1.2 为什么中序遍历能把树“切开”中序遍历的顺序是左子树、根节点、右子树。它的特点是根节点一定出现在整个序列的中间某个位置根节点左边全是左子树的节点右边全是右子树的节点。也就是说如果把前序序列比作一串按“根-左-右”顺序排列的节点名那中序序列就是一组带有“左右归属”信息的列表。你把根节点在中序序列中的位置找出来左子树有哪些节点、右子树有哪些节点一目了然。比如中序[9, 3, 15, 20, 7]根节点3在索引1的位置那么[9]就是左子树的所有节点[15, 20, 7]就是右子树的所有节点。这个过程可以这样理解中序序列像一份“会议室座位表”每个节点告诉你它左边坐了谁、右边坐了谁前序序列像一份“领导进入会场的顺序表”第一个进来的一定是会议主持人。你让主持人往座位表中间一站左边就是他的左团队右边就是他的右团队。1.3 递归成立的条件前序和中序的区间划分是连续的很多人会问在中序里找到了左右子树节点那怎么知道前序里哪些位置对应左子树、哪些对应右子树关键在于前序遍历中同一棵子树的所有节点一定是连续排列的。因为前序遍历整个左子树的节点不可能穿插右子树的节点它必须先把左子树递归遍历完才会去遍历右子树。于是你只需要知道左子树有几个节点就能在前序序列中“切”出左子树区间和右子树区间。这个数量恰好可以从根节点在中序序列中的位置得到左子树节点数 根节点在中序中的位置 - 中序序列左边界。递归的每一步都在重复这个过程对左子树区间、右子树区间分别再做一次“前序第一个节点是根 去中序里找位置 切分左右区间”。因为每棵子树都对应一组连续的前序区间和中序区间所以问题自相似可以直接用递归解决。1.4 节点值唯一一个容易被忽略的前提以上所有推理都建立在“所有节点的值互不相同”这一前提下。如果中序序列里存在两个相同值的节点那么当你在中序序列里查找根节点的位置时到底该选哪一个选错了构造出来的树可能就完全变形。LeetCode 105这道题默认了preorder和inorder中的值都是不重复的所以在代码里用哈希表缓存“值 - 下标”是安全的。如果题目不保证唯一性这个做法就不能用需要另想办法比如用节点对象本身或者加上下标去重。这也是面试官经常追问的细节之一。2. 先别写代码手推一遍两棵子树的区间怎么切2.1 选定一个具体例子我习惯先拿一个具体的树把它的前序和中序写出来再老老实实地按步骤推演一遍确认每一步的索引变化然后再去写代码。这里用一个稍微有一点点复杂度的例子3 / \ 9 20 / \ 15 7它的前序遍历结果是3, 9, 20, 15, 7中序遍历结果是9, 3, 15, 20, 7后面所有的手推过程都基于这两组序列。2.2 划分第一层找到根和左右子树区间先看前序序列[3, 9, 20, 15, 7]第一个元素是3根节点确定为3。然后在中序序列[9, 3, 15, 20, 7]里找到3的位置索引是1。以这个位置为分界线左侧[9]这是左子树的所有节点节点数leftSize 1 - 0 1右侧[15, 20, 7]这是右子树的所有节点接下来用leftSize去切前序序列。前序序列第一个元素3是根所以剩余部分从索引1开始左子树在前序中的区间从索引1开始长度为leftSize 1所以是[9]右子树在前序中的区间紧接着左子树从索引2到末尾所以是[20, 15, 7]现在你已经得到两个子问题左子树前序[9]中序[9]右子树前序[20, 15, 7]中序[15, 20, 7]2.3 继续递归左子树的处理左子树比较直接前序[9]的第一个元素是9根节点是9中序[9]里9的位置是0左右两侧都是空所以它没有孩子。一次递归结束。2.4 继续递归右子树的处理右子树的前序是[20, 15, 7]中序是[15, 20, 7]。前序第一个元素20根节点确定为20在中序[15, 20, 7]里找到20的位置索引是1左子树节点[15]leftSize 1 - 0 1右子树节点[7]用leftSize切前序左子树区间是[15]右子树区间是[7]再继续递归左子树前序[15]中序[15]根节点15无孩子右子树前序[7]中序[7]根节点7无孩子最终还原出完整的树。2.5 关键四个区间的对应关系上面手推过程里最关键的是要建立这样一个映射在中序中找到根的位置 index得到 leftSize index - inLeft然后用它来划分前序区间。这是整道题的核心计算公式左子树前序区间[preLeft 1, preLeft leftSize]左子树中序区间[inLeft, index - 1]右子树前序区间[preLeft leftSize 1, preRight]右子树中序区间[index 1, inRight]后面所有代码本质上都是在维护这四组边界值。3. 递归实现四个边界参数为什么这样设计3.1 为什么用索引区间而不是拷贝子数组写这道题时有些初学者会使用Arrays.copyOfRange或者 Python 的切片把左右子树对应的子数组复制出来再递归。这种写法不是不能通过但它有几个明显的坏处每次都复制数组空间复杂度变成 O(n log n)在极端数据下会吃内存拷贝数组掩盖了索引变化的逻辑让代码的可读性“看起来简单”一旦想优化回索引写法反而更容易迷路面试中如果你用切片写完面试官大概率会追问“如果不用切片呢”——到时候还是要回到索引上来所以建议一开始就习惯用四个边界参数preLeft、preRight、inLeft、inRight分别表示当前子树在前序和中序序列中的区间。用区间递归虽然代码看起来变量多一点但每一步都清楚且空间复杂度稳定在 O(n)。3.2 用哈希表缓存中序位置每次都需要在中序序列里找到根节点的位置如果每次都线性扫描整个过程的时间复杂度可能退化成 O(n^2)。特别是树的形状是斜树时每一层递归都要扫一遍剩余的数组代价非常高。正确做法是先用一个哈希表把中序序列中每个值对应的下标缓存下来MapInteger, Integer indexMap new HashMap(); for (int i 0; i inorder.length; i) { indexMap.put(inorder[i], i); }这样每次查找根节点位置就变成了 O(1) 操作。整棵树的构建时间只取决于节点数量时间复杂度为 O(n)。3.3 核心代码与 leftSize 的来龙去脉下面是我个人比较推荐的递归写法Java版本class Solution { private MapInteger, Integer indexMap; private int[] preorder; private int[] inorder; public TreeNode buildTree(int[] preorder, int[] inorder) { this.preorder preorder; this.inorder inorder; indexMap new HashMap(); for (int i 0; i inorder.length; i) { indexMap.put(inorder[i], i); } return build(0, preorder.length - 1, 0, inorder.length - 1); } private TreeNode build(int preLeft, int preRight, int inLeft, int inRight) { if (preLeft preRight || inLeft inRight) { return null; } int rootVal preorder[preLeft]; TreeNode root new TreeNode(rootVal); int index indexMap.get(rootVal); int leftSize index - inLeft; root.left build(preLeft 1, preLeft leftSize, inLeft, index - 1); root.right build(preLeft leftSize 1, preRight, index 1, inRight); return root; } }Python版本也很直接class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) - Optional[TreeNode]: index_map {val: i for i, val in enumerate(inorder)} def build(pre_left, pre_right, in_left, in_right): if pre_left pre_right: return None root_val preorder[pre_left] root TreeNode(root_val) index index_map[root_val] left_size index - in_left root.left build(pre_left 1, pre_left left_size, in_left, index - 1) root.right build(pre_left left_size 1, pre_right, index 1, in_right) return root return build(0, len(preorder) - 1, 0, len(inorder) - 1)重点理解leftSize在中序序列中当前子树的左边界是inLeft根节点位置是index那么根节点左侧有多少个节点答案是index - inLeft这个数量就是左子树的大小为什么不是index - inLeft 1因为inLeft指向的是左子树最左边的节点不是根index指向根节点两者之间的差值恰好就是根左侧的节点数量。可以回头验证中序[9, 3, 15, 20, 7]中inLeft0根3的index1leftSize 1 - 0 1左子树确实只有[9]一个节点。3.4 递归出口的细节递归出口是preLeft preRight或inLeft inRight。这里要注意用的是而不是。为什么不是因为当区间里恰好有一个元素时比如preLeft preRight它表示一个单独的节点需要正常构建不能提前返回null。只有区间为空时才说明“没有子树了”此时返回null。我见过有人写成if (preLeft preRight)结果单节点情况下直接返回空树整棵树构造出来少了一大半。这个错误非常隐蔽因为小规模测试数据不一定能暴露出来换一个只有两三个节点的用例就立刻翻车。3.5 复杂度分析时间复杂度O(n)。每个节点恰好被构建一次哈希表查询 O(1)递归过程中每个区间只处理一次。空间复杂度O(n)。主要是哈希表占用的 O(n) 空间加上递归栈的深度。最坏情况下树是斜树递归深度是 n所以总空间还是 O(n)。不过这里有个细节indexMap即使不缓存理论上也可以通过在递归前先扫描中序区间来查找根节点位置但那样时间复杂度最坏就是 O(n^2)。哈希表这一层优化基本是必须的。4. 迭代实现不递归的时候怎么用栈还原树4.1 迭代思路的一句话总结如果只追求通过题目递归写法已经够了。但如果你想在面试里多展示一种解法或者想更深入地理解树的结构可以看看迭代写法。迭代写法的核心思想是用栈模拟递归的调用过程用中序序列作为“边界指南针”决定当前节点应该挂在左子树还是右子树上。具体来说创建一个栈先把根节点压入栈用一个指针inIndex指向中序序列的开头遍历前序序列中剩余的每个节点创建新节点cur如果栈顶元素的值不等于inorder[inIndex]说明当前还在处理左子树把cur作为栈顶节点的左孩子压栈如果栈顶元素的值等于inorder[inIndex]说明左子树已经处理完了开始回溯不断弹出栈顶元素inIndex右移直到栈为空或栈顶值不等于inorder[inIndex]然后把cur作为最后一次弹出节点的右孩子压栈4.2 为什么这个逻辑是对的中序遍历的顺序是“左-根-右”。当我们沿着前序序列不断向左下方构建节点时栈里存的是从根节点到当前节点的路径。在这个过程中中序序列指针inIndex指向的是“当前还没有被访问到的最左下节点”。一旦栈顶元素的值和中序指针指向的值相同说明栈顶节点已经没有任何左孩子了否则中序应该先输出左孩子此时应该把下一个节点放到它的右子树位置。而在它右子树之前的那些节点都已经通过弹栈处理完了。这个理解方式可以类比为你一边沿着树的左边界往下走一边根据中序序列判断“这里应该停止了开始往右拐”。栈在这里充当了记录器帮你记得回来时的路径。4.3 代码实现Javaclass Solution { public TreeNode buildTree(int[] preorder, int[] inorder) { if (preorder null || preorder.length 0) { return null; } TreeNode root new TreeNode(preorder[0]); DequeTreeNode stack new ArrayDeque(); stack.push(root); int inIndex 0; for (int i 1; i preorder.length; i) { TreeNode cur new TreeNode(preorder[i]); TreeNode peek stack.peek(); if (peek.val ! inorder[inIndex]) { peek.left cur; } else { while (!stack.isEmpty() stack.peek().val inorder[inIndex]) { peek stack.pop(); inIndex; } peek.right cur; } stack.push(cur); } return root; } }用 Python 写的话只需要把Deque换成普通 list 模拟栈的操作即可。4.4 用前面的例子完整追踪一遍继续用preorder [3, 9, 20, 15, 7]和inorder [9, 3, 15, 20, 7]。创建根节点3栈[3]inIndex 0遍历到9栈顶3inorder[0]9两者不等所以3.left 9压栈栈[9, 3]遍历到20栈顶9inorder[0]9两者相等进入 else 分支。弹出9inIndex1此时栈顶3等于inorder[1]3弹出3inIndex2栈为空。最后一次弹出的是3所以3.right 20压栈栈[20]遍历到15栈顶20inorder[2]15两者不等所以20.left 15压栈栈[15, 20]遍历到7栈顶15inorder[2]15两者相等进入 else 分支。弹出15inIndex3此时栈顶20等于inorder[3]20弹出20inIndex4栈为空。最后一次弹出的是20所以20.right 7压栈栈[7]最终构建出的树是根3左孩子9右孩子2020的左孩子15右孩子7。和递归结果完全一致。这一步追踪做完我对迭代法的信任度就上来了。老实说第一次看这个代码的时候我完全没有头绪是照着上面的流程一步步写下来才看懂的。建议你也自己挑一组数据在纸上走一遍特别是第3步的“连续弹栈”理解了那个 while 循环就理解了整个迭代法。4.5 迭代法的时间与空间复杂度迭代法同样也是 O(n) 时间和 O(n) 空间。每个节点都入栈一次、出栈一次inIndex从 0 移动到 n-1整体线性。两种写法对比的话递归更贴近人类直觉容易理解和记忆迭代则对栈的运用要求更高但能避免递归深度过大的问题。在实际工程中如果树特别深递归很容易触发系统栈溢出迭代法在这种情况下优势明显。5. 翻车记录边界索引的常见坑和自检方法5.1 递归出口的和之坑前面提到过递归出口应该用preLeft preRight而不能写成preLeft preRight。我再展开讲一下为什么这个坑特别容易踩。很多人写递归时习惯先判断“区间里只有一个元素就直接返回”于是写一个if (preLeft preRight)来提前返回。但问题是如果你只判断了等于的情况那么当preLeft preRight时递归依然会继续调用下去最终在访问数组时出现ArrayIndexOutOfBoundsException。而如果你直接写if (preLeft preRight) return null那等于把“单节点”的情况也一并返回了空导致整个树缺了一部分。正确的做法是只在区间为空时返回null单节点的情况让它正常走到构建逻辑里去。也就是if (preLeft preRight || inLeft inRight) { return null; }这个和的差别就是整棵树完整和残缺的差别。我自己就在这个细节上翻过车写出来提醒大家留意。5.2 leftSize 和右子树前序起点的“1”问题另一个高频错误是右子树前序起点算错。很多人写完左子树后右子树的preLeft会写成preLeft 1 leftSize这是对的但有人会写成preLeft leftSize这就错了。原因很简单左子树的前序区间是从preLeft 1开始的长度为leftSize所以左子树区间占用的范围是[preLeft 1, preLeft leftSize]。右子树必须从preLeft leftSize 1开始中间的1必须存在。5.3 leftSize 后面到底要不要再减 1还有一个容易混淆的点leftSize index - inLeft这个index是根节点在中序中的位置。有人会误写成index - inLeft 1导致左子树的区间整体向右偏移一个位置。我们来验证一次中序[9, 3, 15, 20, 7]根3的index 1inLeft 0。如果leftSize 1 - 0 1 2左子树前序区间就会变成[preLeft 1, preLeft 2]也就是[9, 20]这显然不对20根本不属于左子树。所以leftSize一定不要额外加 1。它表达的是“根节点左侧有多少个节点”这个数量在数学上就是index - inLeft。5.4 特殊形态的测试用例写完代码后不要只拿题目自带的用例测一遍就完事我建议你额外跑这几组特殊数据用例preorderinorder期望结果空树[][]null单节点[1][1]根节点1右斜树[1, 2, 3][1, 2, 3]每个节点只有右孩子左斜树[1, 2, 3][3, 2, 1]每个节点只有左孩子完全二叉树[1, 2, 4, 5, 3, 6, 7][4, 2, 5, 1, 6, 3, 7]标准两层完整树很多递归代码在普通用例上跑得好好的一到斜树上就出问题。原因往往就是递归深度过大时某个索引悄悄越界了。这组自测用例花不了两分钟但能帮你过滤掉大部分隐藏 bug。5.5 调试技巧打印四边界如果你写完代码跑出来结果不对又不想用 IDE 的断点去一步步跟我建议你在递归函数的入口处打印一下四个边界值和leftSizeSystem.out.println(preLeft preLeft , preRight preRight , inLeft inLeft , inRight inRight , rootVal rootVal , index index , leftSize leftSize);然后和手推过程对照。只要第一次递归的打印结果和你手推的一致后面的基本也一致。如果第一次就出现leftSize为负数那说明中序区间的左右边界传参有问题优先检查inLeft是不是传成了0。6. 延伸思考中序后序、前序后序以及工程应用6.1 中序 后序一道逻辑对称的变体既然前序中序可以构造二叉树那中序后序同样可以思路几乎完全对称只是在细节上做镜像调整后序遍历的顺序是“左子树、右子树、根节点”所以后序序列的最后一个元素是根节点在中序序列中找到根节点的位置后同样可以得到左子树节点数量和右子树节点数量切分后序序列时要注意左子树对应后序的开头一段右子树对应中间一段根节点是最后一段Java 参考实现class Solution { private MapInteger, Integer indexMap; public TreeNode buildTree(int[] inorder, int[] postorder) { indexMap new HashMap(); for (int i 0; i inorder.length; i) { indexMap.put(inorder[i], i); } return build(inorder, 0, inorder.length - 1, postorder, 0, postorder.length - 1); } private TreeNode build(int[] inorder, int inLeft, int inRight, int[] postorder, int postLeft, int postRight) { if (inLeft inRight || postLeft postRight) { return null; } int rootVal postorder[postRight]; TreeNode root new TreeNode(rootVal); int index indexMap.get(rootVal); int leftSize index - inLeft; root.left build(inorder, inLeft, index - 1, postorder, postLeft, postLeft leftSize - 1); root.right build(inorder, index 1, inRight, postorder, postLeft leftSize, postRight - 1); return root; } }注意这里左子树的后序区间右边界是postLeft leftSize - 1因为整个后序区间从postLeft开始包含左子树节点和右子树节点左子树节点数恰好为leftSize。这道变体搞清楚后你对“通过遍历序列还原树”这类问题的理解就基本打通了。6.2 前序 后序为什么不能唯一确定一棵树聊到这里有个很有意思的延伸问题前序 后序能不能唯一确定一棵二叉树答案是否定的。举个最简单的例子一棵只有根节点 1 和左孩子 2 的树前序是[1, 2]后序是[2, 1]而一棵只有根节点 1 和右孩子 2 的树前序同样是[1, 2]后序同样也是[2, 1]。原因是前序和后序只告诉你“根和子树的相对顺序”但没有告诉你节点到底是左孩子还是右孩子。只有中序这种“根在左右之间”的序列才能提供左右子树的归属信息。这也是为什么中序序列在构造二叉树的问题里是真正的“定海神针”。6.3 “序列还原结构”在工程里的启示很多人刷完这道题觉得它只是个面试题其实“用序列还原结构”的思路在工程里随处可见。比较典型的是二叉树的序列化与反序列化问题。比如 LeetCode 297要把一棵树编码成字符串再从这个字符串还原出原树。一种常见方案就是用前序遍历 空节点标记序列化完成后反序列化的时候同样是通过递归按顺序消费序列还原树结构。这个思路本质上和105题的“按前序顺序建树”是一致的只是一个有完整的中序辅助一个靠空标记来分割子树。再比如处理带嵌套结构的配置文件、JSON、XML 时解析器也经常需要利用类似“先记录父节点信息再根据后续内容决定挂接方式”的机制来构建语法树。理解了树的前序、中序、后序之间的关系你会发现不仅在刷题里在写解析器、写编译器前端、做表达式求值等场景下这套思维方式都非常有用。6.4 相关知识点层序、深度、搜索树和线索二叉树顺便聊几个容易跟着一起复习的知识点。二叉树的层序遍历和前序遍历是两种完全不同的视角层序按层级从上到下、从左到右输出而前序是深度优先地先向下再向右。用层序遍历也可以还原二叉树但需要额外的空节点标记因为层序本身不包含父子关系的位置信息。二叉树的深度和周游序列的关系也很微妙。搜索二叉树BST的中序序列是有序的所以如果给你一棵 BST 的前序序列你甚至可以用更简单的方式重建树因为中序序列可以由排序直接得到。线索二叉树则是对遍历过程的一种优化它利用节点中的空指针记录前驱和后继让中序、前序遍历可以不用栈或递归。理解了“遍历序列和树结构一一对应”这一点再去看线索二叉树的构造过程会轻松很多。写在最后我刷这道题的一些实际感受最后说点个人体会。这道题我刷过不止一遍每次以为自己会了过几个月再看又容易在索引上卡壳。后来我总结出一个笨办法每次写完递归先用一个四五个节点的小树在纸上把四个边界都标一遍特别是leftSize的计算一定要亲自算一次不能只靠背。代码可以抄但边界值一旦理解透才是真的掌握。如果你是在准备面试建议把递归版本先写顺然后把“为什么用哈希表”“为什么中序序列必须无重复值”“左子树区间为什么是这个范围”这几个问题都能解释清楚基本上就能应对面试官的追问了。迭代版本可以作为加分项但不建议在一开始就死磕它。如果你平时主要用 Python 刷题也试着把 Java 版本看懂。两种语言的代码虽然风格不同但算法本质是一样的能让你在求职时对不同语言的要求都能快速适应。
返回列表