学习思维导图
# 树和二叉树
## 树的基本概念
## 二叉树
- 定义和主要特性
- 顺序存储结构和链式存储结构
- 遍历
- 线索二叉树的基本概念和构造
## 树、森林
- 树的存储结构
- 森林和二叉树的转化
- 树和森林的遍历
## 树和二叉树的应用
- 哈夫曼树和哈夫曼编码
- 并查集及其应用
学习思维导图
# 树和二叉树
## 树的基本概念
## 二叉树
- 定义和主要特性
- 顺序存储结构和链式存储结构
- 遍历
- 线索二叉树的基本概念和构造
## 树、森林
- 树的存储结构
- 森林和二叉树的转化
- 树和森林的遍历
## 树和二叉树的应用
- 哈夫曼树和哈夫曼编码
- 并查集及其应用
双亲表示法主要是使用一个数组,其中每个结点都有一个指示其双亲结点在数组中位置的索引。
#define MAXSIZE 100
typedef struct {
int data; // 节点数据
int parent; // 双亲的位置
} PTNode;
typedef struct {
PTNode nodes[MAXSIZE]; // 结点数组
int n; // 结点数
} PTree;
孩子表示法将每个结点的孩子结点排列起来,以单链表作为存储结构。然后再用一个数组与之相配合。
#define MAXSIZE 100
// 孩子结点
typedef struct ChildNode {
int child; // 孩子结点在数组中的位置
struct ChildNode* next; // 下一个孩子
} *ChildPtr;
// 表头结构
typedef struct {
int data; // 结点数据
ChildPtr firstchild; // 第一个孩子的指针
} CTBox;
typedef struct {
CTBox nodes[MAXSIZE]; // 结点数组
int n; // 结点数
} CTree;
孩子兄弟表示法是将树转化为二叉树的形式来存储。每个结点有两个指针,一个指向它的第一个孩子,另一个指向它的右兄弟。
typedef struct CSNode {
int data; // 结点数据
struct CSNode* firstchild; // 第一个孩子
struct CSNode* rightsib; // 右兄弟
} CSNode, *CSTree;
对于一个给定的树,通常有以下两种遍历方式:
注意树是没有中根遍历的,除非这棵树是二叉树。
#define MAXCHILD 20
typedef struct TreeNode {
int value;
int numChildren; // 子节点的数量
struct TreeNode *children[MAXCHILD]; // 子节点指针数组
} TreeNode;
void preOrderTraversal(TreeNode* root) {
if (root == NULL) {
return;
}
// 先访问根节点
// 然后遍历子节点
for (int i = 0; i < root->numChildren; ++i) {
preOrderTraversal(root->children[i]);
}
}
void postOrderTraversal(TreeNode* root) {
if (root == NULL) {
return;
}
// 先遍历子节点
for (int i = 0; i < root->numChildren; ++i) {
postOrderTraversal(root->children[i]);
}
// 然后访问根节点
}
对于上图所示的树:其先根遍历为 A, B, E, F, C, D, G
,后根遍历为E, F, B, C, G, D, A
。
观察可以得到如下结论:
对于一个给定的森林,遍历方式如下:
对于上图所示的森林:其先根遍历为 A, B, C, D, E, F, G, H, I
,后根遍历为 B, C, D, A, F, E, H, I, G
,中根遍历为 B, C, D, A, F, E, H, I, G
观察可以得到如下结论:
typedef struct TreeNode {
ElementType data;
struct TreeNode* left;
struct TreeNode* right;
} TreeNode;
#define MAX_SIZE 100
ElementType tree[MAX_SIZE];
ElementType lchild(int index) {
return tree[2 * i + 1];
})
ElementType rchild(int index) {
return tree[2 * i + 2];
}
void preorderTraversal(struct TreeNode* root) {
if (root == NULL) {
return;
}
// operation here
preorderTraversal(root->left); // 遍历左子树
preorderTraversal(root->right); // 遍历右子树
}
void inorderTraversal(struct TreeNode* root) {
if (root == NULL) {
return;
}
inorderTraversal(root->left); // 遍历左子树
// operation here
inorderTraversal(root->right); // 遍历右子树
}
void inorderTraversal(struct TreeNode* root) {
if (root == NULL) {
return;
}
inorderTraversal(root->left); // 遍历左子树
inorderTraversal(root->right); // 遍历右子树
// operation here
}
// 层次遍历二叉树
void levelOrderTraversal(struct TreeNode* root) {
if (root == NULL) {
return;
}
struct Queue* queue = createQueue();
enqueue(queue, root);
while (queue->front != NULL) {
struct TreeNode* current = dequeue(queue);
// operation here
if (current->left != NULL) {
enqueue(queue, current->left);
}
if (current->right != NULL) {
enqueue(queue, current->right);
}
}
}
线索二叉树(Threaded Binary Tree)是一种对普通二叉树进行优化的数据结构。它的主要目的是在二叉树上添加一些线索(thread),使得在遍历树的过程中可以更加高效地找到前驱和后继节点。线索二叉树在某些应用中能够提高对树的遍历性能,尤其是中序遍历。
线索二叉树中的每个节点都可以包含以下线索信息:
线索二叉树的主要优点是,在不使用递归或栈的情况下,可以通过线索信息直接找到下一个要访问的节点,从而提高了中序遍历的效率。这对于需要频繁进行中序遍历的应用非常有用,例如数据库中的 B 树和 AVL 树的实现。
// ltag = 0 时表示 lchild 域指向节点的左孩子,ltag = 1 时表示 lchild 域指向节点的前驱
// rtag = 0 时表示 rchild 域指向节点的右孩子,rtag = 1 时表示 rchild 域指向节点的后继
typedef struct ThreadNode {
ElementType data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag;
} ThreadNode;
// 中序遍历,找到中序遍历的第一个节点,然后遍历 rchild 即可
void InOrder(ThreadNode *n) {
ThreadNode *p = n;
while (p->lchild) {
p = p->lchild;
}
for (; p != NULL; p = p->rchild) {
visit(p);
}
}
int getHeight(struct TreeNode* node) {
if (node == NULL) {
return 0;
}
return max(getHeight(node->left), getHeight(node->right)) + 1;
}
从前序、中序、后序序列中选两个构造二叉树
例子:
前序遍历为:ABDCE,中序遍历为:BDACE,树的结构如下:
A
/ \
B C
\ \
D E
我们如何从这两个遍历中恢复这棵树?
A
是根节点。A
将序列分为两部分: BD
和 CE
。左边部分 BD
对应于左子树,右边部分 CE
对应于右子树。A
之后的 BD
对应于左子树,而 CE
对应于右子树。BD
,中序遍历为 BD
。从这可以确定 B
是左子树的根,而 D
是它的右孩子。CE
,中序遍历为 CE
。从这可以确定 C
是右子树的根,而 E
是它的右孩子。二叉排序树,也称为二叉查找树(Binary Search Tree, BST),是一种特殊的二叉树。它或者是一棵空树,或者是满足以下性质的二叉树:
二叉查找树的 主要操作 有:
typedef struct TreeNode {
int data;
struct TreeNode* left;
struct TreeNode* right;
} TreeNode;
// 创建新节点
TreeNode* newNode(int data) {
TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
node->data = data;
node->left = NULL;
node->right = NULL;
return node;
}
// 插入新节点
TreeNode* insert(TreeNode* root, int data) {
if (root == NULL) {
return newNode(data);
}
if (data < root->data) {
root->left = insert(root->left, data);
} else if (data > root->data) {
root->right = insert(root->right, data);
}
return root;
}
// 查找节点
TreeNode* search(TreeNode* root, int data) {
if (root == NULL || root->data == data) {
return root;
}
if (data < root->data) {
return search(root->left, data);
}
return search(root->right, data);
}
// 删除节点
TreeNode* deleteNode(TreeNode* root, int data) {
if (root == NULL) {
return root;
}
if (data < root->data) {
root->left = deleteNode(root->left, data);
} else if (data > root->data) {
root->right = deleteNode(root->right, data);
} else {
// 节点有一个或没有子节点
if (root->left == NULL) {
TreeNode* temp = root->right;
free(root);
return temp;
} else if (root->right == NULL) {
TreeNode* temp = root->left;
free(root);
return temp;
}
// 节点有两个子节点,找到右子树的最小节点
TreeNode* temp = findMin(root->right);
// 复制右子树的最小节点的值
root->data = temp->data;
// 删除右子树的最小节点
root->right = deleteNode(root->right, temp->data);
}
return root;
}
平衡二叉树(Balanced Binary Tree),也叫做 AVL 树。它的特点是任意节点的左右子树高度差(平衡因子)不超过 1,这确保了树的高度始终保持在 $O(log_2 n)$ 的水平,使得查找、插入和删除操作的时间复杂度都保持在 $O(log_2 n)$。
理解 AVL 的关键在于理解 AVL 的旋转过程,以下针对几个疑问带大家迅速了解关键知识:
我们时常需要对 AVL 中的结点进行插入和删除操作,这些操作可能导致 AVL 中的某个子树进入不平衡的状态。 通过旋转操作可以使得不平衡的子树 和 整棵 AVL 树 都变得重新平衡。
从最小不平衡子树的根节点旋转,旋转操作会使得最小不平衡子树从不平衡变得平衡。
理解这句话你需要掌握以下概念:
执行插入操作时,如果该次插入使得 AVL 不平衡的话,首先找到最小不平衡子树根节点(用 A 表示该节点)的位置,然后根据插入节点(用 N 表示)相对于 A 的位置,可能有四种不同的旋转操作:
AVL 平衡旋转的代码实现如下所示,过程比较繁琐,了解即可,重点在与如何掌握如何 “人脑” 模拟旋转过程。
struct TreeNode {
int data;
struct TreeNode* left;
struct TreeNode* right;
int height; // 节点高度
};
struct TreeNode* leftRotate(struct TreeNode* x) {
struct TreeNode* y = x->right;
struct TreeNode* T2 = y->left;
// 执行旋转
y->left = x;
x->right = T2;
// 更新高度
updateHeight(x);
updateHeight(y);
return y;
}
struct TreeNode* rightRotate(struct TreeNode* y) {
struct TreeNode* x = y->left;
struct TreeNode* T2 = x->right;
// 执行旋转
x->right = y;
y->left = T2;
// 更新高度
updateHeight(y);
updateHeight(x);
return x;
}
// 计算以 node 为根节点的子树高度
int getHeight(struct TreeNode* node) {
if (node == NULL) {
return 0;
}
return node->height;
}
// 获取平衡因子
int getBalanceFactor(struct TreeNode* node) {
if (node == NULL) {
return 0;
}
return getHeight(node->left) - getHeight(node->right);
}
// 在插入时需要使用该操作,重新计算子树高度
void updateHeight(struct TreeNode* node) {
int leftHeight = getHeight(node->left);
int rightHeight = getHeight(node->right);
node->height = (leftHeight > rightHeight ? leftHeight : rightHeight) + 1;
}
struct TreeNode* insert(struct TreeNode* node, int data) {
// 步骤 1:执行标准 BST 插入
if (node == NULL) {
return createNode(data);
}
if (data < node->data) {
node->left = insert(node->left, data);
} else if (data > node->data) {
node->right = insert(node->right, data);
} else { // 如果键值相等,则不插入
return node;
}
// 步骤 2:更新节点的高度
updateHeight(node);
// 步骤 3:获取节点的平衡因子
int balanceFactor = getBalanceFactor(node);
// 步骤 4:平衡调:根据新插入结点的位置 相对于 最小不平衡子树根节点的位置进行旋转
// LL 型:右旋
if (balanceFactor > 1 && data < node->left->data) {
return rightRotate(node);
}
// RR 型:左旋
if (balanceFactor < -1 && data > node->right->data) {
return leftRotate(node);
}
// LR 型:先左旋,再右旋
if (balanceFactor > 1 && data > node->left->data) {
node->left = leftRotate(node->left);
return rightRotate(node);
}
// RL 型:先右旋,再左旋
if (balanceFactor < -1 && data < node->right->data) {
node->right = rightRotate(node->right);
return leftRotate(node);
}
// 返回未被修改的节点指针
return node;
}
为了保证 AVL 的平衡性,插入和删除操作后,非常频繁地调整全树整体拓扑结构,代价很大。为此在 AVL 树的平衡标准上进一步放宽条件,引入了红黑树的结构。
红黑树的考察不会很深,了解以下概念即可:
哈夫曼树(Huffman Tree)是一种特殊的二叉树,通常用于数据压缩算法中,特别是用于构建哈夫曼编码(Huffman Coding)。哈夫曼树的主要目标是实现数据的无损压缩,通过赋予不同的数据符号不同长度的编码来减少数据的存储空间。
以下是哈夫曼树的关键特点和构建过程:
特点:
构建过程:
一旦构建了哈夫曼树,就可以生成数据符号的哈夫曼编码。哈夫曼编码是一种变长编码,用于表示不同数据符号。在哈夫曼编码中,权值较高的数据符号通常对应较短的编码,权值较低的数据符号对应较长的编码。这种编码方式可以实现数据的高效压缩和解压缩。
哈夫曼树和哈夫曼编码在数据压缩领域具有广泛的应用,例如在无损压缩算法中,如ZIP文件压缩,图像压缩(如JPEG)等。通过构建适用的哈夫曼树和编码,可以大幅减少数据的存储和传输成本。
哈夫曼编码
遍历哈夫曼树,为每个数据符号生成相应的哈夫曼编码。编码的生成方式如下(左0右1):
实例
为a, b, c, d四个字母生成哈夫曼编码,其对应的权值分别为7, 5, 2, 4
并查集(Union-Find)是一种数据结构,主要用于解决集合划分及查询问题。它主要支持两种操作:查找(Find)和合并(Union)。其核心思想是使用一个数组(或其他数据结构)来存储每个元素的父节点信息。
查找
查找操作的目的是找到给定元素所属集合的代表。这可以通过追踪父节点来实现,直到找到根元素(即父节点为其自身的元素)。路径压缩可以在查找过程中应用,使得从指定节点到其根的路径上的每个节点都直接指向根,从而提高后续查找的效率。
合并
合并操作的目的是将两个集合合并为一个集合。为了执行合并,首先使用Find操作找到两个集合的代表,然后决定哪个代表成为新的根。为了保持树的平衡性,并减少查找时间,常用的策略是按秩合并。其中,秩通常表示树的高度。较低的树会被附加到较高的树的根上。
代码实现
#define MAXN 1000
int parent[MAXN]; // 存储每个点的父节点
int rank[MAXN]; // 秩
// 初始化
void initialize(int n) {
for (int i = 0; i < n; i++) {
parent[i] = i; // 初始时,每个元素的父节点是其自身
rank[i] = 0; // 初始时,每个元素的秩为0
}
}
// 查找
int find(int x) {
if (parent[x] != x) {
// 路径压缩
parent[x] = find(parent[x]);
}
return parent[x];
}
// 合并
void unionSet(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else {
parent[rootY] = rootX;
rank[rootX]++;
}
}
}