尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C语言实现二叉树层序遍历:从队列构建到BFS思想解析

C语言实现二叉树层序遍历:从队列构建到BFS思想解析 1. 从“树”到“队列”理解层序遍历的思维转换如果你刚学完二叉树的前序、中序、后序遍历可能会觉得递归是处理树的唯一“正统”方法。但当你第一次接触“层序遍历”时那种感觉就像是从一条蜿蜒的林间小道突然走到了一个需要按楼层、按房间号逐一检查的摩天大楼面前。递归的“深度优先”思维在这里不灵了你需要一种全新的工具队列。层序遍历顾名思义就是按树的层级从上到下从左到右一层一层地访问每个节点。想象一下公司开大会从CEO开始发言然后是各个部门的VP接着是经理最后是基层员工这就是一个典型的层序过程。在C语言里实现它核心不在于复杂的递归调用而在于对另一种数据结构——“队列”的熟练运用。这不仅是二叉树的一个基础算法更是你理解“广度优先搜索”思想的敲门砖。无论是后续的求二叉树宽度、找最短路径还是更复杂的图算法层序遍历都是你必须扎实掌握的基本功。本文将彻底拆解在纯C语言环境下如何从零构建一个队列并用它来实现二叉树的层序遍历。我会假设你已经有链表的基础但即使没有跟着步骤也能理解。我们将不止步于“写出代码”更要深挖“为什么用队列”、“队列如何与树节点互动”以及“在内存有限的嵌入式场景中如何变通”。你会发现这个看似简单的算法是串联起指针、结构体、动态内存管理和基础数据结构思想的绝佳练习。2. 构建基石用C语言手搓一个链式队列在开始遍历树之前我们必须先打造工具。C标准库没有现成的队列所以我们需要自己实现一个。对于层序遍历链式队列是最直观、最常用的选择因为它不需要预先分配固定大小可以动态增长。2.1 定义队列的数据结构队列的核心操作是“先进先出”。我们需要两个指针一个指向队头用于出队一个指向队尾用于入队。队列中的每个元素将存储我们二叉树节点的指针。// 首先定义二叉树的节点结构这是前提 typedef struct TreeNode { int data; // 节点数据这里以整型为例 struct TreeNode* left; struct TreeNode* right; } TreeNode; // 接着定义队列的节点结构。注意队列节点和树节点是两个不同的结构。 typedef struct QueueNode { TreeNode* treeNode; // 队列节点里存放的是“指向树节点的指针” struct QueueNode* next; } QueueNode; // 最后定义队列本身的管理结构 typedef struct Queue { QueueNode* front; // 队头指针 QueueNode* rear; // 队尾指针 } Queue;这里有一个关键理解点QueueNode是一个包装器它的treeNode成员是一个TreeNode*类型。我们不会把树节点本身塞进队列而是塞进它的地址。这样做避免了数据的拷贝效率极高也是C语言操作复杂结构的常规做法。2.2 实现队列的四大基本操作一个可用的队列至少需要初始化、入队、出队、判空这四个操作。初始化队列Queue* createQueue() { Queue* q (Queue*)malloc(sizeof(Queue)); if (!q) { printf(Memory allocation failed for queue.\n); exit(EXIT_FAILURE); } q-front q-rear NULL; // 初始时队列为空 return q; }入队操作入队是在队尾添加一个新节点。需要特别注意处理队列原本为空的情况。void enqueue(Queue* q, TreeNode* treeNode) { QueueNode* newNode (QueueNode*)malloc(sizeof(QueueNode)); if (!newNode) { printf(Memory allocation failed for queue node.\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; } }注意这里传入的treeNode是已经存在的二叉树节点的地址。我们只是把这个地址存入了队列节点并没有创建新的树节点。出队操作出队是从队头移除一个节点并返回该节点存储的树节点指针。同样需要处理队列变为空的情况。TreeNode* dequeue(Queue* q) { if (isQueueEmpty(q)) { printf(Queue is empty, cannot dequeue.\n); return NULL; // 或者根据错误处理策略决定 } QueueNode* tempNode q-front; TreeNode* treeNode tempNode-treeNode; // 取出树节点指针 q-front q-front-next; // 如果出队后队列变空需要将rear也置为NULL if (q-front NULL) { q-rear NULL; } free(tempNode); // 释放队列节点本身的内存 return treeNode; // 返回树节点指针 }判断队列是否为空int isQueueEmpty(Queue* q) { return q-front NULL; }2.3 内存管理一个容易被忽视的坑我们创建了两个层次的结构Queue管理结构、QueueNode节点、TreeNode树节点。它们的生命周期管理需要清晰Queue和QueueNode是在层序遍历过程中临时创建和销毁的辅助结构。TreeNode是二叉树本身的结构它的创建和销毁应由树的构建和销毁逻辑管理与队列无关。在dequeue操作中我们free的是QueueNode而不是TreeNode。出队只是意味着“这个树节点我已经访问过了”并不意味着要删除这个树节点。如果你在出队后错误地free(treeNode)会导致整个二叉树结构被破坏这是初学者常犯的严重错误。请牢记队列只“借用”树节点的指针绝不“拥有”或“处置”树节点本身。3. 算法核心层序遍历的步骤拆解与代码实现工具准备好了现在可以正式进入算法部分。层序遍历的步骤非常清晰是一个标准的“模板化”算法初始化创建一个空队列。启程如果根节点不为空将根节点入队。循环处理只要队列不为空就重复以下步骤 a.出队从队头取出一个节点记为current并访问它例如打印其值。 b.探索左子如果current的左孩子不为空将左孩子入队。 c.探索右子如果current的右孩子不为空将右孩子入队。结束当队列为空时说明所有节点都已按层访问完毕。这个过程保证了“先被访问的节点的孩子也会先被访问”完美符合队列的“先进先出”特性。3.1 完整的C语言实现代码结合我们手写的队列层序遍历函数如下void levelOrderTraversal(TreeNode* root) { if (root NULL) { printf(The tree is empty.\n); return; } Queue* q createQueue(); // 1. 创建队列 enqueue(q, root); // 2. 根节点入队 while (!isQueueEmpty(q)) { // 3. 主循环 TreeNode* current dequeue(q); // 3.a 出队并访问 printf(%d , current-data); // 访问操作这里以打印为例 // 3.b 左孩子入队 if (current-left ! NULL) { enqueue(q, current-left); } // 3.c 右孩子入队 if (current-right ! NULL) { enqueue(q, current-right); } } // 循环结束遍历完成 // 注意这里队列q及其内部节点内存需要释放为简洁起见未写出下文会讲 printf(\n); }3.2 通过一个例子可视化执行过程假设我们有如下二叉树1 / \ 2 3 / \ \ 4 5 6层序遍历的预期结果是1 2 3 4 5 6。让我们一步步拆解队列和当前节点的状态步骤队列内容 (front - rear)出队节点访问入队操作输出初始[ ]--根节点1入队-1[1]112, 3入队12[2, 3]224, 5入队1 23[3, 4, 5]336入队1 2 34[4, 5, 6]44(无)1 2 3 45[5, 6]55(无)1 2 3 4 56[6]66(无)1 2 3 4 5 6结束[ ]---遍历完成这个表格清晰地展示了队列如何像一个“待办事项列表”确保每一层的节点都在下一层的节点之前被处理。3.3 为什么必须用队列用栈行不行这是一个很好的思考题。栈的特点是“后进先出”。如果我们用栈把根节点压栈后弹出访问接着压入右孩子、再压入左孩子为了保证左先访问。那么下一次弹出的是左孩子访问后压入它的右孩子、左孩子……你会发现这实际上变成了深度优先遍历具体是前序遍历的一种变体你会沿着左分支一直走到黑无法实现“按层”访问的效果。队列的“先进先出”特性保证了“早来的节点早被处理早被处理的节点的孩子也早进入等待队列”从而天然地实现了广度优先的层序访问。这是数据结构与算法思想完美结合的一个典范。4. 进阶与变种区分每一层的输出基础的层序遍历把所有节点按顺序输出但有时我们需要知道哪些节点属于同一层。例如LeetCode上经典的“二叉树的层序遍历”题目就要求返回一个二维数组每个子数组代表一层。这需要对基本算法做一个小的升级。4.1 核心思路在每一层开始前记录当前队列的长度关键点在于在进入每一层的处理循环时队列中的节点恰好都是同一层的节点。我们可以在循环开始前先获取当前队列的长度levelSize然后只处理这levelSize个节点。在处理这levelSize个节点的过程中它们的子节点会被加入队列但这些子节点属于下一层会在下一次外层循环中被处理。4.2 C语言实现代码区分层级假设我们用一个动态数组的数组来存储结果这里为了聚焦算法我们用打印并换行来模拟分层。void levelOrderTraversalByLevel(TreeNode* root) { if (root NULL) return; Queue* q createQueue(); enqueue(q, root); while (!isQueueEmpty(q)) { int levelSize 0; // 注意我们自制的队列没有直接获取长度的函数。 // 一种方法是修改队列结构增加size字段。 // 另一种方法是使用“哨兵节点”或“嵌套循环计数”。 // 这里采用最直观的“计数法”在循环开始前队列中的节点数就是当前层的节点数。 // 但由于我们只有front指针无法直接获取数量。因此需要另一种策略。 // 更实用的策略内层循环处理当前层。 // 我们无法直接获知队列初始长度但可以处理完“当前队列中的所有节点”即当前层。 // 在开始处理当前层时队列里只有当前层的节点。 // 我们记录下开始处理前队列的“结束位置”吗不对于链表队列更好的方法是 // 1. 先获取当前队列的长度需要遍历效率低O(n)。 // 2. 使用嵌套循环和计数器。 // 实现方法在每一轮外层循环开始时先计算当前队列中的节点数。 // 由于会破坏队列我们需要一个临时队列来计数或者遍历队列。 // 这里展示一个简洁但非最优的示意逻辑实际项目应优化 QueueNode* iter q-front; int currentLevelSize 0; while (iter) { // 遍历队列计算当前长度 currentLevelSize; iter iter-next; } printf(Level nodes: ); for (int i 0; i currentLevelSize; i) { TreeNode* node dequeue(q); printf(%d , node-data); if (node-left) enqueue(q, node-left); if (node-right) enqueue(q, node-right); } printf(\n); // 换行表示一层结束 } // 释放队列内存... }上面的代码为了概念清晰在每一层都遍历了一次队列来计算长度这导致了O(n^2)的时间复杂度。在实际编码面试或项目中有更高效的方法高效方法使用两个队列或者使用一个队列加“哨兵节点”。更常见的做法是在每一层结束时向队列中插入一个特殊的NULL作为标记。当从队列中取出NULL时就知道一层结束了如果此时队列还不空就再插入一个NULL标记下一层的结束。void levelOrderTraversalByLevelMarker(TreeNode* root) { if (root NULL) return; Queue* q createQueue(); enqueue(q, root); enqueue(q, NULL); // 第一层结束标记 while (!isQueueEmpty(q)) { TreeNode* node dequeue(q); if (node NULL) { // 遇到层结束标记 printf(\n); // 换行表示一层输出完毕 if (!isQueueEmpty(q)) { enqueue(q, NULL); // 为下一层添加结束标记 } } else { printf(%d , node-data); // 访问节点 if (node-left) enqueue(q, node-left); if (node-right) enqueue(q, node-right); } } // 释放队列内存... }这种方法避免了每次计算队列长度时间复杂度是严格的O(n)是更优的实现。理解这两种方法的差异能帮助你更好地掌握层序遍历的精髓。5. 资源管理与边界条件处理写出能跑通的代码只是第一步写出健壮、安全的代码才是工程师的价值所在。对于我们的层序遍历实现有以下几个关键点需要注意。5.1 内存泄漏的预防我们的程序动态分配了内存给Queue和多个QueueNode。在遍历函数结束后这些内存必须被正确释放否则会造成内存泄漏。一个完整的、带有资源清理的遍历函数框架应该是这样的void levelOrderTraversalClean(TreeNode* root) { if (root NULL) return; Queue* q createQueue(); enqueue(q, root); while (!isQueueEmpty(q)) { TreeNode* current dequeue(q); printf(%d , current-data); if (current-left) enqueue(q, current-left); if (current-right) enqueue(q, current-right); } printf(\n); // 遍历结束销毁队列 // 注意dequeue操作已经释放了QueueNode但Queue结构本身和可能剩余的节点需要处理。 // 我们的dequeue在队列空时返回NULL但循环条件保证了队列最终为空。 // 然而更安全的做法是提供一个专门的队列销毁函数。 destroyQueue(q); } // 队列销毁函数 void destroyQueue(Queue* q) { if (q NULL) return; // 释放队列中可能残留的节点理论上循环结束后应为空但这里做安全检查 while (!isQueueEmpty(q)) { dequeue(q); // dequeue内部会free QueueNode } free(q); // 释放队列管理结构本身 }在简单的示例或学习环境中程序结束操作系统会回收所有内存。但在长期运行的服务或嵌入式系统中每一次malloc都必须有对应的free养成这个习惯至关重要。5.2 处理空树与单节点树空树这是最常见的边界条件。如果传入的root是NULL函数应立即返回或给出友好提示避免对空指针进行操作。单节点树算法也应该正确处理。根节点入队出队访问左右孩子为空不入队循环结束。这是检验算法逻辑完整性的好例子。5.3 在资源受限环境中的实现在嵌入式C语言开发中动态内存分配往往是受限或不被推荐的因为可能引起碎片化或分配失败。对于层序遍历我们有替代方案方案一使用静态数组模拟循环队列前提是你能预估树的最大节点数或者树的最大宽度。#define MAX_QUEUE_SIZE 100 void levelOrderTraversalStatic(TreeNode* root) { if (root NULL) return; TreeNode* queue[MAX_QUEUE_SIZE]; int front 0, rear 0; queue[rear] root; // 入队 while (front rear) { TreeNode* current queue[front]; // 出队 printf(%d , current-data); if (current-left) { if (rear MAX_QUEUE_SIZE) { // 队列满检查 printf(Queue overflow!\n); return; } queue[rear] current-left; } if (current-right) { if (rear MAX_QUEUE_SIZE) { // 队列满检查 printf(Queue overflow!\n); return; } queue[rear] current-right; } } printf(\n); }这种方法没有动态内存分配但限制了队列容量。你需要根据应用场景权衡。方案二使用两个静态数组交替存储当前层和下一层这是另一种常见的优化尤其适用于需要分层处理的场景且能避免队列操作。#define MAX_NODES_PER_LEVEL 50 void levelOrderTraversalTwoArray(TreeNode* root) { if (root NULL) return; TreeNode* currentLevel[MAX_NODES_PER_LEVEL]; TreeNode* nextLevel[MAX_NODES_PER_LEVEL]; int currentLevelSize 0, nextLevelSize 0; currentLevel[currentLevelSize] root; // 第一层只有根节点 while (currentLevelSize 0) { for (int i 0; i currentLevelSize; i) { TreeNode* node currentLevel[i]; printf(%d , node-data); // 将孩子节点存入下一层数组 if (node-left) { nextLevel[nextLevelSize] node-left; } if (node-right) { nextLevel[nextLevelSize] node-right; } } printf(\n); // 一层结束 // 交换当前层和下一层并重置下一层 TreeNode** temp currentLevel; currentLevel nextLevel; nextLevel temp; currentLevelSize nextLevelSize; nextLevelSize 0; } }这种方法完全避免了队列数据结构直接用数组管理在嵌入式环境中非常实用。它清晰地分离了层级代码意图也很明确。6. 从理论到实战调试技巧与常见问题理解了原理和代码不代表一次就能写对。在实际编码尤其是在VSCode等编辑器或命令行中编写C程序时你会遇到各种问题。6.1 使用VSCode进行调试假设你的项目结构如下your_project/ ├── tree.h (二叉树和队列结构声明) ├── tree.c (二叉树和队列函数实现) ├── main.c (主函数调用层序遍历) └── .vscode/ (内含tasks.json, launch.json等配置)在main.c中构建一个树并测试#include stdio.h #include stdlib.h #include tree.h // 一个快速创建示例树的辅助函数 TreeNode* createNode(int data) { TreeNode* node (TreeNode*)malloc(sizeof(TreeNode)); node-data data; node-left node-right NULL; return node; } int main() { // 构建树: // 1 // / \ // 2 3 // / \ \ // 4 5 6 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); printf(Level Order Traversal: ); levelOrderTraversal(root); printf(\nLevel Order Traversal (by level):\n); levelOrderTraversalByLevelMarker(root); // 释放树的内存需要实现destroyTree函数此处略 // destroyTree(root); return 0; }在VSCode中配置好C/C扩展和编译器如MinGW-w64的gcc后设置断点并启动调试。你可以观察Queue和QueueNode是如何被创建和链接的。在while循环中current指针如何变化队列内容如何动态更新。指针的值内存地址的变化这有助于理解“指针的指针”等概念。6.2 常见编译与运行时错误Segmentation fault (核心已转储)原因最常见的错误。通常是因为访问了NULL指针或已释放的内存。排查检查malloc的返回值是否为NULL。在访问current-left或current-right之前确保current不为NULL我们的代码在dequeue后直接访问是安全的因为dequeue只在队列非空时调用。确保入队的是有效的树节点指针。使用调试器查看崩溃时的调用栈和变量值。内存泄漏原因malloc了Queue和QueueNode但没有free。工具在Linux/macOS下可以使用valgrind工具检测。在Windows下可以使用VSCode的内存检测插件或编译器自带工具。预防如前所述实现并调用destroyQueue函数。无限循环原因队列的判空逻辑isQueueEmpty有误或者入队/出队逻辑错误导致队列永远不为空。调试在循环内打印队列状态如队头元素的值观察其变化。检查入队条件if (current-left ! NULL)是否正确。输出顺序错误现象输出结果不是严格的层序。检查入队顺序必须是先左孩子后右孩子。如果颠倒虽然仍是层序但同一层内节点的左右顺序会颠倒。确认队列的“先进先出”特性没有被破坏。6.3 一个关于指针的深度思考在enqueue(q, current-left)这行代码中我们传入的是current-left这个指针。如果current-left是NULL我们通过判断没有入队这没问题。但如果current-left指向了一个有效的树节点入队的就是这个节点的地址。这意味着队列中的QueueNode和树中的TreeNode通过指针形成了一个交叉引用的网络。理解这一点对于后续学习更复杂的图算法其中节点可能被多个指针引用非常有帮助。它强化了“C语言中操作的是地址”这一核心概念。7. 举一反三层序遍历的应用场景掌握了基础的层序遍历你就可以解决一系列衍生问题。这些问题在在线编程平台如PTA、LeetCode上非常常见。求二叉树的深度/高度在分层遍历时记录下“层结束标记”出现的次数即为树的深度。求二叉树的最大宽度在每一层遍历时记录该层的节点数取最大值。判断一棵树是否是完全二叉树使用层序遍历在遇到第一个空节点之后后续不应该再出现非空节点。在二叉树中查找值为x的节点层序遍历可以找到离根节点最近的满足条件的节点广度优先搜索的优势。二叉树的右视图/左视图记录每一层最后一个/第一个被访问的节点即可。Z字形层序遍历偶数层反转该层的输出顺序可以通过一个标志位和双端队列或两个栈来实现。解决这些问题的关键都是在标准层序遍历的框架上进行“微创新”。例如求最大宽度的代码骨架int maxWidthOfBinaryTree(TreeNode* root) { if (root NULL) return 0; Queue* q createQueue(); enqueue(q, root); int maxWidth 0; while (!isQueueEmpty(q)) { int levelWidth 0; // ... 使用“层结束标记”或“计数法”获取当前层节点数 levelWidth ... // 假设我们通过遍历队列得到了 currentLevelSize int currentLevelSize getCurrentQueueSize(q); // 需要实现此函数 maxWidth (currentLevelSize maxWidth) ? currentLevelSize : maxWidth; for (int i 0; i currentLevelSize; i) { TreeNode* node dequeue(q); if (node-left) enqueue(q, node-left); if (node-right) enqueue(q, node-right); } } destroyQueue(q); return maxWidth; }通过层序遍历这个切入点你实际上已经摸到了“广度优先搜索”的门槛。在图论中BFS算法用于寻找无权图的最短路径其核心思想与二叉树的层序遍历如出一辙使用队列管理待访问节点保证先发现的节点先被探索。回过头看从定义一个struct到实现入队出队再到组合成完整的遍历算法最后考虑边界和优化这个过程本身就是C语言编程能力的综合体现。它涉及了结构体、指针、动态内存、数据结构、算法逻辑和调试排错。我建议你不要满足于看懂这篇文章一定要打开你的VSCode亲手敲一遍代码构建不同的树进行测试甚至尝试故意制造一些错误比如忘记判空看看会发生什么。只有经过亲手实践和调试这些知识才会真正变成你的。当你下次遇到需要“按层处理”的问题时队列和那个while循环的模板会自然而然地浮现在你的脑海中。
返回列表