C语言二叉树遍历:递归与非递归实现详解与应用场景 📅 2026/8/15 5:48:59 1. 项目概述为什么二叉树遍历是C语言程序员的必修课刚接触数据结构那会儿我觉得二叉树遍历就是个“花架子”——不就是把树里的节点按某种顺序访问一遍吗直到后来在面试中被问到“如何非递归实现中序遍历”以及在实际项目中需要按特定顺序处理文件系统目录树时我才真正明白这四种遍历方式前序、中序、后序、层序是理解递归、栈、队列等核心概念的绝佳载体更是解决无数实际问题的钥匙。二叉树遍历简单说就是按照某种规则不重复地访问树中的每一个节点。这四种遍历方式每一种都有其独特的访问逻辑和应用场景。前序遍历让你在访问节点前先处理它适合复制树结构中序遍历在访问左右子树之间处理节点天然适合二叉搜索树得到有序序列后序遍历则在访问完子树后才处理节点适合释放树内存或计算子树属性层序遍历则按层级“横扫”节点是广度优先思想的直观体现。无论你是正在啃《数据结构》课本的学生还是准备技术面试的求职者亦或是需要处理树形数据如XML/JSON解析、游戏场景树、UI组件树的开发者彻底搞懂这四种遍历尤其是它们的递归与非递归实现以及背后的内存与时间复杂度都能让你对程序的控制流有更深的理解。接下来我就把这几年积累的详细实现、踩过的坑和实战心得毫无保留地分享给你。2. 二叉树遍历的核心思路与方案选型在动手写代码之前我们必须先理清思路。二叉树遍历的核心矛盾在于树是非线性的数据结构但我们的代码执行是线性的。如何用线性的指令去“走遍”一个分叉的结构这里有两种根本性的思想深度优先DFS和广度优先BFS。前序、中序、后序遍历都属于深度优先遍历。它们的共同点是“一条路走到黑”先尽可能深地探索一条分支直到尽头再回溯。区别仅仅在于“处理当前节点”这个操作是放在探索左子树之前、之间还是之后。这种思想天然适合用递归来表达因为递归函数的调用栈完美地记录了我们的“探索路径”和“回溯点”。而非递归实现则是我们手动用一个栈来模拟这个过程这对于理解函数调用栈的机制和避免递归过深导致的栈溢出问题至关重要。层序遍历则属于广度优先遍历。它的策略是“层层推进”先访问离根节点最近的第一层然后是第二层以此类推。这种“公平”访问的策略无法用简单的递归优雅实现必须借助队列Queue这个数据结构。将节点按访问顺序入队、出队就能保证我们总是先处理当前层的节点再处理下一层。为什么同时掌握递归和非递归版本如此重要递归版本简洁、优雅体现了算法的数学美感是理解问题本质的快速路径。但在生产环境中递归有明确的局限性递归深度受限于线程栈大小数据量过大时可能导致栈溢出函数调用的开销也比循环稍大。非递归版本虽然代码复杂一些但稳定性更强并且手动管理栈的过程能极大地锻炼你对程序控制流的掌控能力。在面试中能流畅写出非递归遍历的候选人通常会给面试官留下基础扎实的印象。3. 二叉树结构的定义与基础准备任何遍历算法都建立在数据结构之上。在C语言中我们如何表示一棵二叉树最经典的方式是使用结构体struct配合指针。// 二叉树节点的结构体定义 typedef struct TreeNode { int data; // 节点存储的数据这里以整型为例 struct TreeNode* left; // 指向左子树的指针 struct TreeNode* right; // 指向右子树的指针 } TreeNode; // 创建一个新节点的辅助函数 TreeNode* createNode(int data) { TreeNode* newNode (TreeNode*)malloc(sizeof(TreeNode)); if (newNode NULL) { fprintf(stderr, 内存分配失败\n); exit(EXIT_FAILURE); } newNode-data data; newNode-left NULL; newNode-right NULL; return newNode; }这个结构体是理解后续所有代码的基石。left和right指针可能为NULL这代表该节点没有对应的左孩子或右孩子也就是叶子节点或半满节点。createNode函数封装了内存分配和初始化的过程让后续建树代码更清晰。注意每次使用malloc分配内存都必须记得在适当的时候通常是后序遍历整棵树时使用free释放否则会造成内存泄漏。这是C语言手动管理内存的基本功也是面试常考点。为了后续测试方便我们先手动构建一棵简单的二叉树。假设我们要构建如下结构的树1 / \ 2 3 / \ \ 4 5 6对应的C代码可以这样写// 构建示例二叉树 TreeNode* buildSampleTree() { TreeNode* root createNode(1); root-left createNode(2); root-right createNode(3); root-left-left createNode(4); root-left-right createNode(5); root-right-right createNode(6); return root; }有了这棵树我们就可以用它来测试所有遍历算法观察不同的输出顺序。4. 深度优先遍历一前序遍历的递归与非递归实现前序遍历的访问顺序是根节点 - 左子树 - 右子树。这个“前”字指的就是先访问根节点。它的一个典型应用场景是复制一棵二叉树因为你需要在创建新节点根之后再去复制它的左右子树。4.1 递归实现最直观的版本递归实现简单到令人发指它完美体现了分治思想要遍历整棵树先访问根然后递归遍历左子树再递归遍历右子树。遍历左/右子树的问题和遍历整棵树的问题是完全同构的。void preorderRecursive(TreeNode* root) { // 递归基如果当前节点为空直接返回 if (root NULL) { return; } // 1. 访问根节点这里我们打印节点值 printf(%d , root-data); // 2. 递归遍历左子树 preorderRecursive(root-left); // 3. 递归遍历右子树 preorderRecursive(root-right); }对于我们的示例树调用preorderRecursive(root)会输出1 2 4 5 3 6。你可以跟着代码在脑子里跑一遍从1开始打印1然后进入左子树2打印2进入2的左子树4打印44是叶子左右递归直接返回回溯到2遍历2的右子树5打印5... 这个过程就是深度优先的体现。4.2 非递归实现手动栈模拟现在我们不用递归自己来模拟这个过程。思路是利用一个栈Stack来显式保存我们需要“回溯”到的节点。先将根节点压栈。循环直到栈为空 a. 弹出栈顶节点并访问。 b.先将右孩子压栈再将左孩子压栈注意顺序因为栈是后进先出我们希望左孩子先被处理所以要让右孩子先入栈。// 假设我们有一个简单的栈实现支持push, pop, top, isEmpty操作 // 这里为了聚焦算法省略栈的具体实现使用标准库stdlib.h的动态数组模拟 void preorderIterative(TreeNode* root) { if (root NULL) return; // 创建一个节点指针的栈 TreeNode* stack[100]; // 简单起见使用固定大小数组实际应考虑动态扩容或链栈 int top -1; // 栈顶指针 // 根节点入栈 stack[top] root; while (top 0) { // 栈非空 // 弹出栈顶节点并访问 TreeNode* node stack[top--]; printf(%d , node-data); // 右孩子先入栈后处理 if (node-right ! NULL) { stack[top] node-right; } // 左孩子后入栈先处理 if (node-left ! NULL) { stack[top] node-left; } } }这个非递归版本输出的结果同样是1 2 4 5 3 6。关键点在于入栈顺序由于栈是LIFO后进先出为了保证访问顺序是“根-左-右”我们必须让右孩子先入栈左孩子后入栈。这样左孩子会在栈顶被先弹出访问。这是初学者最容易搞错的地方。实操心得在面试白板 coding 时如果被要求写非递归遍历我建议先画出栈的变化图。从根节点开始每一步都画出栈内的节点序列并标出下一步要访问谁。这样不仅能保证写对还能向面试官展示清晰的思路。对于固定数组栈一定要判断栈满top 99的情况上述示例省略了但在健壮的程序中必须处理。5. 深度优先遍历二中序遍历的递归与非递归实现中序遍历的访问顺序是左子树 - 根节点 - 右子树。对于一颗二叉搜索树BST中序遍历会得到一个升序排列的序列这是它最重要的性质常用于BST的排序输出或范围查询。5.1 递归实现递归版本的调整仅仅在于“访问节点”代码的位置被移到了两次递归调用之间。void inorderRecursive(TreeNode* root) { if (root NULL) return; // 1. 递归遍历左子树 inorderRecursive(root-left); // 2. 访问根节点 printf(%d , root-data); // 3. 递归遍历右子树 inorderRecursive(root-right); }对示例树注意这不是BST执行中序遍历输出为4 2 5 1 3 6。你可以看到输出结果并不是有序的因为这棵树不是二叉搜索树。5.2 非递归实现最需要技巧的一种中序遍历的非递归实现是四种遍历中最需要技巧的因为它访问节点的时机不是在入栈或出栈时而是在“左子树全部处理完毕”之后。核心算法需要用一个指针curr来追踪当前节点并用栈来存储“尚未访问根节点的路径”。算法步骤初始化curr指向根节点栈为空。循环直到curr为NULL且栈为空 a.一路向左如果curr不为空将其压栈然后curr指向其左孩子。重复此步骤直到curr为空到达最左侧。 b.访问节点curr为空时从栈顶弹出一个节点这就是当前应该访问的节点因为它已经没有左孩子或者左孩子已被访问。访问该节点。 c.转向右子树将curr指向刚刚访问过的节点的右孩子然后重复整个过程。void inorderIterative(TreeNode* root) { TreeNode* stack[100]; int top -1; TreeNode* curr root; while (curr ! NULL || top 0) { // 步骤a: 一路向左将路径上的节点全部压栈 while (curr ! NULL) { stack[top] curr; curr curr-left; } // 步骤b: 当前节点为空弹出栈顶并访问 curr stack[top--]; printf(%d , curr-data); // 步骤c: 转向右子树 curr curr-right; } }这个算法巧妙地模拟了递归调用的过程。内层的while循环对应着递归深入左子树pop和访问对应着递归函数返回并执行printf然后将curr指向右孩子则开启了下一轮对右子树的递归或迭代过程。避坑技巧很多人在写这个算法时循环条件容易写错。记住循环继续的条件是“还有未处理的节点”这包含两种情况curr不为空还有新的子树要探索或者栈不为空还有回溯路径上的节点待访问。所以条件是while (curr ! NULL || top 0)用“或”(||)连接。6. 深度优先遍历三后序遍历的递归与非递归实现后序遍历的访问顺序是左子树 - 右子树 - 根节点。它常用于“先处理子问题再处理本问题”的场景比如计算树的高度需要先知道子树高度、释放树的内存必须先释放子树才能安全释放根等。6.1 递归实现同样只需调整访问语句的位置到最后。void postorderRecursive(TreeNode* root) { if (root NULL) return; postorderRecursive(root-left); postorderRecursive(root-right); printf(%d , root-data); }示例树的后续遍历输出为4 5 2 6 3 1。根节点1是最后一个被访问的。6.2 非递归实现双栈法与标记法后序遍历的非递归实现是公认最难的因为一个节点需要在它的左右子树都被访问后才能被访问。这里介绍两种主流方法双栈法和标记法。方法一双栈法相对容易理解思路前序遍历的顺序是“根-左-右”。如果我们能实现一种“根-右-左”的遍历并将其结果逆序就得到了“左-右-根”也就是后序遍历。我们可以用一个栈stack1来辅助进行“根-右-左”的遍历用另一个栈stack2来存储结果以实现逆序。void postorderIterativeTwoStacks(TreeNode* root) { if (root NULL) return; TreeNode* stack1[100]; int top1 -1; TreeNode* stack2[100]; int top2 -1; // 用于逆序的栈 // 根节点入栈1 stack1[top1] root; while (top1 0) { // 1. 从栈1弹出节点 TreeNode* node stack1[top1--]; // 2. 将该节点压入栈2结果栈 stack2[top2] node; // 3. 将左、右孩子按顺序压入栈1 // 注意为了实现“根-右-左”左孩子要先入栈后处理右孩子后入栈先处理 if (node-left ! NULL) { stack1[top1] node-left; } if (node-right ! NULL) { stack1[top1] node-right; } } // 此时栈2中存储的顺序是“根-右-左”的逆序即“左-右-根” // 依次弹出栈2中的节点并访问 while (top2 0) { TreeNode* node stack2[top2--]; printf(%d , node-data); } }方法二标记法单栈更高效思路在栈中不仅存储节点指针还存储一个标记记录该节点是第一次入栈表示其子树还未被遍历还是第二次入栈表示其子树已被遍历可以访问了。这是模拟递归函数调用和返回的状态。// 定义一种方式将节点和标记一起存储。这里用一个结构体包装。 typedef struct StackNode { TreeNode* node; int visited; // 0表示未访问子树1表示已访问子树可访问自身 } StackNode; void postorderIterativeOneStack(TreeNode* root) { if (root NULL) return; StackNode stack[100]; int top -1; TreeNode* curr root; StackNode sn; do { // 一路向左将途径节点以“未访问”状态压栈 while (curr ! NULL) { sn.node curr; sn.visited 0; // 首次入栈标记为未访问 stack[top] sn; curr curr-left; } // 查看栈顶 sn stack[top]; // 如果栈顶节点的右子树为空或者右子树已被访问即上次弹出的是其右孩子 if (sn.node-right NULL || sn.visited 1) { // 可以访问该节点了 printf(%d , sn.node-data); top--; // 弹出 } else { // 右子树存在且未被访问则标记栈顶节点为“已访问”然后转向右子树 stack[top].visited 1; curr sn.node-right; } } while (top 0); // 栈不为空或curr不为空这里用do-while处理初始情况 }标记法更贴近递归的本质且只使用一个栈空间利用更优。但逻辑上比双栈法绕一些需要仔细理解“visited”状态变化的时机。实操心得在项目开发中如果追求代码可读性我推荐双栈法逻辑清晰不易错。如果在内存受限的环境或者追求极致的栈空间优化可以尝试标记法。对于面试最好能掌握并解释清楚其中一种非递归实现这足以证明你对遍历过程的理解深度。7. 广度优先遍历层序遍历的队列实现层序遍历没有递归的天然表达它必须使用队列。算法非常直观将根节点入队。循环直到队列为空 a. 出队一个节点并访问它。 b. 将该节点的左孩子如果存在入队。 c. 将该节点的右孩子如果存在入队。// 假设我们有一个简单的循环队列实现 void levelOrderTraversal(TreeNode* root) { if (root NULL) return; TreeNode* queue[100]; // 使用数组模拟队列 int front 0, rear 0; // 根节点入队 queue[rear] root; rear (rear 1) % 100; // 循环队列计算新队尾 while (front ! rear) { // 队列不为空 // 出队 TreeNode* node queue[front]; front (front 1) % 100; printf(%d , node-data); // 左孩子入队 if (node-left ! NULL) { queue[rear] node-left; rear (rear 1) % 100; } // 右孩子入队 if (node-right ! NULL) { queue[rear] node-right; rear (rear 1) % 100; } } }对于示例树层序遍历输出为1 2 3 4 5 6。可以看到它严格按层级从上到下、从左到右输出。层序遍历的一个经典变体是按层打印即每一层的节点输出在同一行。这需要在每一层开始前记录当前队列的长度即该层的节点数然后处理完这些数量的节点后换行。这常用于需要感知树层级信息的场景比如求树的最大宽度、在图形化界面中绘制树等。8. 四种遍历的对比与应用场景总结为了更清晰地对比我将四种遍历的核心特性整理成下表遍历方式访问顺序 (D:根, L:左子树, R:右子树)核心数据结构 (非递归)时间复杂度空间复杂度 (最坏)典型应用场景前序遍历D - L - R栈 (Stack)O(n)O(h)复制二叉树、序列化、前缀表达式中序遍历L - D - R栈 (Stack)O(n)O(h)二叉搜索树得到有序序列、中缀表达式后序遍历L - R - D栈 (Stack)O(n)O(h)释放二叉树内存、计算子树属性、后缀表达式层序遍历按层级从上到下、从左到右队列 (Queue)O(n)O(w)求树的高度/宽度、按层处理、最短路径(未加权)名词解释n: 树中节点的总数。h: 树的高度。对于深度优先遍历递归或显式栈空间复杂度取决于递归深度或栈的最大深度即树高。平衡树为O(log n)退化成链表的树为O(n)。w: 树的最大宽度某一层最多的节点数。层序遍历的空间复杂度取决于队列的最大长度即树的最大宽度。应用场景深入前序遍历在你想“先处理当前节点再处理其子节点”时使用。例如你要复制一棵树你必须先创建新树的根节点对应访问然后才能递归复制左右子树。文件系统的目录树展开先显示文件夹名再显示其内容也常采用类似前序的思想。中序遍历这是二叉搜索树的“灵魂”。对BST进行中序遍历得到的是有序数据这是BST能进行高效搜索、范围查询的基础。此外在语法树中中序遍历能产生原始的中缀表达式虽然需要加括号。后序遍历适用于“子问题优先”的场景。比如要计算一个目录及其子目录的总大小你必须先知道所有子目录的大小才能加起来得到当前目录的大小。释放内存也是同理必须先释放子节点内存才能安全释放父节点指针。层序遍历任何需要“按层次”或“按距离”处理的场景。比如在社交网络中寻找关系最短路径朋友的朋友在渲染UI组件树时按层级更新布局在网络爬虫中控制抓取的深度。9. 常见问题与排查技巧实录在实际编码和面试中关于二叉树遍历的坑点不少。下面是我总结的几个高频问题和解决思路。9.1 递归遍历导致栈溢出问题描述当二叉树极度不平衡退化成一条链表例如每个节点都只有右孩子且节点数量很大比如10万时递归深度会达到10万层很可能超过系统默认的线程栈大小通常1-8MB导致程序崩溃栈溢出Segmentation Fault。排查与解决预防在实现递归函数时心里要对数据规模有个预估。如果树可能很高优先考虑使用非递归迭代版本。诊断如果程序在处理大数据时崩溃且崩溃点在递归函数内部首先怀疑栈溢出。可以用工具如ulimit -s查看栈大小或用调试器观察调用栈深度来确认。解决将递归算法改为迭代算法。本章介绍的所有非递归版本都是解决方案。它们的空间复杂度虽然最坏也是O(n)但使用的是堆内存通过malloc或容器动态分配空间上限远大于线程栈。9.2 非递归实现中指针操作或栈操作错误问题描述在写非递归中序/后序遍历时curr指针的更新逻辑或栈的压入弹出顺序错误导致死循环、漏节点或访问顺序错误。排查技巧画图这是最有效的调试方法。准备一个小型二叉树3-5个节点在纸上一步步模拟你的算法。画出每一步栈的内容、curr指针的指向以及已输出的序列。打印调试在循环的关键步骤插入printf打印curr节点的值或地址、栈内元素等与纸上模拟的结果对比。边界条件测试用以下特殊树形测试你的代码空树root NULL。只有根节点的树。只有左子树的链状树1-2-3。只有右子树的链状树1-2-3。完全二叉树。 观察输出是否符合预期。9.3 内存泄漏问题描述在创建二叉树使用malloc后遍历完毕没有释放内存。对于长期运行的程序这会逐渐耗尽系统内存。解决方案谁创建谁释放养成良好习惯。在程序结束或二叉树不再需要时必须遍历所有节点并free。释放顺序必须使用后序遍历来释放二叉树。因为只有当一个节点的左右子树都被释放后才能安全地free该节点本身。void freeTree(TreeNode* root) { if (root NULL) return; freeTree(root-left); // 先释放左子树 freeTree(root-right); // 再释放右子树 free(root); // 最后释放根节点 }9.4 遍历结果的应用与验证问题场景给你一个遍历序列比如前序和中序要求你重建二叉树。这是经典的笔试面试题。核心思路利用不同遍历的性质。前序中序前序序列的第一个元素是根节点。在中序序列中找到这个根节点其左边是左子树的中序序列右边是右子树的中序序列。根据左右子树的长度可以在前序序列中划分出左右子树的前序序列。递归此过程。后序中序后序序列的最后一个元素是根节点。后续步骤类似。前序后序无法唯一确定一棵二叉树除非是真二叉树每个节点有0或2个孩子。因为仅凭根和子树的范围无法区分左右子树。验证自己写的遍历代码是否正确一个简单的方法就是构建一棵小树手动推导出遍历序列然后与程序输出对比。对于复杂算法如非递归后序用多个测试用例验证是保证代码正确的唯一途径。掌握这四种遍历不仅仅是记住了几种算法更是掌握了处理树形数据的基本范式。当你遇到更复杂的树结构如AVL树、红黑树、B树或者树形DP问题时你会发现所有的操作都离不开对这几种遍历路径的深刻理解。从递归到迭代的思维转换也为你理解更复杂的算法如图的DFS/BFS打下了坚实的基础。多写、多画、多思考把这些遍历方式变成你的肌肉记忆在需要时它们自然会成为你解决问题的利器。