二叉树非递归遍历与平衡旋转:C语言数据结构实战详解

📅 发布时间:2026/10/13 20:30:33
二叉树非递归遍历与平衡旋转:C语言数据结构实战详解
1. 为什么说非递归遍历是二叉树实战的第一道坎上篇把二叉树的节点定义、递归遍历和建树流程讲完之后很多读者留言说递归版本“看懂了一闭眼就忘”。这太正常了——递归本身就是把函数调用栈当存储结构在用人脑去逐层模拟函数栈反而是反直觉的。但到了面试、竞赛、实际工程里非递归遍历几乎是绕不开的硬要求原因有三一是递归深度受系统栈限制二叉树深度一旦达到几千层直接爆栈二是很多场景需要手动控制访问顺序递归做不到“走到一半暂停”三是非递归能让你真正理解“栈”和“遍历顺序”的对应关系这是后续一切树形算法的基础。1.1 前序与中序遍历共享一套栈模拟逻辑先看最经典的写法。前序遍历的顺序是“根-左-右”中序是“左-根-右”。你只需要记住一句话前序是“进栈时访问”中序是“出栈时访问”。// 前序遍历根-左-右 void preorderIterative(TreeNode *root) { if (root NULL) return; TreeNode *stack[1024]; int top -1; stack[top] root; while (top 0) { TreeNode *node stack[top--]; printf(%d , node-val); // 出栈即访问 if (node-right) stack[top] node-right; // 右先入栈 if (node-left) stack[top] node-left; // 左后入栈保证先左 } }这里有个新手容易踩的坑为什么要让右孩子先入栈、左孩子后入栈因为栈是后进先出想要“先处理左子树”就必须让左子树最后进栈。你要是写成“左先右后”打印出来的顺序就变成了“根-右-左”调半天不知道错在哪。中序版本稍微绕一点核心思想是“沿着左链一路压到底压不动就弹出访问然后转去右子树”// 中序遍历左-根-右 void inorderIterative(TreeNode *root) { TreeNode *stack[1024]; int top -1; TreeNode *cur root; while (cur ! NULL || top 0) { while (cur ! NULL) { // 一直往左走 stack[top] cur; cur cur-left; } cur stack[top--]; // 弹出一个节点 printf(%d , cur-val); // 此时访问 cur cur-right; // 转向右子树 } }这段代码建议你拿着笔一步步画栈的变化画三五个节点就彻底通了。我自己当年学的时候把栈的每一层变化写在本子上画了大概能有一整页才真正明白“为什么中序要先一路压到最左”。这个画栈的笨办法比看三十遍教学视频都管用。1.2 后序遍历两个栈 or 逆序技巧后序“左-右-根”是最麻烦的因为在栈模拟过程中根节点要等到左右子树都处理完才能访问只靠一个栈很难判断“当前节点的右子树到底有没有被处理过”。最简单的方案是利用“前序的变体”加反转。你想一下前序是“根-左-右”如果把前序的左右入栈顺序对调变成“根-右-左”再整体逆序输出不就得到了“左-右-根”吗// 后序遍历左-右-根借助两个栈 void postorderIterative(TreeNode *root) { if (root NULL) return; TreeNode *s1[1024], *s2[1024]; int top1 -1, top2 -1; s1[top1] root; while (top1 0) { TreeNode *node s1[top1--]; s2[top2] node; // 先入结果栈 if (node-left) s1[top1] node-left; // 左先入 if (node-right) s1[top1] node-right; // 右后入 } while (top2 0) { printf(%d , s2[top2--]-val); // 逆序输出 } }第二个栈里存的是“根-右-左”顺序弹出来正好逆成“左-右-根”。这一招在面试里非常好用代码短、不容易出错、说理也通顺。还有一种单栈加标志位的方式需要在节点结构体里塞一个visited字段代码冗长且容易搞混工程上很少用这里就不推荐了。2. 层序遍历与控制队列二叉树应用的地基层序遍历BFS是用队列从上到下、从左到右逐层访问节点。它和栈模拟的DFS最大区别在于BFS天然适合处理“层级”相关的信息比如求树高、求每层的最大值、锯齿遍历等。#include stdio.h #include stdlib.h #define MAX_QUEUE 1024 void levelOrder(TreeNode *root) { if (root NULL) return; TreeNode *queue[MAX_QUEUE]; int front 0, rear 0; queue[rear] root; while (front rear) { TreeNode *node queue[front]; printf(%d , node-val); if (node-left) queue[rear] node-left; if (node-right) queue[rear] node-right; } }这段代码看起来简单但真正应用的时候十有八九是配合一个层级计数器在循环里记录“上一层节点数”否则你只知道按层出队却不知道换行时机。做法是void levelOrderWithNewline(TreeNode *root) { if (root NULL) return; TreeNode *queue[MAX_QUEUE]; int front 0, rear 0; queue[rear] root; while (front rear) { int levelSize rear - front; // 当前层节点数 for (int i 0; i levelSize; i) { TreeNode *node queue[front]; printf(%d , node-val); if (node-left) queue[rear] node-left; if (node-right) queue[rear] node-right; } printf(\n); // 一层结束换行 } }这里的levelSize必须在处理每层开始时用“当前队列长度”固定住因为处理过程中孩子节点会不断入队队列长度一直在变如果你在循环里直接写front rear那就成了全层混在一起出队层级信息全丢了。这个细节在求树高、做“自底向上”层序遍历时尤其关键我见过不少人栽在这里——能输出节点但一要求“每层分组”就懵。还有一个我特别想强调的点层序遍历的队列长度不要用动态分配的定长数组硬写满应该预先算好最坏情况。树的节点数不超过n但队列里可能同时存在两层的数据最坏情况下接近n个节点所以按节点总数开数组就是安全的。你要是图省事开个100深度稍微大一点的树直接数组越界程序行为变得飘忽不定排查起来相当痛苦。有了这套队列思想后面的“二叉树最大宽度”“二叉树右视图”“之字形遍历”刷起来会顺手非常多因为它们本质都是“层序遍历 一层输出的时机判断”。3. 二叉搜索树删除节点的完整推演讲完遍历二叉树的实战重头戏就来了二叉搜索树BST的删除。为什么单独拿出来说因为插入操作就那么几行但删除操作要照顾三种情况还要处理指针的父子关系是C语言指针操作里一个非常典型、也非常容易出错的地方。3.1 三种删除场景的边界处理删除一个节点逻辑上分三种情况叶子节点直接释放把父节点对应的指针置空。只有一个孩子用孩子顶替被删节点然后释放。有两个孩子这是最麻烦的。常规做法是找到右子树中最小的节点也就是中序后继用它的值覆盖当前节点然后转而删除那个“最小节点”。这里有个非常关键的思维转换第三种情况真正删除的不再是当前节点而是被它覆盖信息的那个“后继节点”。后继节点一定没有左孩子或者最多只有一个右孩子所以删除它又退化成了情况1或2。TreeNode *deleteNode(TreeNode *root, int key) { if (root NULL) return NULL; if (key root-val) { root-left deleteNode(root-left, key); } else if (key root-val) { root-right deleteNode(root-right, key); } 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-val minNode-val; root-right deleteNode(root-right, minNode-val); } return root; }注意这里我用的是“递归 返回新根”的方式处理父子关系这在C语言里是相对安全的写法因为你不用在递归返回时去修改parent-left还是parent-right函数返回值天然解决了这个问题。你要是硬用迭代写需要额外维护一个parent指针还得判断当前节点是父节点的左孩子还是右孩子代码长度翻倍出错概率也成倍上涨。3.2 删除删错多半是free时序的问题实际调试中最容易出现的诡异bug是先free了节点然后又通过它访问孩子节点。比如有些版本会这样写if (root-left NULL) { TreeNode *temp root-right; free(root); // 把root释放了 return temp; // 还好没有访问root }但如果是先free再赋值或者free完之后还回头读root-val数据在内存里还没立刻被覆盖程序看表象一切正常直到某次数据量大起来堆上内存被复用才突然crash。这种“延迟爆炸”的bug是最恶心的排查时你甚至会怀疑算法本身不对而不是怀疑free的时机。我的经验是删除节点时永远先保存好要返回的指针再free。顺序是“取孩子指针→free当前节点→返回孩子指针”三步缺一不可也绝不能中间穿插其他访问。另外建议用valgrind或者AddressSanitizer跑一遍删除的测试用例把内存问题暴露在开发阶段而不是留到上线后。4. 高级主题从二叉搜索树到平衡树的旋转与自修正BST有个天生的软肋如果按顺序插入递增数据树会退化成一条链插入和查找的复杂度从O(log n)恶化成O(n)。解决思路是保持树平衡最经典的就是AVL树。这里我不展开全套实现只把旋转这个“核心中的核心”讲透因为它也是红黑树、Treap等几乎所有自平衡树的公共基础。4.1 树的四种失衡模式与旋转策略AVL树在每个节点上维护一个平衡因子balance 左子树高度 - 右子树高度取值范围是-1、0、1。插入或删除后某个节点失衡只可能有四种模式模式失衡条件旋转方案LL左孩子的左子树导致失衡右旋RR右孩子的右子树导致失衡左旋LR左孩子的右子树导致失衡先左旋后右旋RL右孩子的左子树导致失衡先右旋后左旋以LL为例写一个右旋函数。右旋的本质是把失衡节点的左孩子提上来当新根原来的失衡节点变成新根的右孩子左孩子的原右子树挂到失衡节点的左孩子位置。一句话概括就是“把链头往右拎”。TreeNode *rotateRight(TreeNode *y) { TreeNode *x y-left; TreeNode *T2 x-right; x-right y; y-left T2; // 旋转后更新高度省略... return x; }左旋是对称的把rotateRight里的left和right对调即可。而LR和RL是先用一次“反方向”旋转把结构变成LL或RR再利用上述的两次旋转所以代码里往往就是x rotateLeft(x); return rotateRight(x);的组合。4.2 为什么旋转后必须从下往上更新高度这是我自己踩过最深的一个坑。AVL的插入操作在递归回溯的过程中需要检查每个祖先节点的平衡因子一旦发现某节点失调节点就做旋转。但旋转会改变子树高度如果你在旋转之后没有重新计算受影响节点的高度下一次回溯时某个节点的平衡因子就会算错导致整棵树的平衡状态判断全是错的。正确顺序是先更新旋转涉及的孩子节点高度再更新旋转后新根的高度。拿右旋来说y和x都变了结构必须先算y的新高度再算x的新高度因为x的高度依赖于y。顺序反过来就完蛋。高度更新公式很简单int max(int a, int b) { return a b ? a : b; } int getHeight(TreeNode *node) { return node ? node-height : 0; } void updateHeight(TreeNode *node) { node-height 1 max(getHeight(node-left), getHeight(node-right)); }这里还有一个小细节很多教材把空节点的高度记为-1也有人记为0二者推导出的叶子节点高度不同但只要全代码统一就行。我习惯用“空节点高度为-1叶子高度为0”这样平衡因子计算时更直观。提示AVL删除后重新平衡比插入更麻烦因为删除可能会让多个祖先节点依次失衡需要一路回溯到根。如果你刚开始学先把插入自平衡搞透删除的再平衡等理解了旋转本质再去看会容易很多。5. 二叉树的序列化与反序列化把结构存下来平时写算法题时我们总是在内存里建树、遍历、释放很少考虑“怎么把一棵树持久化到文件或者网络传输”。但实际工程里序列化无处不在缓存树的中间结果、把语法分析树传给下一个模块、在不同进程之间传递树结构。5.1 一个用前序加空标记的序列化方案序列化的经典方案是“前序遍历 用特殊标记表示空节点”。比如把NULL记成“#”节点值之间用逗号分隔// 序列化1,2,4,#,#,5,#,#,3,6,#,#,7,#,# void serialize(TreeNode *root, FILE *fp) { if (root NULL) { fprintf(fp, #,); return; } fprintf(fp, %d,, root-val); serialize(root-left, fp); serialize(root-right, fp); }反序列化就是看着字符串重新建树核心是维护一个索引指针按前序顺序依次读tokenTreeNode *deserialize(const char **pp) { if (**pp #) { *pp 2; // 跳过 # return NULL; } int val 0; while (**pp 0 **pp 9) { val val * 10 (**pp - 0); (*pp); } if (**pp ,) (*pp); TreeNode *node createNode(val); node-left deserialize(pp); node-right deserialize(pp); return node; }这里有个容易踩的坑递归函数里移动指针时必须传二级指针const char **pp而不是一级指针。因为C语言参数是值传递一级指针进函数后改了也没法传回调用者除非你返回新指针。用二级指针才保证每次读取token后索引是真的“往前走”。我第一次写这个函数就用的单指针结果递归回去反复读同一个token生成了一棵完全错误的树排查了半小时才意识到是指针传递的问题。另一种思路是层序序列化按层把节点写入空节点写#重建的时候用队列“对号入座”。层序方案的优点是和反序列化时的重建逻辑更直观、不需要依赖递归缺点是字符串里空标记的数量比前序方案多存储开销略大。二者根据场景选就行。对于高频刷题来说你还会遇到“如何判断一棵树是否对称”“如何判断两棵树是否相同”“如何求最近公共祖先”这类问题它们的套路都是从“递归比较结构”入手的。很多人问我刷树的时候总是“看了答案秒懂自己上手就卡”其实症结在于递归的返回值设计不清晰。我给你一个通用方法论先确认这个递归函数要返回什么布尔、节点指针还是数值再确认当前层要做什么判断最后确认递归调用传什么子树下去。三步想清楚树的递归题至少能解出一大半。6. 调试二叉树程序的三个土办法二叉树程序的调试难度比普通线性结构高很多因为结构是分叉的打印一遍中序序列可能看不出来左右子树挂没挂对。以下三个方法是我在实际开发里反复使用、也被证明解决过不少疑难杂症的。6.1 可视化打印把树横向倒过来看自己写一个简单的竖着旋转90度的打印函数对理解树结构极有帮助。思路是逆中序遍历先打印右子树再打印根节点最后打印左子树用缩进表示层级void printTreeHelper(TreeNode *root, int level, char pos) { if (root NULL) return; printTreeHelper(root-right, level 1, R); for (int i 0; i level; i) printf( ); printf(%c: %d\n, pos, root-val); printTreeHelper(root-left, level 1, L); } void printTree(TreeNode *root) { printTreeHelper(root, 0, T); }这个输出相当于把树“放倒”了看根在最左边向右延伸的是右子树。每次插入删除后调一下结构一目了然。比单纯的序列化字符串直观太多。6.2 递归深度计数器定位栈溢出二叉树爆栈的错误有时候根本不是树建错了而是某条路径深度太大。你可以临时在函数入口加一个深度参数void traceDepth(TreeNode *root, int depth) { if (root NULL) return; static int maxDepth 0; if (depth maxDepth) maxDepth depth; // 打印当前深度观察递归是否异常加深 printf(depth%d val%d\n, depth, root-val); traceDepth(root-left, depth 1); traceDepth(root-right, depth 1); }如果某棵“看似平衡”的树递归深度异常大多半是插入时指针挂错了导致结构退化成链。打印深度能让你一眼看出哪条子树异常。6.3 用归零法验证指针关系valgrind能查内存越界和重复释放但对“指针挂错位置、但内存访问仍合法”这种逻辑错误爱莫能助。我的土办法是在插入或删除的关键位置打印当前节点、父节点地址以及孩子地址把所有指针关系手工拉成一张小表一个一个对。比如插入完成后打印printf(inserted %d - parent%p self%p left%p right%p\n, node-val, parent, node, node-left, node-right);看起来笨但排查指针错乱的效率比瞎猜高一倍。特别是连续插入多个节点后某一组的左孩子/右孩子关系错了打印里很快能发现规律性的异常。7. 一些实操中的思考和选型建议最后聊点我在实际写代码时的选型经验这些不是教科书内容但能帮你减少很多折腾。数组存树还是链式存树如果是固定大小的完全二叉树比如堆、线段树数组存储是绝对首选下标关系直接算省去大量指针操作。但如果是动态插入删除的普通二叉树链式结构更灵活不存在数组扩容和空洞问题。“完全二叉树的数组存法普通二叉树的链式存法”这个判断标准我用了很多年几乎没翻过车。递归还是迭代我的习惯是业务代码尽量用迭代因为递归深度不可控、栈溢出后排查成本高算法题里可以放心用递归只要树的深度在合理范围内几百层以内问题都不大。你要是觉得递归代码写起来爽一定要在项目入口加一个“深度上限”的保护。去年维护一个工具模块时就因为有人随手递归合并了两棵深度大的树线上栈溢出了一次从那以后我对所有递归入参都默认做深度校验。节点的值类型怎么办很多教材的节点值直接用int但实际业务里键可能是字符串、结构体、甚至浮点数。这种情况最好的方式是像标准库那样把“比较函数”作为参数传进来而不是写死在代码里。比如typedef int (*CompareFn)(const void *a, const void *b);这样排序树、平衡树可以在不改结构的前提下适配任意类型。这个设计思路一旦用上你就再也不想退回“改节点定义”的老路了。内存释放怎么处理才不容易漏二叉树销毁必须先“后序”释放先释放左右子树再释放根节点。你可以用递归写完销毁函数也可以用一个栈把节点全收集起来再依次free。工程上我倾向于后者因为释放过程中如果递归栈里有野指针被使用排查起来非常麻烦。用栈遍历一次收集所有节点然后统一free内存操作就变成线性了心里踏实很多。void destroyTree(TreeNode *root) { if (root NULL) return; TreeNode *stack[1024]; int top -1; stack[top] root; while (top 0) { TreeNode *node stack[top--]; if (node-left) stack[top] node-left; if (node-right) stack[top] node-right; free(node); } }这样销毁的好处是万无一失不会因为先释放根节点而后再去访问其孩子导致use-after-free。说到遍历顺序对内存管理的意义我再补充一个容易被忽略的坑递归销毁时如果树的深度很深比如几万层递归函数本身就可能先把栈爆了还没到free那一步进程就崩了。所以对于深度指针结构迭代销毁是更稳妥的选择这也是我在实际工程里坚持“能迭代尽量不递归”的根本原因。二叉树的“下篇”到这里差不多把非递归遍历、层序分组、BST删除、AVL旋转、序列化反序列化和调试心得都覆盖了。上篇打底的基础概念配合本篇的实操推演二叉树这块的骨架就算是搭起来了。后面再遇到树的题目核心思路无非是“用什么顺序访问、返回什么信息、怎么处理空节点”这三件事想清楚再下手代码自然流畅不少。