树(Tree)是一种非线性的数据结构,由节点(Node)组成,节点之间通过边(Edge)连接。树结构中包含一个根节点(Root Node),根节点指向其他节点,形成层次关系。树结构常用于表示层次关系、家谱、文件系统等。
树的基本概念:
-
根节点(Root): 树结构的顶端节点,是树的起始节点,没有父节点。
-
父节点(Parent)和子节点(Child): 父节点指向子节点,子节点指向父节点。
-
叶子节点(Leaf): 没有子节点的节点称为叶子节点,位于树的末端。
-
节点的度(Degree): 节点拥有的子节点数量称为节点的度。
-
节点的深度(Depth): 从根节点到当前节点的唯一路径的边的数量。
-
树的高度(Height): 从根节点到最深叶子节点的最长路径的边的数量。
-
子树(Subtree): 每个节点都可以作为根节点,派生出一个子树。
常见树的类型:
-
二叉树(Binary Tree): 每个节点最多有两个子节点。
-
二叉搜索树(Binary Search Tree): 左子树上所有节点的值均小于根节点的值,右子树上所有节点的值均大于根节点的值。
-
平衡二叉树(Balanced Binary Tree): 左右子树的高度差不超过 1 的二叉树。
-
B树(B-Tree): 一种自平衡的树数据结构,常用于数据库和文件系统中。
-
红黑树(Red-Black Tree): 一种自平衡的二叉搜索树,常用于实现集合和映射等数据结构。
树的应用领域:
-
文件系统: 文件和文件夹可以用树的形式组织,树的节点表示文件或文件夹,边表示文件夹内的关系。
-
家谱: 家族关系可以用树结构表示,根节点代表祖先,子节点代表后代。
-
编译器: 抽象语法树(Abstract Syntax Tree,AST)用于表示代码的语法结构,帮助编译器进行语法分析和代码优化。
-
数据结构和算法: 树结构是许多重要数据结构和算法的基础,如堆、图等。
-
计算机网络: 用于路由算法、无线传感器网络等领域。
-
人工智能: 用于决策树、搜索算法等。
-
图形学: 用于构建场景图、动画等。
把它叫做树是因为它看起来像一棵倒挂的树,它是根朝上,而叶朝下的。
注意:树形结构中,子树之间不能有交集,否则就不是树形结构。
孩子兄弟表示法
孩子兄弟表示法(Child-Sibling Representation)是一种用于表示树结构的方法,也称为左孩子右兄弟表示法(Left Child Right Sibling Representation)。这种表示法通常用于表示多叉树,其中每个节点可以有多个子节点。
思想与原理:
-
每个节点包含两个指针:
- 左孩子指针(child): 指向该节点的第一个子节点。
- 右兄弟指针(sibling): 指向该节点的下一个兄弟节点。
-
特点:
- 根据孩子兄弟表示法,树中的每个节点可以有多个子节点,通过左孩子指针指向第一个子节点,通过右兄弟指针指向下一个兄弟节点。
- 如果一个节点没有子节点,则其左孩子指针为 NULL。
- 如果一个节点是其父节点的最后一个子节点,则其右兄弟指针为 NULL。
优点:
-
灵活性: 孩子兄弟表示法适用于多叉树,能够灵活表示每个节点的多个子节点。
-
节省空间: 相比其他表示方法,孩子兄弟表示法节省空间,因为每个节点只需存储两个指针。
示例:
下面是一个使用孩子兄弟表示法表示的简单树的例子:
A
/
B -- C
/ /
D E -- F
使用孩子兄弟表示法表示上述树:
- A节点的左孩子指针指向B,B的右兄弟指针指向C。
- B节点的左孩子指针指向D,D没有兄弟节点。
- C节点的左孩子指针指向E,E的右兄弟指针指向F,F没有兄弟节点。
这样,通过节点之间的左孩子和右兄弟关系,可以清晰地表示整个树的结构,方便进行树的遍历和操作。

