
二叉树是一种重要的数据结构,它具有一些独特的性质,这些性质在算法设计和数据结构的优化中非常有用。以下是二叉树的一些基本性质:
的二叉树中,最多有
个节点。
空树、只有根节点、根节点只有左子树、根节点只有右子树,以及根节点同时有左子树和右子树,这五种形态构成了所有二叉树的逻辑基础,其递归定义是根据这些基本单元构建的。
五种基本形态详解:

这五种基本形态是后续二叉树相关算法的基础。
再来介绍一些特殊的二叉树。这些树可能暂时你不知道他们有什么用处,但还是先了解一下,后续会提到它们的具体用处。
个结点的二叉树按层序编号,如果编号为
(
) 的结点与同样深度的满二叉树中编号为
的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树。
首先从字面上要区分,“完全”和“满”的差异,满二叉树一定是一棵完全二叉树,但完全二叉树不一定是满的。 其次,完全二叉树的所有结点与同样深度的满二叉树,它们按层序编号相同的结点,是一一对应的。这里有个关键词是按层序编号。
其中完全二叉树还算是相对较了解的,因为这是之前我们实现的堆结构的物理基础。

性质 1:在二叉树的第
层上至多有
个节点 (
)。
个节点,第二层是
个节点,第三层是
个节点。以此类推,每层节点的上限遵循几何级数。
性质 2:深度为
的二叉树至多有
个节点 (
)。
。
性质 3:对任何一棵二叉树
,如果其终端节点数为
,度为 2 的节点数为
,则
。
为总结点数,
为度为 1 的节点数,则
。
(除了根节点,每个节点上方都有一条连线),同时也等于
(度为 1 的节点引出 1 条线,度为 2 的节点引出 2 条线)。
,可推导出
。
性质 4:具有
个节点的完全二叉树的深度为
。
的满二叉树节点数为
。对于完全二叉树,其节点数
满足:
。通过对数运算可得
。
性质 5:如果对一棵有
个节点的完全二叉树的节点按层序编号,对任一节点
(
) 有:
,则节点
是二叉树的根,无双亲;如果
,则其双亲是节点
。
,则节点
无左孩子(节点
为叶子节点);否则其左孩子是节点
。
,则节点
无右孩子;否则其右孩子是节点
。
⽤链表来表⽰⼀棵⼆叉树,即⽤链来指⽰元素的逻辑关系。 通常的⽅法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别⽤来给出该结点左孩⼦和右孩⼦所在的链结点的存储地址 ,其结构如下:
typedef struct BinaryTreeNode
{
BTDataType data;//数据
struct BinaryTreeNode* left;//左孩子
struct BinaryTreeNode* right;//右孩子
} BTNode;二叉树的遍历原理: 假设,我手头有 20 张 100 元的和 2000 张 1 元的奖券,同时撒向了空中,大家比赛看谁最终捡的最多。如果是你,你会怎么做?
相信所有同学都会说,一定先捡 100 元的。道理非常简单,因为捡一张 100 元等于 1 元的捡 100 张,效率高的不是一点点。所以可以得到这样的结论,同样是捡奖券,在有限时间内,要达到最高效率,次序非常重要。对于二叉树的遍历来讲,次序同样显得很重要。
二叉树的遍历(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。
这里有两个关键词:访问和次序。
访问其实是要根据实际的需要来确定具体做什么,比如对每个结点进行相关计算,输出打印等,它算作是一个抽象操作。在这里我们可以简单地假定访问就是输出结点的数据信息。
二叉树的遍历次序不同于线性结构,最多也就是从头至尾、循环、双向等简单的遍历方式。树的结点之间不存在唯一的初始前驱和后继关系,在访问一个结点后,下一个被访问的结点面临着不同的选择。主要分为以下的形式
逻辑顺序可以总结为:根结点 -> 左子树 -> 右子树。
void PreOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
printf("%d ", root->data); // 访问根
PreOrder(root->left); // 递归左子树
PreOrder(root->right); // 递归右子树
}规则是若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。如下图所示,遍历的顺序为 ABDGHCEIF。

