资讯动态

按输入顺序建立二叉树:C语言层序建树原理与实现

发布时间:2026/10/14 0:56:55 来源:尧图企业网站定制
先说一个我经常在同学代码里看到的现象很多人一看到按照输入顺序建立二叉树下意识就写成了一个递归版的先序遍历建树。结果输入ABC建出来的树却是根A左子树B左子树的左子树C——一条斜线。而题目的本意通常是让你按层序从上到下、从左到右接收输入也就是输入ABC时A是根B是左孩子C是右孩子一棵规范的两层二叉树。这个理解偏差几乎每周都会在讨论群里出现一次。这篇文章就围绕按输入顺序建立二叉树C语言实现这个经典问题展开。我会先剖析输入顺序到底指什么、底层依赖了二叉树的哪个性质再给出两种可落地的C语言实现方案配合完整的可运行代码、空节点占位约定、以及我在调试中踩过的几个高频坑。适合正在学数据结构、准备机考或期末复习的同学参考也适合想搞清楚为什么层序序列能唯一确定一棵二叉树的读者。1. 输入顺序的语义陷阱层序、先序与顺序存储的对应关系1.1 三种遍历顺序与建树顺序的本质区别二叉树有四种常见的访问顺序先序、中序、后序、层序。前三种本质上是深度优先的递归展开顺序而层序是广度优先的扫描顺序。如果一个题目只说按输入顺序建立二叉树没有任何额外说明那么输入顺序默认指层序遍历顺序因为只有这个顺序能与数组下标建立起一一对应的算术关系且不需要额外信息就能唯一确定一棵树。先序遍历优先把根和左子树链条上的所有节点读完它的输入顺序天然携带了空位信息一般用#表示空节点。例如ABD##E##C##就代表A为根、B为左孩子、D为B的左孩子、E为B的右孩子、C为A的右孩子。而层序输入则完全按照从上到下、从左到右的顺序铺开每个位置对应树的完整结构坐标。两者的核心差异在于先序输入描述的是递归路径层序输入描述的是物理位置。理解到这个层面你就明白为什么很多教材会把按顺序建立二叉树和顺序存储结构放在一起讲。顺序存储不借助指针直接用一个数组保存节点再通过下标公式恢复父子关系——它依赖的正是一棵完全二叉树或接近完全二叉树的形态。而层序输入天然具有这种按位就座的特征。1.2 层序输入与顺序存储结构的对应关系你见过那种用数组存的堆吗小顶堆、大顶堆、优先队列用的都是同一个规律某个节点在数组中的下标是i那么它的左孩子下标是2 * i 1右孩子下标是2 * i 2父节点下标是(i - 1) / 2。这套公式不是约定俗成的规定而是从层序遍历的数学递推中推导出来的。我拿一棵完整的满二叉树来验证这个规律。根节点是下标0它左孩子是下标1右孩子是下标2。再看下标1这个节点它的左孩子是下标3右孩子是下标4恰好是2 * 1 1 3和2 * 1 2 4而它的父亲(1 - 1) / 2 0。继续往下推下标3的左孩子是2 * 3 1 7对应满二叉树第三层最左端的那个节点。这套关系在完全二叉树中成立在非完全二叉树中如果遇到空位只需要把对应下标上的值标记为占位符就行。这就是按输入顺序建立二叉树最底层的数学依据。理解了这套下标映射你至少有两条路可以走一条是先把输入存进数组然后根据下标递归建树另一条是用队列辅助每读一个新节点就把它挂到当前节点的孩子位置同时把新节点入队等待后续挂接它自己的孩子。这两条路殊途同归但适用场景和编码复杂度不同下一节展开对比。2. 两条实现路线对比数组下标推导法与队列辅助插入法2.1 数组下标法为什么能直接建树数组下标法的优点是直观几乎不需要额外数据结构。假设输入序列已经全部读入一个字符数组arr有效长度为n那么从下标0开始递归如果当前下标i越界或者arr[i]是空占位符#则返回NULL。否则创建节点值为arr[i]左子树递归调用i 2 * i 1右子树递归调用i 2 * i 2。这种方法有一个隐含条件输入序列必须符合层序的位置语义。也就是说即使树的某个分支缺失输入序列里也必须用占位符把这个空位顶住否则后面所有节点的位置都会错位。举个例子。输入序列A B C # D E #前7个字符分别是下标0到6的值。A是根B是左孩子C是右孩子D是B的右孩子E是C的左孩子而B的左孩子是空节点#C的右孩子也是空节点#。如果你去掉这两个#把输入改成A B C D E那么按下标法建出来的树就完全变样了D会变成B的左孩子E会变成B的右孩子整棵树的形状与你的预期完全不同。数组下标法的时间复杂度是O(n)空间复杂度是O(n)。它的主要缺点是必须等完整输入才能开始建树无法实现数据流式的边读边建。另外它要求输入长度预先可知如果你用scanf(%s, arr)读入一个字符串还要注意控制结束条件不能把字符串末尾的\0当成有效节点。2.2 队列插入法的设计逻辑与优势队列插入法是我个人更推荐的方案因为它在思路上更贴近层序构建的动态扩展过程。核心思想是搞一个队列队列里存的是树节点的指针这些指针代表已经创建好、但孩子还没挂完的节点。建树流程如下读第一个字符作为根节点。如果根节点不是空占位符创建根节点并入队。循环执行以下步骤直到队列为空取出队头节点cur。读一个字符作为cur的左孩子如果字符不是占位符则创建左孩子节点并入队。再读一个字符作为cur的右孩子如果字符不是占位符则创建右孩子节点并入队。队列为空时所有节点的孩子都已经确定树构建完成。之所以要这样设计是因为层序输入的顺序天然决定了先创建的节点先挂孩子。根节点最先被读入它自然排在最前面挂接两个孩子第二层左侧的节点第二个被读入所以当根节点的两个孩子创建完成后接下来就是给这个节点挂接它的孩子。这个先到先服务的过程和队列的FIFO特性完全吻合。我试过用链表实现队列也用过简单数组模拟循环队列。实际编码中链表队列虽然代码稍长但不用担心队列容量问题数组模拟队列则要提前估算最大节点数。两种方案各有优劣不过对初学者来说数组模拟队列因为代码短、逻辑直白更容易一次写对。为了让你更直观地感受两条路线的差异我把关键特性整理成一个对照表。对比维度数组下标推导法队列辅助插入法输入时机必须完整读入后再建树可以边读边建额外数据结构不需要需要一个队列空间占用需要存储整个序列队列中最多同时存一层节点理解难度依赖下标公式数学感强依赖队列逻辑过程感强典型应用堆、完全二叉树层序构建、在线建树表格里的队列中最多同时存一层节点值得解释一下。层序构建过程中当你正在处理第k层的某个节点时队列里除了它还会有这一层尚未处理的兄弟节点以及已经创建好的第k1层节点。队列的峰值容量和树的宽度有关最坏情况下接近n/2但平均情况远小于n所以空间上的优势是真实存在的。3. 完整可运行的C语言实现从队列设计到层序建树3.1 数据结构定义与队列辅助模块的准备写代码之前先把基础结构定下来。树节点需要data、left、right三个字段。队列节点为了支持指针存储需要一个指向树节点的指针作为数据域。#include stdio.h #include stdlib.h typedef struct TreeNode { char data; struct TreeNode *left; struct TreeNode *right; } TreeNode; typedef struct QueueNode { TreeNode *node; // 队列里存的是树节点的指针 struct QueueNode *next; } QueueNode;队列操作我直接用链表实现避免数组扩容的额外判断。入队操作在尾部插入出队操作从头部删除。队列为空的条件是队头指针为NULL。写得稍微规整一点方便后面复用。typedef struct { QueueNode *front; QueueNode *rear; } Queue; void initQueue(Queue *q) { q-front q-rear NULL; } int isEmpty(Queue *q) { return q-front NULL; } void enqueue(Queue *q, TreeNode *node) { QueueNode *newNode (QueueNode *)malloc(sizeof(QueueNode)); newNode-node node; newNode-next NULL; if (q-rear NULL) { q-front q-rear newNode; } else { q-rear-next newNode; q-rear newNode; } } TreeNode *dequeue(Queue *q) { if (isEmpty(q)) return NULL; QueueNode *temp q-front; TreeNode *node temp-node; q-front q-front-next; if (q-front NULL) { q-rear NULL; } free(temp); return node; }我见过的不少同学在链表队列的dequeue里忘记处理rear指针导致最后一个节点出队后rear还指向一个已经释放的内存区域。这是一个隐蔽但后果很严重的错误后面再展开说。创建树节点的辅助函数很简单TreeNode *createNode(char ch) { TreeNode *node (TreeNode *)malloc(sizeof(TreeNode)); node-data ch; node-left node-right NULL; return node; }3.2 核心建树函数边读入边挂接接下来是建树的核心函数。我用getchar()逐个读取字符这样可以直接处理包含回车换行的连续输入序列。空格会被跳过#作为空占位符。这个取舍很重要因为如果你用scanf(%c, ch)输入中的换行符也会被读走不处理就会莫名其妙多出很多空节点。TreeNode *buildByLevelOrder() { char ch; // 跳过空白字符读取第一个有效字符作为根 do { ch getchar(); } while (ch || ch \n || ch \t); if (ch # || ch EOF) { return NULL; } TreeNode *root createNode(ch); Queue q; initQueue(q); enqueue(q, root); while (!isEmpty(q)) { TreeNode *cur dequeue(q); // 读取左孩子 do { ch getchar(); } while (ch || ch \n || ch \t); if (ch # || ch EOF) { cur-left NULL; } else { cur-left createNode(ch); enqueue(q, cur-left); } // 读取右孩子 do { ch getchar(); } while (ch || ch \n || ch \t); if (ch # || ch EOF) { cur-right NULL; } else { cur-right createNode(ch); enqueue(q, cur-right); } } return root; }这段代码的关键判断是读取一个字符后先判断是否为#或EOF。如果是就不创建节点也不入队把对应孩子指针置为NULL如果不是创建节点、挂接、入队。入队这一步不能省因为新创建的孩子节点将来还要挂接它自己的孩子。这里有一个逻辑上的微妙之处当输入读到EOF时理论上输入已经结束但队列里可能还有等待挂接孩子的节点。实际测试中我发现只要输入序列完整循环会在读到恰好配对的节点数量后自然结束如果输入端缺少必要字符循环会因为EOF不断命中cur-left NULL和cur-right NULL把剩下的节点全部挂成空孩子然后队列清空函数正常返回。虽然这个过程不会崩溃但它掩盖了输入不完整的错误——建出来的树是残缺的而且没有任何报错。3.3 遍历验证建出来的树到底长什么样建完树不能光看不说话必须用一个遍历函数验证结构。写一个简单的先序遍历打印函数void preorderPrint(TreeNode *root) { if (root NULL) { printf(# ); return; } printf(%c , root-data); preorderPrint(root-left); preorderPrint(root-right); }然后写主函数测试int main() { printf(请输入层序序列# 表示空节点\n); TreeNode *root buildByLevelOrder(); printf(先序遍历结果为\n); preorderPrint(root); printf(\n); return 0; }我测试时的输入序列是A B C # D E #期望的树结构是A为根B和C是左右孩子B的左孩子为空、右孩子为DC的左孩子为E、右孩子为空。程序输出的先序遍历结果应该是A B # D # # C E # # #。这个结果里每个#都对应一个空位能够清晰看出树的结构是否正确。我强烈建议你在写完建树函数后至少用一个已知结构的测试序列跑一遍并打印先序或层序遍历结果。因为建树过程中很容易出现以为对但其实位置错了的情况而遍历输出能把结构错误暴露得明明白白。4. 空节点占位与非完全二叉树的构建边界4.1 两种输入约定占位符法与特殊值法前面已经提到#作为空占位符。这是最通用的做法但实际工作中其实还有另一种约定如果树节点存储的数据类型是整数可以用一个绝对不会出现在正常数据里的特殊值比如-1或0表示空节点。例如1 2 3 -1 5 -1 6就代表节点值为1的根左孩子2右孩子32的右孩子是53的左孩子是6其余位置为空。这种特殊值法的好处是输入序列更紧凑、视觉上更自然坏处是你必须明确约定这个值永远不会出现在真实数据里否则会把真实节点误判为空。我在实际项目中就遇到过一个问题某只股票的涨跌幅数据里含有0值结果建树时所有0值节点全部被当成空位处理整棵树缺了一大片。相比之下字符占位符#几乎没有歧义也符合大多数教材和OJ题目的惯例。如果你自己设计输入格式我建议字符数据就用#整数数据就用一个不会出现的哨兵值并在文档或注释里明确写清楚。4.2 占位节点对树结构的影响与边界处理在层序输入中#不是一个被跳过的字符而是一个真实的空位标记。它的作用是维持树的整体位置结构让后续节点能找到正确的父节点。拿上节的例子A B C # D E #来说第4个字符#表示B的左孩子为空第7个字符#表示C的右孩子为空。如果你把这两个#删掉输入变成A B C D E数组下标法建立的树就会变成D成为B的左孩子E成为B的右孩子。树的形状完全不同。这里我要特别提醒一个边界情况当输入序列末尾包含大量#时队列法仍然会逐个读取并判断但不会创建任何节点。比如A B C # # # #根A、左右孩子B和C都正常建好B和C各自的两个孩子全都是#最终形成一棵只有3个节点的树。程序会正确工作但你可以想象一下输入里#的数量如果不恰好匹配会出现什么情况假设你输入A B # C也就是A为根B为左孩子B的左孩子为C整个序列恰好用完。这时队列处理的顺序是A出队读B和#B入队B出队读C然后把队列中剩下的元素已经没有了处理完毕树构建完成。这个过程没有报错但如果你期望C是A的右孩子那结果就错了。根本原因是层序输入要求每个非空节点的两个孩子位置都必须显式给出无论是否为空。所以如果你要在OJ或考试中处理输入不完整的层序序列一定要在题目里明确这棵树的输入保证是完整层序序列或者已知输入序列是某棵完全二叉树的层序序列。没有这个保证任何建树方案都无法正确推导出缺失节点的位置。5. 实测复盘内存泄漏、EOF误读与队列越界三个高频坑5.1 内存管理三个易错点一次说清第一个坑是创建节点后忘记初始化指针。createNode里如果漏掉node-left node-right NULL那么后续遍历时遇到一个看似没有孩子的节点实际会拿着一个野指针继续递归访问结果就是段错误。排查起来极其痛苦因为你可能只在某些输入下才会触发。第二个坑是队列节点释放不完整。用链表实现队列时dequeue里只free了队列节点但树节点本身不能在这里释放否则树结构会被破坏。正确的归属是树节点由整棵树的销毁过程负责队列节点由队列操作负责。两者生命周期不同混用就会造成双重释放或内存泄漏。第三个坑是忘记写树的销毁函数。学习阶段很多人不写销毁函数程序跑完就退出操作系统会回收所有内存所以看起来没有泄漏。但如果你把这个代码放进一个会反复建树的循环里内存会以肉眼可见的速度增长。正确的销毁函数是后序遍历释放void destroyTree(TreeNode *root) { if (root NULL) return; destroyTree(root-left); destroyTree(root-right); free(root); }5.2 scanf与getchar的读取差异EOF误读问题用scanf(%c, ch)直接读字符时换行符会残留在输入缓冲区。如果你在主函数里先让用户输入一个数字比如节点数量再用循环读字符很容易出现后续字符全部错位的情况。解决方案有两个一是每次读字符前都用getchar()清掉残留的回车二是干脆统一用getchar()逐个读取并在读入时过滤空白字符。我在代码里用的do { ch getchar(); } while (ch || ch \n || ch \t);就是为了过滤空白。这里有一个细节getchar()返回int类型当你把它赋值给char类型的变量时如果输入流结束返回的EOF被截断成-1的char表示在某些编译器下和#的ASCII码判断不会冲突但如果你同时判断ch EOF最好把ch声明成int而不是char否则可能出现EOF永远无法判断或者莫名进入EOF分支的奇怪行为。这是C语言里一个老生常谈的坑我这次写示例代码时为了清晰用了char实际工程代码里建议改用int。5.3 数组模拟队列时的容量估算如果你不想写链表队列可以用一个固定大小的数组模拟。此时最大的坑是容量不够导致入队越界。层序建树过程中队列里最多会存多少节点来分析一下当前处理的节点属于某一层队列里除了它自己还有这一层未处理的兄弟节点加上已经创建好的下一层节点。最坏情况是一棵完全二叉树倒数第二层有m个节点最后一层有2m个节点所以队列峰值大约是2m也就是总节点数的一半多一点。最稳妥的做法是让队列容量等于输入序列的长度。因为你最多创建的节点数不可能超过输入字符数队列入队次数也不可能超过节点总数。用一个大小为MAXN的数组MAXN取输入序列长度的上限就不会越界。不过这样数组的内存占用可能偏大对于几十万个节点的输入来说额外多占用几百KB内存其实无伤大雅。6. 应用延伸这个建树方法还能用在哪些地方6.1 从数组或JSON数据快速重建二叉树真实业务里最常见的需求是把一个扁平化的数组重建为树形结构。比如某公司维护了一套组织架构数据格式是[1,2,3,#,4,5,#]其中1是根节点2和3是直属下级4是2的下级5是3的下级。这种扁平层序序列格式在配置系统、权限树、文件目录同步场景中很常见因为它的存储成本低、遍历顺序可预期。用队列插入法可以直接把这种数组转换成内存里的二叉树不需要额外的连接信息。相比之下如果用先序中序两个序列重建二叉树你需要两组数据还要先做中序序列的索引映射代码量和时间复杂度都明显更高。6.2 层序建树与先序中序建树的成本对比如果你学习数据结构时已经做过根据先序和中序遍历结果还原二叉树可能会觉得建立二叉树是一件需要两套序列的事。但层序输入有一个特殊之处它天然包含了树的完整空间信息所以只需要一套序列就能重建。这是按顺序建立二叉树这个题目和根据遍历序列还原二叉树的最大区别。从复杂度角度看层序建树的时间复杂度是O(n)空间复杂度取决于树的宽度先序中序建树通常需要先扫描中序序列建立哈希映射时间是O(n)但空间和代码复杂度都更高。如果输入来源就是层序数据完全没有必要绕道先序中序。我经历的一个模拟项目里最初是把层序数组转成先序序列再配合中序序列建树绕了一个大圈。后来同事提醒直接用队列法代码量砍掉一半还多。不过在技术选型时也要注意层序建树要求输入数据本身没有歧义每个节点的空位必须被显式标记。而先序中序建树的输入冗余度更高遇到不完整的数据时更容易发现错误。两者各有适用场景不是替代关系。6.3 从层序序列反推遍历顺序一个练习建议最后分享一个我自己练过的做法实现完层序建树后再写一个层序遍历输出函数把建好的树重新输出成一个层序序列然后对比输入和输出是否一致。void levelOrderPrint(TreeNode *root) { if (root NULL) return; Queue q; initQueue(q); enqueue(q, root); while (!isEmpty(q)) { TreeNode *cur dequeue(q); if (cur NULL) { printf(# ); continue; } printf(%c , cur-data); enqueue(q, cur-left); enqueue(q, cur-right); } }这个函数里有一个值得注意的地方即使孩子是NULL也要把它们作为空节点入队打印。这样输出的层序序列才会保留所有空位才能和原始输入逐字符对上。如果你在层序输出时跳过NULL节点输出的序列就会丢失空位信息无法用来验证树的形状。把建树函数和层序输出函数放在一起做回环测试是我目前最推荐的验证方法。输入一个序列建树再输出对比两端是否一致。如果一致基本可以确认建树逻辑没问题如果不一致把输出结果和输入序列逐字符对照很快就能定位是哪个节点的哪个孩子挂错了位置。我在测试过程中发现用随机生成的层序序列做批量回环验证比手工想几个例子有效得多。你可以写一个小的随机序列生成器自动生成大量含#的层序序列然后让程序批量建树并回环输出自动比对结果。几百组测试跑下来比任何静态检查都能更快暴露边界问题。这个方法后来也用在了我所有涉及树结构的功能模块里效果非常稳定。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