
1. 为什么“节点”才是理解二叉树的钥匙1.1 一个节点的自我修养数据、左孩子、右孩子二叉树这个概念网上定义一抓一大把但真正动手写过的人都知道核心就两个字节点。节点是组成二叉树的最小单元每一个节点里面装着三样东西自己的数据、指向左孩子的引用指针、指向右孩子的引用指针。用代码写出来极其简单class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right就这么点东西。但就是这三个字段能组合出无穷无尽的树形结构。数据域可以存整数、字符串、对象、表达式甚至另一个数据结构left 和 right 分别指向下一层节点没有孩子时就指向 None 或 null。理解二叉树第一件事就是把“节点是树的单元树是节点的集合”这句话刻在脑子里后面所有遍历、查找、插入、删除操作本质都是在对节点的引用做搬运和修改。1.2 递归结构树是由节点嵌套出来的二叉树有个特别有意思的性质它天然是递归结构。一棵树由根节点、左子树、右子树组成而左子树和右子树本身又是一棵二叉树。这意味着什么意味着你用处理整棵树的方法去处理任何一个子树逻辑完全一致。比如你想数一棵树有多少个节点最直观的做法就是def count_nodes(root): if root is None: return 0 return 1 count_nodes(root.left) count_nodes(root.right)一个 return 搞定。因为树的递归定义算法天然也是递归的。这一点和链表很像链表是“节点 指向下一个节点的引用”二叉树是“节点 指向左右两个节点的引用”。链表是线性的二叉树是分叉的但底层思路一脉相承。我见过不少人被二叉树吓住其实只要先在链表上把指针、引用、递归这些东西搞明白二叉树就是一拍大腿就能通的事。1.3 为什么不用数组存二叉树有些读者会问既然树是一堆数据为什么不用数组存存完不也能遍历吗确实能但不能。数组适合存完全二叉树比如堆排序里的堆父节点下标是 i左孩子是 2i1右孩子是 2i2。但普通二叉树形状不规则你拿数组硬存中间会空出大量无效下标。假设一棵树只有一个根节点和一个挂在极右的叶子节点数组长度要开到 3 才能放两个有值的位置再往下挂一层长度要开到 7实际只用了 3 个。节点越稀疏空间浪费越离谱。节点实现就好得多left 和 right 指向真正存在的子节点空的位置不需要占内存。更重要的是节点实现这棵树“长什么样”就是“结构是什么”不存在下标换算问题。我自己的经验是如果是完全二叉树、且你知道数据总数用数组省内存、好定位但只要树的形状可能不完整或者你要做插入删除直接上节点别犹豫。2. 从零构建一棵二叉树三种常用方式与适用场景2.1 手动创建最直观但最容易写错最简单的构建方式就是手动 new 节点然后手动串起来。比如构建下面这棵树1 / \ 2 3 / \ \ 4 5 6代码如下root TreeNode(1) root.left TreeNode(2) root.right TreeNode(3) root.left.left TreeNode(4) root.left.right TreeNode(5) root.right.right TreeNode(6)这种方式的优点是直观适合写测试用例时临时造一棵小树。缺点也明显树一大代码就变成一堆赋值语句而且很容易手滑把 left 和 right 写反。我的建议是手动构建时一定先在草稿纸上画出图来再照着图写写完后用下面的可视化打印函数确认结构别靠脑补。2.2 层序反序列化按层搭出完整结构面试和实际项目中更常见的方式是给一个层序遍历序列比如[1, 2, 3, None, 5, None, 6]其中 None 表示该位置没有节点让你重建整棵树。这个场景我在面试题里见过无数次也是不少框架做配置树时用的存储格式。实现思路用队列from collections import deque def build_tree_from_level_order(data): if not data or data[0] is None: return None root TreeNode(data[0]) queue deque([root]) idx 1 while idx len(data): node queue.popleft() if data[idx] is not None: node.left TreeNode(data[idx]) queue.append(node.left) idx 1 if idx len(data) and data[idx] is not None: node.right TreeNode(data[idx]) queue.append(node.right) idx 1 return root这里有一个我踩过坑的细节每次从队列头部弹出一个节点就要消费两个数组元素分别作为它的左孩子和右孩子即使某个孩子是 None 也要消费掉对应的下标。如果你少写了一个idx 1后面节点全部错位整棵树就歪了。层序构建尤其适合数据源是 JSON、数据库表这类扁平结构的场景配合序列化函数可以做到树的持久化和恢复。2.3 根据遍历结果重建已知前中后序怎么还原还有一类高频面试题给定前序和中序遍历结果重建二叉树。比如前序是[1, 2, 4, 5, 3, 6]中序是[4, 2, 5, 1, 3, 6]。重建的核心思想是前序第一个元素一定是根然后在中序里找到根的位置根的左边是左子树的中序序列右边是右子树的中序序列再根据左右子树长度把前序序列切分递归向下建树。def build_tree_from_preorder_inorder(preorder, inorder): if not preorder: return None root_val preorder[0] idx inorder.index(root_val) root TreeNode(root_val) left_size idx root.left build_tree_from_preorder_inorder( preorder[1:1 left_size], inorder[:idx] ) root.right build_tree_from_preorder_inorder( preorder[1 left_size:], inorder[idx 1:] ) return root能重建的前提是序列里没有重复值或者你能清楚区分每个节点的唯一性。注意前序中序、后序中序都能重建但前序后序不能唯一重建因为左右子树的边界无法确定。这个点很多人不知道遇到“给前序后序让重建”的题才会懵。3. 遍历的四种姿势递归、栈、队列与 Morris3.1 三种深度优先遍历的递归与迭代写法二叉树的遍历是绕不开的基本功。前序、中序、后序分别指“根节点”被访问的时机前序是根左右中序是左根右后序是左右根。递归写法就是把访问逻辑放在不同位置def preorder_recursive(root, result): if root is None: return result.append(root.val) preorder_recursive(root.left, result) preorder_recursive(root.right, result) def inorder_recursive(root, result): if root is None: return inorder_recursive(root.left, result) result.append(root.val) inorder_recursive(root.right, result) def postorder_recursive(root, result): if root is None: return postorder_recursive(root.left, result) postorder_recursive(root.right, result) result.append(root.val)递归写法短小精悍但工程上要小心递归深度。面试时通常还要求手写非递归版本。前序非递归用栈def preorder_iterative(root): if root is None: return [] result [] stack [root] while stack: node stack.pop() result.append(node.val) if node.right: stack.append(node.right) if node.left: stack.append(node.left) return result注意压栈顺序先右后左这样才能保证出栈时先处理左孩子。中序非递归稍微绕一点先一路往左压栈弹出来访问根节点然后再去处理右子树。def inorder_iterative(root): result [] stack [] cur root while stack or cur: while cur: stack.append(cur) cur cur.left cur stack.pop() result.append(cur.val) cur cur.right return result这个写法其实是“跟着最左路径走到底再回头处理右子树”的模拟多写几遍手感就有了。3.2 层序遍历的队列实现层序遍历就是逐层从左往右访问用队列最自然def level_order(root): if root is None: return [] from collections import deque queue deque([root]) result [] while queue: level_values [] for _ in range(len(queue)): node queue.popleft() level_values.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(level_values) return result这里有个小技巧每轮循环先记录当前队列长度然后只处理这么多个节点这样就能自然地把每层节点分成一组。如果不记录长度队列里会混入下一层节点输出的层边界就乱了。层序遍历在“按层级展示组织架构”“求二叉树宽度”“找最底层最左节点”等问题里都有应用。3.3 Morris 遍历不用额外空间的方法递归用函数调用栈迭代用显式栈或队列空间复杂度都是 O(h) 或 O(n)。Morris 遍历则把空闲的右指针用起来实现 O(1) 额外空间的遍历。核心思想是在中序遍历时找到当前节点左子树的最右节点把它的 right 指针临时指向当前节点这样遍历完左子树后能沿着这个“线索”回到根节点。def inorder_morris(root): result [] cur root while cur: if cur.left is None: result.append(cur.val) cur cur.right else: prev cur.left while prev.right and prev.right is not cur: prev prev.right if prev.right is None: prev.right cur cur cur.left else: prev.right None result.append(cur.val) cur cur.right return resultMorris 遍历的代码看着绕但搞清楚“线索的建立与删除”之后也就不难了。它的价值主要在嵌入式、低内存环境下遍历大树平时业务开发用得少。我建议面试前把这个实现至少手写一遍理解它为什么不会死循环临时线索用完即断树的结构最终会恢复原样。四种遍历方式的对比如下遍历方式顺序数据结构空间复杂度典型场景前序根-左-右栈O(h)复制树、序列化中序左-根-右栈O(h)二叉搜索树排序输出后序左-右-根栈O(h)删除树、求子树和层序逐层从左到右队列O(w)层级统计、最短路径h 是树高w 是树的最大宽度。别小看这张表选错遍历方式会导致代码复杂一大截。比如判断一棵树是不是二叉搜索树用中序遍历最方便做序列化时前序遍历加 None 标记最直接计算树的宽度非层序遍历不可。4. 节点式二叉树的高频操作深度、节点数、叶子数与搜索树判定4.1 递归求高度与节点数的通式二叉树的高度定义为从根到最远叶子的节点数或边数不同题目定义不同先确认题目要求。递归式异常简单空树高度为 0非空树高度为左右子树最大高度加 1。def max_depth(root): if root is None: return 0 return 1 max(max_depth(root.left), max_depth(root.right))节点数、叶子节点数同理def count_leaf(root): if root is None: return 0 if root.left is None and root.right is None: return 1 return count_leaf(root.left) count_leaf(root.right)这三个操作放在一起学是有原因的它们的递归模式完全统一先处理空节点再分治左右子树最后合并结果。这种模式在二叉树题目里出现频率极高我把它们称为“二叉树递归三板斧”。掌握了这套模式遇到“求直径”“求最大路径和”“判断高度平衡”之类的题至少能很快想到递归合并的思路。4.2 判断一棵树是不是二叉搜索树二叉搜索树BST的定义是左子树的所有节点值小于根节点右子树的所有节点值大于根节点且左右子树也分别是 BST。很多人第一反应是递归判断“左孩子小于根、右孩子大于根”这个判断是错的因为只比较了直接孩子没有限制整棵子树的上下界。经典的“错误题解”长这样# 错误示例 def is_bst_wrong(root): if root is None: return True if root.left and root.left.val root.val: return False if root.right and root.right.val root.val: return False return is_bst_wrong(root.left) and is_bst_wrong(root.right)这棵树能骗过它5 / \ 1 6 / \ 4 76 的左孩子是 4虽然 4 6但 4 应该大于根节点 5所以这不是 BST。正确做法是给递归函数传上下界或者用中序遍历看序列是否严格递增def is_bst(root): def helper(node, lower, upper): if node is None: return True val node.val if lower is not None and val lower: return False if upper is not None and val upper: return False return helper(node.left, lower, val) and helper(node.right, val, upper) return helper(root, None, None)中序遍历版本更简单BST 的中序遍历结果一定是有序的用一个 prev 变量记录前一个节点值一旦发现当前值小于等于 prev就判定非法。4.3 搜索二叉树的插入与删除最容易被忽视的指针细节BST 的插入操作不复杂从根开始小于当前节点走左子树大于走右子树遇到空位就插入新节点。难点在删除。删除一个节点有三种情况叶子节点直接删只有一个孩子让孩子顶上来有两个孩子用右子树的最小节点或左子树的最大节点替换被删节点再删掉那个最小节点。def delete_node(root, key): if root is None: return None if key root.val: root.left delete_node(root.left, key) elif key root.val: root.right delete_node(root.right, key) else: if root.left is None: return root.right if root.right is None: return root.left min_node root.right while min_node.left: min_node min_node.left root.val min_node.val root.right delete_node(root.right, min_node.val) return root这段代码我建议你自己完整写一遍再在纸上走一遍流程。特别是“用右子树最小节点替换”那行容易忽略一点替换完之后要递归删除右子树里的那个最小节点否则树里会出现重复值。4.4 公共祖先问题给定两个节点找它们的最近公共祖先LCA也是高频题。递归思路很清晰如果当前节点等于 p 或 q直接返回否则分别去左右子树找左子树和右子树都找到了说明当前节点就是 LCA只有一边找到返回那一边的结果。def lowest_common_ancestor(root, p, q): if root is None or root p or root q: return root left lowest_common_ancestor(root.left, p, q) right lowest_common_ancestor(root.right, p, q) if left and right: return root return left if left else right这个算法的精妙之处在于用返回值隐式地传递“是否找到了目标节点”的信息空间复杂度是递归深度 O(h)。如果是 BST 的 LCA还能利用有序性优化如果 p 和 q 都小于当前节点往左走都大于往右走否则当前节点就是分叉点。5. 实战中的坑空指针、递归爆栈与树的退化5.1 递归写法最容易踩的坑忘记递归出口二叉树的递归算法我见过最多的问题就是“一上来就递归忘了判断 root 是不是 None”。比如求深度如果不写if root is None: return 0空树会直接抛空指针异常或者无限递归直到栈溢出。这个错误在所有树相关算法里都会出现甚至很多人写了几年前端、后端写树时还是会漏。提示写递归函数时第一行永远是边界条件。这是优先级最高的习惯没有之一。除了空节点判断还有一类边界是“当前节点是叶子节点”比如求叶子数、找所有路径时需要在递归主体前判断root.left is None and root.right is None。别把这个判断放在调用方而应放在递归函数内部这样逻辑才封装得干净。5.2 树深度很大时递归转迭代二叉树严重不平衡时深度会逼近节点数。比如一直往左挂的树10000 个节点深度就是 10000。Python 默认递归深度限制通常在 1000 左右超过就抛RecursionError。这时候有两个选择一是用sys.setrecursionlimit()调大限制但这只是治标递归栈本身的内存消耗还是很大二是改成显式栈的迭代写法把递归逻辑手工翻译成循环不依赖函数调用栈。我在实际处理层级很深的业务树时一般会选择迭代写法。虽然代码稍长但可控性高不会因为某个极端数据把服务打挂。5.3 二叉树退化成链表时的性能问题正常的平衡二叉树搜索、插入、删除时间复杂度都是 O(log n)。但如果你从有序序列一个一个插入BST 会退化成一条链表比如插入 1,2,3,4,5每次往右挂搜索 5 就要遍历 5 个节点复杂度变成 O(n)。这就是为什么工程上很少直接用裸 BST而是用红黑树、AVL 树这些自平衡版本。理解“退化”是理解平衡树价值的关键也解释了为什么数据库索引不用普通 BST 而用 B 树磁盘 IO 场景下树的高度直接决定访问次数控制高度就是控制延迟。5.4 可视化调试怎么把树打印成一棵树调试二叉树算法时光靠 print 中序遍历结果往往不够直观。我习惯写一个简单的可视化打印函数把树的结构画出来一眼就能看出 left/right 有没有挂错。思路是用层序遍历收集每层的节点然后逐层打印缩进def print_tree(root): if root is None: print(empty tree) return from collections import deque queue deque([(root, 0)]) current_level 0 level_data [] while queue: node, level queue.popleft() if level ! current_level: print( * (max_depth(root) - current_level) .join(level_data)) current_level level level_data [] val str(node.val) if node else N level_data.append(val) if node: queue.append((node.left, level 1)) queue.append((node.right, level 1))不用纠结排版是否完美关键是能把节点的父子关系一目了然。我每次写完树相关的构建代码第一件事就是打印一棵树确认结构比自己盯着代码猜靠谱得多。6. 比我预期的还要有用的应用场景6.1 表达式树编译器怎么用节点树做运算表达式树是把算术表达式表达成二叉树的经典例子。叶节点是数字或变量内部节点是运算符。表达式(3 4) * 5的树形结构是根是*左子树是的左叶子是 3、右叶子是 4右子树是叶子 5。后序遍历这棵树就能得到后缀表达式也就是不少计算器内部求值用的形式。我刚工作那会儿参与过一个规则引擎运营人员配置的复杂条件表达式就是先解析成表达式树再递归遍历求值。遇到AND、OR、NOT这些逻辑运算符也是同一套树形求值逻辑。可以说只要涉及“表达式解析”表达式树就是最自然的数据结构。6.2 哈夫曼树与最优编码哈夫曼树是一种带权路径长度最短的二叉树核心思想是每次从所有节点中选出权值最小的两个合并成一个新节点新节点的权值是两者之和。重复这个步骤直到只剩一个根节点。哈夫曼编码用它来为字符生成变长编码出现频率高的字符编码短出现频率低的编码长压缩效果显著。构建哈夫曼树时通常用小顶堆优先队列来维护节点权重的最小值。这里也体现了“节点”思想的价值合并过程中不断产生新节点树的形态动态变化用数组或链表实现都别扭节点结构最自然。6.3 业务系统里的树形结构回到日常业务组织架构、商品分类、菜单权限、评论回复全都是树形数据。绝大多数时候是“多叉树”不是二叉树但二叉树的知识完全通用遍历、深度、最近公共祖先、序列化与反序列化这些操作在一棵多叉树里同样成立。比如权限系统里判断两个部门是否在同一分支下本质上就是找最近公共祖先商品分类的层级展示就是层序遍历。你需要做的只是把数据结构的字段从left、right换成一个children列表。很多人在“二叉树刷题”和“业务开发”之间建立不起连接其实一旦把二叉树学扎实多叉树、邻接表、甚至文件系统目录树都会觉得似曾相识。6.4 从二叉树到多叉树与 B 树二叉树的节点只有两个孩子看起来简单但也有限制。比如数据库索引需要极低的树高来减少磁盘 IO于是有了 B 树和 B 树它们每个节点可以有很多子节点一层能覆盖大量数据。Redis 的跳跃表、Linux 的文件系统也都有自己的树形或类树结构。掌握好“基于节点”的思维方式后理解这些进阶数据结构就是加字段、加规则的事。我在带新人时经常打一个比方二叉树是树的“最小完整单元”孩子数从 2 变成 n很多逻辑不是变复杂只是循环次数变了。节点式实现给你带来的是一种通用能力看到任何树形结构你都知道怎么构建、怎么遍历、怎么查询、怎么修改。这个能力放到任何语言、任何业务场景里都不会过时。最后分享一个我自己的小习惯每次拿到一棵树的数据不管 JSON 还是数据库查出来的我都先画图再写构建代码然后用打印函数验证最后才动手写业务逻辑。这套流程帮我省掉了至少一半的调试时间。二叉树这东西代码本身不复杂复杂的是心一急就想跳过中间步骤。慢一点把节点一个一个接好树自然就立起来了。