☰
树结构完全指南:从二叉树、递归遍历到B+树与哈夫曼树
2026/9/24 21:04:43 网站建设 项目流程

开篇聊两句

数据结构这课,学的时候最怕什么?不是数组不是链表,而是学到“树”这一章突然感觉前面的全白学了——指针本来就绕,结果树还得“指来指去”,递归遍历更是让人脑子打结。其实树这个东西,没那么玄乎。你手机里的文件目录、公司组织架构、网页的DOM节点、编译器怎么解析表达式、数据库索引底层长什么样,全是树。可以说,只要涉及“层级关系”和“一对多关系”,树就是最自然、最省事的一种组织方式。

这篇文章我把树这块从头到尾捋一遍:树的本质到底是什么、那一堆术语到底在说什么、各种树(二叉树、平衡树、B树、哈夫曼树、字典树)各自解决什么问题、树的存储和遍历怎么做,顺带把考研408里最爱考的细节也埋进去讲。不管是期末突击、考研复习,还是刷LeetCode前想先把底层概念打牢,这篇都够你看一阵子。

1. 树的本质:递归生长出来的层级结构

1.1 为什么线性表不够用?

在数组和链表的世界里,每个元素只有一个前驱、一个后继,这种结构处理“排队”“有序列表”这类问题很顺手。但现实里大量关系是一对多的:一个部门下面有多个员工,一个文件夹下面有多个子文件夹,一个网页标签里嵌套着多个子标签。这种结构天生就是“从根上分叉出去”的,叫层级结构或树形结构。

所以树的本质就一句话:n个结点的有限集合,满足两个条件——有且仅有一个根结点;除根结点外,其余结点被分成m个互不相交的有限集合,每个集合又是一棵树。注意第二句话,它是在“递归地定义树本身”。这也是初学者最容易忽略的点:树不是“画出来”的,是“自己定义自己”定义出来的。这种递归的定义方式,直接决定了后面所有遍历算法都天然适合用递归去写。

1.2 树与线性表的结构差异

线性表和树的区别,我用四个字概括:线、面、体、网。

数组和链表是“线性”的,栈和队列是受限制的线性表,它们解决的是“一维”的问题。树就是“二维”的,有上下层级,有兄弟结点,但还没有跨层级之间的回路。到了图,那就是“三维”的,任意两个结点之间都可能拉一条边,所以图比树更复杂,树其实是图的一种特例——无环连通图。

这个认知很重要,因为后面学图的时候,你会发现很多算法(比如DFS、BFS、最短路径)本质上都是“在树上怎么走”的推广。现在把树的递归思维扎根了,后面学图就不会那么痛苦。

1.3 一个例子帮你把“递归定义”焊死在脑子里

我当年理解递归定义,靠的是Windows的文件目录。C盘是根目录,它下面有Program Files、Users、Windows三个顶层目录。点开Users,里面又有Administrator、Public。再点开Administrator,里面有Desktop、Documents。每一次“点开”的动作,看到的其实还是一棵小树,只不过它的根变成了你点开的那个文件夹。

这就是树的递归本质:任何一棵非空树,把它的根结点去掉,剩下的若干子树仍然各自是一棵树。写算法的时候,你永远只需要关心“当前结点怎么处理、左右子树怎么递归处理”,剩下的交给递归自己去做,这比在脑子里硬模拟一个深层的调用栈要轻松得多。

2. 树的专业术语体系

树的术语一堆,背起来容易混。我在给学生讲的时候,喜欢用“家谱”打比方——树里很多术语天生就是家族用语,这么一对照就好记了。

术语含义家族类比
根结点树顶那个唯一没有前驱的结点老祖宗
叶子结点度为0的结点,没有孩子没有后代的人
分支结点度大于0的结点有后代的人
双亲结点某个结点的直接上层爹妈
孩子结点某个结点的直接下层子女
兄弟结点同一个父结点的多个孩子亲兄弟姐妹
祖先结点从根到某结点路径上的所有结点往上数一辈一辈的全算
子孙结点某结点下面的所有结点往下数一辈一辈的全算
结点的度结点拥有的子树个数有几个孩子
树的度所有结点度的最大值整个家族里孩子最多的人有几个娃
结点的层次根为第1层,根的孩子为第2层……第几代人
树的深度所有结点层次的最大值这个家族最多传了几代
森林m棵互不相交的树的集合多个家族放一起