逻辑顺序可以总结为:左子树 -> 根节点 -> 右子树。
void InOrder(BTNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
InOrder(root->left);
printf("%d ", root->data);
InOrder(root->right);
}规则是若树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。如下图所示,遍历的顺序为 GDHBAEICF。

逻辑顺序可以总结为:左子树 -> 右子树 -> 根节点。
void PostOrder(BTNode* root)
{
if (root == NULL)
{
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->data);
}规则是若树为空,则空操作返回。否则从左到右先叶子后结点的方式遍历访问左右子树,最后是访问根结点。如下图所示,遍历的顺序为 GHDBIEFCA。


层序遍历需要借助队列,这里就不给源码了,给出参考思路: 若树为空则返回,否则从根结点开始,按从上到下、从左到右的顺序逐层访问每个结点。在实际算法实现中,通常利用队列这一数据结构,先将根结点入队,然后循环执行“出队一个结点进行访问,并将该结点的左、右孩子(若存在)依次入队”的操作,直到队列为空为止。
二叉树的题目几乎全靠递归。递归的精髓在于:不要试图在大脑里压入所有的执行栈,而是要相信函数的功能,并处理好边界条件。
一棵树的高度是多少?它取决于它的左子树和右子树中较那个“更高”的一个,再加上根节点本身的一层。
详细逻辑推导:
leftHeight,求右子树高度 rightHeight。int TreeHeight(BTNode* root)
{
if (root == NULL)
{
return 0;
}
// 注意:一定要定义变量存储结果,否则会导致指数级的重复计算!
int leftHeight = TreeHeight(root->left);
int rightHeight = TreeHeight(root->right);
return leftHeight > rightHeight ? leftHeight + 1 : rightHeight + 1;
}深度性能优化笔记:在 C 语言中,如果不使用变量记录 leftHeight 而是在 return 时写 TreeHeight(root->left) > TreeHeight(root->right) ? ...,会导致在比较时调用一次递归,在返回加一时又调用一次递归。这会使时间复杂度从
降级为
,在 OJ 测试中会超时。
如何统计一棵森林里有多少棵树?数数叶子,数数中间。
对于二叉树:总节点数 = 左子树节点数 + 右子树节点数 + 1(根节点)。
int TreeSize(BTNode* root)
{
// 递归出口:空树节点数为0
return root == NULL ? 0 : TreeSize(root->left) + TreeSize(root->right) + 1;
}解析:这段代码极其简洁,体现了递归的分治思想。每一次调用 TreeSize 都在解决一个小规模的“根+左右”问题。
要判断两棵树 p 和 q 是否相同,必须满足:
递归逻辑拆解:
true。false。false。left 相同 且 right 也相同时,整棵树才相同。bool isSameTree(struct BinaryTreeNode* p, struct BinaryTreeNode* q)
{
// 1. 同时为空,是真的相同
if (p == NULL && q == NULL)
{
return true;
}
// 2. 一个为空,一个不为空,或者值不同,则不同
if (p == NULL || q == NULL || p->val != q->val)
{
return false;
}
// 3. 递归比较左右子树
return isSameTree(p->left, q->left) && isSameTree(p->right, q->right);
}虽然二叉树的递归解法非常优雅,但在 C 语言中,我们必须时刻关注栈溢出(Stack Overflow) 风险。
。在最坏情况下(树退化成链表),
,此时递归深度为
,极易导致系统栈空间耗尽。
二叉树的学习不仅仅是掌握几种遍历算法,更是对递归思想的一次洗礼。通过 C 语言的手动内存管理和指针操作,你能更清晰地看到节点是如何在堆区诞生的,以及递归是如何在系统栈上起舞的。