ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

二叉树从遍历到搜索树:递归、陷阱与数据结构备考指南

二叉树从遍历到搜索树:递归、陷阱与数据结构备考指南 提到二叉树很多人的第一反应就是“数据结构课里的那个递归树”或者是考研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 和 2i1算起来非常快。但如果是斜树数组空间浪费极其严重。所以平时讨论算法时默认都是链式存储除非题目明确说是完全二叉树。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 遍历的应用从表达式到序列化别以为遍历只是打印数字它的实际应用特别广。比如编译原理里的表达式树操作符在根结点操作数在叶子结点。前序遍历得到的是前缀表达式波兰式中序遍历得到的是中缀表达式后序遍历得到的是后缀表达式逆波兰式。你用中序遍历遍历一个(ab)*c的表达式树得到ab*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个用来连接结点剩下n1个指针都是NULL。线索二叉树的思路就是把这些空指针利用起来让它们指向遍历序列中的前驱或者后继结点。具体规则很清晰如果某结点没有左孩子就把左指针指向它的前驱结点根据某种遍历次序如果某结点没有右孩子就把右指针指向它的后继结点。但这样一来指针到底是“孩子”还是“线索”就分不清了所以每个结点要额外增加两个标志位比如ltag和rtag。当ltag0时左指针指向左孩子当ltag1时左指针指向前驱线索。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开始编号左孩子是2k1右孩子是2k2。如果从1开始编号左孩子是2k右孩子是2k1。逢考必考一定要分清。写到这里你可能会发现二叉树这个章节特别像盖房子的地基。遍历是骨架递归是思路搜索树和线索树是应用而各种报错则是你真正写代码时必须跨过去的坎。我个人的体会是学二叉树最有效的办法就是“动手画动手写动手调”。画几棵不同的树亲手实现一次遍历和求深度再把所有报的错记录下来比刷二十道选择题都有用。如果你正被某个运行时错误卡住不妨先把代码里的所有指针打出来看看再检查递归终止条件多半就能解决了。毕竟二叉树的代码就这么点花样能错的地方也就那几个亲手排查过一次后面就稳了。
返回列表