ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

数据结构入门:彻底搞懂树与二叉树的核心概念与遍历

数据结构入门:彻底搞懂树与二叉树的核心概念与遍历

很多人在学数据结构时,第一次遇到感觉"有点不一样"的知识点,几乎都是“树”。线性表、栈、队列再复杂,好歹可以画成一条线,从头走到尾。可树一出现,维度瞬间变了:一个节点后面可能跟着好几个节点,父子、兄弟、祖先这些关系一股脑涌进来。不少初学者在这里卡住,要么被各种术语绕晕,要么遍历顺序背了又忘。这篇内容就把初识“树”这件事讲透,不堆砌公式,而是把每个概念背后的直觉、为什么这么设计、以及实操里最容易踩的坑都说清楚。适合刚学数据结构的学生、准备机试和面试的读者,以及工作中需要补这块基础的开发者。


1. 先弄明白:为什么线性结构不够用

1.1 你早就用过“树”,只是没意识到

先不急着下定义。试着回忆几个日常场景:电脑里的文件夹系统,一个文件夹下面可以有多个子文件夹,子文件夹还能继续嵌套;公司的组织架构图,一个部门下面挂着若干小组,小组里又有具体岗位;再比如一个网站的导航菜单,一级菜单点开有二级菜单,二级下面还能有三级。这些结构里都存在同一个规律:某个“容器”下面能挂多个“子容器”,而且挂载关系可以一层层传下去。

这就是树的直觉原型。树本质上是一种“一对多”的层次化组织方式,它表达的是一组数据之间存在"上级—下级"的归属关系。对比之下,数组、链表、栈、队列全都是"一对一"的前后关系,也就是线性结构。线性结构有它的好处:位置明确、遍历简单、实现容易。但它描述不了"一个节点连着多个后续节点"的情况。

从这个角度看,树是把我们现实中大量存在的层级关系搬进程序里的自然方式。文件系统用树来管理路径,数据库的索引结构用树来加速查找,编译器用语法树来分析代码,连前端框架的组件嵌套也可以用树来抽象。先建立这个"它就在身边"的认知,后面学术语就不会觉得抽象了。

1.2 线性表解决不了的三个问题

线性结构处理不了哪几类需求?我总结下来主要有三个。

第一是一对多的关联。一个父节点需要直接关联多个子节点,这在单链表里做起来很别扭。你可以给节点加多个next指针,但不同的位置的“最大分支数”不一样,用固定数量的指针要么浪费空间、要么不够用。

第二是层级的归属关系。比如组织机构里,人事变动只需要把某个小组从A部门移到B部门,如果信息是用一张扁平的表格存储,就需要同时维护每个成员的部门编号,改动会牵一发动全身。而用树结构,"移动一棵子树"这个操作在逻辑上非常自然,只要把子树的根节点重新挂接就可以了。

第三是祖先与后代这类深层关系查询。比如要找某个节点向上几层的所有上级,或者判断两个节点是不是属于同一个祖先之下。线性结构没办法存储这种语义信息,因为它的信息只存在于相邻节点之间。

从这三个痛点能看出,树解决的不是"存不存得下"的问题,而是**"关系表达得对不对、查起来快不快"**的问题。这也就是为什么树在数据结构的课程里占据那么大篇幅——它是从"线性思维"迈入"非线性思维"的第一道坎。

2. 树的基本概念:术语多,但千万别死记

2.1 一棵树到底由什么组成

给树下个标准定义并不复杂:树是n(n≥0)个节点的有限集合。当n=0时称为空树;当n>0时,满足两个条件:有且仅有一个称为根的节点;其余节点可以划分成m个互不相交的有限集合,每个集合本身又是一棵树,称为根的子树。

注意“互不相交”几个字,它保证了一个节点不会同时属于两个父节点。这一点和现实中的"多重归属"不一样,也是初学时容易忽略的约束。定义里还有一个递归描述,就是用"树"来解释“树”。这不是文字游戏,而是树天然适合递归表示的信号,后面写遍历代码时这一点会反复起作用。

2.2 用生活化类比把术语串起来

