提到二叉树,很多人的第一反应就是“数据结构课里的那个递归树”,或者是考研408里永远绕不开的大题。但真正刷过题、写过实验报告、背过王道的人都知道,二叉树不是背几个定义就能过去的,它是后续搜索树、堆、哈夫曼树、图论算法的基础,也是面试里“手撕代码”的高频考点。这篇内容我就以二叉树为切入点,把“树的定义—遍历—深度—搜索树—线索化—运行时错误排查—考试复习”这条线完整走一遍,适合正在学数据结构的人、准备考研的朋友,以及写过二叉树但总是报错、想彻底搞懂原理的开发者。网上关于二叉树的资料很多,但大多数只贴代码不给思路,我尽量把背后的“为什么”也讲清楚。
1. 二叉树到底是什么:从一棵树说起
1.1 树结构与二叉树的定义
树是一种非线性的数据结构,它的逻辑结构就像一棵倒挂的树:根在最上面,分支往下展开。树里面的每个元素叫“结点”,结点之间的连接叫“边”。在工程和算法里,树最常见的形态就是二叉树——每个结点最多只有两个子结点,分别叫左孩子和右孩子。这个“最多两个”的约束看似简单,却把树这种结构变得极其可控,因为两个孩子天然形成了“左”和“右”的顺序,于是很多递归、分治、搜索的策略都能在这上面展开。
在C语言里,二叉树结点的定义通常长这样:
typedef struct BiTNode { int data; // 数据域 struct BiTNode *lchild, *rchild; // 左右孩子指针 } BiTNode, *BiTree;这里最容易被忽略的是两个指针一定要初始化成NULL。很多运行时错误都是因为malloc出来的结点里指针是随机值,导致遍历时走到了非法内存。这个坑后面专门讲。
1.2 为什么必须是“二叉树”而不是普通多叉树
普通的树可以有任意多个孩子,操作起来很灵活,但计算机处理“多分支”时,就需要额外存储孩子的数量或者用链表把兄弟结点串起来,否则你不知道它到底有几个孩子。而二叉树把分支固定为“左、右”两个,配合递归定义,很多算法可以直接用“左子树递归 + 右子树递归”的思路写出来,逻辑非常规整。
更重要的是,任何一棵普通的树都可以通过“左孩子右兄弟”的表示法转换成二叉树。什么意思呢?就是让一个结点的左指针指向它的第一个孩子,右指针指向它在原树中的下一个兄弟。这样一来,处理多叉树的问题就退化成了处理二叉树的问题。所以二叉树不是“树的特例”,而是“树的核心代表”,理解了二叉树,普通树和森林也就能顺带拿下了。
1.3 二叉树的基本形态与术语
先别急着写代码,把这几组概念理清,考试和面试才不会翻车。
- 满二叉树:每一层都是满的。深度为k的满二叉树,结点总数是 2^k - 1。
- 完全二叉树:除了最后一层外都是满的,最后一层的结点都集中在左侧连续排列。堆排序里的堆,就是用完全二叉树存储的。
- 斜二叉树:所有结点都偏向一边,退化成类似链表的结构。这种树的高度就是结点数n,查找效率也是O(n),是树结构里最坏的情况。
- 叶子结点:没有孩子的结点。
- 结点的度:结点拥有的子树个数。二叉树的度最大是2。
- 高度/深度:从根到叶子的最长路径上的结点数。根结点深度是1,空树深度是0。
下面这个表格把常见的树形态放一起对比:
| 形态 | 定义要点 | 结点数公式 | 典型用途 |
|---|---|---|---|
| 满二叉树 | 所有层全部填满 | 深度k,结点数2^k - 1 | 理论推导、性质证明 |
| 完全二叉树 | 除最后一层外满,最后一层靠左 | 适合用数组顺序存储 | 堆、优先队列 |
| 斜二叉树 | 每个结点只有一个孩子 | 结点n,高度n | 最坏情况分析 |
| 一般二叉树 | 任意结点最多有两个孩子 | 无固定公式 | 表达式树、搜索树等 |
这几类形态背后其实牵出一个重要问题:二叉树用链式存储还是顺序存储?顺序存储就是把二叉树按层填进数组,适合完全二叉树,比如堆排序里父结点下标是 i/2,孩子下标是 2i 和 2i+1,算起来非常快。但如果是斜树,数组空间浪费极其严重。所以平时讨论算法时,默认都是链式存储,除非题目明确说是完全二叉树。
2. 二叉树的遍历:顺序就是一切
遍历是二叉树里最重要、也最容易出错的点。所谓遍历,就是按照某种规则把每个结点访问一次。为什么二叉树的遍历这么重要?因为它把非线性结构“线性化”了。一旦你把树里的所有结点排成一个序列,那就可以做序列化、表达式求值、打印树形结构等等。可以说,遍历是二叉树所有操作的基础。
2.1 前序、中序、后序:递归视角
先记住一个口诀:前序、中序、后序,“前/中/后”指的是根结点的相对访问顺序。
- 前序遍历:根 → 左子树 → 右子树。
- 中序遍历:左子树 → 根 → 右子树。
- 后序遍历:左子树 → 右子树 → 根。
C语言递归代码就是一板一眼地翻译:
void PreOrder(BiTree T) { if (T == NULL) return; printf("%d ", T->data); PreOrder(T->lchild); PreOrder(T->rchild); } void InOrder(BiTree T) { if (T == NULL) return; InOrder(T->lchild); printf("%d ", T->data); InOrder(T->rchild); } void PostOrder(BiTree T) { if (T == NULL) return; PostOrder(T->lchild); PostOrder(T->rchild); printf("%d ", T->data); }这里最重要的就是那个if (T == NULL) return;。很多人写递归的时候容易忘记终止条件,或者写成while循环,一旦结点为空还会继续访问孩子的字段,结果就是运行时错误。递归理解的关键不是去模拟栈里的每一步,而是相信函数已经能正确处理左右子树,做到“只负责当前结点”。
中序遍历还有一个重要特点:对一棵二叉搜索树做中序遍历,得到的序列是递增有序的。这句话在考研和面试里经常被拿来出题,比如“给出前序和中序,求后序”,本质就是利用中序序列划分左右子树。
2.2 层次遍历:队列的妙用
前中后序本质上是深度优先遍历。而层次遍历是广度优先,就是一层一层从左往右访问。它的实现不靠递归,靠队列:
void LevelOrder(BiTree T) { if (T == NULL) return; Queue *q = InitQueue(); EnQueue(q, T); while (!IsEmpty(q)) { BiTNode *p = DeQueue(q); printf("%d ", p->data); if (p->lchild != NULL) EnQueue(q, p->lchild); if (p->rchild != NULL) EnQueue(q, p->rchild); } }层次遍历的经典应用包括:判断一棵树是否为完全二叉树、求树的宽度(某一层最多结点数)、打印之字形遍历等等。很多题目看题干说“层序遍历”,就是这个套路。队列操作本身不复杂,但要注意实现队列时,队头队尾指针的初始化,以及入队的是结点指针,不是结点值。
2.3 遍历的应用:从表达式到序列化
别以为遍历只是打印数字,它的实际应用特别广。比如编译原理里的表达式树,操作符在根结点,操作数在叶子结点。前序遍历得到的是前缀表达式(波兰式),中序遍历得到的是中缀表达式,后序遍历得到的是后缀表达式(逆波兰式)。你用中序遍历遍历一个(a+b)*c的表达式树,得到a+b*c,但如果没有加括号,就会丢失运算符优先级信息,所以真正求值时一般用后缀表达式。
另一个常见场景是二叉树的序列化与反序列化。LeetCode上有一道经典题“二叉树的序列化”,做法就是按前序遍历递归生成字符串,空结点用特殊符号标记。比如1,2,#,#,3,4,#,#,5,#,#,读到数字就创建结点,读到#就返回NULL,这样依靠前序序列就能完整恢复一棵二叉树。理解了遍历序列的生成规则,这些题目就迎刃而解。
3. 二叉树深度与结构计算:看似简单,细节不少
每次写二叉树实验报告,除了遍历,第二个必写内容就是求深度。二叉树的深度也叫高度,指的是从根结点到最远叶子结点的结点数。它的递归公式简单到让人怀疑:
int TreeDepth(BiTree T) { if (T == NULL) return 0; int leftDepth = TreeDepth(T->lchild); int rightDepth = TreeDepth(T->rchild); return (leftDepth > rightDepth) ? leftDepth + 1 : rightDepth + 1; }3.1 深度计算背后的递归分治
为什么是左右子树深度的最大值加1?因为一棵树的深度,取决于它最高的那棵子树。加上的这个1,是根结点本身占的一层。这个思路就是分治法:把整棵树的问题拆成左右两个子树的问题,最后合并结果。
这样一个递归在树很深时会爆栈,所以面试时可能会追问“非递归写法”。非递归用层次遍历来实现:每遍历完一层,深度加1。看着不难,但关键是怎么知道当前层结束了?常见做法是在队列尾部放一个标记结点(比如NULL),每次遇到标记就层级+1;或者记录当前层的结点个数,循环处理完这些结点后再进下一层。这些细节,写一次就会记住。
3.2 统计结点数和叶子数
统计结点数也是递归分治:
int CountNodes(BiTree T) { if (T == NULL) return 0; return CountNodes(T->lchild) + CountNodes(T->rchild) + 1; }统计叶子数:
int CountLeaf(BiTree T) { if (T == NULL) return 0; if (T->lchild == NULL && T->rchild == NULL) return 1; return CountLeaf(T->lchild) + CountLeaf(T->rchild); }这几个代码放在一起,你就能看出规律:几乎所有二叉树性质的计算,都可以用“空树返回0或1 + 递归左右子树 + 合并结果”的模板套出来。做题的时候先问自己三件事:空树是什么?只有一个结点是什么?左右子树的结果怎么合并?想清楚这三件事,代码几乎不会错。
3.3 判断平衡、对称、相同的通用思路
知道深度之后,很多题目就顺手了。判断一棵树是不是平衡二叉树,就是在递归返回左右子树深度的同时,检查高度差是否大于1;判断两棵树是否对称,是递归比较左树的左孩子和右树的右孩子;判断两棵树是否相同,是同时递归比较两棵树的左右孩子。这一类题在LeetCode上全是“简单/中等”,但考的是你能不能把递归的“参数”和“返回结果”设计得合理。
一个经常翻车的点是:递归函数既需要返回子树深度,又需要返回“是否平衡”的布尔值。这种情况的解法是用返回值同时携带两个信息,或者简单粗暴用“先求深度再比较”的多次遍历。很多人第一次写会写出“在递归里调用两次TreeDepth”的笨办法,虽然能过,但复杂度是O(n²)。面试时最好能写出O(n)的剪枝方案——一旦发现左右不平衡就立即返回,不再继续递归。
4. 把二叉树升级成搜索树与线索树
单独一棵二叉树,最大的问题是没有“顺序性”。你想找某个值,只能遍历全树。但如果给二叉树加一个约束:左子树上所有结点的值都小于根结点,右子树上所有结点的值都大于根结点,它就变成了二叉搜索树。搜索树是二叉树最经典、最实用的应用形态,也是考研数据结构里的必考内容。
4.1 二叉搜索树:插入、删除、查找的底层逻辑
二叉搜索树的查找思路和二分查找很像:从根开始比较目标值,小了往左走,大了往右走。平均时间复杂度是O(log n),但如果输入序列是递增有序的,树就会退化成一条斜链,也就是一条链表,查找变成O(n)。所以后面才有了平衡二叉树、红黑树这些“不让树歪”的改进结构。
插入操作不复杂,关键是找到插入位置。新结点一定会被插入在“某个空指针”的位置,也就是某个叶子结点的左或右孩子处。查找和插入的核心代码几乎一样,差别只在找到位置后是返回还是创建结点。
删除操作是二叉搜索树里最麻烦的,分三种情况:
- 删除叶子结点:直接释放,父结点对应指针置NULL。
- 删除只有一个孩子的结点:让父结点指针直接指向它的唯一孩子,相当于“跳过”这个结点。
- 删除有两个孩子的结点:需要找一个“替身”。通常是用右子树中的最小结点,或者左子树中的最大结点来替换被删除结点的值,然后把那个“替身”结点删掉。这样既保持了搜索树的性质,又不会破坏树的结构。
这个“替身”逻辑是期末和考研的高频考点。很多人第一次写删除代码容易把内存释放和指针连接搞混,导致树断掉或内存泄漏。
4.2 线索二叉树:把空指针用起来
普通二叉树的n个结点,有2n个指针域,但实际只用了n-1个(用来连接结点),剩下n+1个指针都是NULL。线索二叉树的思路就是:把这些空指针利用起来,让它们指向遍历序列中的前驱或者后继结点。
具体规则很清晰:
- 如果某结点没有左孩子,就把左指针指向它的前驱结点(根据某种遍历次序);
- 如果某结点没有右孩子,就把右指针指向它的后继结点。
但这样一来,指针到底是“孩子”还是“线索”就分不清了,所以每个结点要额外增加两个标志位,比如ltag和rtag。当ltag=0时,左指针指向左孩子;当ltag=1时,左指针指向前驱线索。rtag同理。
线索二叉树最大的好处是遍历效率高,不用递归也不用栈就能线性地遍历整棵树。考研里常考“中序线索二叉树”的构造,画图题尤其多。动手前最好自己先画一棵二叉树,把中序遍历序列写出来,再一个结点一个结点地补线索,画上几道题就彻底懂了。
4.3 平衡化思路:AVL与红黑树的概念扫盲
二叉搜索树最大的隐患是“不平衡”。于是有了AVL树:任何结点的左右子树高度差绝对值不超过1。AVL树的插入和删除后,要通过四种旋转(LL、RR、LR、RL)来恢复平衡。旋转这个概念刚学时觉得抽象,其实本质就是“调整结点之间的父子关系”,把树变得更矮。
红黑树则是“弱平衡”的二叉搜索树,不要求高度差严格不超过1,只要求从任意结点到其每个叶子结点的路径上黑色结点数量相同,并且不能出现连续红色结点。它比AVL树稍微松弛一些,所以插入删除的旋转操作更少,实际应用比如Java的TreeMap、C++的map,底层都是红黑树。在考研408里,红黑树通常只考概念,不考手写实现,但AVL的旋转是可能考的,一定要动手画一画。
5. 写二叉树程序为什么总报运行时错误:排查实录
“写二叉树程序时为什么总是报运行时错误”,这是很多初学者的真实困惑,也是网上经常被搜到的热词。我自己带过很多人做数据结构实验,看到的问题十有八九是同一个类型:内存管理不当。下面把这些坑集中列出来,帮你少走弯路。
5.1 典型错误:空指针、未初始化、递归爆栈
先说最经典的错误代码:
BiTree CreateNode(int data) { BiTNode *node = (BiTNode*)malloc(sizeof(BiTNode)); node->data = data; // 错误:没有初始化 lchild 和 rchild node->lchild = NULL; node->rchild = NULL; return node; }听上去很简单,但很多人写的时候会漏掉后两行。malloc出来的内存是“脏”的,里边的左孩子右孩子指针可能是个随机的垃圾地址。遍历的时候一旦访问到垃圾地址,就会报Segmentation Fault。
第二个常见错误是忘了让父结点连接孩子。比如你在递归创建树的时候,只在函数内部创建了子树,却没有把返回值赋给父结点的指针,结果树建出来只有一个根,其他结点全部“丢了”。检查这种问题最直接的办法就是把树的中序遍历打出来,看看是不是符合预期。
第三个错误是递归终止条件不对。比如求深度的函数里,你把T == NULL写成了T->lchild == NULL,或者忘了写终止条件,递归就会永远跑下去,最终栈溢出,报Stack Overflow。栈溢出在数据量小的时候不会暴露,一旦结点一多,立即崩。
第四个错误是scanf输入顺序和建树逻辑不匹配。很多实验题要求按扩展二叉树输入,比如AB#D##C##,#代表空结点。如果你的建树函数先scanf再判断字符,就需要确保输入里没有多余空格,否则在处理换行符时,scanf("%c")会读到回车,导致树建得乱七八糟。这时建议用scanf(" %c", &ch),前面的空格可以跳过空白字符。
下面是一个标准的前序建树代码:
BiTree CreateBiTree() { char ch; scanf(" %c", &ch); if (ch == '#') { return NULL; } BiTNode *node = (BiTNode*)malloc(sizeof(BiTNode)); node->data = ch; node->lchild = CreateBiTree(); node->rchild = CreateBiTree(); return node; }5.2 调试手段与测试用例
遇到运行时错误,不要毫无头绪地乱试。我的习惯是分三步走:
- 先用小样例。比如只有一个根结点、一个根加两个叶子、以及空树
#。空树是最容易被忽略的测试用例,但很多递归函数在空树上会直接崩。 - 打印关键变量。在递归函数入口处打印当前结点的值和左右指针是否为空。虽然不优雅,但对小白来说,比调试器直观得多。
- 用gdb或IDE断点。如果崩溃点在某个函数里,就在函数开头打断点,单步跟踪,看看是在哪一行访问了空指针。
还有一个经常翻车的点:内存泄漏。很多实验报告要求统计二叉树的结点数,写完之后没有释放树的内存。虽然程序结束后操作系统会回收,但在长时间运行的工程里,不释放就是定时炸弹。释放二叉树用后序遍历:
void DestroyTree(BiTree T) { if (T == NULL) return; DestroyTree(T->lchild); DestroyTree(T->rchild); free(T); }理由很简单:你得先把孩子都放掉,再来释放根。因为一旦free了根,你就拿不到孩子的指针了。
5.3 实验报告怎么写(结合数据结构实验报告热词)
数据结构实验报告是很多课程必须交的作业,包含实验目的、实验原理、实验步骤、代码、结果截图、总结。二叉树实验通常要求实现建树、遍历、求深度、求叶子数、统计结点数这几个功能。报告里最容易扣分的地方是:
- 没有对算法复杂度进行分析;
- 没有贴测试数据和结果;
- 代码没有注释或者变量命名混乱;
- 缺少“遇到的问题与解决方案”。
我的建议是,实验报告的代码部分不要直接抄网上的完整代码,至少自己跑一遍、改一改。很多时候老师并不在意你的代码有多完美,而是你能不能准确描述“我是怎么实现递归的”。在总结部分,写一句“递归实现时我一开始忘了设置终止条件,导致栈溢出,后来加上了空树判断解决”,这比堆砌大段复制内容有价值得多。
6. 如何准备数据结构考试与考研(个人经验)
无论你是期末复习还是备战考研,数据结构都是一座大山。二叉树作为树这章的核心,占的分数相当可观。结合“数据结构王道”、“大话数据结构”、“数据结构考研”、“数据结构期末复习”这些热搜词,分享一些我自己备考和辅导别人的经验。
6.1 复习路线:从教材到真题
如果你是刚开始学,推荐先把教材(比如《数据结构(C语言版)》)看一遍,重点理解树链式存储的代码。但教材往往讲得很细,不适合冲刺。到了复习阶段,就要学会“以题带点”。
考研复习的话,王道和数据结构考研辅导书的思路是很好的:每一节先讲核心概念,然后直接上选择题和算法题。二叉树这一章的选择题高频点有:前中后序遍历序列的转换、根据两种遍历序列还原二叉树、完全二叉树的下标关系、线索二叉树的指向前驱后继的条件。算法题高频点有:求深度、判断平衡、求宽度、求叶子数、找最近公共祖先。这些题几乎都能用递归模板解决。
期末复习则更偏向于“会做实验、会写基础函数、会画树”。建议把课后的实验题逐个过一遍。很多期末考试会直接考“写出中序遍历的非递归算法”,非递归中序遍历是用栈模拟递归,代码如下:
void InOrderNonrec(BiTree T) { Stack *s = InitStack(); BiTNode *p = T; while (p != NULL || !IsEmpty(s)) { while (p != NULL) { Push(s, p); p = p->lchild; } if (!IsEmpty(s)) { p = Pop(s); printf("%d ", p->data); p = p->rchild; } } }理解这段代码的关键是:先把左孩子一路入栈,直到没有左孩子;然后弹出栈顶结点并访问它,再处理它的右子树。这个“左到底、访问根、转右”的顺序,就是非递归中序遍历的核心。
6.2 必背结论与手写代码模板
复习到后期,不需要背网上几百行代码。你需要的是几个“模板级”的代码,能够应对大多数算法题。我整理了以下几个:
- 递归模板:求深度、求结点数、求叶子数,都是三行以内的递归。
- 非递归模板:中序遍历、层次遍历。
- 二叉树构造:由前序+中序序列还原二叉树。
- 搜索树模板:查找、插入、删除。
由前序和中序还原二叉树是考研的高频题,也需要理解递归思路。前序序列的第一个元素是根,在中序序列里找到这个根的位置,中序左边就是左子树,右边就是右子树。然后递归处理左右部分就好。代码不算短,但写熟之后非常有成就感。
6.3 避坑建议
最后说几个自己踩过的坑。
第一,不要只看视频不动手。很多考研视频讲二叉树,听起来全懂,关上电脑就写不出来。二叉树必须自己画、自己敲、自己调。我建议每学完一种遍历,就在纸上画一棵树,把遍历序列写出来,再对着代码跑一遍,验证自己的结果。
第二,不要忽视递归的边界条件。每次写递归函数,先写空树判断。这个习惯能帮你省掉大量调试时间。
第三,不要把网上的代码直接贴进实验报告。至少自己重新敲一遍。因为考试是手写代码,面试也要白板写,只有自己写过,肌肉记忆才有意义。
第四,重视层序遍历。很多教材把层次遍历放在队列章节里,有些同学只背了前中后序,结果考试考“之字形打印二叉树”就直接蒙圈。其实层序遍历原理并不难,队列一上,问题就解决一大半。
第五,理解二叉树的数组存储。考研经常考“完全二叉树中第k个结点的左孩子下标是多少”,这需要记住:从0开始编号,左孩子是2k+1,右孩子是2k+2。如果从1开始编号,左孩子是2k,右孩子是2k+1。逢考必考,一定要分清。
写到这里,你可能会发现,二叉树这个章节特别像盖房子的地基。遍历是骨架,递归是思路,搜索树和线索树是应用,而各种报错则是你真正写代码时必须跨过去的坎。我个人的体会是,学二叉树最有效的办法就是“动手画,动手写,动手调”。画几棵不同的树,亲手实现一次遍历和求深度,再把所有报的错记录下来,比刷二十道选择题都有用。如果你正被某个运行时错误卡住,不妨先把代码里的所有指针打出来看看,再检查递归终止条件,多半就能解决了。毕竟二叉树的代码就这么点花样,能错的地方也就那几个,亲手排查过一次,后面就稳了。