数据结构-二叉树(六):BFS层序遍历与完全二叉树判断|复用链式队列 + (N_0=N_2+1) 性质证明

写在前面

前面的二叉树专题中,我们已经完成了链式二叉树的基础结构、前中后序递归遍历、层序遍历、结点统计、树高度、递归建树、结点查找以及二叉树销毁等全套基础接口。

其中之前实现的层序遍历使用的是数组模拟队列

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*,而队列结点、frontrearsize 等结构保持原来的链式队列设计。

因此原来的接口全部可以直接使用:

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->leftfront->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:两阶段法

  1. 第一阶段 :不断出队非空结点,直到第一次遇到 NULL
  2. 第二阶段 :继续检查队列剩余元素,必须全部为 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 重定义 QDataTypeBTNode*,使队列支持存储二叉树结点指针
BinaryTree.h 新增 BinaryTreeLevelOrderBfsBinaryTreeComplete 函数声明
BinaryTree.c 新增链式队列BFS层序遍历、完全二叉树判断两个接口实现
BinaryTree_Test.c 补充两棵测试树的BFS与完全性测试,修正执行顺序(所有访问操作先于销毁)

代码仓库

本篇是在已有工程上增量新增功能,原有代码全部保留,无需删除旧文件、旧接口,读者可以直接复用整套工程。

  1. 修改Queue.h:把队列元素类型改为typedef BTNode* QDataType;Queue.c不用改动,原有队列接口全部继续可用,队列现在可以存放二叉树结点指针。
  2. 将本篇新增的两个函数声明复制到BinaryTree.h;把BinaryTreeLevelOrderBfsBinaryTreeComplete两份实现追加到BinaryTree.c文件末尾。
  3. BinaryTree_Test.c中,在二叉树销毁之前调用新增接口做测试,销毁后的空指针不可再传入函数。
  4. 编译运行即可,同时保留旧版数组模拟层序遍历,方便读者对比两种 BFS 实现输出结果。

提示:

  • 新增接口完全独立,不影响之前写的二叉树遍历、统计、查找、建树、销毁等所有旧功能,旧测试代码可以原样运行。
  • 同时配套掌握二叉树性质N_0=N_2+1,用于快速求解完全二叉树结点计算题。

数据结构/BinaryTree_FullVersion · Luminous/Code_2026 - 码云 - 开源中国


写在最后

今天新增的代码量不多,但把之前学过的数据结构真正串了起来:链表实现队列,队列服务于二叉树BFS,BFS稍加改造又能解决完全二叉树判断问题。数据结构从来不是孤立的知识点,而是层层复用、彼此支撑的体系。

从「写代码实现接口」到「研究结构本身的性质」,也是数据结构学习的进阶路径。

本篇核心要点回顾

  • 层序遍历 = BFS = 队列;
  • BFS队列中存储的是 BTNode* 结点指针,而非单纯的值;
  • 判断完全二叉树时,NULL 也要入队,空位置本身就是判断依据;
  • 首次遇到空位置后,再出现非空结点 → 一定不是完全二叉树;
  • 任意非空二叉树:
  • 完全二叉树附加约束:

理解这些核心逻辑后,绝大多数完全二叉树的判断、计数题都可以从定义直接推导,无需死记硬背。

相关推荐
Brilliantwxx44 分钟前
【Linux】 进程(10) 进程控制深度解析:创建、终止与等待
linux·运维·服务器·c语言·开发语言·网络
ZhouDevin1 小时前
算法论文/数据集3——CLD(TMLR2025)压缩训练集,仅保留对验证集有益的样本
人工智能·深度学习·算法·计算机视觉
wuyk5551 小时前
从零吃透Modbus通信|第7章:终极工程整合(模块化架构、双模式主机从机、RTOS适配、量产级模板)
c语言·stm32·学习·架构
Tim_101 小时前
【LeetCode】29、两数相除
算法·leetcode·职场和发展
纪念 2292 小时前
数据结构排序(三)
数据结构
卢锡荣2 小时前
国产PD3.1全集成Type-C管控芯片LDR6020P
c语言·开发语言
船厂电气自动化ai大模型2 小时前
AI大模型与数学/第63课:矩阵定义、矩阵加法、标量乘法(逐级精讲)
数据结构·人工智能·深度学习·线性代数·算法
一条破秋裤2 小时前
STM32 学习笔记:PWM 驱动 LED 呼吸灯与舵机代码
笔记·stm32·学习
余额瞒着我当琳2 小时前
算法修炼 chapter 2 双指针进阶、盛最多水的容器、有效三角形的个数、两数之和、三数之和、四数之和
算法