资讯动态

树的定义、存储、遍历与手写实现:从二叉树到工程应用

发布时间:2026/10/17 3:11:58 来源:尧图企业网站定制
数据结构里树可以说是我见过“初学觉得绕、考试必考、工作还常用”的一类结构。很多同学一开始被二叉树、递归、遍历砸懵后面又在红黑树、B树这些实际工程应用里重新认识它。这篇博文不打算泛泛讲概念而是围绕树的定义、存储、遍历、手写实现和典型问题排查这几个维度把你能实操、能复现、能直接拿去用的东西写清楚。适合正在复习数据结构、准备面试笔试、以及工作中需要手写树结构的人参考我会尽量讲明白“为什么这么做”而不是只给结论。1. 树的整体设计与思路拆解1.1 树的定义与核心术语先建立共同语言树是一种非线性数据结构由节点和节点之间的边构成。它的特点非常直观有一个根节点剩下的节点被分成若干个互不相交的子树。生活中最典型的类比就是电脑里的文件夹根目录是根节点每个子文件夹是一棵子树叶子节点就是没有子文件夹的目录。这里需要厘清几个高频术语因为很多人在写代码时混淆了它们节点Node树的基本单元通常包含数据域和指向子节点的引用。根节点Root没有父节点的节点一棵树只有一个根节点。叶子节点Leaf没有子节点的节点也叫终端节点。父节点、子节点、兄弟节点对应树中的相对关系通常用一条边连接的上下层来描述。度Degree节点拥有的子树个数整个树的最大度就是这棵树的度。深度Depth、高度Height深度从根节点向下计数根节点深度为0或1按不同教材定义高度从叶子节点向上计数两者在刷题时容易搞混。森林Forestn棵互不相交的树的集合把根节点去掉后一棵树就变成了森林。这些术语不是死记硬背用的而是在写代码和沟通时避免歧义的工具。比如在定义二叉树节点时你如果能把左孩子、右孩子、数据域三个字段想明白树的链接结构基本就掌握了一半。1.2 为什么用树而不是数组或链表数组和链表是线性结构适合表达一对一的关系。但现实中的数据关系往往是一对多比如组织架构、目录结构、编译器里的语法分析树、数据库索引里的B树。用线性结构表达一对多要么浪费空间要么查询效率低下。拿数组来说如果你把树节点按层级顺序填进数组确实可以利用数组下标计算父子关系但插入和删除节点时需要移动大量元素代价很高。链表能解决插入删除的问题但找某个节点的父节点或子节点需要从头遍历无法直接定位。树结构通过“节点子节点引用”的方式把一对多关系直接建模。更重要的是树的某些变体二叉搜索树、平衡树可以让查找、插入、删除的平均时间复杂度降到O(log n)级别。这就是为什么树能成为数据库、文件系统、搜索引擎底层的核心结构而数组和链表在这些场景里通常不够用。1.3 二叉树所有树问题的最佳切入点严格来说树可以是多叉的但实际做题和工程实现中我们几乎都在讨论二叉树。原因有几点一是二叉树可以用数组或链式结构方便存储二是很多多叉树可以通过“左孩子右兄弟”的表示法转成二叉树三是二叉树的递归性质最强更容易用代码表达。二叉树的定义很简单每个节点最多有两个子节点分别叫左孩子、右孩子。根据限制条件不同又衍生出满二叉树、完全二叉树、二叉搜索树、平衡二叉树AVL、红黑树等。新人最容易犯的错误是把这些概念混在一起这里先区分清楚满二叉树每一层节点数都达到最大节点总数是2^h - 1h是高度。完全二叉树除了最后一层其他层都满最后一层从左到右连续填入。完全二叉树非常适合用数组存储。二叉搜索树BST左子树所有节点值小于根节点右子树所有节点值大于根节点且左右子树也满足该性质。平衡二叉树左右子树高度差不超过1同时左右子树自身也是平衡二叉树。在后面实操时我默认以二叉树为讨论对象因为它的代码最典型也最能在面试中体现一个人的递归和指针功底。2. 树的存储方式与核心操作原理2.1 链式存储最直观也最常用的方式链式存储是新手最容易理解的表示方法把树中的每个节点定义成一个结构体包含数据域和若干指针域指针分别指向它的子节点。以二叉树为例节点定义通常是这样的struct TreeNode { int val; TreeNode *left; TreeNode *right; TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} };这种表示法非常直观一个节点就是一块内存通过指针把关系串联起来。它的优点是容易做插入和删除操作缺点是每个节点需要额外保存指针对于n个节点的二叉树如果每个节点有两个指针域那么会有一部分指针是空值。n个节点有n-1条边所以总的空指针数量是2n - (n-1) n1个这个数量常在线索二叉树相关的题目里出现。链式存储的难点在于对指针的理解。我见过不少人能画出树的图但一写代码就不知道node-left到底表示什么。这里有一个很好的思维习惯不要把“指针”看成地址数字而是把它看成“通往下一个节点的入口”。node-left为空说明这个节点没有左孩子node-left不为空说明通过它就能访问到左孩子对应的整棵子树。2.2 顺序存储用数组表达完全二叉树顺序存储适合完全二叉树。它的核心思想是利用下标关系定位父子节点如果根节点存放在数组下标1的位置不是0那么对于下标为i的节点它的左孩子下标是2i右孩子下标是2i1父节点下标是i/2向下取整。数组下标从0开始也可以只是公式要调整成左孩子2i1右孩子2i2父节点(i-1)/2。这个公式在堆排序和优先队列的实现里你会经常用到。顺序存储的好处是省去了指针字段访问速度快缓存友好。但它不适合存储一般形态的二叉树因为如果一个树形态很“歪”用数组存储会有大量空洞空间浪费。所以顺序存储最常见的应用场景是堆完全二叉树而链式存储是通用方案。2.3 遍历操作深度优先与广度优先树的遍历是核心操作几乎所有的树算法都建立在遍历之上。按访问根节点的顺序不同树的深度优先遍历分为前序遍历、中序遍历和后序遍历。这里的“前、中、后”指的是根节点的位置而不是左子节点和右子节点的相对位置。无论是前序、中序还是后序左子树的访问永远在右子树之前。用人话来说前序遍历先处理当前节点再递归处理左子树最后递归处理右子树。中序遍历先递归处理左子树再处理当前节点最后递归处理右子树。后序遍历先递归处理左子树再递归处理右子树最后处理当前节点。还有一个广度优先遍历也叫层序遍历。它按层从上到下、从左到右访问节点通常配合队列实现。前面三种深度优先遍历在非递归实现时通常用栈而层序遍历用队列这是后文排查问题时会反复提到的关键点。为什么遍历顺序重要因为很多算法直接依赖遍历顺序。比如二叉搜索树的中序遍历结果是一个升序序列这就是判断一棵树是否是合法BST的重要手段。后序遍历则常用于删除节点或计算子树高度因为你需要先处理完所有子节点才能得到父节点的结果。前序遍历则常用于序列化或复制一棵树因为你要先创建根节点才能递归创建子树。3. 实操手写二叉树的构建、遍历与查询3.1 准备一套稳定的遍历模板我建议所有人在学习二叉树时先把以下递归模板背到滚瓜烂熟因为它就是树的“骨架”。以C为例void traverse(TreeNode* root) { if (root nullptr) return; // 前序位置 traverse(root-left); // 中序位置 traverse(root-right); // 后序位置 }很多人写树代码卡住不是不理解逻辑而是不知道代码该写在哪一行。上面代码里的三个注释位置非常关键如果你在递归左子树之前写处理逻辑就是前序遍历如果在左子树和右子树之间写就是中序遍历如果在右子树之后写就是后序遍历。你不需要背三种不同的函数只需要记住这一个框架然后调整处理逻辑的位置即可。用相同的思路层序遍历的模板也很固定。层序遍历需要用队列暂存待访问的节点每次处理当前层的全部节点后再进入下一层。这里有一个特别实用的技巧如果想记录每一层有哪些节点可以在进入循环时先记下当前队列的长度size然后只处理前size个节点这样就能区分出每一层的边界。3.2 从数组构建一棵二叉树在做题或调试时经常需要把“可视化”的树转成代码里的树。最常见的输入格式是一个层序遍历的数组其中空节点用某个特殊值通常是null表示。构建过程本质上就是模拟层序遍历用队列记录待连接的节点每次从数组中取出左右孩子的值创建节点后挂到当前节点上。以下是Python版本的一个参考实现我实际调试时用的就是类似逻辑class TreeNode: def __init__(self, val0, leftNone, rightNone): self.val val self.left left self.right right def build_tree_from_level_order(values): if not values or values[0] is None: return None root TreeNode(values[0]) queue [root] idx 1 while idx len(values): node queue.pop(0) if values[idx] is not None: node.left TreeNode(values[idx]) queue.append(node.left) idx 1 if idx len(values) and values[idx] is not None: node.right TreeNode(values[idx]) queue.append(node.right) idx 1 return root注意几个细节。第一队列里存的永远是待处理的孩子节点而不是原始数组的全部元素。第二数组里连续的null会跳过不会创建节点但队列下标仍然要推进。第三如果左右孩子都是null当前节点就没有需要入队的后代但后续其他节点仍然可以从数组后续位置取数据。构建完成后用层序遍历打印检查能确保树的形状和预期一致。3.3 实现三种深度优先遍历并对比输出有了树之后遍历就是检验自己是否真正理解递归的试金石。我把三种遍历写在一起对比方便你看清处理逻辑的位置差异def preorder(root): result [] def dfs(node): if node is None: return result.append(node.val) # 前序位置 dfs(node.left) dfs(node.right) dfs(root) return result def inorder(root): result [] def dfs(node): if node is None: return dfs(node.left) result.append(node.val) # 中序位置 dfs(node.right) dfs(root) return result def postorder(root): result [] def dfs(node): if node is None: return dfs(node.left) dfs(node.right) result.append(node.val) # 后序位置 dfs(root) return result你可以用一个简单的树验证一下根节点是5左孩子是3右孩子是83的左孩子是18的右孩子是9。前序遍历结果是5、3、1、8、9中序是1、3、5、8、9后序是1、3、9、8、5。看着这三个结果试着在纸上模拟递归过程比看任何文档都管用。实际做项目时有一个经验之谈写遍历代码时先写if node is None: return也就是递归的终止条件再写业务逻辑。很多空指针问题都是因为终止条件没写好。另外嵌套函数里使用result列表时要确保它是可变对象这样在递归里append才能修改同一个列表。如果你不小心在递归里重新赋值result ...整个逻辑就会崩掉。3.4 非递归遍历手动模拟系统栈递归虽然清晰但会有两个问题一是递归深度过大时可能导致调用栈溢出二是部分面试或底层开发场景要求你必须明确控制栈的行为。非递归遍历的核心思路是用一个显式的栈替换系统递归栈。非递归前序遍历最简单只需要把每个节点入栈然后按根、左、右的顺序处理def preorder_iter(root): result [] stack [root] while stack: node stack.pop() if node is None: continue result.append(node.val) stack.append(node.right) stack.append(node.left) return result注意这里入栈的顺序是“先右后左”因为栈是后进先出的结构为了保证左孩子先出栈就得让左孩子最后入栈。中序遍历的非递归实现相对难理解核心是只要节点有左孩子就一直往左走把路径上的节点全部入栈直到左孩子为空然后弹出栈顶节点访问它再转向它的右孩子。这个逻辑可以用一句口诀记忆左路入栈弹出访问转向右路。def inorder_iter(root): result [] stack [] cur root while cur is not None or stack: while cur is not None: stack.append(cur) cur cur.left cur stack.pop() result.append(cur.val) cur cur.right return result后序遍历的非递归实现是三者中最绕的。一个常用技巧是可以先实现根、右、左的遍历然后反转结果得到左、右、根。因为前序遍历是根、左、右如果把入栈顺序改为先左后右得到根、右、左反转后就是后序遍历。def postorder_iter(root): result [] stack [root] while stack: node stack.pop() if node is None: continue result.append(node.val) stack.append(node.left) stack.append(node.right) return result[::-1]这个技巧第一次看可能觉得投机取巧但它严格保证了每个节点都被访问一次而且顺序正确。实际写代码时我非常推荐这个方法因为逻辑简单、不容易出错时间复杂度和空间复杂度都与标准后续遍历一致。3.5 求树的高、统计节点数后序位置的经典运用很多树相关的计算比如求高度、节点数、直径本质上是用后序遍历的思想等你把左右子树的结果都算出来才能计算当前节点的结果。求高度的递推公式是当前节点高度 max(左子树高度, 右子树高度) 1。代码很短但道理很重要def max_depth(root): if root is None: return 0 left_depth max_depth(root.left) right_depth max_depth(root.right) return max(left_depth, right_depth) 1如果你在系统栈阶段用非递归实现求高度就需要额外记录每个节点的高度信息比如用两个栈或者用(node, depth)这样的元组入栈。复杂度会增加不少。这里也解释了为什么递归在树这种结构里如此受欢迎树的天然递归结构让系统栈帮你完成了大量状态保存工作你用几行代码就能表达复杂逻辑。我实际带过的一些同学在求高度时常犯一个错把max(left_depth, right_depth) 1写成了max(left_depth 1, right_depth 1)结果也能跑但语义混乱。其实前者是在计算完子树高度后统一加1后者是在左右子树各加1的基础上取最大两种写法结果是一样的但前者更能体现“当前节点高度由子树高度决定”的逻辑。4. 常见树问题的排查思路与避坑技巧4.1 递归无限循环与栈溢出递归无限循环的典型症状是程序卡住不结束或者直接抛栈溢出异常。排查思路并不复杂先检查递归函数里是否有终止条件再检查终止条件是否真的可能被触发。在树递归里最常见的终止条件就是判断root is None或node is None。另一种隐蔽的情况是你创建了一个带环的树。比如某段构建代码错误地把某个父节点设置成了子节点的左孩子导致递归永远走不完。解决办法是在调试时打印每个节点的id和值或者限制递归深度后观察栈中的节点序列。这里提供一个实用小技巧在调试阶段可以在递归函数入口加一行print(node.val)随后观察输出是否能按预期回到根节点。如果输出序列出现重复说明树中存在环。4.2 空指针错误的10种诱因树相关的空指针错误几乎是人人都会踩的坑。我总结几个高频诱因未判断节点是否为空就直接访问node.val或node.left。构建树时数组边界没控制好导致多个节点重复索引同一个数组位置。在非递归遍历时把null节点压入栈或队列后又在出栈时直接访问其子节点。在删除节点操作中修改了指针却没有维护父节点的引用。层序遍历时忘记让空节点的左右孩子跳过入队。应对空指针问题有一个非常有效的方法在访问任何节点的字段之前先问自己一句“这个节点有没有可能为null”如果有可能就加一层判断。在代码审查时我会刻意把每一个node.left和node.right都圈出来检查它前面是否已经确认node本身不为空。4.3 遍历顺序傻傻分不清用一个固定测试树练手很多人在纸上画树时能分清前序、中序、后序但代码一写就乱。我的建议是准备一个固定的小树比如根5、左3、右8、再左1、再右9每次写完遍历函数直接用这个树跑一遍对比结果是否与预期一致。如果你发现自己经常把中序和后序搞混可以用一个生活化类比前序是“先看自己再看左边最后看右边”中序是“先看左边再看自己最后看右边”后序是“先看左边再看右边最后看自己”。把“自己”的位置固定住剩下的永远是先左后右这样就不会记混了。还有一个刷题时非常有用的小技巧如果给你两个遍历序列让你还原整棵树比如前序中序最常见的思路是用前序确定根节点再到中序里找根节点的位置从而把中序分成左右子树区间。如果是中序后序就用后序确定根节点。代码实现时会大量使用下标计算最容易出错的是子树的区间边界。我的习惯是统一采用左闭右开区间比如[in_left, in_right)这样递归边界不容易乱。4.4 层序遍历的“分层”难题与队列边界层序遍历的基础版本只需要一个队列但不少场景要求按层输出比如“把每一层的节点值放到一个列表里”。问题的难点在于你怎么知道当前队列里的节点属于同一层解决方案是在每次开始处理新一层之前先记录当前队列的长度size然后只循环处理size个节点。这样即使后续新入队的节点是下一层的也不会被当前循环处理。如果没有这个size记录就会把所有节点混到一起分不清层与层的边界。我把分层遍历的代码写成一个可以直接复用的模板很多树的题目比如层序遍历、二叉树的右视图、每层最大值等都建立在这个模板上def level_order(root): if root is None: return [] result [] queue [root] while queue: level_size len(queue) current_level [] for _ in range(level_size): node queue.pop(0) current_level.append(node.val) if node.left: queue.append(node.left) if node.right: queue.append(node.right) result.append(current_level) return result这里使用pop(0)只是示例实际性能敏感场景建议用collections.deque替代因为列表的头部弹出复杂度是O(n)换成双端队列后是O(1)。这是我在优化代码时踩过的真实坑小数据量看不出问题数据一多队列操作就成了瓶颈。4.5 排序树与二叉搜索树中序遍历的神奇作用二叉搜索树的中序遍历结果是递增序列这个性质非常有用。用它来判断一棵树是否是一棵合法的二叉搜索树时很多人会犯错比如只判断当前节点和左右孩子的大小关系结果忽略了整棵树的约束。比如根节点是5左子树里有一个节点是8只和左孩子比较可能发现不了问题因为8和左孩子3相比确实更大但它不应该出现在5的左子树里。正确做法是用中序遍历在遍历过程中记录上一个节点的值如果当前节点值小于或等于上一个值就说明不是合法的BST。这其实是把树的验证问题转换成了线性序列的递增判断问题大大降低了思维难度。这里想提醒一点边界条件里节点值相等的情况到底算不算合法取决于具体题意。通常是要求严格递增也就是左子树所有节点值严格小于根节点右子树严格大于根节点。为了方便理解我在代码里用作为非法条件这是比较通用的写法。4.6 递归返回值的设计什么时候需要返回状态很多树算法要求你在递归过程中返回一些状态比如“是否存在某条路径的和等于目标值”。这时递归函数的返回值往往不是单一的值而是一个布尔结果或一个对象。我见过不少同学在这个环节卡住原因是他们试图用一个全局变量记录状态而没有把子递归的结果带上。对于这种问题正确的思维是如果子问题返回了某个结果父节点必须基于它的结果做出决定。比如判断一棵树是否对称你可以把问题拆成两个节点是否互为镜像的递归函数这样代码会清晰很多。递归返回值的另一个常见坑是没有统一返回类型。比如某个递归函数在有结果时返回节点指针在无结果时返回None调用方需要使用返回值做判断。很多空指针错误就出在这种“返回状态但调用方没处理”的情况里。我写代码时会特别留意递归调用的返回值是不是每次都被接收并使用了如果没有使用就问自己一句“这个返回值真的可忽略吗”。5. 树结构的进阶方向与工程应用5.1 从二叉树到多叉树与字典树二叉树是把问题讲清楚的基础但真实场景中多叉树并不罕见。组织架构里一个人可能有多个下属一个网页的DOM节点可能有多个子节点这时候用二叉树表达会比较别扭。多叉树的链式存储通常使用一个子节点数组或者“孩子兄弟表示法”后者可以把多叉树优雅地转成二叉树表达。字典树Trie作为多叉树的典型代表在搜索引擎关键词提示、词频统计等场景应用广泛。它的每个节点不存储完整单词而是存储一个字符以及若干指向下一个字符的指针。实际写字典树时大多数人会用哈希表存放子节点比如children字段用一个dict这样判断“是否有某个前缀”就变成了从根节点开始沿着字符路径逐层寻找。字典树的优点是查询复用了公共前缀空间换时间非常适合处理大规模字符串集合。5.2 树的平衡与自平衡机制普通二叉搜索树有一个很致命的问题如果按有序序列插入节点树会退化成链表查找复杂度退化为O(n)。解决之道就是让树保持平衡。AVL树是严格平衡的树左右子树高度差不超过1插入和删除后需要通过旋转恢复平衡。红黑树则是近似平衡的树它通过节点颜色约束和旋转操作保证了从根到叶子最远路径不超过最短路径的两倍因此被广泛用在各种语言的映射和集合实现中。新手没必要一上来手写红黑树但需要理解“自平衡”的核心动机让树的形态尽量紧凑避免查询路径过长。理解旋转操作时可以想象把一颗“弯腰过深”的树抬起一侧然后重新分配子节点。左旋、右旋、先左后右、先右后左这四种组合本质上都是在满足BST性质的前提下通过调整指针结构把高度差降下来。我曾经花了一整周反复画旋转的示意图最终发现最好的学习方式就是亲手实现一种简单平衡树比如AVL然后用随机插入的顺序测试观察树的高度是否稳定在对数级别。5.3 树思想在各类系统中的应用树的思维方式并不仅限于“写一个二叉树类”。数据库用B树组织索引因为B树的节点可以有多个键值且叶子节点之间用指针连接适合磁盘的顺序读取。文件系统用类似树的多级目录结构管理文件。编译器用语法分析树表示代码结构然后在树上做语义分析和中间代码生成。正则表达式引擎也会把模式解析成抽象语法树再进行匹配和优化。理解了这些你就会发现树的真正价值不只是让你写出遍历算法而是让你养成“分层处理、递归分解”的思维方式。面对一个复杂问题时“把问题拆分为子问题分别解决再合并结果”往往就是最有效的路径而这种路径的图形化表达就是一棵树。我个人在实际操作中有一个很深刻的体会写树代码最忌讳“背模板式”地抄代码因为树的形态多变套模板时稍微改一点参数就会出问题。我现在教人的方法是先把递归三位置框架和层序遍历队列框架练熟再遇到不同的树题目时先判断该用哪种遍历再确认递归的返回值是什么最后才动手写代码。这样看起来慢实际上最稳。如果你正在学树建议你也准备一个专门的本子把所有遍历结果和边界条件画出来亲手推演三五个例子比看一百篇教程都管用。

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

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

免费获取报价 →
↑