C语言实现二叉树层序遍历:从队列构建到BFS应用实战
发布时间:2026/8/25 7:30:44 作者:尧图编辑部 阅读量:1,286

1. 项目概述为什么层序遍历是理解二叉树的关键一步在C语言的数据结构学习中二叉树是一个绕不开的核心。很多朋友在掌握了前序、中序、后序遍历后会觉得二叉树不过如此。但当你真正去解决一些实际问题比如“打印二叉树的结构”、“找到二叉树的最大宽度”或者“在二叉树中按层查找某个节点”时你会发现之前学的递归遍历方式在这里有点使不上劲。这时层序遍历Level Order Traversal的价值就凸显出来了。层序遍历顾名思义就是按树的层级从上到下、从左到右一层一层地访问节点。这听起来很简单但它恰恰模拟了我们肉眼观察一棵树最自然的方式。更重要的是它是广度优先搜索BFS在二叉树上的直接应用。掌握层序遍历不仅是为了多学一种遍历方法更是为了打通你从“理解结构”到“解决实际问题”的任督二脉。无论是准备面试中的算法题还是未来在开发中处理任何具有层级关系的数据比如文件目录、组织架构、游戏中的场景树层序遍历的思维都是必备的。今天我们就用最纯粹的C语言抛开任何花哨的库从零开始彻底搞懂二叉树的层序遍历。我会带你手写一个队列一步步拆解算法流程并分享几个我实际编码中踩过的坑和调试技巧。无论你是正在啃《数据结构》课本的学生还是想巩固基础的开发者这篇内容都能让你获得可以直接“抄作业”的实战代码和清晰思路。2. 核心思路拆解用队列实现“先进先出”的访问逻辑层序遍历的核心难点在于当你访问完第N层的某个节点后你需要立刻去访问它同层的兄弟节点而不是像深度优先那样一头扎进它的子树里。等第N层全部访问完毕才能访问第N1层。这种“一层一层来”的访问顺序和我们熟悉的“递归栈”那种“一条路走到黑”的后进先出LIFO模式是相反的。那么什么数据结构能完美支持“先进先出”FIFO的访问顺序呢答案就是队列Queue。2.1 为什么必须是队列我们可以把层序遍历的过程想象成一场“核酸检测”。根节点是第一个来排队的。我们处理它访问它然后让它把自己的两个孩子左孩子和右孩子叫来排队。此时根节点的工作就完成了可以离开了。接下来队伍的最前面是根节点的左孩子我们处理它并让它的孩子如果存在也来队伍末尾排队。紧接着我们处理队伍中根节点的右孩子……如此循环。这个过程的关键在于访问顺序先来的节点先被访问FIFO这保证了“层”的顺序。暂存机制在访问一个节点时它的孩子需要被暂时保存起来等待后续访问。队列的队尾就是天然的“等候区”。如果我们错误地使用了栈就会变成先处理最后加入的孩子顺序就全乱了。所以队列是层序遍历算法不可替代的基石。2.2 算法流程的标准化描述理解了队列的核心作用后我们可以将层序遍历的算法流程标准化初始化创建一个空队列。将二叉树的根节点指针如果根节点存在入队。循环处理当队列不为空时重复以下步骤 a.出队从队头取出一个节点并访问它例如打印其数据。 b.扩展如果该节点有左孩子则将左孩子指针入队。 c.扩展如果该节点有右孩子则将右孩子指针入队。结束当队列为空时说明所有节点都已按层访问完毕。这个流程清晰、通用是必须刻在脑子里的模板。注意在C语言中我们“访问”一个节点通常意味着读取或操作其数据域。在层序遍历的经典实现中访问动作如printf发生在节点出队之后、其孩子入队之前。这个顺序不能乱它保证了我们是在处理“当前层”的节点。3. 手搓一个C语言队列从结构定义到基本操作C标准库没有提供现成的队列数据结构所以我们需要自己实现一个。这是理解底层原理的绝佳机会。我们将实现一个链式队列因为它更灵活无需担心数组队列的容量问题。3.1 队列结点的结构定义队列里的每个元素需要存储一个指向二叉树节点的指针。同时为了形成链式结构还需要一个指向下一个队列结点的指针。// 首先定义二叉树节点这是前提 typedef struct TreeNode { int data; // 假设节点存储整型数据 struct TreeNode* left; struct TreeNode* right; } TreeNode; // 然后定义队列节点 typedef struct QueueNode { TreeNode* treeNode; // 队列元素一个二叉树节点指针 struct QueueNode* next; // 指向下一个队列节点 } QueueNode;3.2 队列本体的结构定义为了高效地进行入队在尾和出队在头操作我们需要同时记录队列的头和尾。typedef struct Queue { QueueNode* front; // 队头指针 QueueNode* rear; // 队尾指针 } Queue;3.3 队列的四大基本操作接下来我们实现队列的初始化、入队、出队和判空操作。这些函数将是我们层序遍历算法的“轮子”。// 1. 初始化一个空队列 Queue* createQueue() { Queue* q (Queue*)malloc(sizeof(Queue)); if (!q) { printf(内存分配失败\n); exit(EXIT_FAILURE); } q-front q-rear NULL; // 初始时队头和队尾都为空 return q; } // 2. 入队将二叉树节点指针加入队尾 void enqueue(Queue* q, TreeNode* treeNode) { QueueNode* newNode (QueueNode*)malloc(sizeof(QueueNode)); if (!newNode) { printf(内存分配失败\n); exit(EXIT_FAILURE); } newNode-treeNode treeNode; newNode-next NULL; if (q-rear NULL) { // 如果队列为空 q-front q-rear newNode; } else { // 队列不为空追加到队尾 q-rear-next newNode; q-rear newNode; // 更新队尾指针 } } // 3. 出队从队头移除一个节点并返回其存储的二叉树节点指针 TreeNode* dequeue(Queue* q) { if (isEmpty(q)) { printf(错误队列为空无法出队\n); return NULL; } QueueNode* tempNode q-front; // 临时保存队头节点 TreeNode* treeNode tempNode-treeNode; // 取出二叉树节点指针 q-front q-front-next; // 队头指针后移 if (q-front NULL) { // 如果出队后队列变空 q-rear NULL; // 队尾指针也要置空 } free(tempNode); // 释放原队头节点的内存 return treeNode; } // 4. 检查队列是否为空 int isEmpty(Queue* q) { return q-front NULL; // 队头为空即队列为空 }实操心得内存管理是C语言的必修课。在dequeue函数中我们free了队列节点但请注意我们free的是QueueNode而不是TreeNode。二叉树节点本身的生命周期应由创建它的逻辑比如main函数来管理。队列只负责暂时持有它们的指针。忘记释放队列节点会导致内存泄漏错误释放二叉树节点则可能导致程序崩溃。务必理清这个所有权关系。4. 层序遍历的核心实现与逐行解析有了队列这个强大的工具实现层序遍历就水到渠成了。下面给出完整的函数实现并附上详细的逐行解析。#include stdio.h #include stdlib.h // ... (此处插入前面定义的TreeNode, QueueNode, Queue以及队列操作函数) ... /** * 二叉树的层序遍历 * param root 二叉树的根节点指针 */ void levelOrderTraversal(TreeNode* root) { // 1. 边界条件检查如果树是空的直接返回 if (root NULL) { printf(树为空\n); return; } // 2. 创建并初始化队列 Queue* queue createQueue(); // 3. 将根节点入队开始遍历 enqueue(queue, root); // 4. 核心循环当队列中还有节点待处理时继续 while (!isEmpty(queue)) { // 4.1 出队获得当前层的一个节点 TreeNode* currentNode dequeue(queue); // 4.2 访问这里是打印节点数据你可以替换成任何操作 printf(%d , currentNode-data); // 4.3 扩展将当前节点的左孩子如果存在入队 if (currentNode-left ! NULL) { enqueue(queue, currentNode-left); } // 4.4 扩展将当前节点的右孩子如果存在入队 if (currentNode-right ! NULL) { enqueue(queue, currentNode-right); } // 注意先左后右的顺序保证了同一层内从左到右的访问顺序 } // 5. 遍历结束释放队列结构本身占用的内存 // 注意循环内的dequeue已经释放了所有QueueNode这里只需释放Queue结构体 free(queue); printf(\n); // 最后换行让输出更美观 }逐行解析与关键点第1步边界检查这是健壮性编程的好习惯。处理空树时函数应有明确的反应这里选择打印提示并返回而不是崩溃。第2、3步初始化与启动createQueue和enqueue是我们的基础轮子。算法从根节点开始“播种”。第4步核心while循环这是算法的引擎。isEmpty(queue)是循环条件。只要队列不空就意味着还有节点及其后代等待访问。currentNode dequeue(queue)这是获取当前待处理节点的唯一方式。dequeue操作同时完成了“取出”和“从等待队列中删除”两件事。printf代表“访问”操作。在实际应用中这里可能是将节点值存入数组、进行某种计算或判断等。两个if判断这是广度优先扩展的关键。我们只将直接子节点入队而不是所有后代。下一层的节点会自然地排在当前层所有节点的后面因为队列是FIFO的。第5步清理free(queue)释放了Queue结构体占用的内存。这是一个良好的习惯防止内存泄漏。虽然对于这个简单函数程序结束也会回收但在大型或长期运行的程序中主动管理内存至关重要。为了测试我们的函数我们需要一个辅助函数来创建一棵简单的二叉树。// 快速创建一个示例二叉树 // 1 // / \ // 2 3 // / \ \ // 4 5 6 TreeNode* createSampleTree() { // 动态申请内存创建节点 TreeNode* node1 (TreeNode*)malloc(sizeof(TreeNode)); TreeNode* node2 (TreeNode*)malloc(sizeof(TreeNode)); TreeNode* node3 (TreeNode*)malloc(sizeof(TreeNode)); TreeNode* node4 (TreeNode*)malloc(sizeof(TreeNode)); TreeNode* node5 (TreeNode*)malloc(sizeof(TreeNode)); TreeNode* node6 (TreeNode*)malloc(sizeof(TreeNode)); // 赋值 node1-data 1; node1-left node2; node1-right node3; node2-data 2; node2-left node4; node2-right node5; node3-data 3; node3-left NULL; node3-right node6; node4-data 4; node4-left NULL; node4-right NULL; node5-data 5; node5-left NULL; node5-right NULL; node6-data 6; node6-left NULL; node6-right NULL; return node1; // 返回根节点 } int main() { TreeNode* root createSampleTree(); printf(二叉树的层序遍历结果); levelOrderTraversal(root); // 预期输出1 2 3 4 5 6 // ... (后续需要编写释放二叉树内存的代码此处省略) ... return 0; }运行上述代码输出应为二叉树的层序遍历结果1 2 3 4 5 6。可以看到节点严格按照从上到下、从左到右的顺序被访问。5. 层序遍历的威力解决LeetCode经典题目只会打印顺序不算真本事。层序遍历的真正价值在于解决实际问题。我们来看两道经典的LeetCode题目它们都是层序遍历的“变体”或直接应用。5.1 题目一二叉树的层平均值LeetCode 637题目要求给定一个非空二叉树返回一个数组其中每个元素是每一层节点的平均值。思路分析 我们不能像基础版那样一股脑地遍历因为我们需要知道哪些节点属于同一层。关键在于在每一轮循环开始时队列queue里的所有节点恰好就是当前层的所有节点。算法升级在每一层开始前先记录当前队列的长度levelSize。然后执行levelSize次出队操作这levelSize个节点就是当前层的全部节点。在处理这levelSize个节点时累加它们的值并将它们的孩子入队。该层处理完后计算平均值存入结果列表。然后回到步骤1处理下一层。C语言实现核心逻辑double* averageOfLevels(TreeNode* root, int* returnSize) { if (root NULL) { *returnSize 0; return NULL; } Queue* q createQueue(); enqueue(q, root); // 动态分配结果数组假设树高不超过1000层可根据需要调整 double* averages (double*)malloc(1000 * sizeof(double)); int levelCount 0; while (!isEmpty(q)) { int levelSize 0; // 如何获取当前队列长度我们需要修改队列结构或遍历计算。 // 简单方法使用一个临时队列或者在内循环中计数。 // 这里采用内循环前先记录当前队列长度的方法需要额外函数getQueueSize // 为了简化我们使用另一种常见写法每次循环处理一层。 long levelSum 0; // 关键获取当前层的节点数 // 由于我们的队列是链表实现需要遍历计数这里为了清晰假设有getQueueSize函数 // 实际更优的实现是在while循环开始先记录下当前队列长度。 // 我们重构一下循环 int currentLevelSize getQueueSize(q); // 假设这个函数存在 for (int i 0; i currentLevelSize; i) { TreeNode* node dequeue(q); levelSum node-data; if (node-left) enqueue(q, node-left); if (node-right) enqueue(q, node-right); } averages[levelCount] (double)levelSum / currentLevelSize; } *returnSize levelCount; free(q); // 注意这里只释放了Queue结构体dequeue中已释放节点 // 可以realloc averages到精确大小这里省略 return averages; }注意事项上面的代码示意了核心逻辑。在实际编写getQueueSize函数时需要遍历队列链表进行计数这会导致O(n)的时间复杂度。更工程化的做法是在Queue结构体中维护一个int size变量在enqueue和dequeue时同步更新它这样就能以O(1)的代价获得队列大小。这是数据结构设计中的一个重要优化点。5.2 题目二在每个树行中找最大值LeetCode 515题目要求给定一棵二叉树的根节点找出该二叉树中每一层的最大值。思路分析 这道题和求层平均值异曲同工。我们依然需要在每一层内进行遍历和比较。算法框架完全一样只是把计算平均值换成维护最大值。C语言实现核心逻辑int* largestValues(TreeNode* root, int* returnSize) { // ... (边界检查、队列创建等与上题类似) ... int* result (int*)malloc(1000 * sizeof(int)); int levelCount 0; enqueue(q, root); while (!isEmpty(q)) { int levelSize getQueueSize(q); // 同样假设此函数能O(1)获得大小 int levelMax INT_MIN; // 初始化为整型最小值 for (int i 0; i levelSize; i) { TreeNode* node dequeue(q); // 更新当前层最大值 if (node-data levelMax) { levelMax node-data; } // 孩子入队 if (node-left) enqueue(q, node-left); if (node-right) enqueue(q, node-right); } result[levelCount] levelMax; } *returnSize levelCount; free(q); return result; }通过这两道题你应该能深刻体会到基础的层序遍历模板稍加改动就能解决一大类“按层处理”的问题。核心技巧就是在每一轮外层循环开始时锁定当前队列的长度这个长度就是当前层的节点数。6. 常见问题、调试技巧与性能考量即使理解了算法亲手实现时还是会遇到各种问题。下面是我在学习和教学过程中总结的一些常见坑点和实战技巧。6.1 内存访问违规与空指针这是C语言编程中最常见也最危险的错误。问题场景在dequeue函数中如果队列为空q-front就是NULL。此时执行TreeNode* treeNode q-front-treeNode;就会导致程序崩溃。解决方案在dequeue中必须首先用isEmpty(q)判断队列是否为空。我们的实现已经做了这个检查。延伸检查在层序遍历的主循环中currentNode不可能是NULL因为从队列中取出。但在将currentNode-left或currentNode-right入队前必须判断它们是否为NULL。我们的代码也做了这个判断。调试技巧在VS Code或任何IDE中调试时遇到崩溃先看调用栈。如果崩溃点在访问某个指针的成员第一时间检查这个指针是否为NULL。可以在所有可疑的指针解引用前加上assert(pointer ! NULL)来快速定位问题。6.2 队列操作逻辑错误导致死循环或漏节点入队/出队顺序错误务必牢记孩子节点是在父节点出队访问后才入队的。如果顺序颠倒逻辑会混乱。队尾指针更新遗漏在enqueue函数中当队列从空变为非空或者添加新节点时必须正确更新rear指针。我们的实现中if (q-rear NULL)这个分支就是处理队列初始为空的情况这是很容易遗漏的细节。出队后队首指针更新错误在dequeue中q-front q-front-next;之后如果q-front变成了NULL意味着队列已空此时必须将q-rear也置为NULL。否则rear还指向一个已经被free掉的节点后续操作会导致未定义行为。6.3 层序遍历的时空复杂度分析理解算法的效率是进阶的关键。时间复杂度O(N)。其中N是二叉树中的节点总数。因为每个节点恰好会被访问一次出队一次并且每个节点也只会被入队一次。空间复杂度O(W)。其中W是二叉树的最大宽度即节点数最多的那一层的节点数。在最坏情况下完全二叉树最后一层的节点数约为N/2因此空间复杂度也可以说是O(N)。但通常我们用O(W)来描述因为它更准确地反映了算法对额外空间的需求量——队列在任何时刻存储的最大节点数就是某一层的所有节点。6.4 如何直观地“看到”遍历过程对于初学者在脑子里模拟队列和树的变化有点困难。我强烈推荐使用纸笔或白板来手动模拟。画出一棵简单的二叉树。画一个队列的示意图一个长方形左头右尾。严格按照算法步骤一步步地把根节点指针写入队列。从队列头部取出指针访问该节点在节点上打个勾。把这个节点的左右孩子指针如果存在按顺序加到队列尾部。重复直到队列为空。这个过程能让你对FIFO和层级扩展有肌肉记忆般的理解。7. 从层序遍历到更广阔的数据结构与算法世界掌握了二叉树的层序遍历你收获的不仅仅是一个算法。你获得了一个强大的思维模型——广度优先搜索BFS。二叉树只是一种特殊的图每个节点最多有两个子节点的有向无环图。层序遍历就是BFS在二叉树上的特例。当你未来遇到以下场景时你会想起今天学到的队列和按层扩展的思想图的广度优先遍历访问完一个顶点后将其所有未访问的邻接顶点入队。这和二叉树如出一辙只是“孩子”变成了“邻居”。最短路径问题无权图BFS是解决无权图上单源最短路径问题的天然算法因为它总是先访问距离源点更近的顶点。二叉树上根节点到任意节点的路径长度边数其实就是该节点所在的层数减一。序列化与反序列化二叉树层序遍历的顺序非常适合用来序列化一棵二叉树因为它能完整保留结构信息。查找二叉树的最小深度使用层序遍历当第一次遇到一个叶子节点左右孩子都为空的节点时当前的层数就是最小深度。这比深度优先搜索DFS在某些情况下更高效。最后再分享一个我调试复杂二叉树问题时的私藏技巧给节点编号。在创建示例树或者打印调试信息时我常常会给每个节点一个唯一的编号甚至可以用它的内存地址后几位然后在层序遍历打印时同时打印出它的编号和父节点编号。这样树的结构、遍历顺序一目了然对于排查指针错乱等问题有奇效。学习数据结构切忌死记硬背代码。理解“队列”为何是核心理解“出队访问”和“入队孩子”这两个动作如何默契配合从而铺满整棵树你才算真正抓住了层序遍历的灵魂。希望这篇长文能帮你把这个灵魂牢牢握在手中。