一堆术语放在一起很容易劝退人,我习惯用“家族谱”来串讲。

  • 根节点:整个家族谱里的始祖,一棵树只有一个根。
  • 节点:谱里的每一个人,数据存在这里。
  • 父节点、孩子节点:直接上下级关系的称呼,一个节点的直接上级叫父节点,直接下级叫孩子节点。
  • 兄弟节点:同一个父节点下的几个节点互称兄弟。
  • 叶子节点:没有任何孩子节点的节点,对应谱里“没有后代”的那个人。
  • 分支节点/内部节点:至少有一个孩子的节点。
  • 祖先、后代:往上一路追溯经过的所有节点都是祖先,往下一路蔓延覆盖的所有节点都是后代。
  • 子树:某个节点连同它之下所有后代构成的整体结构,它的规模可大可小。

还有两个深度相关的术语特别容易混:深度是指从根节点到该节点的边的条数,根节点深度为0(有些教材记为1,学习前先确认定义,做题才不会翻车);高度是指从该节点到最远叶子节点的边的条数,叶子节点高度为0。简单说,从根往下数叫深度,从叶子往上数叫高度。

2.3 注意辨析:树和森林、有序与无序

树之外还有一个"森林"概念,其实就是多棵互不相交的树的集合。实操中森林并不意味着一堆孤立的树,很多算法会在逻辑上把一个树拆成森林来处理,转换起来非常方便。

另一个容易忽略的点是有序树与无序树。如果一棵树每个节点的各子树从左到右是有次序的,不能互换位置,它就是有序树,否则就是无序树。二叉树天然是有序树,左右子树不能交换,这是很多判断类题目的隐藏条件,别只看节点值,还要看位置。

最后还有个空树的定义。别看空树太简单,递归算法的边界往往就落在空树上。判断空、返回空、对空树操作不报错,是后续写树算法的基本功。

3. 二叉树:入门阶段的核心中的核心

3.1 为什么偏偏是二叉树

树本身可以是多叉的,但在学习中“二叉树”占据了绝对主导地位。为什么强调二叉?

从实现角度看,每个节点的状态最简化,最多两个分支,容易用左、右指针来存储,天然适配链式结构。如果允许任意多叉,节点的孩子指针数量就得动态管理,复杂度会明显上升。

从建模角度看,任何一棵有序树都能转换成二叉树。方法叫"左孩子右兄弟":把每个节点的第一个孩子作为左孩子,把这个节点原来的兄弟作为右孩子。转换后,二叉树仍然保留了原树的全部层次与顺序信息。既然多叉树可以转换到二叉树,那就先熟练掌握二叉树,再推广到森林就好办了。

从运算角度看,二叉树具有很多优美的数学性质,节点数与高度之间的关系、叶子节点的计数公式都比较简洁。这些性质直接决定了查找、插入等操作的效率分析,是很多高级结构(搜索树、堆、平衡树)的共同基础。

3.2 满二叉树与完全二叉树:别混了

二叉树里有两种特殊形态,几乎每次考试、机试都会碰到。

满二叉树:每一层的节点数都达到最大值。也就是高度为h的二叉树,共有2^h-1个节点,没有一点浪费。

完全二叉树:去掉最后一层后,是一个满二叉树,并且最后一层的节点都连续地集中在最左侧。换句话说,在按层编号时,完全二叉树节点的编号与同高度的满二叉树一致,中间不能出现空位。

区分这两个概念最简单的方法是画图。最后一层不是满的,但缺失的空位没有把左侧节点隔开,那是完全二叉树;每一层都挤得满满当当,则是满二叉树。满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树,这个包含关系可以刻在脑子里。完全二叉树因为能用数组紧凑存储,在堆排序、优先队列中得到大量应用,这也是它被单独拎出来的原因。

3.3 三个必须会的性质推导

有些性质教材直接给结论,但推导过程才是理解的关键。我挑三个最常用的手推一遍。

第一个性质:对任意非空二叉树,叶子节点数 n0 = 度为2的节点数 n2 + 1。推导很简单,设总节点数为n,边的总数为n-1;又因为每个节点贡献的边数等于它的度,所以有 n-1 = n1 + 2×n2;而n = n0 + n1 + n2。两式联立消去n1,就得到n0 = n2 + 1。这个公式在选择题里出现频率极高,比如知道度为2的节点有10个,就能直接推断叶子节点是11个。

第二个性质:第i层(根为第0层)上最多有2^i个节点。因为根节点只有一个,第二层最多2个,第三层最多4个,每层翻倍。进一步可以推出高度为h的二叉树最大节点数为2^(h+1)−1(如果根第0层),也就是等比数列求和的结果。反过来,有n个节点,完全二叉树的高度约为log2(n+1)−1,这就是二叉树查找效率高的数学来源。

