我的第一轮二叉学习笔记:从概念到遍历,我建议所有初学者都按这个顺序来
很多初学者学到数组、链表之后,突然碰到二叉树,第一反应往往是懵的:“这东西到底比链表好在哪?”说实话,我在某高校第一次接触树结构的时候也是这个感受。那会儿我刚把单链表的结构体指针捣鼓明白,觉得“每个节点指向下一个”已经挺神奇了,猛然看到节点左右两个指针,脑子确实是转了一阵子才适应。
但等到把二叉树真正吃透,我才意识到这个结构的价值:它解决了线性结构里“一对多”关系表达力不足的问题。链表无论怎么玩,每个节点最多跟前后两个节点产生直接关系。而现实里我们处理的数据,绝大部分不是一根直线,而是一棵树——比如目录结构、家族族谱、公司组织架构、甚至比赛淘汰赛程,全都是一层套一层的分支结构。二叉树就是这种结构在计算机里最朴素的一种落地方式。
这篇文章分享我的二叉树初阶学习笔记,面向的是刚学完顺序表、链表,准备开始啃树的朋友。我会按“为什么需要树 → 基础概念和性质 → 两种存储方式 → 四种遍历算法 → 常见的陷阱和经验”这个顺序来讲,配合我实际写代码时踩过的坑。
1. 为什么线性结构不够用,偏偏要引入二叉树
1.1 一对多关系的存储困境
链表和数组解决的是“一个接一个”的线性关系,但现实里更常见的是“一个管多个”的层级关系。典型的例子就是文件系统:一个文件夹下面可以有好几个子文件夹,每个子文件夹又可以继续往下挂内容。你用单链表去模拟这种结构会非常痛苦——要么每个节点维护一个子节点指针数组(这实际上已经在往多叉树的方向走了),要么强行用顺序编号去模拟层级,可读性和操作效率都很差。
我当时试着用链表去组织某次期末项目的文件分类结构(模拟项目X),结果节点定义里塞了三个指向其他节点的指针,自己写着写着就分不清哪个是兄弟、哪个是孩子。而二叉树的概念一出来,一切就自然了:每个节点有一个数据域和两个指针域,习惯上叫左孩子和右孩子。
1.2 为什么是“二”而不是“三”或“四”
你可能会问:既然一对多,为什么要限定成最多两个孩子?这不是白白牺牲了很多表达能力吗?实际上,任意一棵多叉树都可以通过“左孩子右兄弟”的方式转化成二叉树——第一个孩子挂到左指针上,剩下的兄弟节点依次挂在右指针链上。所以“二”是最小但足够完备的度约束,既能简化存储结构,又保留了把普通树转换过来的能力。
另外,二叉结构让很多算法变得对称:左子树和右子树天然形成两个独立子问题,递归函数的写法非常规整。等你学到平衡二叉树、红黑树、堆的时候,会发现这些树的高级玩法几乎都是在二叉骨架上做文章。所以初阶阶段扎实理解二叉树,是为后面的高级数据结构铺路。
1.3 二叉树究竟能装下什么逻辑
一句话总结:凡是把问题空间不断二分,或者需要按层级拆分数据的场景,二叉树都合适。经典比如表达式求值,一个表达式“A + B * C”可以画成一棵树,根是加号,左子树是A,右子树是以乘号为根的小树;再比如比赛冠亚军淘汰赛,每场比赛就是一次二分。初学阶段不需要急着接触太复杂的应用,只要能把这些生活化的例子跟树结构对上号,就已经赢过一半的人了。
2. 二叉树的核心概念和必须记牢的性质
2.1 基础术语别硬背,结合图示理解
根节点是全树唯一的入口,没有父节点;叶子节点是不再有孩子的节点;孩子、双亲、兄弟这些称谓跟家族关系一致,比较容易联想。有一个词稍微需要绕一下:“节点的度”。度指的是一个节点拥有的孩子数量,二叉树里每个节点的度只能是0、1或2。树的度则取全部节点度数的最大值。
树的深度和层次也要谨慎区分。根所在的层次一般定义为第1层,深度就是最大层次数。初学者经常混淆“深度”和“高度”的区别,其实在大部分教材里深度是从根往下数,高度是从叶子往上数,但考试和实际沟通中两词混用也很常见,关键是你自己内心要有一个清晰的坐标参照。
2.2 五个必背性质,推导过程比背诵更重要
以下五条性质几乎占据二叉树初阶考试的半壁江山,每条我都建议自己画小树验证一遍,而不是强记结论。
性质1:第 i 层最多有 2^(i-1) 个节点。根是第1层只有一个节点,第2层最多2个,第3层最多4个,等比数列的关系,这个最直白。
性质2:深度为 k 的二叉树最多有 2^k - 1 个节点。把每一层最大节点数求和,得到等比数列之和,结果就是2^k减去1。满二叉树就是达到这个最大节点数的树。
性质3:叶子节点数 n0 等于度为2的节点数 n2 加1,即 n0 = n2 + 1,对于任何非空二叉树都成立。这名字叫某势力的入门课叫“节点数与度数的守恒关系”,初中级考试常拿这个出选择题。我曾经只背公式不理解,直到有一天自己去推:总边数等于总节点数减1,而总边数又等于 n1 + 2n2,然后从两个表达式的恒等关系里把 n0 解出来了。那一刻公式不再是玄学。
性质4:如果一颗完全二叉树有 n 个节点,把它从上到下、从左到右按从1开始编号,那么它的任何节点 i(从1开始),左孩子编号为2i,右孩子为2i+1,双亲为 i/2 向下取整。这个性质是顺序存储的基石。
性质5:具有 n 个节点的完全二叉树,深度为 floor(log2 n) + 1。考试常考,思路就是完全二叉树的节点数介于2^(k-1)和2^k - 1之间,两边取对数就行。
2.3 满二叉树与完全二叉树的区别
满二叉树就是除了叶子节点之外,每个节点都是两个孩子,而且所有叶子都在同一层。完全二叉树则是“只有最后一层可能不满,而且最后一层的节点靠左排列”。我学的时候总把这两个概念搞混,后来找到一个直观记忆法:满二叉树是“全满”,一个坑都不留;完全二叉树是“满到不能满为止,最后一层只允许在右边缺”。
这里有个常见考题陷阱:完全二叉树最后一层如果不是满的,那一定是左侧连续、右侧有空缺,不会出现左边缺一块右边满的情况。画图验证一下马上就能分清。
3. 二叉树的两大存储方案:顺序存储和链式存储
3.1 顺序存储在完全二叉树里的神奇地位
顺序存储不复杂,就是用一个数组按层序把节点存起来。前面提到性质4给了编号关系,所以只要父节点在数组下标 i 的位置,孩子节点就能用乘法定位。这种存储方式对满二叉树和完全二叉树极其友好——空间零浪费,不需要存指针。
我在某次练习里直接用数组实现了一棵小型的完全二叉树。节点值是字符,从下标1开始存,遍历时的下标计算配合循环,写起来甚至比链表还省事。那一瞬间我真的觉得数组才是最简单强大的结构。
但顺序存储也有明显的“阿喀琉斯之踵”:如果是一棵极端退化的斜树(所有节点只有右孩子),每一层只有一个节点,深度到了n,数组却要分配2^n级别的空间,几乎全是空闲的下标空洞。所以顺序存储基本只用于完全二叉树(以及堆、优先队列),其他形态的二叉树还是链式存储更实在。
3.2 链式存储的结构体定义和使用
链式存储才是二叉树最常见的落地姿势,也是我真正花时间调试的结构。节点定义通常长这样:
typedef struct BiTNode { int data; // 数据域 struct BiTNode *lchild; // 左孩子 struct BiTNode *rchild; // 右孩子 } BiTNode, *BiTree;这里的“BiTree”本质上是指向结构体根节点的指针。很多初学者看到 typedef 后面的两个名字会有点晕,但用着用着就习惯了:前者代表节点类型,后者代表一棵树的入口。
创建一个二叉树的节点,只需要三步:申请内存、填数据、把左右孩子置空。我一开始总图省事,申请完就忘把指针置空,结果遍历的时候指针乱飞,直接段错误。这里提醒各位:每次 new 出来节点,先强制设 lchild 和 rchild 为 NULL,养成肌肉记忆,能救你很多个晚上的睡眠。
3.3 创建一棵树的常见生成思路
实际操作里建树不会真的手写几十个节点,通常用递归方式动态构建。比如让用户输入节点字符,输入“#”表示空节点,程序按前序序列递归创建:
void createBiTree(BiTree *T) { char ch; scanf(" %c", &ch); if (ch == '#') { *T = NULL; } else { *T = (BiTNode *)malloc(sizeof(BiTNode)); if (!*T) exit(OVERFLOW); (*T)->data = ch; createBiTree(&(*T)->lchild); createBiTree(&(*T)->rchild); } }这种“前序递归建树”的代码在需要用户配合输入的场景里很典型。如果输入序列是“AB#D##C##”,生成的树结构就是根为A,左孩子B,B的左孩子D,A的右孩子C。我当时把这个序列写下来自己模拟了一遍递归栈,才真正明白为什么递归能自动把左右子树一层层挂上去。这一步模拟很重要,千万别跳过。
4. 二叉树的遍历:递归与层序遍历从入门到熟练
4.1 四种遍历的口诀和打印时机
遍历是二叉树的灵魂操作。前序、中序、后序三种遍历都是深度优先,区别只在根节点的访问时机。我在学习时的记忆口诀是“根的位置决定顺序”,再配合一种非常机械的方法:三个遍历的递归代码结构完全一样,唯一的区别就是控制那行“访问根节点”的操作放在哪:
前序是“根左右”,先访问根,再走左子树,后走右子树;中序是“左根右”,先走左子树,再到根,再走右子树;后序是“左右根”,先走左子树,再走右子树,最后才回到根。层序则是按每一层从左到右依次访问,属于广度优先的范畴。
初学者常犯的错是:觉得前序遍历就是“按层从上到下访问”。不是的!前序是先往下探到整条左链的最深叶子,再一层层回退访问右子树。我当时做某次练习,把前序和中序结果画了树之后才恍然大悟,这个弯如果不拐过来,后面所有递归树形算法都会跟着错。
4.2 递归遍历代码的三行核心
递归遍历短得让人惊讶,但每一行都有讲究:
void preOrder(BiTree T) { if (T == NULL) return; printf("%c ", T->data); // 访问根 preOrder(T->lchild); // 递归左子树 preOrder(T->rchild); // 递归右子树 }把访问行换到中间就是中序,换到最后就是后序。为什么递归能自动完成“先整棵左子树,再整棵右子树”的分工?因为每次递归调用都把当前节点的子树当成一棵新树,新树的根就是当前节点的孩子。树归约成更小规模的树,最终规模为NULL时触底返回。这个概念我在“归约”层面理解后,写树相关递归就再没那么怵过。
每次递归进入一个节点,系统会把当前状态压入函数调用栈,返回时才恢复。所以递归遍历天然利用程序运行时的栈结构保存了回溯信息,这也是为什么递归写起来如此优雅——你保留了“后路”,剩下的交给操作系统。
4.3 层序遍历必须用队列,不能递归硬碰
层序遍历和前面三种就完全不是一个思路了。它要一层一层地扫,左到右访问,这时候栈的“后进先出”派不上用场,得请出队列的“先进先出”。算法逻辑很朴素:根节点先入队,然后循环——出队一个节点,访问它,把它的左孩子和右孩子依次入队。直到队列为空。
我一开始偷懒想用递归实现层序,结果发现递归天然是深度优先的货,怎么都写不顺。后来老老实实用一个循环队列:
void levelOrder(BiTree T) { BiTree queue[MAXSIZE]; int front = 0, rear = 0; if (T == NULL) return; queue[rear++] = T; while (front < rear) { BiTree p = queue[front++]; printf("%c ", p->data); if (p->lchild) queue[rear++] = p->lchild; if (p->rchild) queue[rear++] = p->rchild; } }这段代码我愿称之为初阶树遍历里性价比最高的一段:既考了队列的循环思想,又考了树的层级关系。面试也好、考试也好,层序默写是家常便饭。
4.4 遍历结果能反推唯一二叉树吗
很多教材都会配套一道经典题:给出前序和中序序列,重建二叉树;或者给出后序和中序序列,重建二叉树。这里的关键约束是:必须要有中序。因为前序能确定根的相对位置,但无法区分左右子树的范围;中序能根据根的位置把左子树和右子树的元素精确切开,两个信息一配合,整棵树就唯一确定了。
只给前序和后序是无法得到唯一二叉树的,因为左右子树的分界点消失了。我为了验证这个结论,自己随手画了两棵形态不同但前序和后序序列完全一样的树,瞬间说服了自己。这类题目适合用手动推导的方式去练,拿一张纸先把前序序列里的根从中间序列里圈出来,然后递归划分左右,多练几道就能形成直觉。
5. 从遍历到高级结构:平衡思想与常见二叉树变体
5.1 二叉排序树的插入与查找逻辑
学完基本遍历之后,自然就过渡到二叉排序树(也叫二叉搜索树)。它给每个节点增加了一条有趣的约束:左子树所有节点值小于根节点值,右子树所有节点值大于根节点值。这个约束让查找变成了剪刀式二分——从根出发,比根大往右走,比根小往左走,每次抛弃一侧子树,平均查找复杂度降到了 O(log n)。
我第一次写二叉排序树的插入时犯了一个经典错误:忘了处理根节点为空的情况,直接访问 T->data 导致程序崩溃。正确写法是插入函数里先判断树是不是空,为空就创建新节点返回;不为空再比较大小决定向左还是向右递归。二叉排序树的删除操作更麻烦,分三种情况:叶子直接删、只有一个孩子的删除、有两个孩子时要找中序前驱或后继来顶替。初阶阶段做到能推演原理即可,代码实现可以等到深入学数据结构再啃。
5.2 平衡二叉树为什么重要
二叉排序树有一个隐患:如果输入序列本来就排好序,它就会长成一棵“斜树”,查找退化成O(n),等于链表。平衡二叉树就是来解决这个问题的——它限制任何节点的左右子树高度差绝对值不超过1。严格平衡的实现代码在初阶阶段不要求掌握,但思想必须吃透:失衡就旋转,四种旋转类型(LL、RR、LR、RL)都是为了在插入或删除之后把树重新修正到平衡状态。
我自己的感受是,平衡二叉树的旋转如果先画图理解,比硬背代码有效得多。LL型就是左子树的左边太深,整体右旋一次;LR型先左旋再右旋。学到这里不必过度深入,能理解平衡的动机和旋转的基本方向,就已经比囫囵吞枣背概念的初学者高出一截了。
5.3 堆:完全二叉树在数组上的经典应用
堆是跟完全二叉树绑定最深的一种结构。大顶堆要求每个节点都大于等于它的孩子,小顶堆则相反。由于堆一定是完全二叉树,就能用数组存储,父节点跟孩子节点的下标关系直接套用性质4的两个公式。
我当时学堆最震撼的一点是“堆不一定有序”,它只保证父子之间的相对大小,不保证兄弟之间的大小。因此它在找最大值和最小值时极其高效,但在做全局排序时还得配合反复调整。堆排序的原理就是把数组调成堆结构,反复把堆顶元素跟末尾元素交换,再重新调整,最终得到一个有序序列。如果数组下标从0开始,父子关系公式会相应变成“(i-1)/2”“2i+1”“2i+2”,初学阶段务必注意下标起点的问题,考试和代码里混用下标起点是翻车重灾区。
6. 初学二叉树最容易踩的坑和排查经验
6.1 递归边界条件写错,栈爆炸
递归遍历树最怕的就是边界条件没写对。比如有些同学把递归出口写成“T->lchild == NULL && T->rchild == NULL”再return,看起来是叶子节点才返回,但空子树的情况就漏了,一旦某节点的某个孩子是NULL,函数就会继续尝试访问 NULL->data,直接崩溃。正确边界就是一行:if (T == NULL) return。
我在某次模拟项目里就吃过这个亏:递归打印二叉树结构时,读到了叶子节点还不肯返回,硬要往下走空指针,结果调试器定位到段错误,找了半小时才发现是出口写得太“聪明”,把最简单的NULL判断给丢了。从此我给自己立了一条规矩:树的递归函数第一个判断永远是“这个节点存不存在”,而不是“这个节点是不是叶子”。
6.2 内存管理混乱,节点泄漏
链表和树这类动态结构的共同麻烦是内存管理。初学阶段很多人建树时高高兴兴malloc一坨节点,学完就忘释放。在小型练习题里问题不大,但一旦涉及循环创建多棵树的程序,内存泄漏会让程序占用的空间越来越大,最终被系统暴力终结。经验做法是写一个 destroyTree 函数,以后序遍历的方式依次释放每个节点:
void destroyBiTree(BiTree T) { if (T == NULL) return; destroyBiTree(T->lchild); destroyBiTree(T->rchild); free(T); }用后序遍历释放的原因很简单:先把孩子干掉,再干自己,不会出现访问已释放内存的问题。这个习惯从初阶就养成,后面做工程向的链式结构会少掉一卡车头疼事。
6.3 调试二叉树要善用“空位输出法”
二叉树的可视化比数组、链表麻烦得多。链表可以一路next打印出序列,树却因为结构不规则,直接打印中序遍历只能得到一个序列,没法直观看到层级关系。我初学时的调试技巧是:写一个带缩进的递归打印函数。
具体思路是printTree(T, depth),每个节点打印时先输出若干空格,深度越深缩进越多,再输出节点数据。这样打印出来,树的左右分支形状能看得清清楚楚。我配合这个工具快速定位过建树错误、左右孩子挂反的问题,效率比对着数据手动脑内构图高十倍。建议每个学习二叉树的人都写一个这样的工具函数存着,后续学任何树结构都用得上。
6.4 纸上推演永远比盯屏幕有用
最后一条经验听起来最简单,但我见过太多人忽略:学二叉树,一定要拿起笔,在纸上画树。先画一棵包含七八个节点、形态不对称的树,然后自己动手写出它的前序、中序、后序、层序序列,再反过来用前序+中序推导它的原始形态。这个过程重复十次,你对递归栈的理解会从“模糊的感觉”变成“肌肉记忆”。
我在某高校的实验室里兼职答疑时发现,每个能流畅写出树的递归遍历代码的同学,几乎都在纸上做过大量推演。而那些只会盯着PPT抄代码的人,遇到稍微变换一下题面就无从下手。数据结构这门课,本质上就是“用代码表达空间关系”,脑子里的图和纸上的图越清晰,代码自然越顺。
说实话,二叉树这个章节刷完之后,我觉得整个数据结构的难度曲线才真正开始爬坡。它前面的线性结构是“走路”,到了树才算是“上楼梯”。后面图的那章又是从树这层楼梯望向更高层的风景。但只要你把二叉树的基本概念、四种遍历、两种存储结构吃得足够透,后面的平衡树、堆、哈夫曼树、并查集和图论算法,学起来都会有底气得多。
回头再看开头那个“二叉树到底好在哪”的疑问,我现在会回答:它不止是一种数据结构,更是一种分而治之的思维方式。树给你一个模板,让你把复杂的问题不断拆成更小的同类子问题,而递归就是执行这套拆分的翻译器。初阶阶段,别贪多,把一棵树从创建到遍历到释放的完整生命周期亲手写完一遍,比做十道花哨的题都管用。