资讯动态

二叉树基础入门:从结构定义到遍历应用全解析

发布时间:2026/10/8 18:31:02 来源:尧图企业网站定制
训练营进行到第十三天前两周还在线性表、栈、队列里打转今天终于切到了二叉树。说实话第一天接触二叉树最直观的感觉就是“原来代码还能长成这样”——一个节点牵着左右两个分支递归地往下延伸像一棵倒着长的树。但这玩意儿看着简单真写起代码来空指针、栈溢出、递归出口漏写各种运行时错误轮番上阵。这篇就把我在训练营第十三天啃下来的二叉树基础做一个完整复盘从结构定义、遍历套路到深度计算、二叉搜索树验证再到写代码时那些防不胜防的坑一次性讲透。1. 二叉树到底是什么先把这个最基础的概念啃透1.1 节点、根、叶子用家谱图理解二叉树的结构我特别喜欢用家谱来类比二叉树。一棵二叉树里最顶上的节点叫“根节点”它是整棵树的起点就像家族里的始祖。每个节点最多只能有左、右两个孩子对应二叉树名字里“二”的由来。没有孩子的节点叫“叶子节点”类比成家族里没有后代的人。有孩子的节点叫“内部节点”。在代码里一个二叉树节点通常长这样以Python为例class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right就这么简单一个值两个指针。但这三个字段组合起来却能表达极其复杂的层次关系。训练营老师第一天就强调二叉树的核心是“递归定义”——一棵二叉树要么为空要么由根节点和左子树、右子树组成而左子树和右子树本身又是一棵二叉树。这个定义直接决定了后面所有遍历、搜索、深度计算的写法本质都是在递归地处理“根、左、右”三部分的组合。我在学习时最容易犯的误区是想把整棵树“看”成一个平面结构比如数组那样的连续内存。但二叉树在逻辑上是分层的每一层可能有不同数量的节点物理存储也通常是通过指针串起来的除了后面会提到的堆式存储。所以写代码时要时刻提醒自己当前函数处理的是“某一个节点”而不是“整棵树”剩下的交给递归。1.2 满二叉树与完全二叉树面试最爱问的两个定义训练营里老师讲二叉树分类时特意把“满二叉树”和“完全二叉树”拎出来对比。这两个概念经常在计算题里出现比如给定节点数让你求层数或者判断某棵树是不是堆。满二叉树Full Binary Tree的定义是除叶子节点外每个内部节点都有两个子节点。换句话说没有节点只有一个孩子。这种树每一层的节点数都达到最大值如果高度是h总节点数就是2^h - 1。完全二叉树Complete Binary Tree稍微绕一点除了最后一层每一层都是满的最后一层的节点都靠左排列中间不能有空洞。这么说可能抽象我举个例子假如最后一层应该有5个节点那么这5个节点必须从左往右依次填充不能出现“左边空了、右边还有一个节点”的情况。完全二叉树的一个重要应用就是堆排序——因为可以用数组连续存储父节点下标 i 的左孩子下标是 2i1右孩子是 2i2计算起来非常快。这里有个容易混淆的点满二叉树一定是完全二叉树但完全二叉树不一定是满二叉树。用一个判断口诀思考完全二叉树只要求“最后一层靠左连续”而满二叉树要求“所有层必须铺满”。训练营里的练习题有一道就是给出一棵完全二叉树的数组表示让你还原成树形结构这个思路后续看堆排序时还会再遇到。2. 二叉树的遍历四种写法一次搞清楚2.1 前序、中序、后序递归写法与调用栈的对应关系二叉树的遍历是整个基础的核心后面所有操作深度计算、构建、序列化都离不开它。三种深度优先遍历的区别只在“访问根节点的时机”前序遍历先访问根再遍历左子树最后遍历右子树根左右中序遍历先遍历左子树再访问根最后遍历右子树左根右后序遍历先遍历左子树再遍历右子树最后访问根左右根我一开始死记“前中后是指根的位置”但写代码时还是经常搞混。后来用了一个土办法心里默念“处理顺序”前序就是“一上来就打印自己然后处理左边再处理右边”。下面直接用代码说话def preorder(root): if root is None: return print(root.val) # 前先访问根 preorder(root.left) # 再递归左 preorder(root.right) # 最后递归右 def inorder(root): if root is None: return inorder(root.left) # 先递归左 print(root.val) # 中访问根 inorder(root.right) # 最后递归右 def postorder(root): if root is None: return postorder(root.left) # 先递归左 postorder(root.right) # 再递归右 print(root.val) # 后最后访问根这三种写法除了print语句的位置不同其他完全一样。但别小看这个位置差异——它决定了整个递归过程中的调用顺序。我在训练营里用“打印节点编号缩进”的方式观察递归过程发现中序遍历有个特别有意思的性质对一棵二叉搜索树做中序遍历得到的序列是升序的这个性质后面验证BST时直接能用。初学阶段还容易忽略的一点是递归遍历的时间复杂度是O(n)空间复杂度是O(h)其中h是树高。最坏情况下树退化成单链表递归调用栈深度等于n会导致栈溢出。这就是很多同学在LeetCode上遇到“递归深度越界”的原因。所以老师特别提醒如果题目没有明确限制递归写法完全够用但如果树的节点数达到十万级而且偏向单链就要考虑用迭代法显式栈来模拟递归。2.2 层序遍历队列怎么用才不会出错层序遍历BFS是另一种完全不同的遍历方式它要求从上到下、从左到右一层一层地访问节点。实现层序遍历的核心数据结构是队列。我一开始试图用递归写层序结果写得特别别扭后来才明白层序遍历本质是“广度优先”递归天然擅长“深度优先”两者不对路。标准写法是这样的from collections import deque def level_order(root): if root is None: return [] result [] queue deque([root]) while queue: level_size len(queue) level_vals [] for _ in range(level_size): node queue.popleft() level_vals.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(level_vals) return result这里有个关键细节进入每一层循环前先记录当前队列的长度level_size然后只处理这个长度内的节点。如果不这么做直接用while queue就会把现在这一层和下一层的节点混在一起无法按层分组。我在第一次写的时候忘了这一步结果输出的是一维数组没有层级感。面试时如果要求按层返回二维数组这个level_size就是必考细节。另外在popleft()取出节点后一定要先检查左孩子再检查右孩子顺序不能乱因为层序遍历的顺序是从左到右。如果把右孩子先入队输出顺序就错了。这个错误特别隐蔽因为对于一棵完美二叉树来说左右顺序颠倒后看起来还是“层序”但在非对称树上会立刻暴露。3. 二叉树的深度与搜索二叉树训练营里的两个重点实操3.1 最大深度与最小深度递归返回值的写法计算二叉树的深度是经典基础题也是训练营第三天下午的实战内容。所谓深度就是从根节点到最远叶子节点的路径上的节点数。递归解法非常简洁但新手容易写出“看起来对、实际错”的版本。先看最大深度的正确写法def max_depth(root): if root is None: return 0 left_depth max_depth(root.left) right_depth max_depth(root.right) return 1 max(left_depth, right_depth)这里的关键是递归函数返回的是“以当前节点为根的子树的最大深度”。空节点深度为0非空节点的深度等于“左右子树深度的最大值加1”——加1是因为当前节点本身占一个深度层级。我一开始写错的地方是直接在return里写max(max_depth(root.left), max_depth(root.right)) 1没有先判断root is None结果当递归到叶子节点的孩子即None时调用max_depth(None)虽然不会崩溃因为Python None没有属性但函数体里直接访问root.left会报错但如果函数体写成return 1 max(max_depth(root.left), max_depth(root.right))就会无限递归直到栈溢出。所以递归函数的第一行必须是边界条件检查这是铁律。最小深度比最大深度容易错的地方在于最小深度指的是从根节点到“最近叶子节点”的最短路径上的节点数而不是简单地取左右子树最小深度加1。如果某个节点只有一个孩子另一个孩子为空那么空的那一侧不能算作路径因为路径必须终止在叶子节点。def min_depth(root): if root is None: return 0 if root.left is None and root.right is None: return 1 if root.left is None: return min_depth(root.right) 1 if root.right is None: return min_depth(root.left) 1 return min(min_depth(root.left), min_depth(root.right)) 1训练营里有一道题专门考这个一个只有左子树、没有右子树的链状树如果误用return 1 min(min_depth(root.left), min_depth(root.right))会得到1 0 1可实际上根到最近叶子的路径是沿着左子树一直往下的深度大于1。所以遇到单支情况必须单独处理。3.2 搜索二叉树BST的性质与验证搜索二叉树Binary Search Tree简称BST让二叉树从“存储结构”升格为“查找结构”。它的性质只有一句话对于任意节点其左子树的所有节点值都小于该节点的值右子树的所有节点值都大于该节点的值且左右子树本身也是BST。注意是“所有节点”不是“左孩子和右孩子”。这句话看似简单但写验证代码时特别容易用错。我见过很多人的第一个版本是def is_valid_bst(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_valid_bst(root.left) and is_valid_bst(root.right)这个版本只能判断“父节点和孩子节点之间的大小关系”并不能约束“左子树的所有节点”。比如下面这棵树5 / \ 3 7 / \ 2 8按上面代码检查每个父节点和孩子都满足条件35, 75, 27, 87但它不是BST因为节点2位于5的右子树却小于5。正确的验证方法是使用范围传递def is_valid_bst(root, lowfloat(-inf), highfloat(inf)): if root is None: return True if root.val low or root.val high: return False return (is_valid_bst(root.left, low, root.val) and is_valid_bst(root.right, root.val, high))每次递归向下左子树的范围被更新为(low, root.val)右子树被更新为(root.val, high)。这种“范围约束”的思路正是BST区别于普通二叉树的核心。另一个等价做法是中序遍历如果遍历结果是严格递增的那这棵树就是BST。因为BST的中序遍历天然升序反过来也成立。我在训练营里两种方法都试了递归范围法不需要额外数组效率更高推荐优先掌握。BST的实际价值在于查找在平衡的BST中查找某个元素的时间复杂度是O(log n)远远优于链表的O(n)。这也是后面AVL树、红黑树的入门基石——它们都是为了解决BST可能退化成链表的痛点而设计的。4. 写二叉树程序时为什么总是报运行时错误常见问题与排查技巧4.1 空指针访问90%以上崩溃案例的根源训练营里同学们写二叉树代码运行报错最多的一句话就是“AttributeError: NoneType object has no attribute left”以Python为例。说白了代码试图访问空节点的属性或方法。这个错误几乎都源于一个习惯没在递归函数入口检查root is None。比如前面提到的max_depth如果函数体写成def max_depth(root): left_depth max_depth(root.left) right_depth max_depth(root.right) return 1 max(left_depth, right_depth)当递归到空节点时root是Noneroot.left直接报错。不要觉得这种低级错误不会发生在自己身上——我在写层序遍历时如果忘记判断node.left是否为空直接queue.append(node.left)同样会报错。排查这类问题有几个技巧。第一先看调用栈错误信息会告诉你具体是第几行的什么对象为空。沿着调用栈往回看找到第一次传入空节点的位置通常就是递归出口漏写。第二在函数入口加一行防御代码if root is None: return ...。第三用打印调试在递归函数开头打印当前节点值看什么时候出现None。掌握了这些绝大多数空指针问题能在两分钟内定位。4.2 递归出口漏写与无限递归与空指针并列的高频错误是“RecursionError: maximum recursion depth exceeded”也就是栈溢出。这通常有两种原因一是递归出口确实没写二是出口写了但逻辑不对导致函数始终不能到达出口。举个例子有人写遍历时def inorder(root): inorder(root.left) print(root.val) inorder(root.right)完全没写if root is None那么在访问到叶子节点的空孩子时就会无限递归调用inorder(None)→inorder(None.left)→ ...直到栈爆掉。这种情况在LeetCode上特别常见因为树的测试用例里会有大量空节点。另一个容易忽视的陷阱是循环引用。虽然二叉树理论上不会出现指向祖先的节点但在手工构造测试数据时如果有两个节点互相指向对方递归遍历就会在两者之间死循环。排查方法是限制递归深度或者打印访问过的节点值看看是否有重复访问。训练营里老师建议构造树结构时尽量使用TreeNode构造器避免手动改left/right指向能减少这类问题。4.3 线索二叉树的一点点延伸提到二叉树基础不可回避一个进阶概念——线索二叉树。它解决的是“浪费空闲指针”的问题。一棵有n个节点的二叉树通常有2n个指针域其中只有n-1个指向孩子剩下的n1个指针域都空着。线索化就是利用这些空指针将中序遍历的前驱和后继信息存储起来让遍历过程不再依赖递归或栈。我刚开始学线索二叉树的时候总觉得多此一举后来在做一个需要频繁中序遍历的场景时才体会到它的好处普通二叉树每次中序遍历都要从头递归时间复杂度O(n)而线索化之后可以从任意节点直接找到后继遍历更高效。不过训练营只要求理解概念代码实现Morris遍历是它的现代版本可以放到后面再说。如果你在LeetCode上看到“不用递归和栈实现中序遍历”的题目核心思路就来自线索化。5. 二叉树的应用从堆排序到表达式求值5.1 堆排序里的完全二叉树为什么堆用数组存储树结构在真实世界的应用远不止“存家谱”。前面提到的完全二叉树最经典的应用就是堆排序。堆Heap是一种特殊的完全二叉树大顶堆要求每个节点的值都不小于其孩子节点小顶堆则相反。因为完全二叉树天然适合数组存储所以堆排序可以在O(n log n)的时间内完成排序且不需要额外的链式指针。贴一段用Python实现的大顶堆向下调整核心代码def heapify(arr, n, i): largest i left 2 * i 1 right 2 * i 2 if left n and arr[left] arr[largest]: largest left if right n and arr[right] arr[largest]: largest right if largest ! i: arr[i], arr[largest] arr[largest], arr[i] heapify(arr, n, largest)这里left 2*i 1和right 2*i 2就是完全二叉树的下标规律。我在训练营里手动模拟过一遍调整过程才真正理解“堆”和“二叉树的深度”之间的关系堆排序的建堆和调整都依赖树的高度复杂度O(log n)就建立在完全二叉树高度为O(log n)这个前提上。5.2 搜索二叉树与表达式树经典应用场景一网打尽除了堆二叉树还有两个高频应用场景分别是搜索二叉树和表达式树。搜索二叉树前面已经详细讲了它在实际系统中常被用来实现“键值查找”。比如Java的TreeMap底层是红黑树而红黑树本身就是一种自平衡的BST。理解BST的查找过程其实很简单从根开始如果目标值比当前节点值小就去左子树找如果大就去右子树找相等就找到。这个过程要求每次比较都能排除一半的子树所以前提是树要平衡——这又回到为什么AVL树、红黑树要调整旋转的原因。表达式树则是把数学表达式如(a b) * c表示成二叉树叶子节点是操作数变量或常量内部节点是运算符。对这棵树做后序遍历得到的序列就是后缀表达式逆波兰表示非常方便计算机计算。我在训练营中用Python构建了一棵简单的表达式树并用递归求值class Node: def __init__(self, val, leftNone, rightNone): self.val val self.left left self.right right def evaluate(node): if node.left is None and node.right is None: return float(node.val) left_val evaluate(node.left) right_val evaluate(node.right) if node.val : return left_val right_val if node.val -: return left_val - right_val if node.val *: return left_val * right_val if node.val /: return left_val / right_val这种“左右子树分别求值再做运算”的思路实际上就是把递归的思想用到了极致。学二叉树时如果你能彻底想明白表达式树的求值过程那就说明递归、遍历、子树划分这些概念真正融会贯通了。5.3 其他生活中的二叉树例子二叉树的应用其实无处不在。文件系统的目录结构就是一种树不一定是二叉树但二叉树是基础编译器做语法分析时会构建语法树游戏中的场景管理、搜索引擎的倒排索引结构也离不开树的思想。甚至你看到的这段文字在浏览器渲染时也会被解析成DOM树。训练营老师说过一句话我印象很深“学二叉树不只是学一种数据结构而是在学一种把复杂问题逐层分解的思维方式。”这句话放到任何领域都成立。比如你要处理一个文件夹里面套着很多子文件夹每个子文件夹又是独立的文件夹树——用递归去遍历它和处理二叉树异曲同工面对一棵大而复杂的问题树先解决根节点再拆成左右子树分别攻克这是典型的递归分治理念。从第十三天开始我们正式告别了线性结构进入了一个“非线性”的新世界。二叉树作为这个世界的第一课决定了后续图论、堆、AVL、红黑树能不能学扎实。如果让我给后来者一句建议那就是不要急着刷题先用纸笔画几十棵不同形态的二叉树自己模拟前序、中序、后序、层序遍历的每一步打印顺序再动手写递归代码。画图能帮你建立直观感受递归代码才不会变成“对着模板背”。我个人实际练习时还有一个习惯每写一个递归函数都先在草稿纸上写清楚“边界条件是什么递归调用返回的语义是什么当前层应该做什么”。比如遍历函数返回None深度函数返回int验证函数返回bool。把这三个问题想明白写出来的代码基本不会出大错。十三天的训练营还只是开始二叉树后面的路平衡、线索化、序列化还很长但基础打牢了后面的一切都是水到渠成的事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