第三个性质:完全二叉树按层编号后,节点i的左孩子编号为2i+1,右孩子编号为2i+2,父节点编号为(i−1)/2取整(根节点编号0的情况下)。这个性质解释了为什么完全二叉树可以用数组存储而不需要额外指针,也是"堆"这种数据结构能用数组实现的前提。很多人在理解堆时卡住,其实就是这里没吃透。

4. 树的存储:从数学定义到内存落地

4.1 三种经典存储模型

定义树是一回事,把它存进内存是另一回事。教材里常见的存储模型有三类,每种解决的核心问题不同。

双亲表示法:用一组连续空间存储所有节点,每个节点除了数据域还存一个"父节点下标"。优点是可以快速找到父节点;缺点是找孩子需要扫描整个数组,并不方便。

孩子表示法:每个节点存一个孩子链表的头指针。查找某个节点的所有孩子非常直接,但要知道父节点就得遍历全树。

孩子兄弟表示法:每个节点只存第一个孩子指针和下一个兄弟指针。它最大的特点是把任意多叉树统一转成二叉树,节点结构固定,操作逻辑清晰。这也呼应了前面3.1里提到的转换思路。

4.2 代码实现:最实用的组合与孩子兄弟结构

做题时最常用的还是二插链表(二叉树的链式存储),但作为“树”的基础,孩子兄弟表示法值得单独写一下。以C语言风格为例:

typedef struct CSNode { int data; struct CSNode *firstChild; struct CSNode *nextSibling; } CSNode;

注意这里没有“左孩子右孩子”,而是“第一个孩子”和“下一个兄弟”。如果画出结构,整棵树会呈现一个向右下展开的样子:垂直方向是父子关系,水平方向是兄弟关系。用这种方式,原本可能占用不定长度孩子数组的树,被压缩成了两个指针一棵二叉树。

我在带入门者时喜欢让他们先用这种方法把一棵3层多叉树画出来,对照着看指针的指向,比单纯记代码有效得多。等理解了孩子兄弟表示法,再回头看二叉树的左右孩子指针,会觉得异常亲切,因为二叉树正是没有“兄弟”的退化情况。

4.3 怎么选型:按操作频率决定

存储方式没有绝对好坏,关键看你要频繁执行什么操作。

  • 如果经常按“从下往上”找父节点,双亲表示法最省心,直接读下标。
  • 如果经常需要罗列一个节点的全部孩子,孩子表示法更合适。
  • 如果既要灵活转换多叉与二叉,又要统一节点结构,孩子兄弟表示法是最通用的解法。

实际应用中很少有单一的存储方案,更多是混合设计。比如操作系统中描述文件目录时,往往会同时维护指向父目录的指针、指向子目录的链表、以及兄弟目录的连接。初学阶段的建议是:先吃透孩子兄弟表示法,理解它怎么把树转成二叉树,再回到二叉树链式存储,这条路走通后,存储结构基本就不慌了。

5. 树的遍历:递归背后的直觉

5.1 四种遍历的直观理解

遍历就是按某种顺序把每个节点访问一次。树的遍历方式通常分两类:深度优先遍历(DFS)和广度优先遍历(BFS)。

深度优先里,根据“访问根节点”的时机不同,分成了三种:

  • 先序遍历(前序遍历):根、左子树、右子树。想象先看一眼当前节点,马上往左边深处钻,直到左边走完再回退走右边。
  • 中序遍历:左子树、根、右子树。左子树完整访问完才轮到根,然后才是右子树。对二叉搜索树而言,中序遍历的结果会得到有序序列,这是它最重要的用途之一。
  • 后序遍历:左子树、右子树、根。把左右子树都处理完,最后才碰根节点。这个顺序在一些“先解决子问题再汇总”的场景里非常实用。

广度优先对应的就是层序遍历:一层一层从上到下、从左到右扫描。它与前面三种最大的区别是不能靠简单的递归栈推进,需要借助队列,把当前层的节点先进队,再依次出队并把孩子入队。

5.2 递归为什么能“自动”完成遍历

很多初学者看到递归遍历代码觉得神奇:明明函数体里只有三次调用,为什么能自动完成整棵树的访问?关键在于理解系统栈在背后做了什么。

每次递归调用都会把当前函数的现场(局部变量、返回地址等)压入系统栈。遍历左子树时,当前节点的信息并没有丢失,它被存在栈里;等左子树全部处理完,从栈里弹出现场,才继续处理右子树。也就是说,递归不是“魔法般”地完成遍历,而是系统帮我们维护了一个显式的历史记录。