真正考试和面试常考的隐藏考点有两个。

第一个是“度为m的树”和“m叉树”的区别。度为m的树,至少有一个结点的度等于m,它强调的是“这棵树最大分叉数是m”;m叉树则要求每个结点最多m个孩子,不强求必须出现m度结点。前者由这棵树的实际形态决定,后者是定义时就框死的,两者不能混为一谈。

第二个是“结点的深度和高度”的区别。深度是从根开始往下数,根是第1层,越往下数字越大,反映的是“你离根有多远”;高度是从叶子结点开始往上数,叶子结点高度为1,越往上数字越大,反映的是“你离最远的叶子有多远”。整棵树的高度自然等于根结点的高度,也等于所有结点深度的最大值。

3. 树、森林与二叉树的转换逻辑

3.1 为什么要强行转成二叉树?

树的孩子个数不固定,有的结点两个孩子,有的五个,有的一个都没有,这给存储和遍历都带来了很大麻烦——你要么每个结点预留最大度数的指针位(浪费空间),要么用动态列表存孩子(实现复杂)。而二叉树所有结点最多两个孩子,存储和操作都规范化了,所以教材里几乎都默认:先把普通树转成二叉树,再研究它的存储和遍历。

这种“把不规整的问题转成规整的问题”的思路,在计算机领域特别常见。你真去写代码处理一棵树的时候,第一步往往不是直接遍历它,而是先考虑它能不能用更规整的方式表达出来。

3.2 孩子兄弟表示法的记忆口诀

树转二叉树的规则,教材上说的“左孩子右兄弟”六个字就是全部。实际操作里我加一句口诀:“亲儿子放左边,亲兄弟放右边。”

拿一棵普通的树举例:根结点A有三个孩子B、C、D。转成二叉树后,A的左孩子不再是原来的B,而是A的长子B;B的右孩子是谁?是B的二弟C;C的右孩子是谁?是三弟D。就这样,原来的兄弟关系被“压”成了右链。

这个转换对不对,有个很妙的验证方法:转换前后树的深度不变(如果原树只有一个孩子且那个孩子没有后代,深度会差,但考试一般不会出这种极端题),而且二叉树的右链上的结点在原树里都有同一个爸爸。反过来看,二叉树转回树,就是“右链拆开,兄弟归位”。

3.3 森林与二叉树的互相转换

森林是m棵互不相交的树的集合。森林转二叉树三句话:每棵树先各自转成二叉树;把第二棵树的根接到第一棵树根的右孩子上;第三棵、第四棵依次往右边接。最后得到的二叉树,根没有右子树?不,根一定会有右子树,因为森林里所有树的根,最后全变成了整棵二叉树根结点右边的一条链。

二叉树转森林就是逆操作:先看根有没有右孩子,有右孩子说明原森林里不止一棵树,一直沿右链拆下去,把每一棵拆出来的二叉树再还原成树。这个逆操作,很多同学考试时容易漏了“根结点右边沿右链拆到最后”这一步,记住森林转二叉树的时候,右链上有几个结点,原来就有几棵树,逆过去拆就能数清楚。

4. 二叉树:树结构里的绝对主角

4.1 二叉树不是“每个结点最多两个孩子”这么简单?

二叉树定义听起来人畜无害——要么空树,要么左子树和右子树都是二叉树。但这里面藏着一个不少老手都容易忽略的点:二叉树的左右子树是有顺序的,左子树和右子树是两棵不同的树,即使交换后结点的连接关系一样,它们也是两棵不同的二叉树。

这是二叉树和普通树最本质的区别。普通树的子树是无序的,树B和树C换一下位置,树还是那棵树;二叉树不行,左是左、右是右,变了位置就是另一棵树。这个特性在后面讲遍历序列还原二叉树的时候特别关键——知道中序和另一种遍历序列能唯一确定一棵二叉树,靠的就是“左右有顺序”这个性质。

4.2 满二叉树与完全二叉树