#include <stdio.h>
#include <stdlib.h>
// 定义树节点结构
typedef struct Node {
char data;
struct Node* child; // 左孩子指针
struct Node* sibling; // 右兄弟指针
} Node;
// 创建一个新节点
Node* createNode(char data) {
Node* newNode = (Node*)malloc(sizeof(Node));
newNode->data = data;
newNode->child = NULL;
newNode->sibling = NULL;
return newNode;
}
// 先序遍历打印树
void preOrderTraversal(Node* root) {
if (root == NULL) {
return;
}
printf("%c ", root->data);
// 递归遍历子节点
preOrderTraversal(root->child);
// 递归遍历兄弟节点
preOrderTraversal(root->sibling);
}
int main() {
// 创建树结构
Node* root = createNode('A');
root->child = createNode('B');
root->child->sibling = createNode('C');
root->child->child = createNode('D');
root->child->sibling->child = createNode('E');
root->child->sibling->child->sibling = createNode('F');
// 执行先序遍历打印
printf("Preorder Traversal: ");
preOrderTraversal(root);
printf("\n");
return 0;
}
树不能构成回路
树是一种特殊的图,具有以下特点:
-
无环性质: 树是一种无向图,其中任意两个节点之间只有一条唯一的路径。因此,在树结构中不存在环(回路)。
-
层次性质: 树是一种层次化的数据结构,具有唯一的根节点和从根节点到任意节点的唯一路径。每个节点最多有一个父节点,且任意节点之间通过唯一的路径相连。
-
无交叉性质: 树的子树之间是不相交的,每个节点的子节点是互不相交的,不存在交叉或重复的子树。
因此,树是一种无环的、层次化的数据结构,不可能存在回路。如果存在环路,那么该结构就不再是树,而是图中的一种更一般的结构,如环路图(Cyclic Graph)。
二叉树(Binary Tree)是一种树形数据结构,其中每个节点最多有两个子节点,分别称为左子节点和右子节点。二叉树常用于实现二叉搜索树、堆等数据结构,具有广泛的应用。
二叉树的基本性质:
-
根节点(Root): 二叉树的顶端节点,是整棵树的起点。
-
父节点、子节点: 每个节点最多有一个父节点和两个子节点。
-
叶子节点(Leaf): 没有子节点的节点称为叶子节点。
-
深度(Depth): 从根节点到当前节点的唯一路径的边的数量。
-
高度(Height): 从当前节点到最深叶子节点的最长路径的边的数量。
-
层次遍历: 从上到下,从左到右依次访问每一层节点。
二叉树的常见类型:
-
满二叉树(Full Binary Tree): 每个节点都有 0 或 2 个子节点。
-
完全二叉树(Complete Binary Tree): 除了最后一层外,每一层都被完全填充,且所有节点都向左对齐。
-
二叉搜索树(Binary Search Tree): 左子树上所有节点的值均小于根节点的值,右子树上所有节点的值均大于根节点的值。
-
平衡二叉树(Balanced Binary Tree): 左右子树的高度差不超过1,以保证树的高度尽可能小。
二叉树的遍历方式:
-
前序遍历(Preorder Traversal): 根 - 左 - 右。
-
中序遍历(Inorder Traversal): 左 - 根 - 右。
-
后序遍历(Postorder Traversal): 左 - 右 - 根。
-
层次遍历(Level Order Traversal): 逐层从左到右遍历节点。
二叉树作为一种重要的数据结构,被广泛应用于计算机科学领域,在算法设计、数据库索引结构、编译器设计等方面发挥着重要作用。
二叉树概念及结构
二叉树的每个节点包含三部分信息:数据、左子节点指针和右子节点指针。
typedef struct Node {
int data;
struct Node* left; // 左子节点指针
struct Node* right; // 右子节点指针
} Node;
一棵二叉树是结点的有限集合,该集合可为空或者由一个根节点加上两棵别称为左子树和右子树的二叉树组成。
关于二叉树的两个重要性质:
二叉树不存在度大于2的结点:每个节点最多有两个子节点,即左子节点和右子节点。
二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树:二叉树中左子树与右子树是有序的,它们的次序不能颠倒。
若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2^(i - 1)个结点。
若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2^h - 1。
根节点的层数为1时,对于深度为h的二叉树,最大结点数是 2^ℎ−1。这个公式是基于二叉树的性质和结构推导出来的。
-
根节点所在的层级为1,因此根节点本身算作第一层。
-
对于深度为1的二叉树,即只有根节点,结点数为 2^1−1=1。
-
对于深度为2的二叉树,根节点下面有2个子节点,总结点数为 2^2−1=3。
-
对于深度为3的二叉树,根节点下面有4个子节点,总结点数为 2^3=7。
以此类推,对于深度为h的二叉树,每向下增加一层,结点数是前一层的两倍(因为每个节点最多有两个子节点),所以总结点数为 2^ℎ−1。
对任何一棵二叉树, 如果度为0其叶结点个数为n0, 度为2的分支结点个数为n2 ,则有 n0=n2+1
在具有 2n 个结点的完全二叉树中,叶子结点个数为( A )
A n
B n+1
C n-1
D n/2
2n 个结点的完全二叉树中,叶子结点的个数为 n。
A
/ \
B C
/ \ /
D E F
6个结点的完全二叉树有3个叶子结点
一棵完全二叉树的节点数位为531个,那么这棵树的高度为( B )
A 11
B 10
C 8
D 12
对于一个完全二叉树,其节点数 n 和高度 h 之间存在以下关系:
- 最少有 2^(ℎ−1) 个节点。
- 最多有 2^ℎ−1 个节点。
根据提供的信息,完全二叉树的节点数为 531 个。
-
找到最小高度 ℎ1:
- 2^(ℎ1−1)≤531
- 2^ℎ1≤531*2=1062
- ℎ1=10
-
找到最大高度 ℎ2:
- 2^(ℎ2−1)≤531
- 2^(ℎ2−1)≤531<2^ℎ2−1
- ℎ2=10
因此,这棵完全二叉树的高度为 10
当涉及到树结构时,深度和高度是两个重要的概念。让我们通过一个简单的树结构来说明深度和高度的计算。
考虑以下树结构:
A
/ | \
B C D
/ \
E F
在这个树中:
- 节点 A 的深度为 0,高度为 2。
- 节点 B 的深度为 1,高度为 1。
- 节点 C 的深度为 1,高度为 1。
- 节点 D 的深度为 1,高度为 0。
- 节点 E 的深度为 2,高度为 0。
- 节点 F 的深度为 2,高度为 0。
计算方法如下:
- 深度计算:从根节点到目标节点的边的数量。
- 高度计算:从目标节点到最远叶子节点的最长路径的边的数量。
5.一个具有767个节点的完全二叉树,其叶子节点个数为( B )
A 383
B 384
C 385
D 386
在一个完全二叉树中,如果节点数为 n,则叶子节点数为 ⌈n/2⌉,其中⌈x⌉ 表示对 x 向上取整。
给定这个问题中完全二叉树的节点数为 767,计算叶子节点的个数:
叶子节点数=⌈767/2⌉=⌈383.5⌉=384
因此,这棵具有 767 个节点的完全二叉树的叶子节点个数为 384
二叉树先/中/后序遍历
二叉树的遍历方式主要有三种:先序遍历、中序遍历和后序遍历。它们的定义如下:
1. 先序遍历 (Preorder Traversal)
- 顺序:递归访问根节点 → 访问左子树 → 访问右子树
- 示例:
A / \ B C / \ D E
- 先序遍历结果:
A, B, D, E, C
- 先序遍历结果:
过程
-
访问根节点 A:
- 打印或记录
A
。
- 打印或记录
-
递归访问左子树 B:
- 进入节点
B
,打印或记录B
。
- 进入节点
-
继续递归访问 B 的左子树 D:
- 进入节点
D
,打印或记录D
。 - 节点
D
没有左子树和右子树,返回到节点B
。
- 进入节点
-
递归访问 B 的右子树 E:
- 进入节点
E
,打印或记录E
。 - 节点
E
没有左子树和右子树,返回到节点B
,再返回到根节点A
。
- 进入节点
-
递归访问右子树 C:
- 进入节点
C
,打印或记录C
。 - 节点
C
没有左子树和右子树,结束遍历。
- 进入节点
2. 中序遍历 (Inorder Traversal)
- 顺序:递归访问左子树 → 访问根节点 → 访问右子树
- 示例:
A / \ B C / \ D E
- 中序遍历结果:
D, B, E, A, C
- 中序遍历结果:
过程
-
递归访问左子树 B:
- 进入节点
B
,递归访问左子树 D。
- 进入节点
-
访问左子树 D:
- 进入节点
D
,打印或记录D
。 - 节点
D
没有左子树和右子树,返回到节点B
。
- 进入节点
-
访问根节点 B:
- 打印或记录
B
。
- 打印或记录
-
递归访问 B 的右子树 E:
- 进入节点
E
,打印或记录E
。 - 节点
E
没有左子树和右子树,返回到根节点A
。
- 进入节点
-
访问根节点 A:
- 打印或记录
A
。
- 打印或记录
-
递归访问右子树 C:
- 进入节点
C
,打印或记录C
。 - 节点
C
没有左子树和右子树,结束遍历。
- 进入节点
3. 后序遍历 (Postorder Traversal)
- 顺序:递归访问左子树 → 访问右子树 → 访问根节点
- 示例:
A / \ B C / \ D E
- 后序遍历结果:
D, E, B, C, A
- 后序遍历结果:
过程
-
递归访问左子树 B:
- 进入节点
B
,递归访问左子树 D。
- 进入节点
-
访问左子树 D:
- 进入节点
D
,打印或记录D
。 - 节点
D
没有左子树和右子树,返回到节点B
。
- 进入节点
-
递归访问 B 的右子树 E:
- 进入节点
E
,打印或记录E
。 - 节点
E
没有左子树和右子树,返回到节点B
。
- 进入节点
-
访问根节点 B:
- 打印或记录
B
,返回到根节点A
。
- 打印或记录
-
递归访问右子树 C:
- 进入节点
C
,打印或记录C
。 - 节点
C
没有左子树和右子树,返回到根节点A
。
- 进入节点
-
访问根节点 A:
- 打印或记录
A
,结束遍历。
- 打印或记录
总结
- 先序遍历强调根节点在前,适合用于复制树。
- 中序遍历用于生成有序序列,常用于二叉搜索树的遍历。
- 后序遍历常用于删除树或计算树的大小。
已知先序和中序还原二叉树
先序遍历结果:A, B, D, E, C
中序遍历结果:D, B, E, A, C
构建二叉树
先序遍历的第一个元素一定是根结点(A),再看中序遍历在先序时根节点(A)知(D,B,E)是A的左子树,C是A的右子树。这时(D,B,E)中构成的子树谁是该子树的根结点看先序未知元素先出现的就是该子树的根结点(B),这时再看中序B刚好隔开D和E即D是B的左子树,E是B的右子树。
根据上述步骤,最终构建的二叉树如下:
A
/ \
B C
/ \
D E
根据构建的二叉树还可以求后序:D,E,B,C,A
已知后序和中序还原二叉树
后序遍历结果:D, E, B, C, A
中序遍历结果:D, B, E, A, C
构建二叉树
后序遍历的最后一个元素一定是根结点(A),再看中序遍历在先序时根节点(A)知(D,B,E)是A的左子树,C是A的右子树。这时(D,B,E)中构成的子树谁是该子树的根结点,看后序未知元素后出现的就是该子树的根结点(B),这时再看中序B刚好隔开D和E即D是B的左子树,E是B的右子树。
根据这些步骤,最终构建的二叉树如下:
A
/ \
B C
/ \
D E
根据构建的二叉树还可以求先序:A,B,D,E,C
已知先序和后序不能还原二叉树
从先序遍历和后序遍历构建二叉树相对复杂,因为这两种遍历方式并不能唯一确定一棵树。通常情况下,先序和中序或中序和后序可以唯一确定一棵树,但先序和后序的组合并不足以做到这一点。
理由
- 先序遍历:根节点在前,后面是左子树和右子树的遍历结果。
- 后序遍历:根节点在最后,前面是左子树和右子树的遍历结果。
由于没有中序遍历的帮助,我们无法确定左右子树的具体构成,可能会出现多种不同的树形结构。
示例说明
假设我们有以下先序和后序遍历的结果:
- 先序遍历:
A B C
- 后序遍历:
C B A
从这两种遍历中,我们可以构建出至少两种不同的二叉树。
示例 1
A
/
B
/
C
- 先序遍历:
A B C
- 后序遍历:
C B A
示例 2
A
\
B
\
C
- 先序遍历:
A B C
- 后序遍历:
C B A
如上所示,这两棵树的先序和后序遍历结果相同,但它们的结构不同。因此,先序和后序遍历无法唯一确定一棵二叉树。
结论
因此,仅凭先序遍历和后序遍历的信息,无法唯一确定一棵二叉树。如果有中序遍历的信息,则可以通过先序和中序或后序和中序来构建树。
堆
堆是一种特殊的树形数据结构,通常用数组来表示。堆具有以下特性:
-
完全二叉树结构: 堆是一个完全二叉树,即除了最底层之外,其它层都被完全填充,且在最底层从左到右填充。
-
堆序性质: 堆中的每个节点的值都必须满足堆的性质。对于最小堆来说,父节点的值小于等于子节点的值;对于最大堆来说,父节点的值大于等于子节点的值。
-
数组表示: 堆通常使用数组来表示,数组中的元素按照特定规则排列,可以通过数组下标计算节点之间的关系。
-
父子节点关系: 堆中任意节点的索引为
i
,则其父节点的索引为(i-1)/2
,左子节点的索引为2*i+1
,右子节点的索引为2*i+2
。 -
常见操作:
- 向上调整(上滤): 将新插入的元素向上移动,直到满足堆的性质。
- 向下调整(下滤): 将某个节点的元素向下移动,直到满足堆的性质。
- 插入元素: 将新元素插入到堆的末尾,然后进行向上调整。
- 弹出堆顶元素: 将堆顶元素取出,然后将最后一个元素移到堆顶,再进行向下调整。
-
应用:
- 堆排序算法
- 优先队列等数据结构的实现
#include "Heap.h"
// 交换两个元素的值
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
// 前提:左右子树都是小堆
void AdjustDown(HPDataType* a, int n, int root)
{
// 找出左右孩子中较小的那一个
int parent = root;
int child = parent * 2 + 1; // 左孩子
while (child < n)
{
// 判断右孩子是否存在且比左孩子小
if (child + 1 < n && a[child + 1] < a[child])
{
++child; // 如果右孩子存在且比左孩子小,指向右孩子
}
// 判断孩子节点是否比父节点小
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]); // 交换孩子节点和父节点
parent = child; // 更新父节点为当前孩子节点
child = parent * 2 + 1; // 更新孩子节点为新的左孩子
}
else
{
break;
}
}
}
// 将指定位置的元素向上调整以维持堆的性质
void AdjustUp(HPDataType* a, int n, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]); // 交换孩子节点和父节点
child = parent; // 更新孩子节点为父节点
parent = (child - 1) / 2; // 更新父节点为新的父节点
}
else
{
break;
}
}
}
// 在堆中打印元素
void HeapShow(Heap* php, int n)
{
int c = 2;
int e = 1;
Heap* p = php;
HPDataType* tempPtr = p->_a; // 临时指针用于遍历数据
while (n--)
{
printf("%d ", *tempPtr); // 打印当前元素
if (c == pow(2, e))
{
printf("\n"); // 换行
e++;
}
c++;
tempPtr++; // 移动临时指针到下一个位置
}
printf("\n");
}
// 初始化堆
void HeapInit(Heap* php, HPDataType* a, int n)
{
php->_a = (HPDataType*)malloc(sizeof(HPDataType) * n); // 分配内存
memcpy(php->_a, a, sizeof(HPDataType) * n); // 复制数据
php->_size = n;
php->_capacity = n;
// 从最后一个非叶子节点开始,对每个节点进行向下调整
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(php->_a, php->_size, i);
}
}
// 销毁堆
void HeapDestory(Heap* php)
{
free(php->_a); // 释放内存
php->_a = NULL;
php->_capacity = php->_size = 0;
}
// 向堆中插入元素
void HeapPush(Heap* php, HPDataType x)
{
if (php->_size == php->_capacity)
{
php->_capacity *= 2; // 扩容
HPDataType* tmp = (HPDataType*)realloc(php->_a, sizeof(HPDataType) * php->_capacity); // 重新分配内存
php->_a = tmp;
}
php->_a[php->_size++] = x; // 插入新元素
AdjustUp(php->_a, php->_size, php->_size - 1); // 向上调整
}
// 从堆中弹出元素
void HeapPop(Heap* php)
{
if (php->_size > 0)
{
Swap(&php->_a[0], &php->_a[php->_size - 1]); // 将堆顶元素与最后一个元素交换
php->_size--; // 减少堆的大小
AdjustDown(php->_a, php->_size, 0); // 向下调整
}
}
// 获取堆顶元素
HPDataType HeapTop(Heap* php)
{
return php->_a[0]; // 返回堆顶元素
}