想验证这一点,可以在纸上模拟一个只有三层的小树,手动走一遍先序递归调用过程,观察函数调用的进入与返回顺序。我试过很多次,画调用图比看十遍代码有效得多。等你理解了系统栈,甚至会重新认识所有递归算法,因为一切递归本质都是栈与函数调用的配合。

5.3 手写代码:不要背模板,要画图推演

给出二叉树节点定义和三种遍历的递归写法,这是最标准的实现:

class TreeNode: def __init__(self, val=0, left=None, right=None): self.val = val self.left = left self.right = right 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)

错误是根节点为空时没有判断——只要有递归,空树边界就必须先写好。更重要的建议是:不要背模板。写每个函数前先在纸上画一棵小树,比如根节点值为1、左孩子是2、右孩子是3,然后手动走一遍你要的顺序。理解了“访问根”是放在哪一步,代码自然就写出来了。所谓遍历,本质只是“什么时候printf/print”的区别。

层序遍历用队列实现也很直观:

from collections import deque def level_order(root): if not root: return q = deque([root]) while q: node = q.popleft() print(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right)

这里的要点是在出队时访问节点,而不是在入队时访问,不然顺序会变成层的反序,或者出现重复访问的问题。

6. 新手最容易踩的坑:实操排查与速查

6.1 递归边界与空指针问题

树相关代码大部分崩溃原因都是空指针,尤其是递归到叶子节点的左右孩子时忘了判空。凡是要访问node.left或node.right,先确认node本身不为空,这是最基础的防御。另一个容易被忽略的边界是空树,很多函数对“根为空”这种情况直接报错,但空树往往是有意义的输入,比如子树可能为空,算法要能返回默认结果而不是抛异常。

6.2 混淆深度与高度:先统一定义

不同教材对深度和高度的定义不一,有的根节点深度记为1,有的记为0。如果不统一,试题里就会翻车。我的习惯是:代码注释里明确写清“深度:根为0层”,做题时先看题目给的约定。实际业务场景中,两种记法都可能出现,做接口封装时尽量统一对外口径,内部转换好再返回。

6.3 遍历顺序没吃透:从“加工时机”理解

有人会把先序、中序、后序背成“根左右”“左根右”“左右根”,但一到复杂树就乱了。建议换个角度:把访问根节点看作“加工时机”。先序就是先加工根再加工子树,中序就是先加工完左子树才加工根,后序就是左右都处理完再加工根。在这个意义上,遍历顺序不过是你对每个节点动手的时机选择。做递归题时,每次只思考“当前节点该在什么时机被处理”,子问题都一样,整棵树自然就完成了。

6.4 调试技巧:打印缩进与构造小样例

调试树代码比链表麻烦,因为树的中间状态不好用一维输出展示。我常用的技巧是打印带缩进的遍历信息:进入左子树前多缩进两格,这样能直观看到递归的深度和路径。也可以写一个简单的树形打印函数,用层序信息反推位置,图像化输出。

构造样例时,优先用小树(三到七个节点)验证逻辑,不要一上来测试上万节点的大树。如果算法在小树上都不对,大树上只会放大错误。另一个技巧是用中序遍历+先序遍历互推来校验,若两棵树的先序和中序序列不同,则结构一定不同。

6.5 速查表:概念、公式与易错点

概念关键点易错提示
根节点唯一,没有父节点空树没有根
叶子节点没有孩子的节点与度为0等价
深度/高度根为0层 vs 1层看题目约定
完全二叉树最后一层左侧连续右侧空缺可以,左侧缺不行
满二叉树每层都满一定是完全二叉树
叶子数n0 = n2 + 1适用于任意非空二叉树
最大节点数2^(h+1)−1(根第0层)注意高度起点
孩子兄弟法左孩子右兄弟转换后是二叉树形态

最后分享一点我自己的体会。树之所以让很多人觉得难,不是因为它算法有多复杂,而是它第一次强迫我们从“数据排成一队”的惯性里跳出来,去理解“数据可以分叉、可以嵌套、可以共享祖先关系”。把一张张树的图画熟练,把手里的递归调用栈走明白,后面学查找树、平衡树、堆、并查集,甚至图结构时,那种“它不过也是一棵演化后的树”的感觉会越来越强。数据结构是慢功夫,树这关慢一点没关系,因为值得。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进