满二叉树:每一层的结点数都拉满。深度为k的满二叉树,结点总数为2^k - 1,第i层有2^(i-1)个结点。这是二叉树里“最胖”的形态。

完全二叉树:只有最下面两层可以不满,而且最下一层的叶子结点必须靠左连续排列,倒数第二层如果有叶子结点,必须集中在右侧连续排列。一句话记忆法:编号为1到n的结点,和同样深度的满二叉树前n个结点的位置一一对应。

完全二叉树有个超实用的编号特性:对编号为i的结点,它的左孩子编号是2i(如果存在),右孩子编号是2i+1(如果存在),双亲编号是⌊i/2⌋(向下取整)。这个性质是堆排序和优先队列的地基——数组里存着堆,通过下标计算就能在树上跳来跳去,不需要任何指针。

顺带一提,二叉树的顺序存储就是用这个编号来映射数组下标的。但普通二叉树用数组存很浪费空间,比如一棵深度为4、每层只有一个结点的“斜树”,数组得开15个位置才能放下4个结点,大量空间空着。所以完全二叉树适合顺序存储,普通二叉树一般用链式存储,这是教材里反复强调的选择逻辑。

4.3 二叉树的性质与推导

考研408和期末考试都很喜欢考这几个性质:

  1. 非空二叉树的叶子结点数等于度为2的结点数加1,即n₀ = n₂ + 1。推导很简单:设结点总数为n,度为0、1、2的结点数分别为n₀、n₁、n₂,则n = n₀ + n₁ + n₂;从边的角度看,n个结点的树有n-1条边,而边的总数又等于n₁ + 2n₂。联立两式就能得到n₀ = n₂ + 1。
  2. 二叉树的第i层最多有2^(i-1)个结点。
  3. 深度为k的二叉树最多有2^k - 1个结点。
  4. 具有n个结点的完全二叉树的深度为⌊log₂n⌋ + 1。

这些性质看着多,其实不用死背。第1个最常考,我建议把它当结论记住;第2、3个是等比数列求和,现场能推;第4个记公式就行。这些推导过程比结论本身重要,因为题目稍微变形,你要是只记了公式不会推,很容易掉坑。

5. 树的存储方式:从指针到数组的思路演变

5.1 双亲表示法

每个结点除了存数据,还存一个“父结点的下标”。这种存法找爸爸特别快,但找孩子得遍历全表。

我用一个二维数组就能实现:

#define MAX_TREE_SIZE 100 typedef struct { char data; int parent; } PTNode; typedef struct { PTNode nodes[MAX_TREE_SIZE]; int n; } PTree;

这个方案用在“并查集”这种只需要快速找根、偶尔合并的场合很合适,因为并查集的操作基本集中在“找爸爸”上。

5.2 孩子表示法

每个结点存一个孩子链表的头指针。找孩子方便了,但找爸爸得遍历整棵树。这个方案的变种在操作系统文件系统、编译器语法树里用得比较多,因为自顶向下遍历是常态。

typedef struct ChildNode { int childIndex; struct ChildNode *next; } ChildNode; typedef struct { char data; ChildNode *firstChild; } CTNode;

5.3 孩子兄弟表示法(最推荐的通用方案)

每个结点只存两个指针:firstChild(第一个孩子)和 nextSibling(下一个兄弟)。这就是前面说的“左孩子右兄弟”的存储实现。

typedef struct CSNode { char data; struct CSNode *firstChild, *nextSibling; } CSNode;

这个方案最优雅的地方在于:一棵多叉树,用这套结构存下来,本质上就是一棵二叉树。你在内存里操作的是一棵二叉树,但逻辑上是原来的多叉树,这就把存储和算法从“多叉”这个麻烦概念里解放出来了。

我看到不少初学者写树的相关代码,一上来就去定义“每个结点最多有若干孩子”的通用结构,写到最后满屏的for循环遍历孩子列表,复杂度上去了不说,还特别容易错。其实用孩子兄弟表示法,很多操作都能直接套二叉树的现成逻辑。

6. 树的遍历:前序、中序、后序、层序

6.1 递归遍历的记忆方法

