二叉树(Binary Tree)

目录

[1 · 二叉树的概念](#1 · 二叉树的概念)

[2 · 二叉树的链式结构](#2 · 二叉树的链式结构)

[2 - 1 · 链式结构的实现](#2 - 1 · 链式结构的实现)

[3 · 二叉树的遍历](#3 · 二叉树的遍历)

[3 - 1 · 二叉树遍历的意义](#3 - 1 · 二叉树遍历的意义)

[3 - 2 · 前序遍历](#3 - 2 · 前序遍历)

[3 - 3 · 中序遍历](#3 - 3 · 中序遍历)

[3 - 4 · 后序遍历](#3 - 4 · 后序遍历)

[3 - 5 · 层序遍历](#3 - 5 · 层序遍历)

[3 - 6 · 测试一下](#3 - 6 · 测试一下)

[4 · 二叉树的常见接口实现](#4 · 二叉树的常见接口实现)

[4 - 1 · 二叉树结点个数](#4 - 1 · 二叉树结点个数)

[4 - 2 · 二叉树叶子结点个数](#4 - 2 · 二叉树叶子结点个数)

[4 - 3 · 求第K层结点个数](#4 - 3 · 求第K层结点个数)

[4 - 4 · 求树的高度](#4 - 4 · 求树的高度)

[4 - 5 · 查找值为x的结点](#4 - 5 · 查找值为x的结点)

[4 - 6 · 二叉树的销毁](#4 - 6 · 二叉树的销毁)

[5 · 判断二叉树是否为完全二叉树](#5 · 判断二叉树是否为完全二叉树)

总结


摘要: 二叉树作为一种基础且重要的非线性数据结构,在计算机科学领域应用广泛。本文将系统性地介绍二叉树的核心概念、基本操作、常见类型及其应用场景,并辅以清晰的代码示例(C)。通过本文,读者将能够理解二叉树的结构特点,掌握其遍历与操作方法,并了解其在算法与工程中的实际价值。

1 · 二叉树的概念

我们在上一篇中已经介绍过了。

简单来说,二叉树是每个结点最多拥有左,右两个子节点且区分左右次序的树形数据结构。

再让我们回顾一下二叉树的概念:一棵二叉树可以为空树或非空树。

对于非空树,可以分成三个部分:根节点,左子树,右子树。

而左子树和右子树又可以分为:根节点,左子树,右子树。

因此二叉树是递归定义 的,我们后面的操作也会多次用到递归


2 · 二叉树的链式结构

⼆叉树的链式存储结构是指,用链表来表示⼀棵⼆叉树,即⽤链来指示元素的逻辑关系。 通常的⽅法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为⼆叉链和三叉链。

本文介绍的是二叉链,至于三叉链,是之后红黑树用到的。


2 - 1 · 链式结构的实现

下面我们来实现一下链式二叉树:

复制代码
typedef int BTDataType;

typedef struct BinaryTreeNode
{
	BTDataType _val;
	struct BinaryTreeNode* _left;
	struct BinaryTreeNode* _right;
}BTNode;

用链表来表示一棵二叉树,最常见的方式是每个结点由三个域组成:一个数值域和两个指针域,两个指针域分别指向左孩子结点和右孩子结点。


3 · 二叉树的遍历

3 - 1 · 二叉树遍历的意义

1. 访问树中所有节点,是许多操作的基础(搜索、修改、统计等)。

2. 对于搜索二叉树(左 < 根 < 右),中序遍历可以天然升序输出数据。

3. 算术表达式可以构建成二叉树,(运算符为根,数字为叶子),如此,后序遍历 = 后缀表达式,前序遍历 = 前缀表达式。

4. 层序遍历可以求二叉树宽度,按层级输出菜单目录,也是最短路径,图BFS算法的基础原型。


3 - 2 · 前序遍历

代码如下:

复制代码
void PrevOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}

	printf("%d ", root->_val);
	PrevOrder(root->_left);
	PrevOrder(root->_right);
}

前序遍历(Preorder Traversal 亦称先序遍历):访问根结点的操作发生在遍历其左右子树之前
即访问的顺序为 根节点 -> 左子树 -> 右子树
为了更直观展现访问的顺序,我们在遇到空结点的时候选择打印一个 'N'


3 - 3 · 中序遍历

代码如下:

复制代码
void InOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}

	InOrder(root->_left);
	printf("%d ", root->_val);
	InOrder(root->_right);
}

中序遍历(Inorder Traversal):访问根结点的操作发生在遍历其左右子树之中(间)
即访问的顺序为 左子树 -> 根节点 -> 右子树
为了更直观展现访问的顺序,我们在遇到空结点的时候选择打印一个 'N'
我们也能看的出来,中序遍历和前序遍历只是调换了一下代码顺序而已,包括后面的后序遍历也是一样的。


3 - 4 · 后序遍历

代码如下:

复制代码
void PostOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("N ");
		return;
	}

	PostOrder(root->_left);
	PostOrder(root->_right);
	printf("%d ", root->_val);
}

后序遍历(Postorder Traversal):访问根结点的操作发生在遍历其左右子树之后
即访问的顺序为 左子树 -> 右子树 -> 根节点
为了更直观展现访问的顺序,我们在遇到空结点的时候选择打印一个 'N'


3 - 5 · 层序遍历

上面介绍的前序 中序 后序遍历,都是深度优先遍历(DFS)。

层序遍历是广度优先遍历(BFS)。

那么什么是层序遍历呢?
设⼆叉树的根结点所在层数为1,层序遍历就是从所在⼆叉树的根结点出发,首先访问第⼀层的树根结点,
然后从左到右访问第2层上的结点,
接着是第三层的结点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历
实现层序遍历,我们需要借助队列
代码如下:

复制代码
void BinaryTreeLevelOrder(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root != NULL)
	{
		QueuePush(&q, root);
	}

	//持续出队,每次出队都把自己的孩子结点入队
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);

		printf("%d ", front->_val);

		if (front->_left != NULL)
		{
			QueuePush(&q, front->_left);
		}

		if (front->_right != NULL)
		{
			QueuePush(&q, front->_right);
		}
	}

	QueueDestroy(&q);
}

大致思路是:如果不为空树,就让根节点入队,然后重复 出队 并把左右孩子结点入队 这个操作,如果左右孩子为空则不入队,直至队列为空,遍历就完成了。


3 - 6 · 测试一下

由于普通二叉树的插入没有什么意义,所以我们手动创建一棵树(结构如下图)

下面我们测试一下上面写的四个遍历:

复制代码
void Test1()
{
	BTNode* root = CreateTree();

	printf("前序:");
	PrevOrder(root);
	printf("\n");

	printf("中序:");
	InOrder(root);
	printf("\n");

	printf("后序:");
	PostOrder(root);
	printf("\n");

	printf("层序:");
	BinaryTreeLevelOrder(root);
	printf("\n");
}

运行一下:


4 · 二叉树的常见接口实现

4 - 1 · 二叉树结点个数

代码如下:

复制代码
int BinaryTreeSize(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}

	//自己 + 左子树结点个数 + 右子树结点个数
	return 1 + BinaryTreeSize(root->_left) + BinaryTreeSize(root->_right);
}

可能会有这种想法:在函数内部定义一个变量用来计数,但是对于我们的递归调用,函数内部每次定义的变量都是在不同的函数栈帧里的,他们的数据并不互通。

对于递归调用,简单来说,就是函数内部调用函数,而对于被调用的函数,直到被调用的函数执行完毕才会返回到上一层函数,并继续往下运行。

那么还有一种想法:定义一个全局变量用来计数,这样的做法可行,但是麻烦,每次调用之前需要将用来计数的全局变量置0,不然就会统计错误。

一个更好的方法就是上面写的这种,用递归分治的思想,将总结点个数分为 根节点,左子树结点,右子树结点三部分,然后求和。


4 - 2 · 二叉树叶子结点个数

代码如下:

复制代码
int BinaryTreeLeafSize(BTNode* root)
{
	if (root == NULL)
	{
		return 0;
	}

	if (root->_left == NULL && root->_right == NULL)
	{
		return 1;
	}

	//左子树叶子 + 右子树叶子
	return BinaryTreeLeafSize(root->_left) + BinaryTreeLeafSize(root->_right);
}

同样是走递归的思想,将一棵二叉树的叶子结点总数,分为左子树叶子结点 + 右子树叶子结点。

注意:一定要判断是否为空,并且返回,不然会无限递归。


4 - 3 · 求第K层结点个数

代码如下:

复制代码
int BinaryTreeLevelKSize(BTNode* root, int k)
{
	//防止出界
	if (root == NULL)
	{
		return 0;
	}

	if (k == 1)
	{
		return 1;
	}

	//左子树和右子树往下找,直到第K层
	return BinaryTreeLevelKSize(root->_left, k - 1) + BinaryTreeLevelKSize(root->_right, k - 1);
}

仍然使用递归的思想,往下找直到第K层,然后将值一层层带回来。


4 - 4 · 求树的高度

代码如下:

复制代码
int BinaryTreeLevel(BTNode* root)
{
	//为空不计入高度
	if (root == NULL)
	{
		return 0;
	}

	//左子树和右子树的较高者 + 自己
	//防止调用两次递归,用临时变量来存储
	int leftHigh = BinaryTreeLevel(root->_left);
	int rightHigh = BinaryTreeLevel(root->_right);

	return leftHigh > rightHigh ? leftHigh + 1 : rightHigh + 1;
}

递归的思想,树的高度 = 左子树和右子树的较高者 + 自己。

注意:如果直接用三目操作符,而没有存储递归调用的值,如下面这样写:

复制代码
    return BinaryTreeLevel(root->_left) > BinaryTreeLevel(root->_right) ? 
           BinaryTreeLevel(root->_left) + 1 : BinaryTreeLevel(root->_right) + 1;

这样写的话,会比储存值的写法多一次递归调用,因为递归调用是不记事的,如果没有存储他得出来的值,再想拿到值就要再走一趟递归,这样显然是消耗比较大的。

看似只多了一次,但是当树的高度比较高的时候,每一层往下走都会重复调用这个三目,调用三次递归,这样造成的浪费是很大的。


4 - 5 · 查找值为x的结点

代码如下:

复制代码
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
	if (root == NULL)
	{
		return NULL;
	}

	if (root->_val == x)
	{
		return root;
	}

	//左子树和右子树进行查找,如果已找到,直接一层层返回上来
	//防止调用两次递归,用一个变量存储
	BTNode* ptr = NULL;

	ptr = BinaryTreeFind(root->_left, x);
	if(ptr != NULL)
	{
		return ptr;
	}

	ptr = BinaryTreeFind(root->_right, x);
	if (ptr != NULL)
	{
		return ptr;
	}

	return NULL;
}

返回的是第一次找到的值为x的结点,毕竟函数返回值也不能有两个。

先从左树找,如果找到了就一层层带上来,防止多调用递归,我们定义了一个变量来存储。

左树没找到就去右树找,上面的代码是可以简化的,我们先找的是根,再是左树,而既然代码运行到了查找右树这里,说明如果有,那也只可能在右树这里,那么我们可以直接在查找完左树后面写:

复制代码
return BinaryTreeFind(root->_right, x);

两种写法效率差异不大。


4 - 6 · 二叉树的销毁

代码如下:

复制代码
void BinaryTreeDestroy(BTNode* root)
{
	if (root == NULL)
	{
		return;
	}

	BinaryTreeDestroy(root->_left);
	BinaryTreeDestroy(root->_right);
	free(root);
}

走一个后序,很合适。

需要注意的是,我们参数列表用的是一级指针,调用完销毁需要我们主动将变量置空

比如:

复制代码
BinaryTreeDestroy(root);
//手动置空
root = NULL;

如果用二级指针接收就不用手动置空。


5 · 判断二叉树是否为完全二叉树

代码如下:

复制代码
bool BinaryTreeComplete(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root != NULL)
	{
		QueuePush(&q, root);
	}

	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);

		if (front == NULL)
		{
			break;
		}

		QueuePush(&q, front->_left);
		QueuePush(&q, front->_right);
	}

	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		QueuePop(&q);

		if (front != NULL)
		{
			QueueDestroy(&q);
			return false;
		}
	}

	QueueDestroy(&q);
	return true;
}

大致思路是走层序遍历,但是我们让空结点也入队,

当出队出到了空结点,我们遍历队列,

如果后面全是空结点,说明是完全二叉树,

如果后面含有非空结点,说明不是完全二叉树。


总结

以上简单介绍了二叉树有关内容,关于数据结构其余内容,请期待后续更新。


以上内容如有错误或不准确之处,欢迎指出,或者你有更好的想法,也欢迎交流。

相关推荐
黎阳之光1 小时前
流域面源污染防控+生态屏障数字化落地:黎阳之光以视频孪生守护南水北调水源安全
人工智能·物联网·算法·安全·数字孪生
搞科研的小刘选手1 小时前
【高届数计算机方向会议】第七届计算机视觉与数据挖掘国际学术会议(ICCVDM 2026)
人工智能·算法·计算机·数据挖掘·软件工程·视觉·信息
小小工匠2 小时前
Redis - 异步机制与阻塞规避:Redis 单线程模型的生存之道
数据结构·redis·性能优化·集群·持久化
fengxin_rou2 小时前
LeetCode 三道高频中等数组算法详解|除自身乘积、矩阵置零、螺旋矩阵
算法·leetcode·矩阵
8Qi811 小时前
LeetCode 75:颜色分类(荷兰国旗问题)—— Java 题解 ✅
java·算法·leetcode·指针·排序
888CC++12 小时前
如何在 C 语言中进行程序调试?
前端·javascript·算法
pluviophile_s13 小时前
数据结构:第2讲:线性表
数据结构·笔记
(●—●)橘子……14 小时前
力扣第503场周赛练习理解
python·学习·算法·leetcode·职场和发展·周赛