ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

二叉树从概念到C语言实现:遍历、深度与BST实战全解析

二叉树从概念到C语言实现:遍历、深度与BST实战全解析 1. 从数组和链表说起为什么偏偏是二叉树先聊点实在的。我刚开始学数据结构时一直有个疑惑数组和链表用得好好的为什么要发明树直到后来写了一个带层级关系的功能才彻底想明白。数组适合按下标随机访问链表适合按顺序插入删除但它们在表达父子关系这件事上都很别扭。比如你这个节点有三个子节点数组怎么存就算能用下标公式硬凑代码可读性也基本宣告死亡。链表虽然可以嵌套指针但你得自己定义一套遍历规则等于每次都在重造轮子。树这种结构之所以能存在这么多年核心就一条它把层级关系直接做进了结构里而不是靠人为约定。而二叉树作为所有树结构中最基础的一种连计算机教材都把它放在最前面是因为它的分支数固定为二行为最简单、最可预测同时又能模拟几乎所有多叉树——用左孩子右兄弟那条路就行。至于二叉树全面解析这类话题网上的资料其实不少但大多数停留在贴代码的层面看完能跑换个场景就懵。所以这篇博文我想换一种讲法把二叉树从概念到代码实现这一条线上最容易踩的坑、最容易想不通的逻辑一个一个掰开说清楚同时带上可直接抄走的C语言代码和排查经验。2. 二叉树的基础结构与三个绕不开的术语2.1 节点结构到底怎么定义树的基本组成单元叫节点。在C语言里二叉树节点最常见的定义方式是用自引用结构体#include stdio.h #include stdlib.h // 二叉树节点定义 typedef struct TreeNode { int data; // 数据域根据需要可以换成其他类型 struct TreeNode *left; // 左孩子指针 struct TreeNode *right; // 右孩子指针 } TreeNode;这里的left和right分别指向左、右子树根节点。没有孩子时置为NULL。很多初学者会问为什么非要指针把孩子嵌进结构体里不行吗答案是不行至少在有方向性的树里不行。你用嵌套结构体会导致节点大小不固定而且无法表示多个节点指向同一个子节点这种图结构。指针本质上是把节点之间的关系外置了这也是二叉树代码实现的基础。2.2 根、叶子、父子与兄弟概念少但必须分清楚二叉树里术语不多但每个都得记准确不然看代码时容易对不上号。根节点树的最顶层节点没有父节点一棵树只有一个根。叶子节点没有左右孩子的节点也就是度数为0的节点。内部节点至少有一个孩子的节点。父子关系直接连接的两个节点上面的叫父下面的叫子。兄弟节点同一个父节点的多个子节点互称兄弟。前驱/后继在某种遍历顺序下某个节点的前一个/后一个节点。这里最容易混淆的是深度和高度。我见过不少人把这两个词互换着用。严格来说深度是从根向下数根节点的深度为0或1取决于约定高度是从叶子向上数叶子节点高度为0。很多教材和程序库的设定不一致看代码前先确认它的约定否则算出来的结果对不上时会怀疑是自己的逻辑出错。2.3 为什么左右子节点不能调换位置二叉树和普通的有序树有一个重要区别它区分左右顺序。比如一个只有单个子节点的节点这个孩子是放left还是放right在二叉搜索树或表达式树里会导致完全不同的行为。左子树和右子树的次序一旦确定遍历结果也会不一样。比如中序遍历时先走左还是先走右得到的结果序列完全不同。搜索二叉树BST尤其依赖这个次序后面会单独展开讲。所以从第一天写代码起就要养成严格区分左右孩子的习惯这比背一百遍定义都管用。3. 二叉树的遍历四种方式背后的同一个套路3.1 前序、中序、后序递归写法两分钟能搞定遍历就是按某种规则把每个节点访问一遍。二叉树的遍历按根、左、右的相对顺序分成三种经典深度优先遍历。// 前序遍历根 - 左 - 右 void preorder(TreeNode *root) { if (root NULL) return; printf(%d , root-data); preorder(root-left); preorder(root-right); } // 中序遍历左 - 根 - 右 void inorder(TreeNode *root) { if (root NULL) return; inorder(root-left); printf(%d , root-data); inorder(root-right); } // 后序遍历左 - 右 - 根 void postorder(TreeNode *root) { if (root NULL) return; postorder(root-left); postorder(root-right); printf(%d , root-data); }这三段代码的框架完全一样区别只是printf放在哪一行。初学者最容易在这里绕进去的地方是递归调用为什么不会乱套。我的理解方式是这样每次调用递归函数时系统会把当前的执行状态压进调用栈包括当前节点和已经执行到哪一行。前序走到preorder(root-left)时当前节点先存在栈里等左子树全部访问完再从栈里取出来继续走右子树。这个先记住当前状态处理完子问题再回来的过程是整个二叉树程序的底层逻辑。3.2 递归转迭代用栈显式模拟为什么必须这样做递归写起来爽但在工程上有两个问题一是树的深度很大时容易爆栈二是有时候需要手动控制访问顺序。所以面试和工程里常见的考察点是把递归改成迭代。用C语言模拟递归核心就是显式使用一个栈#include stdbool.h #define MAX_NODES 1024 // 用数组实现一个简单的栈避免动态扩容的复杂度 typedef struct { TreeNode *data[MAX_NODES]; int top; } Stack; void push(Stack *s, TreeNode *node) { if (s-top MAX_NODES) return; // 栈满保护 s-data[s-top] node; } TreeNode *pop(Stack *s) { if (s-top 0) return NULL; return s-data[--s-top]; } bool isEmpty(Stack *s) { return s-top 0; } // 前序遍历的非递归实现 void preorderIterative(TreeNode *root) { if (root NULL) return; Stack s {0}; push(s, root); while (!isEmpty(s)) { TreeNode *cur pop(s); printf(%d , cur-data); // 注意栈是后进先出所以先压右孩子再压左孩子 if (cur-right) push(s, cur-right); if (cur-left) push(s, cur-left); } }这里的压栈顺序有个细节我当初栽过跟头因为栈后进先出想先访问左子树就必须把左孩子最后压进去。中序遍历的迭代版比前序稍麻烦因为它要一路走到最左再回来访问根再去右子树需要两层循环配合。很多教材直接给代码但我的建议是拿到代码后先画一个小树自己走一遍把每个节点的进出栈顺序标出来这个过程再走不通就回到递归版对照。写代码最怕的是能跑但想不通为什么因为程序一崩溃你就完全无从下手。3.3 层序遍历队列的经典应用场景层序遍历和上面三种不一样它是广度优先按层从上到下、每层从左到右访问。这需要用队列而不是栈。#define MAX_QUEUE 1024 typedef struct { TreeNode *data[MAX_QUEUE]; int head; int tail; } Queue; void enqueue(Queue *q, TreeNode *node) { q-data[q-tail] node; } TreeNode *dequeue(Queue *q) { return q-data[q-head]; } // 层序遍历实现 void levelOrder(TreeNode *root) { if (root NULL) return; Queue q {0}; enqueue(q, root); while (q.head q.tail) { TreeNode *cur dequeue(q); printf(%d , cur-data); if (cur-left) enqueue(q, cur-left); if (cur-right) enqueue(q, cur-right); } }层序遍历在实际工程中非常有用比如按层级展开菜单、按层级输出组织架构图。和前面三种深度优先遍历相比层序不需要处理复杂的回溯逻辑只要保证先进先出就能天然保证先到先访问。3.4 四种遍历方式的对比与选择这里用一张表总结四种遍历的本质差别和典型用途遍历方式访问顺序数据结构典型用途前序根-左-右栈/递归复制二叉树、序列化中序左-根-右栈/递归二叉搜索树排序输出后序左-右-根栈/递归删除树、计算表达式值层序从上到下队列按层统计、最短路径类问题后序有个特点常被忽略它先处理完子树再处理当前节点这意味着可以用它安全地释放整棵树的节点。先释放孩子、再释放父亲不会出现孩子还没释放父亲就被释放了的悬空情况。4. 二叉树的深度看似简单却藏着几个致命细节4.1 最大深度的递归公式先算出子树的深度再加一二叉树的深度或高度是一个非常经典的递归问题公式一句话树的深度 左子树深度和右子树深度中较大的那个 1。// 求二叉树的最大深度 int maxDepth(TreeNode *root) { if (root NULL) return 0; // 空树的深度为0 int leftDepth maxDepth(root-left); int rightDepth maxDepth(root-right); return (leftDepth rightDepth ? leftDepth : rightDepth) 1; }这代码只有几行但是递归过程值得好好画一遍。以某个叶节点为例它的左右孩子都是NULL两个子问题都返回0加上1得到1说明以这个叶子为根的子树深度是1这符合直觉。往上一层再把左右子树的深度取大加1就可以逐步回溯算出整棵树的深度。这个问题的另一种问法是树的高度其实计算过程和深度是一样的区别只在根节点的起始计数是0还是1。你在网上会看到两种答案不要慌先看清楚题目的约定。4.2 最小深度并不只是镜像一下那么简单既然有最大深度很多初学者会顺手写出对称的代码int minDepth(TreeNode *root) { if (root NULL) return 0; int leftDepth minDepth(root-left); int rightDepth minDepth(root-right); return (leftDepth rightDepth ? leftDepth : rightDepth) 1; }这段代码在有些测试用例上是错的。原因是一棵树如果只有一个左孩子、没有右孩子那么最小深度按定义应该从根一直走到最近的叶子节点也就是左孩子那条路答案是2。但上面这段代码在right NULL时会返回0取小后加1变成了1这明显不对。正确的做法是分别处理三种情况int minDepth(TreeNode *root) { if (root NULL) return 0; // 叶子节点深度为1 if (root-left NULL root-right NULL) return 1; // 只有一边有子树走有子树的那边 if (root-left NULL) return minDepth(root-right) 1; if (root-right NULL) return minDepth(root-left) 1; // 两边都有取较小深度再加1 int leftDepth minDepth(root-left); int rightDepth minDepth(root-right); return (leftDepth rightDepth ? leftDepth : rightDepth) 1; }这个例子特别适合拿来理解能不能直接套对称代码。最大深度因为空树返回0、空指针不会干扰路径计算所以看起来是合理的最小深度却要把空指针当成这条路不通而不是深度为0。很多运行时错误和逻辑错误本质上都是没有分清这两种语义。4.3 深度计算的迭代方案层序遍历的意外收获递归求深度代码简洁但是碰到特别深的树仍然有爆栈风险。用层序遍历可以无递归地求深度每遍历完一整层计数器加一。实现方式是在层序遍历的外层再套一层for循环循环次数等于当前队列长度正好是本层的节点数。int treeDepthByLevel(TreeNode *root) { if (root NULL) return 0; Queue q {0}; enqueue(q, root); int depth 0; while (q.head q.tail) { int levelSize q.tail - q.head; // 当前层的节点数 for (int i 0; i levelSize; i) { TreeNode *cur dequeue(q); if (cur-left) enqueue(q, cur-left); if (cur-right) enqueue(q, cur-right); } depth; } return depth; }这段代码的写法值得记一下因为它用一个levelSize变量把当前层和下一层分开了。如果没有这层隔离队列会一直往外弹根本分不清第几层是第几层。实际做按层统计场景时这个技巧几乎是标配。5. 搜索二叉树BST有序性的威力与退化危机5.1 左小右大的定义与中序遍历的奇妙性质搜索二叉树的定义不复杂对于任意节点左子树所有节点的值都小于它右子树所有节点的值都大于它。注意是所有不只是它的直接孩子。这个性质带来的最大福利是对BST做中序遍历结果一定是升序序列。这一条可以直接用来验证一棵树是不是合法的BST也可以用来把BST转成有序数组。很多程序员把BST当成能快速查找的树但中序遍历这个有序性是它区别于普通二叉树最核心的价值面试时经常问。5.2 查找与插入一路走到底的分支判断BST的查找就像在字典里翻词比当前节点小就往左大就往右相等就返回。TreeNode *searchBST(TreeNode *root, int target) { TreeNode *cur root; while (cur ! NULL) { if (target cur-data) return cur; if (target cur-data) { cur cur-left; } else { cur cur-right; } } return NULL; }插入操作在逻辑上和查找几乎一样区别是查找没找着时直接返回插入没找着时把新节点挂在最后那个空位上TreeNode *insertBST(TreeNode *root, int value) { if (root NULL) { TreeNode *newNode (TreeNode *)malloc(sizeof(TreeNode)); newNode-data value; newNode-left NULL; newNode-right NULL; return newNode; } if (value root-data) { root-left insertBST(root-left, value); } else if (value root-data) { root-right insertBST(root-right, value); } return root; }这里要特别注意返回值的设计插入函数把新的根节点返回给上层上层再把它赋给root-left或root-right。如果直接用传值方式传入指针函数内部改指针是传不出效果的。这个坑很多人第一次写都会遇到表现为插入后树没有任何变化。5.3 删除节点三种情况里最难的是双孩子BST的删除分三种情况叶子节点直接释放掉父节点对应指针置NULL。只有一个孩子把唯一的孩子提上来顶替它。有两个孩子需要找到右子树中的最小节点或左子树中的最大节点用它替换被删节点的值然后删除那个用来替换的节点。第一种和第二种写起来相对直接第三种容易卡住格外考验对BST性质的理解。TreeNode *deleteBST(TreeNode *root, int value) { if (root NULL) return NULL; if (value root-data) { root-left deleteBST(root-left, value); } else if (value root-data) { root-right deleteBST(root-right, value); } else { // 当前节点就是要删的节点 if (root-left NULL) { TreeNode *temp root-right; free(root); return temp; } if (root-right NULL) { TreeNode *temp root-left; free(root); return temp; } // 两个孩子的场景找右子树的最小值 TreeNode *minNode root-right; while (minNode-left ! NULL) { minNode minNode-left; } root-data minNode-data; root-right deleteBST(root-right, minNode-data); } return root; }这段代码最后一步deleteBST(root-right, minNode-data)是关键它不直接释放minNode而是递归调用删除函数去删那个最小值。因为minNode最坏情况可能有右子树递归删除能正确处理所有情况不需要单独为它写释放逻辑。5.4 为什么极端情况下BST会退化成链表BST在数据随机分布时性能很好平均查找复杂度是 O(log n)。但如果按接近有序的顺序插入数据比如依次插入 1、2、3、4、5那么每个新节点都会被放到前一个节点的右孩子位置这棵树就彻底变成一条线性的链表查找复杂度退化成 O(n)。这就是平衡二叉树存在的意义。对于先会BST、再了解AVL和红黑树这条学习路径来说理解退化问题比背平衡算法更重要。你在实际工程项目里直接用自带的平衡树实现比如C的map/set内部结构但它们能保持高效正是因为内部自动做了平衡调整。如果你自己手写BST必须接受输入顺序敏感这一现实不能假设它一直是快的。6. 线索二叉树把空指针翻出来打工的进阶思路6.1 浪费的指针与线索化的动机一个包含n个节点的二叉树有 n1 个空指针这是一个可以用归纳法证明的结论每个节点贡献2个指针n-1条边对应n-1个非空指针剩下的2n-(n-1)n1个全是空的。这些空指针什么也不干白白占着空间。线索二叉树的基本思想是用空指针来记录遍历顺序中的前驱和后继。规定如果节点的左指针为空就让它指向前驱节点根据某种遍历顺序。如果节点的右指针为空就让它指向后继节点。问题是指针可能本来就有孩子也可能是指向前驱/后继的线索怎么区分答案是加两个标志位字段。经典结构如下typedef struct ThreadNode { int data; struct ThreadNode *left; struct ThreadNode *right; int leftTag; // 0表示左孩子1表示前驱线索 int rightTag; // 0表示右孩子1表示后继线索 } ThreadNode;6.2 中序线索化的实现思路与代码骨架中序线索化是三种线索化里最常用的因为它能让中序遍历不借助栈、不递归地完成。核心思路是在中序遍历的过程中用一个pre指向前一个访问的节点一边遍历一边设置线索。ThreadNode *pre NULL; // 全局变量记录前驱 void inThread(ThreadNode *root) { if (root NULL) return; inThread(root-left); if (root-left NULL) { root-left pre; root-leftTag 1; // 左指针作为前驱线索 } if (pre ! NULL pre-right NULL) { pre-right root; pre-rightTag 1; // 前驱节点的右指针指向当前节点 } pre root; inThread(root-right); }注意inThread(root-left);和最后inThread(root-right);的递归顺序不能乱。中序线索化必须在中序遍历的框架内做也就是左-根-右这样pre和root的关系恰好对应中序顺序里的前后关系。有了线索之后查找后继就变得非常简单右指针是指向右孩子还是指向后继线索。第二种情况直接返回root-right第一种则要继续深入到右子树的最左节点因为右子树里最左的节点才是中序顺序中当前节点的后继。6.3 线索化的代价与适用场景线索二叉树的代价是每个节点多了两个标志位如果你原来的结构体里有字节对齐或内存冗余加标志位后可能并没有省下内存。它的价值在于遍历速度不需要递归、不需要栈就能线性完成中序遍历这在某些嵌入式或对实时性要求严格的场景里是有意义的。我自己的体会是线索二叉树更多是思维拓展层面的价值。理解它之后你对指针不只是指向孩子还可以表达遍历关系这件事会理解得更深。但真正做项目时除非你确实在做内存受限的底层开发否则普通的栈式遍历已经足够快可维护性也更高。权衡取舍本来就是工程的一部分。7. 二叉树代码实战建树与排查运行时错误7.1 用数组按层序建树最不容易出错的入门方式写二叉树代码第一个拦路虎根本不是遍历或深度而是怎么把一棵树建出来。很多教程不细讲建树过程直接给一个写死的根节点就开跑导致读者换一组输入就不知道怎么办了。最稳的入门方式是按层序序列建树也就是给你一串数据空的位置用特殊值如 -1 占位按从上到下、从左到右的顺序重建二叉树。这个过程中要维护一个队列#include stdio.h #include stdlib.h TreeNode *createTreeByLevel(int arr[], int n, int emptyFlag) { if (n 0 || arr[0] emptyFlag) return NULL; TreeNode *root (TreeNode *)malloc(sizeof(TreeNode)); root-data arr[0]; root-left root-right NULL; Queue q {0}; enqueue(q, root); int i 1; while (i n) { TreeNode *cur dequeue(q); // 处理左孩子 if (i n arr[i] ! emptyFlag) { TreeNode *leftNode (TreeNode *)malloc(sizeof(TreeNode)); leftNode-data arr[i]; leftNode-left leftNode-right NULL; cur-left leftNode; enqueue(q, leftNode); } i; // 处理右孩子 if (i n arr[i] ! emptyFlag) { TreeNode *rightNode (TreeNode *)malloc(sizeof(TreeNode)); rightNode-data arr[i]; rightNode-left rightNode-right NULL; cur-right rightNode; enqueue(q, rightNode); } i; } return root; }建好树之后再依次调用前面写的遍历和深度函数就能看到完整效果。这个建树函数有个小设计点遇到emptyFlag就不创建孩子但依然会消耗数组中的一个位置这样空位占位才能和数组下标对齐。很多发现自己建出来的树和预期不一样的报错都是因为跳过了空位后忘了同时跳过对应下标。7.2 为什么总是报运行时错误空指针才是最普遍的元凶回到热搜词里那条写二叉树程序时为什么总是报运行时错误我的回答通常是十次有九次是空指针解引用剩下一次是递归没有出口导致栈溢出。空指针解引用最常见于遍历和查找时比如我见过这样的代码void printData(TreeNode *root) { if (root ! NULL) { printf(%d , root-data); } printData(root-left); // 递归调用前没有判空 printData(root-right); }这段代码的问题是递归调用时没有检查当前节点是否为空。可能在某个节点上root-left本来就是空指针调用printData(NULL)后函数体开头虽然避免了打印NULL-data但紧接着又调用了NULL-left直接触发段错误。正确写法是递归函数第一件事就判断当前节点是否为空空就直接返回。这个习惯如果能从第一天写代码就养成可以替你省下一大半的崩溃排查时间。另外很隐蔽的一类是忘记malloc直接使用指针TreeNode *node; node-data 5; // 错误node没有分配内存node只是个野指针指向哪个地址无从知道写入数据可能导致程序崩溃或者悄悄改坏了别的内存。排查这类问题在C语言里只能靠调试器看栈帧和变量地址但最好的办法还是从源头避免凡是创建节点一律先malloc并做分配失败检查。7.3 递归爆栈树的深度到底能撑多久递归虽然优雅但每次函数调用都要在调用栈上占一块空间。树特别深的时候比如退化成链表的BST递归深度等于树的高度很容易把默认栈空间打满程序直接异常退出。凶险之处在于这种Bug在数据量小的时候测不出来只有上了大规模数据才会突然崩溃。排查办法之一是改用迭代遍历另一个办法是先算一下树的深度如果深度过大说明递归方案不可行。工程里比较稳妥的做法是只对明确知道高度可控的树使用递归否则优先考虑迭代实现。除了栈溢出还有一类问题是返回值用错造成的逻辑错误典型行为是调用deleteBST或insertBST之后没有把返回值赋回去TreeNode *root createTreeByLevel(arr, n, -1); insertBST(root, 10); // 错误返回值被丢弃如果插入的位置导致新的根节点产生比如原树为空函数返回了新节点指针但root还停留在旧值上后续遍历全部失效。这类bug的表现形式是程序不报错但数据总是对不上排查起来比崩溃更难。我的建议是养成一个习惯所有可能改变树结构的函数返回值统一表示当前子树的根节点调用方永远把它赋回去。8. 二叉树学习的进阶路线遍历玩熟之后再往前走写到这里二叉树最核心的概念和代码实现基本都覆盖了。我在实际带人学数据结构时通常建议的路线是建树 - 四种遍历 - 最大/最小深度 - BST插入查找删除 - 平衡树的概念了解 - 线索化。这条路线的设计逻辑是每一步都在复用前一步的内容遍历复习了递归深度的计算本质是遍历过程中的状态累积BST的查找删除又依赖遍历逻辑线索化则是对中序遍历的深入改造。如果你现在卡在代码能跑但看不懂为什么我建议你别急着写新的先把maxDepth那个四行递归自己在纸上画一遍把每次调用栈的内容写出来哪怕只是三个节点的树也能帮助你建立递归直觉。这个方法我用了很多年比看任何长篇讲解都管用。至于什么时候需要掌握到什么程度我的经验是如果目标是系统学习四种遍历、BST、深度计算、层序统计这几项是需要手写级别的熟练度的线索二叉树和平衡树可以放在第二梯队掌握理解层面就行不用强求一次就能默写。如果你是在准备面试那么BST删除的双孩子情况与层序遍历的按层统计这两块一定要练到闭眼能写因为它们太常考了。最后再分享一个小技巧调试二叉树相关代码时不要光靠printf打印建议自己写一个简单的可视化辅助函数把树横向打印出来。打印方式是把根节点放在最左侧右子树向上偏移左子树向下偏移调试时一眼就能看出结构对不对比在脑子里还原递归过程快多了。这个打印树的函数本身也是很好的递归练习等你写顺手了二叉树这关也就算真正迈过去了。
RELATED READING

延伸阅读

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