二叉树的四种遍历,前中后序的区别在于“什么时候访问根结点”。

  • 前序(先序):根左右。先访问根,再遍历左子树,最后遍历右子树。
  • 中序:左根右。先遍历左子树,再访问根,最后遍历右子树。
  • 后序:左右根。先遍历左子树,再遍历右子树,最后访问根。
  • 层序:从上到下、从左到右,一层一层扫过去。

初学者最怕的就是递归遍历的回溯过程,一递归就晕。我的建议是:画一棵三层的树,把递归调用过程一层一层拆开,每访问一个结点就在结点上标出“第几步访问”,完整走一遍,你对递归的理解就到位了。

递归代码非常固定,我直接给个模板:

void PreOrder(BiTree T) { if (T != NULL) { visit(T); // 访问根 PreOrder(T->lchild); // 遍历左子树 PreOrder(T->rchild); // 遍历右子树 } }

中序和后序只是把visit(T)那一行的位置换一下,其他完全一样。

6.2 层序遍历的核心套路:队列

层序遍历靠的是队列,不是递归。思路是:根结点先入队;然后循环——出队一个结点,访问它,再把它左右孩子(非空)依次入队;直到队列为空。

这个套路刷LeetCode时特别好用,二叉树的层序遍历、树的之字形遍历、求每层最大值、求树的宽度,全是这个框架改出来的。之字形遍历(也叫锯齿形遍历)就是在层序基础上加了一个“层号奇数从左往右、偶数从右往左”的标志,用双端队列或者普通队列+反转就能实现。

6.3 由遍历序列反推二叉树

知道了前序+中序,或者后序+中序,就能唯一确定一棵二叉树;但前序+后序不能唯一确定。原因前面提过:前序和后序不能区分左右子树。

具体反推方法:前序序列的第一个结点一定是根,找到它在中序序列中的位置,左边就是左子树的中序序列,右边就是右子树的中序序列;再看前序序列中对应长度的部分,就能把左、右子树的前序序列也切出来,递归做下去。

这个考点是408的常客,也是“递归思维”最典型的应用场景。我自己刷题时的一个习惯是:拿到这类题先别急着想代码,先手推两三遍,推熟练了再写递归代码,效率反而高很多。

7. 树家族全览:从二叉排序树到B+树

树这个家族太大了,这里列一个对比表,帮大家快速建立整体认知。

树类型核心规则主要用途
二叉排序树/二叉搜索树(BST)左子树所有结点小于根,右子树所有结点大于根查找、排序
平衡二叉树(AVL)每个结点左右子树高度差不超过1高频查找场景
红黑树五条染色规则保证最长路径不超过最短路径2倍底层实现(如关联容器)
B树多路平衡查找树,一个结点存多个关键字数据库、文件系统索引
B+树B树变体,数据全在叶子,叶子间有指针数据库索引的主流实现
哈夫曼树带权路径长度最小的二叉树哈夫曼编码、压缩算法
字典树(Trie)按字符前缀分叉的多叉树字符串匹配、自动补全
线段树用树维护区间信息区间查询、区间更新
并查集用双亲表示法维护集合关系连通性问题
四叉树递归划分二维空间碰撞检测、地图索引

7.1 二叉排序树的查找效率为什么不稳定?

BST的查找效率取决于树的高度。理想情况下,n个结点的BST高度是O(log n),查找一次很便宜;但如果插入的顺序是升序或降序,BST会退化成一个“链表”,高度变成n,查找效率直接掉到O(n)。

这也就是为什么光有BST远远不够——它的形态不受控制,退化风险太大。AVL树就是来解决这个问题的:它规定了每个结点左右子树高度差不能超过1,插入、删除后一旦失衡就通过旋转(LL型、RR型、LR型、RL型四种旋转方式)恢复平衡。这样做让树高度始终保持在O(log n)量级,代价是插入、删除时的旋转操作稍微费点时间。

7.2 红黑树:平衡不是目的,性能才是

AVL是严格平衡的,树特别“平整”;红黑树则是“近似平衡”的——它牺牲了一点点严格性,换来了更少的旋转次数。红黑树用颜色标记结点(红或黑),通过五条性质保证从根到叶子的最长路径不超过最短路径的2倍。

红黑树的五条性质:每个结点非红即黑;根必须是黑;叶子(NIL结点)是黑;红结点的孩子必须是黑(不能有连续两个红结点);从任一结点到其每个叶子的所有路径都包含相同数目的黑结点。

咋一看规则很绕,但我建议大家这样记忆:红黑树的本质是在“AVL严格平衡”和“懒散保存”之间找一个平衡点——它不追求左右子树高度严格相等,只要求“黑路一样长、红路不连续”,这样旋转次数少了,插入删除整体变快了。Java的TreeMap、C++的std::map底层都是红黑树,都是因为它整体性能最稳。

7.3 磁盘上的树:B树与B+树

B树和B+树是“磁盘友好型”的树。内存里我们追求低高度,因为内存随机访问便宜;但磁盘不一样,一次磁盘I/O可能要几毫秒,比内存慢好几个数量级,所以我们要尽量减少I/O次数。B树的一个结点可以存很多关键字,同时有多个孩子,这样整棵树的高度可以压得非常低,比如几百万条数据,B+树可能只需要3~4层。

B树和B+树最大的区别在于:B树的所有结点都存数据,B+树只有叶子结点存数据,内部结点只存索引;B+树的叶子结点通过指针串成链表,范围查询特别高效。数据库索引选B+树而不是B树,主要是因为B+树对范围查询友好、磁盘I/O次数稳定,而且叶子结点存数据的特性让查询更规整。

7.4 哈夫曼树与哈夫曼编码

哈夫曼树(最优二叉树)解决的是“怎么让带权路径长度WPL最小”的问题。WPL是所有叶子结点的权值乘以路径长度之和。

构造方法很机械:每次从结点集合中选两个权值最小的结点,把它们合并成一个新结点,新结点的权值等于两棵树权值之和,再把这个新结点放回集合,重复到只剩一棵树。

比如权值分别为2、3、5、7的四个叶子,先选2和3合并成5,集合变成5、5、7;再选两个5合并成10,集合变成10、7;最后合并成17。WPL就是 2×3 + 3×3 + 5×2 + 7×1 = 32。这套东西在数据压缩里就是哈夫曼编码的底子。

7.5 字典树、线段树与并查集

字典树(Trie)按字符前缀分叉,插入、查找单词都是O(len),跟数据量无关,是搜索引擎自动补全的经典方案。前几年“CTFHub技能树”这类CTF平台特别喜欢考Trie的变体,常见的就是让你实现一个字典树支持插入、搜索、前缀匹配。

线段树是竞赛选手的必备工具,专门处理区间查询和区间修改问题。它的思想是“把区间递归二分成树”,每个结点维护一段区间的信息(比如和、最大值),查询和修改都是O(log n)。

并查集没有严格意义的“树形存储”外观,但底层原理就是双亲表示法——每个集合用一棵树表示,树的根是集合的代表元素。它支持两个操作:查(Find,找根)和并(Union,把两棵树合在一起)。优化时用路径压缩和按秩合并,代码短到极致但功能强大,刷连通分量类题目时属于“无脑套模板”的存在。

8. 树的实操应用场景

很多同学学完树觉得抽象,不知道学了干嘛。这里列几个最直观的应用场景:

  • 文件系统:Linux的目录结构、Windows的资源管理器,都是典型的树形结构,挂载点、符号链接本质上就是对树结点的操作。
  • 编译器:表达式树是编译器把中缀表达式转成语法树、生成中间代码的基础。比如a + b * c的表达式树,根是+,左孩子是a,右孩子是以*为根的子树。后缀表达式求值也和表达式树的遍历密切相关。
  • 数据库索引:MySQL的InnoDB引擎索引就是B+树,这也是为什么“树的索引”和“数据库性能优化”总被放在一起讨论。
  • 路由器与交换机的转发:前缀树(Trie)在网络路由表里用来做最长前缀匹配,查找目的IP对应的转发端口。
  • 游戏开发:四叉树(和八叉树)用来做碰撞检测和空间管理,把地图递归划分,大大减少碰撞检测的计算量。
  • 机器学习:决策树、随机森林、XGBoost这些模型,核心结构就是树——回归树(CART)用树结构做回归拟合,特征分裂的过程本质上就是“建树”。

可以说,树结构是贯穿计算机系统、算法、数据库、人工智能、网络的全能型基石。你要是把树学透了,后面学任何一门计算机专业课都会轻松很多。

9. 常见问题与误区:我踩过的坑和总结的诀窍

9.1 一道经典坑题:2011个结点的树,叶子结点有116个

网上讨论度很高的一道题:“已知一棵有2011个结点的树,其叶结点个数是116,该树对应的二叉树中无右孩子的结点个数是多少?”这题难倒过一大片人,因为它把“树转二叉树”和“结点计数”两个知识点搅在一起考。

解析思路:设树中度为1、2、3的结点数分别为n₁、n₂、n₃……,叶子结点n₀=116。由边的数量关系可知 n = 1 + n₁ + 2n₂ + 3n₃ + ...(所有结点的度数之和 = 边数 = 结点数-1)。再用树转二叉树的规律:原树中每个结点的“长子”(第一个孩子)在二叉树中变成左孩子,其余孩子通过右链连接。最终二叉树中“无右孩子”的结点,主要对应原树中的叶子结点(除长子外)、没有兄弟的结点等。

这类题出错的根源,是大家把“无右孩子”误当成“右孩子为空就一定是叶子”。其实在树转二叉树后,原树里没有“右兄弟”的结点,转出来右孩子就是空。做题前先在草稿纸上画一棵三层的树,标好转换过程,再套计数公式,出错率会大幅下降。

9.2 递归层数为何难把握?先学会画递归树

很多同学写树的递归遍历,代码模板背得很熟,但一旦遇到“求二叉树的直径”“判断是否是平衡二叉树”这类需要返回多个值或全局变量的题,就不知道怎么写。

我的建议是:动手画递归树。把递归调用想象成树的分支,每一层就是一个函数栈帧。递归的终止条件想清楚——什么时候返回空、什么时候返回0、什么时候返回一个特殊标记,剩下的“当前层该干什么”看清楚,代码就出来了。写过三遍以上,你就不需要再画了。

9.3 关于递归转迭代

树的递归遍历很简单,但面试官偶尔会问“不用递归,怎么实现前序遍历/中序遍历?”这时就要用显式栈模拟系统栈。前序是入栈右孩子再入栈左孩子,中序是“一路向左入栈,弹出访问后再处理右子树”。

层序遍历天然是迭代的,用队列就行。之字形遍历在层序基础上,用双端队列控制头尾插入的方向,这就是LeetCode上那道经典题目的核心思路。

9.4 数据结构期末复习的一个高效方法

期末复习最怕“什么都知道一点,什么都不会做”。我给一个笨但有效的方法:把每种数据结构的底层层层拷问三连——“它的逻辑结构是什么?存储结构是什么?支持哪些操作?每个操作的时间复杂度是多少?”树这一章也不例外。能把这四个问题对答如流,期末考基本稳了。

408的数据结构代码题,树的代码题无非就是遍历、求高度、求宽度、判断平衡、构建二叉树、最近公共祖先这几类。每种题型固定思路,我把这些当成“套路模板”反复练,不仅能应付考试,后面做项目看过一些源码的实现,也能更快看懂别人写的树结构相关代码。

10. 写在最后的几点体会

树这种结构真正让人舒服的地方,不在于它有多少变种、多少术语,而在于它给“层级关系”提供了一套优雅的表达方式。我见过很多初学者花大量时间死记红黑树的旋转过程,却搞不定最简单的递归遍历,这是本末倒置。先把一棵普通树、一棵二叉树用最土的方式实现一遍、遍历一遍,再往上加平衡、加颜色、加多路,每一步都踏踏实实,树的体系才算是真正长在你自己脑子里了。

最后提一个我个人测试过的方法:学完树之后,试着用树结构去重新审视你电脑里的一个文件夹,或者把一本教材的目录画成一棵树,你会发现——知识体系和文件系统,本质上都在用同一种思维组织信息。这种感觉很奇妙,也是从“背概念”到“用结构”的真正分水岭。祝大家写树不再晕头转向,调试不再无限递归。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询