尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

构造二叉树全攻略:前序+中序与中序+后序递归构建详解

构造二叉树全攻略:前序+中序与中序+后序递归构建详解 1. 为什么刷题第17天会遇到“构造二叉树”1.1 从力扣105/106两道原题说起如果你在坚持算法训练打卡到第17天这个节点大概率已经啃完了数组、链表、哈希表、双指针开始进入二叉树专题。而“构造二叉树”几乎是二叉树章节里最难绕开的一道坎对应的经典题目就是LeetCode 105从前序与中序遍历序列构造二叉树和LeetCode 106从中序与后序遍历序列构造二叉树。这两道题有多高频这么说吧我在刷题群里统计过近两年大厂面试题库里“给定两个遍历序列还原二叉树”这个考点的出现频率能排进二叉树板块前三。原因特别简单它既考了遍历序列的理解又考了递归分治还捎带考察了哈希表优化和边界条件处理。一道题能同时测出四个能力维度出题人当然爱用。很多人第一次看到这个题目会懵因为直觉上觉得“遍历序列不就是顺序打乱的节点列表嘛怎么还原成一棵树”。实际上这类题目有个隐藏前提两个遍历序列必须是“不同顺序的同一棵树”的产物比如中序前序、中序后序。拿到这两个序列之后不是靠猜而是靠严格的递归推导每一步都能确定一个根节点和左右子树的区间边界直到所有节点都被填回树里。1.2 三个必须学会构造二叉树的人群我把需要认真攻克这个知识点的人分成三类你可以对号入座。第一类是准备算法面试的求职者。面试中被问到二叉树相关题目如果连“前序第一个节点就是根节点”“中序遍历用来切分左右子树”这种基础结论都说不利索基本等于告诉面试官二叉树基础不扎实。而构造二叉树是这些结论的综合应用题它能把你对三种遍历顺序的理解深度一次测个透。第二类是真正在做工程开发的程序员。你可能觉得工作中用不到手写这种递归建树逻辑但别忘了几乎所有编程语言里都有“序列化”和“反序列化”的概念。序列化之后的字符串或字节流最终要还原成内存里的二叉树结构这个过程本质上就是构造二叉树。只要遇到自定义二叉树结构的持久化存储或者通过API传输树形数据你就要用到这个能力。第三类是打算法竞赛、刷信奥题的选手。这类题往往不会直接出裸题让你“一棵树”而是会包装成表达式解析、语法树构建、哈夫曼树编码等形式。底层逻辑还是一样的给你前序和中序或者中序和后序让你还原结构。建树能力不过关后面的复杂题目根本没有抓手。1.3 先说结论会用中序序列你就能赢这里直接给你剧透核心结论——构造二叉树的破局点永远在中序序列上。为什么因为前序遍历的顺序是“根左右”它只能告诉你谁是根后序遍历的顺序是“左右根”它也只能告诉你谁是根。但只有中序遍历“左根右”这个顺序能告诉你根节点的左边有哪些节点、右边有哪些节点从而确定左右子树各自的集合。拿前序中序来说前序的第一个元素是根节点然后拿着这个根节点的值去中序序列里查位置。中序序列里根节点左边的所有元素就是左子树右边的所有元素就是右子树。然后对左子树序列和右子树序列分别递归重复这个过程。整个算法就是这么朴素。所以整套代码写下来核心就两件事找根、切中序。找根的位置靠前序或后序切左右子树靠的是中序。弄明白这个逻辑两道题就是同一套模板的两次套用。2. 构造二叉树的第一性原理2.1 前序、中序、后序遍历的本质区别要真正理解构造二叉树的原理还是得回到三种遍历方式本身。我尽量用最直白的方式说清楚。前序遍历Preorder先访问根节点再递归遍历左子树最后递归遍历右子树。顺序是根、左、右。中序遍历Inorder先递归遍历左子树再访问根节点最后递归遍历右子树。顺序是左、根、右。后序遍历Postorder先递归遍历左子树再递归遍历右子树最后访问根节点。顺序是左、右、根。这里有一个生活化的类比。想象一家公司发通知领导在最上层下属一层一层在下面。前序遍历就是“按部门通知顺序挨个点名”领导先看到自己的名字然后是所有左子部门的员工最后是右子部门的员工。中序遍历则是“按工位顺序从左往右扫”左边工位的人先读到扫到中间某个位置时才是领导的工位然后再往后扫右边工位。后序遍历则是“从最底层员工开始汇报”所有底层员工的名字先出来领导的名字最后才出现。这个类比的用处在于三种遍历方式只是访问顺序不同节点集合完全一致。所以当我们拿到某一种遍历序列时其实拿到的只是一串“被打乱顺序的节点名单”。要还原整棵树就必须利用不同遍历顺序之间的“位置关系”。2.2 哪些遍历组合能唯一还原一棵二叉树这个问题非常关键面试中也经常被追问。不是任意两个遍历序列都能还原出一棵唯一的二叉树。先说结论前序 中序可以唯一还原。后序 中序可以唯一还原。层级遍历 中序可以唯一还原。前序 后序不能唯一还原。因为前序和后序都只能确定根节点但无法区分左右子树的具体划分。举个简单的例子一棵只有两个节点的树前序是[1,2]后序是[2,1]那么节点2既可以是节点1的左孩子也可以是节点1的右孩子。两种结构完全合法你无法从这两串序列中确定到底是哪一种。这个结论面试时一定要能说清楚。很多练习者在被问到“构造二叉树的充要条件是什么”时答不上来关键就是没有把“为什么必须要有中序”这个问题想透。中序序列的价值在于它能通过根节点的位置把节点集合精确划分为左右两个子集。这是前序和后序都做不到的。换个角度理解前序和后序告诉你“谁先谁后”的层级关系中序则告诉你“谁在谁的左边还是右边”的左右关系。构造一棵二叉树左右关系是绝对不能错的方向。2.3 核心突破口在中序序列里找根的位置前面说了中序是破局点现在把这个“根的位置”到底怎么找再拆细一点。以下面的树为例3 / \ 9 20 / \ 15 7它的前序遍历是[3, 9, 20, 15, 7]中序遍历是[9, 3, 15, 20, 7]后序遍历是[9, 15, 7, 20, 3]。先看前序中序的组合前序的第一个元素是3所以3是整棵树的根。在中序里找到3的位置发现它左边是[9]右边是[15, 20, 7]。所以根节点3的左子树只包含节点9右子树包含节点20、15、7。再对左子树[9]递归前序的后半段第一个是9中序区间就一个元素9建立节点9。再对右子树递归前序区间里剩下[20, 15, 7]第一个是20所以20是右子树的根在中序区间[15, 20, 7]里找到20左边是[15]右边是[7]。继续递归搞定。你会发现整个过程就像不断“切蛋糕”每次从前序或后序中确定当前子树的根然后在中序里找到根的位置蛋糕被切成左右两块分别交给下一层递归去处理。所以这个算法在思维上没有任何跳跃每一步都严格可推导。3. 前序中序构造二叉树手把手带写3.1 整体思路递归分治是怎么切入的前序中序的组合是LeetCode 105的原题也是构造二叉树最基础、最经典的场景。我建议你完全吃透这一题因为106题就是换个对称姿势。递归函数的设计思路一定要先想清楚“这个函数承担什么职责”。我们的递归函数职责是给定一棵子树在前序序列中的区间范围以及这棵子树在中序序列中的区间范围返回这棵子树的根节点。也就是说递归函数需要接收6个关键参数前序数组和它的左边界、右边界中序数组和它的左边界、右边界每次进入递归执行以下四步如果区间越界左边界大于右边界返回null表示当前子树为空。取前序区间的第一个元素作为当前根节点的值。在中序区间中找到根节点值的下标用它切分左右子树。递归构造左子树和右子树挂到根节点上。这里的关键在于左右子树的区间范围怎么计算。很多人写到这里就乱了我用一个简单的方法帮你锁定先算左子树的节点数量。假设中序区间是[inLeft, inRight]根节点在中序中的下标是rootIndexInInorder那么左子树的节点数量 leftSize rootIndexInInorder - inLeft。这个leftSize至关重要它决定了前序区间里左子树占据多长。前序区间的结构是第一个元素是根接着的leftSize个元素全是左子树的再往后剩下的全是右子树的。所以左子树在前序中的区间是[preLeft 1, preLeft leftSize]右子树在前序中的区间是[preLeft leftSize 1, preRight]中序区间的结构更清晰根节点下标左边是左子树右边是右子树。所以左子树在中序中的区间是[inLeft, rootIndexInInorder - 1]右子树在中序中的区间是[rootIndexInInorder 1, inRight]3.2 Java完整实现与参数推导直接给你一份可以跑通的Java代码我习惯用全局哈希表缓存中序序列每个值的下标这样在中序里找根节点位置时就是O(1)时间整体复杂度降为O(n)。class Solution { private MapInteger, Integer indexMap; public TreeNode buildTree(int[] preorder, int[] inorder) { int n preorder.length; indexMap new HashMap(); for (int i 0; i n; i) { indexMap.put(inorder[i], i); } return build(preorder, 0, n - 1, inorder, 0, n - 1); } private TreeNode build(int[] preorder, int preLeft, int preRight, int[] inorder, int inLeft, int inRight) { if (preLeft preRight || inLeft inRight) { return null; } int rootVal preorder[preLeft]; int rootIndexInInorder indexMap.get(rootVal); int leftSize rootIndexInInorder - inLeft; TreeNode root new TreeNode(rootVal); root.left build(preorder, preLeft 1, preLeft leftSize, inorder, inLeft, rootIndexInInorder - 1); root.right build(preorder, preLeft leftSize 1, preRight, inorder, rootIndexInInorder 1, inRight); return root; } }我把关键推导过程整理成一张表方便你对照理解区间含义前序数组索引中序数组索引当前子树根节点preLeftrootIndexInInorder左子树区间[preLeft 1, preLeft leftSize][inLeft, rootIndexInInorder - 1]右子树区间[preLeft leftSize 1, preRight][rootIndexInInorder 1, inRight]空子树条件preLeft preRightinLeft inRight这里我特别提醒一个容易出错的地方递归出口判断用哪个区间的越界条件我习惯两个区间同时判断写成if (preLeft preRight || inLeft inRight) return null;。虽然实际运行时通常只会有一个条件率先触发但两个都写更稳妥配合上面的推导表也更不容易懵。3.3 为什么要用哈希表缓存下标再多说一句哈希表的事情。很多初学者第一次写这题用的是在中序区间里循环查找根节点下标的方式for (int i inLeft; i inRight; i) { if (inorder[i] rootVal) { rootIndexInInorder i; break; } }这样也能通过但时间复杂度会变成O(n²)——每一层递归都要在中序区间里线性扫描。如果树是斜树极端情况下退化成链表递归深度是n每一层扫描的长度也是n总代价就是n²。而用哈希表预处理中序序列中每个值对应的下标每次查找变成O(1)整个算法的时间复杂度就降到了O(n)。这个优化在面试中属于“默写级”优化你必须主动做出来否则会被追问为什么不用哈希表。注意使用哈希表的前提是二叉树节点的值互不重复。题目本身保证了这个条件如果题目没说明你需要先和面试官确认或者自己额外判断。4. 中序后序构造二叉树对称写法全掌握4.1 后序确定根节点的方式与差异LeetCode 106给你的是中序后序逻辑和前序中序是对称的但有两个细节差异需要特别注意。第一个差异是根节点的位置。前序是第一个元素是根后序则是最后一个元素是根。所以每次递归时要取的是后序区间的右边界对应元素。第二个差异是递归构造左右子树的先后顺序。前序中序那题因为前序遍历是“根左右”所以前序序列中根节点之后紧接着的就是左子树区间因此你从前往后取根时天然先处理左子树再处理右子树代码写起来是先left后right。而后序遍历是“左右根”后序序列中根节点在末尾从整体上看右子树的节点在逻辑上更靠近根节点。所以在处理中序后序时我推荐的写法是从后往前消费后序序列先处理右子树区间再处理左子树区间。如果不理解这一点边界很容易写乱。为了更好地讲解我用递归函数只接收中序区间的写法因为后序序列整体作为闭包变量来消费代码更简洁。4.2 Go完整实现与代码拆解用Go写一遍可以帮助你对比不同语言的递归写法差异func buildTree(inorder []int, postorder []int) *TreeNode { n : len(inorder) indexMap : make(map[int]int, n) for i, v : range inorder { indexMap[v] i } var build func(inLeft, inRight int) *TreeNode build func(inLeft, inRight int) *TreeNode { if inLeft inRight { return nil } // 从后序序列末尾取出当前子树的根节点 val : postorder[len(postorder)-1] postorder postorder[:len(postorder)-1] root : TreeNode{Val: val} // 在中序中找到根节点位置 mid : indexMap[val] // 注意顺序先构造右子树再构造左子树 root.Right build(mid1, inRight) root.Left build(inLeft, mid-1) return root } return build(0, n-1) }这段代码怎么理解首先因为后序序列的根节点在末尾我每次从postorder里弹出一个末尾元素作为当前子树的根值。然后在中序中找到这个根的位置mid。关键点来了因为后序序列是“左右根”顺序从后往前消费时先弹出的是父节点再往前弹的是右子树的根再往前才是左子树的根。所以在递归里我优先递归root.Right build(mid1, inRight)把右子树建好再递归root.Left build(inLeft, mid-1)。这样做能让后序序列的消费顺序和二叉树的构造顺序保持一致。如果你非要用显式的后序左右边界参数来写也可以公式如下后序区间的最后一个是根节点下标是postRight。左子树在后序中的区间是[postLeft, postLeft leftSize - 1]右子树在后序中的区间是[postLeft leftSize, postRight - 1]其中leftSize mid - inLeft。只是这个写法在处理索引时更繁琐容易出错所以我个人更推荐上面的Go写法让后序序列自动被消费。4.3 前序写法 vs 后序写法对比我把两种写法的核心差异整理成一张对比表对比项前序 中序中序 后序根节点来源前序区间第一个元素后序区间最后一个元素中序中找根后的左右子树划分相同相同左右子树构建顺序先左后右先右后左是否适合用隐含区间消费序列不适合需显式维护前序区间适合可以从尾部弹元素时间复杂度用哈希表O(n)O(n)理解这个对比表后你会发现两题其实共用同一个脑回路中序定左右前序/后序定根递归切区间。唯一需要记牢的就是根节点的取法不同以及构建左右子树的先后顺序不同。5. 构造二叉树最容易踩的4个坑5.1 边界越界leftSize才是关键我见过太多人在写边界时把preLeft leftSize 1写成preLeft leftSize或者把左子树的中序右边界写成rootIndexInInorder而不是rootIndexInInorder - 1。这类错误不会导致超时但会直接让你构造出一棵错误的树甚至递归栈溢出。我的避坑心得是永远先算leftSize然后在所有涉及左子树边界的表达式中都优先用leftSize来表达而不是直接用rootIndexInInorder。比如左子树中序区间是[inLeft, rootIndexInInorder - 1]这里必须减1因为根节点不能算进子树里右子树中序区间是[rootIndexInInorder 1, inRight]必须加1。要养成“算完就把区间代入递归”的习惯而不是凭感觉。5.2 递归返回nil的条件递归出口写作if (preLeft preRight || inLeft inRight) return null;。不过有些练习者在递归调用自己时左右子树区间都写在递归函数内部这时可能会漏掉某个区间的越界情况导致无限递归。我自己调试时的经验是在递归函数入口处先打印一下当前函数收到的四个边界参数如果发现某次递归里preLeft和preRight完全不在合理范围内或者中序区间长度已经不对那一定是边界推导出了问题。多做几次这种调试边界感会提升得非常快。5.3 节点值重复怎么办这个坑很隐蔽。题目默认所有节点值唯一如果实际场景中出现重复值比如树里有两个值为2的节点那么哈希表里一个key对应两个下标直接查会出错。面试时如果被追问这个问题我的建议是先明确题目中节点值的唯一性约束。如果确实有重复值最简单的处理方式是“基于下标比较而不是值的比较”或者再附加一个额外的约束条件比如“左子树中不允许出现与根节点相同值的节点”。但实际面试中把唯一性假设说清楚基本就能过关。5.4 为什么前序后序无法唯一构造不少人刷完105和106会好奇那前序后序能不能构造结论是不能唯一构造。原因是前序和后序都只能确定“根是谁”但无法切分左右子树。比如根节点只有一个孩子节点时这个孩子既可以放在左边也可以放在右边两种树的遍历序列完全相同。这个知识点虽然不会直接出现在105/106的代码里但面试官很可能追问“为什么题目不是前序后序”这时候你能答出“因为无法唯一确定左右子树划分”这一句面试分就拿到了。6. 构造二叉树的应用延伸6.1 反序列化场景工程中的直接对照二叉树序列化与反序列化是面试中非常常见的综合题比如LeetCode 297。序列化阶段把一棵二叉树转成字符串或数组反序列化阶段再把字符串重新还原成二叉树。虽然标准反序列化通常用层级遍历或前序遍历来实现但当序列化格式设计为“前序序列 中序序列”或“中序序列 后序序列”时反序列化的核心逻辑就和你今天学到的构造二叉树完全一致。换句话说你现在掌握的代码本质上就是一套“反序列化器”的核心方法。以后再看任何系统里有关树形数据结构持久化的代码会有一种“这不就是我在算法题里写过的构造二叉树吗”的感觉。6.2 最大二叉树变体递归思维的延伸LeetCode 654最大二叉树是构造二叉树思路的另一种应用。题目给你一个不含重复元素的整数数组最大元素作为根节点最大值左侧的子数组构造左子树右侧的子数组构造右子树。这本质上是“用数组区间构造二叉树”只不过确定根节点的规则是“区间最大值”。如果你完整掌握了105题的递归区间切分思路最大二叉树几乎可以无痛秒杀。这说明构造二叉树这个能力点是很多二叉树中级题的基础底座。学一道题其实是在为后面的一批题打基础。6.3 线索二叉树的关联知识在热词里有“什么是线索二叉树”。线索二叉树利用节点中空余的左右指针记录遍历序列中前驱和后继的信息。它的构建过程通常需要基于中序遍历来完成——因为中序遍历能给出每个节点的前驱后继关系。这跟构造二叉树有什么关系线索二叉树是在已经存在的一棵树上额外做“线索化”操作而构造二叉树是在没有任何结构的情况下“凭空建树”。两者一个是在已有树的结构上加工一个是从零开始构建。但都对二叉树的遍历顺序有极其深刻的理解要求。如果你能把构造二叉树搞明白再去看线索二叉树的构建过程会觉得顺畅很多。6.4 深度、遍历、搜索等关键词的串联复习构造二叉树学完之后建议你顺手把“二叉树的深度”“二叉树的遍历”“搜索二叉树”这几个热词串联起来复习一遍。比如构造出一棵树之后立刻用它来计算最大深度、做三种遍历、验证是否满足二叉搜索树的性质这一步可以把已经学过的知识点一次性串起来。我个人在刷题训练时有个习惯每学完一个新的“建树类”题目就立刻用这棵树去做5道“树操作类”题目比如求深度、求路径和、判断平衡、镜像反转、遍历输出。这一套组合拳打完二叉树这个板块基本就稳了。最后分享一点个人的体会构造二叉树这题表面上是考代码实际上考的是“你有没有真正理解遍历序列之间的内在关系”。如果只背模板换个题目照样不会但如果你能合上代码本在白纸上画出遍历序列推导树结构的过程这题才算真正掌握。我建议你学完本篇文章后立刻找一张纸自己手写一遍前序中序、中序后序的全部分析流程不写代码光是推导每个根节点和区间划分。这个过程走过一遍比闷头写十遍代码都管用。
返回列表