写在前面
前面的二叉树专题中,我们已经完成了链式二叉树的基础结构、前中后序递归遍历、层序遍历、结点统计、树高度、递归建树、结点查找以及二叉树销毁等全套基础接口。
其中之前实现的层序遍历使用的是数组模拟队列:
cpp
void BinaryTreeLevelOrder(BTNode* root);
它可以正确完成遍历,但层序遍历本质上就是非常典型的 BFS(广度优先搜索) 问题,而 BFS 最天然的载体正是队列。
因此本篇在原有工程基础上扩展两个新接口:
cpp
// 链式队列实现 BFS 层序遍历
void BinaryTreeLevelOrderBfs(BTNode* root);
// 判断二叉树是否为完全二叉树
bool BinaryTreeComplete(BTNode* root);
同时直接复用之前实现的链式队列模块,仅将队列存储的数据类型从整数调整为 BTNode*(二叉树结点指针)。原有全部基础接口保持不动,仅新增功能。
除代码实现外,本篇还会推导二叉树经典性质:
并结合完全二叉树的约束,解决两道经典结点计数选择题。
一、层序遍历
1.1 为什么层序遍历天然适配队列?
以一棵满二叉树为例:
A
/ \
B C
/ \ / \
D E F G
层序遍历要求的访问顺序为:A B C D E F G。
和前/中/后序遍历「沿着一条路径往深处钻」的逻辑不同,层序遍历的核心是:
先处理完整的一层,再由当前层的结点带出下一层。
例如:
bash
第1层:A
A处理完成 → 把B、C加入待处理序列
第2层:B C
B处理完成 → 加入D、E
C处理完成 → 加入F、G
第3层:D E F G
执行过程天然满足「先加入的结点先处理」的 FIFO(先进先出) 规则,而这正是队列的核心特性。
1.2 BFS层序遍历的完整执行流程
仍以上述满二叉树为例,完整走一遍BFS流程:
A
/ \
B C
/ \ / \
D E F G
初始状态
根结点 A 入队:
队列:[ A ]
第一步:处理结点 A
队头 A 出队,打印值;再将 A 的左孩子 B、右孩子 C 依次入队。
输出:A
队列:[ B C ]
第二步:处理结点 B
队头 B 出队,打印值;再将 B 的左孩子 D、右孩子 E 依次入队。
输出:A B
队列:[ C D E ]
第三步:处理结点 C
队头 C 出队,打印值;再将 C 的左孩子 F、右孩子 G 依次入队。
输出:A B C
队列:[ D E F G ]
第四步:处理叶子结点
D、E、F、G 依次出队打印,均无孩子,队列为空时遍历结束。
最终输出:A B C D E F G
BFS核心框架:根入队 → 队列非空则循环 → 取队头访问 → 左孩子入队 → 右孩子入队。
1.3 复用已有的链式队列模块
本次没有重新实现队列,而是直接复用之前写好的链式 Queue 模块------数据结构的学习本就是前后复用、层层递进的。
队列主体逻辑完全不用改,仅需修改存储的数据类型:
- 原队列存整数:
typedef int QDataType; - BFS需要存结点地址:改为
typedef BTNode* QDataType;
压入队列的是:BTNode*,QueueFront() 返回的也是:BTNode;当前 Queue.h 就是通过包含 BinaryTree.h,把 QDataType 重定义为了 BTNode*,而队列结点、front、rear、size 等结构保持原来的链式队列设计。
因此原来的接口全部可以直接使用:
cpp
void QueueInit(Queue* q);
void QueuePush(Queue* q, QDataType data);
void QueuePop(Queue* q);
QDataType QueueFront(Queue* q);
int QueueEmpty(Queue* q);
void QueueDestroy(Queue* q);
1.4 链式队列版 BFS 层序遍历
完整实现代码:
cpp
// 层序遍历(链式队列实现 BFS)
void BinaryTreeLevelOrderBfs(BTNode* root)
{
Queue q;
QueueInit(&q);
// 根结点非空则入队
if (root != NULL)
{
QueuePush(&q, root);
}
while (!QueueEmpty(&q))
{
// 取出队头结点并出队
BTNode* front = QueueFront(&q);
QueuePop(&q);
// 访问当前结点
printf("%c ", front->data);
// 左孩子非空则入队
if (front->left != NULL)
{
QueuePush(&q, front->left);
}
// 右孩子非空则入队
if (front->right != NULL)
{
QueuePush(&q, front->right);
}
}
// 遍历结束销毁队列,释放内存
QueueDestroy(&q);
}
1.4.1 关键点1:队列存储的是结点指针
队列里保存的是 BTNode* 而非结点值 BTDataType。
原因很简单:后续不仅要访问 front->data,还要通过 front->left、front->right 找到子结点,只存值会丢失树的结构关系。
1.4.2 关键点2:上层结点自然带出下层
当前结点出队时:
cpp
if (front->left != NULL)
{
QueuePush(&q, front->left);
}
if (front->right != NULL)
{
QueuePush(&q, front->right);
}
它会自然地把下一层结点补到队尾,于是:
- 当前层正在不断从队头出去
- 下一层正在不断从队尾进来
最终就形成了一层一层向下扩展的遍历效果。
1.5 数组模拟队列 vs 链式队列BFS
上一篇其实已经实现过:
cpp
void BinaryTreeLevelOrder(BTNode* root);
它先统计二叉树结点总数,再动态申请一个 BTNode** 数组,通过:
cpp
front
rear
两个下标模拟队列,当前原实现仍然保留在工程中。
两种实现本质都是BFS思想,仅底层队列载体不同,对比如下:
| 实现函数 | 底层实现 | 特点 |
|---|---|---|
BinaryTreeLevelOrder |
动态数组 + front/rear下标模拟 | 内存连续,需提前统计结点总数 |
BinaryTreeLevelOrderBfs |
复用链式Queue模块 | 逻辑解耦,专注BFS流程,无需管理下标 |
学习BFS思想时,更推荐直接复用队列模块,代码语义更清晰。
二、拓展:如何判断完全二叉树?
2.1 完全二叉树定义
高度为h的二叉树,前 h-1 层全部排满,最后一层结点从左到右连续排列,中间不能出现空缺。
例如:
A 是完全二叉树
/ \
B C
/ \ /
D E F
A 不是完全二叉树
/ \
B C
\ /
E F
第二个例子中,最后一层先出现空位、后又出现结点,不符合「从左到右连续」的要求。
2.2 完全二叉树判断的核心思路
普通BFS只把非空孩子入队,但判断完全二叉树时,空位置本身就是判断依据。
所以这一次:
cpp
QueuePush(&q, front->left);
QueuePush(&q, front->right);
无论左右孩子是否为空,全部入队,保留完整的位置信息。
例如:
A
/ \
B C
/ \ /
D E F
按照包含空结点的层序顺序,会逐渐出现:A B C D E F N N N N N N N;
一旦第一次遇到:N,那么按照完全二叉树的特点:后面应该全部都是 N。
只要后面再次出现非空结点,就说明:已经有空位,如果后面却还有有效结点,不符合**"最后一层从左向右连续"**的要求。
所以判断规则为:
第一次遇到空结点后,后续所有位置必须全部为空;如果再出现非空结点,则一定不是完全二叉树。
2.3 两种理解视角
视角1:两阶段法
- 第一阶段 :不断出队非空结点,直到第一次遇到
NULL; - 第二阶段 :继续检查队列剩余元素,必须全部为
NULL,否则判定失败。
视角2:单循环标记法
通过一个布尔变量 meetNull 记录是否已经遇到空结点,遍历过程中实时判断,代码更紧凑。本文采用这种实现。
2.4 完整实现代码
cpp
bool BinaryTreeComplete(BTNode* root)
{
Queue q;
QueueInit(&q);
if (root != NULL)
{
QueuePush(&q, root);
}
bool meetNull = false; // 标记是否已经遇到空结点
while (!QueueEmpty(&q))
{
BTNode* front = QueueFront(&q);
QueuePop(&q);
if (front == NULL)
{
// 遇到空位置,标记后续必须全为空
meetNull = true;
}
else
{
// 前面已经出现空位,现在又有有效结点 → 不合法
if (meetNull)
{
QueueDestroy(&q);
return false;
}
// 无论是否为空,左右孩子全部入队
QueuePush(&q, front->left);
QueuePush(&q, front->right);
}
}
QueueDestroy(&q);
return true;
}
和普通BFS的核心区别
| 场景 | 入队规则 | 关注点 |
|---|---|---|
| 普通层序遍历 | 非空孩子才入队 | 有哪些有效结点 |
| 完全二叉树判断 | 空/非空孩子都入队 | 有效结点之间有没有非法空位 |
2.5 典型场景验证
2.5.1 场景1:标准完全二叉树
A
/ \
B C
/ \ /
D E F
含空位的层序序列:A B C D E F N N N N N N N 首次遇到N后后续全为空 → 返回 true。
2.5.2 场景2:左空右非空
A
/ \
B C
\
E
含空位的层序序列:A B C N E ... 先遇到N,后又出现E → 返回 false。
2.5.3 场景3:中间出现空缺
A
/ \
B C
/ \
D G
含空位的层序序列:A B C D N N G 空位后又出现G → 返回 false。
2.6 部分问题及其处理与测试
2.6.1 空树的边界处理
当 root == NULL 时,不会向队列插入任何元素,循环直接跳过,最终返回 true。
空树在定义上通常视为完全二叉树,符合常规约定。
2.6.2 测试代码的顺序修正
本次在测试代码中新增了BFS和完全性判断,这里有一个极易踩的坑:
所有遍历、查询、判断操作,必须全部放在销毁树之前执行。
正确顺序:
cpp
创建树
↓
前/中/后序遍历
↓
结点统计、查找
↓
BFS层序遍历
↓
完全二叉树判断
↓
销毁树
一旦先执行 BinaryTreeDestory(&root),结点内存已释放,再访问就是非法的野指针操作。
2.6.3 数组前序建树的同步测试
对 BinaryTreeCreate 构建的第二棵树,也同步补充了BFS遍历和完全性判断,测试完成后再销毁,保证流程完整。
2.6.4 为什么队列模块几乎无需修改?
因为之前实现链式队列时,已经做了标准的接口封装:入队、出队、取队头、判空、销毁。底层链表逻辑和上层业务解耦。
BFS场景下,我们只修改了 QDataType 的类型定义,队列的FIFO特性、接口逻辑完全不变。这正是数据结构封装的意义:
底层实现一旦稳定,后续仅需调整数据类型,即可在新场景中复用。
三、经典性质:(N_0 = N_2 + 1)
规定:
- (N_0):度为0的结点数(叶子结点)
- (N_1):度为1的结点数
- (N_2):度为2的结点数
对于任意一棵非空二叉树,恒有: \\boxed{N_0 = N_2 + 1} 即:叶子结点永远比度为2的结点多1个。
3. 两种证明方法
3.1.1 证明方法一:边数推导法
整棵二叉树总共有:
个结点。
树有一个非常基本的性质:
如果一棵树有 N 个结点,那么一定有 N - 1 条边。
所以二叉树总边数: N-1 。
另一方面,也可以根据每个结点有几个孩子统计边数。
- 度为 0 的结点贡献 0 条向下边;
- 度为 1 的结点贡献 1 条;
- 度为 2 的结点贡献 2 条。
所以:
又因为:
代入:
两边消去:
最终:
,证明完成。
3.1.2 证明方法二:归纳法(结点增量视角)
例如:
A
/
B
增加:
A
/ \
B C
变化是:
- A:度 1 → 度 2,因此
; - 新结点 C:成为叶子,因此
。
两边同时加 1:
仍然不变。
因此不管二叉树怎样一步步生长:
始终成立。
因此无论二叉树如何生长,
恒成立。
3.2 完全二叉树的附加约束
对于完全二叉树,由于最后一层结点从左到右连续排列,度为1的结点最多只能有1个 ,且只能是「有左孩子、无右孩子」。即:
结合
和
,可推导出:
这是解决完全二叉树计数题的核心公式。
3.3 两个例题
3.3.1 例题1:2n个结点的完全二叉树
题目:具有 (2n) 个结点的完全二叉树,叶子结点个数为? A. n B. n+1 C. n-1 D. n/2
已知总节点数 N = 2n(偶数),代入公式:
左边为偶数,
又
为偶数,因此
必须为偶数。
结合
,得
。
代入计算:
,再由
,得
。
答案:A
3.3.2 例题2:767个结点的完全二叉树
题目:具有767个结点的完全二叉树,叶子结点个数为多少?
总节点数767为奇数,代入公式:
,
由于
为奇数,因此
。
计算得:
,所以
,又
,
因此:767 个结点的完全二叉树有 384 个叶子结点。
答案:384
3.4 规律总结
完全二叉树叶子结点数可以总结为:

即:叶子结点数等于总结点数的一半向上取整。
- 总结点数为偶数:N_1=1,叶子数 = N_0 = n
- 总结点数为奇数:N_1=0,叶子数 = N_0 = n+1
当然,更推荐先理解:
和
。
四、本次工程文件变更汇总
| 文件 | 变更内容 |
|---|---|
Queue.h |
重定义 QDataType 为 BTNode*,使队列支持存储二叉树结点指针 |
BinaryTree.h |
新增 BinaryTreeLevelOrderBfs、BinaryTreeComplete 函数声明 |
BinaryTree.c |
新增链式队列BFS层序遍历、完全二叉树判断两个接口实现 |
BinaryTree_Test.c |
补充两棵测试树的BFS与完全性测试,修正执行顺序(所有访问操作先于销毁) |
代码仓库
本篇是在已有工程上增量新增功能,原有代码全部保留,无需删除旧文件、旧接口,读者可以直接复用整套工程。
- 修改
Queue.h:把队列元素类型改为typedef BTNode* QDataType;,Queue.c不用改动,原有队列接口全部继续可用,队列现在可以存放二叉树结点指针。 - 将本篇新增的两个函数声明复制到
BinaryTree.h;把BinaryTreeLevelOrderBfs、BinaryTreeComplete两份实现追加到BinaryTree.c文件末尾。 - 在
BinaryTree_Test.c中,在二叉树销毁之前调用新增接口做测试,销毁后的空指针不可再传入函数。 - 编译运行即可,同时保留旧版数组模拟层序遍历,方便读者对比两种 BFS 实现输出结果。
提示:
- 新增接口完全独立,不影响之前写的二叉树遍历、统计、查找、建树、销毁等所有旧功能,旧测试代码可以原样运行。
- 同时配套掌握二叉树性质N_0=N_2+1,用于快速求解完全二叉树结点计算题。
数据结构/BinaryTree_FullVersion · Luminous/Code_2026 - 码云 - 开源中国
写在最后
今天新增的代码量不多,但把之前学过的数据结构真正串了起来:链表实现队列,队列服务于二叉树BFS,BFS稍加改造又能解决完全二叉树判断问题。数据结构从来不是孤立的知识点,而是层层复用、彼此支撑的体系。
从「写代码实现接口」到「研究结构本身的性质」,也是数据结构学习的进阶路径。
本篇核心要点回顾
- 层序遍历 = BFS = 队列;
- BFS队列中存储的是
BTNode*结点指针,而非单纯的值; - 判断完全二叉树时,
NULL也要入队,空位置本身就是判断依据; - 首次遇到空位置后,再出现非空结点 → 一定不是完全二叉树;
- 任意非空二叉树:
; - 完全二叉树附加约束:
。
理解这些核心逻辑后,绝大多数完全二叉树的判断、计数题都可以从定义直接推导,无需死记硬背。