树
这是一颗树,尽管在我看来这像串葡萄,而不是树,但这不重要。
树,不同于先前介绍的数据结构,它是一种非线性的数据结构,模拟了一种层次或分层结构,非常适合于表示具有层次关系的数据。树由结点(nodes)组成,这些结点通过边(edges)连接。树是有n()个结点的有限集,当n = 0时,树为一个空树。
树的逻辑结构
- 结点(Node):树的基本单位,包含数据和对其他结点的引用。
- 根节点(Root):树结构中的第一个结点,没有祖先和双亲。
- 边(Edge):连接两个节点的线,表示它们之间的关系。
- 孩子(Child):一个结点的直接后继者。
- 双亲(Parent):一个结点的直接前驱者。
- 叶子结点(Leaf):没有孩子的节点。
- 兄弟(Siblings):具有相同双亲的节点。
树的分层结构
树的每一层代表了数据的一个层次或级别,从根节点开始:
- 层(Level):根节点在第1层,其孩子在第2层,以此类推。
- 高度(Height):树中结点的最大层次数。
- 深度(Depth):节点到根节点之间的边的数量。
一些基本术语
- 路径(Path):从一个节点到另一个节点的节点序列,这些节点通过边连接。
- 树的度(Degree of a tree):树中节点度的最大值。节点的度是其孩子的数量。
树的性质
我们根据前面提到的简单概念,
(1)树中的结点数等于所有结点的度数加1
(2)度数为m的树中第i层上至多有个结点
(3)高度为h的m叉树至多有个结点
二叉树
二叉树,每个结点至多只能有两颗子树的树,也就是说二叉树中不存在度大于2的结点。
此外,二叉树的子树有左右之分,其次序不能随意颠倒。
- 完全二叉树(Complete Binary Tree):除了最后一层外,每层都被完全填满,且所有节点都尽可能地靠左。如图是一个完全二叉树的例子。
- 平衡二叉树(AVL):树上任一结点的左子树和右子树深度之差绝对值不超过1。
二叉树的一些性质
观察一个二叉树,我们可以在应试的角度提出一些相关的数学性质,大家可以对照简单理解,这些性质对于我们的Coding环节帮助很有限。
1. 非空二叉树的叶子结点数 (L) 与度为2的结点数 (D) 的关系
对于任何非空二叉树,叶子结点的数量 (L) 比度为2的结点数 (D) 多1。
L=D+1
2. 在二叉树的第 k 层上至多有多少个结点
在二叉树的第 k 层上,最多有 个结点(这里层数 k 从1开始计数)。
3. 高度为 (h) 的二叉树至多有多少个结点
一个高度为 h 的二叉树(高度也从1开始计数),至多有 个结点。
4. 对于一个有 (n) 个结点的完全二叉树,结点的编号与其双亲和孩子的编号关系
-
如果我们为二叉树的结点从上至下、从左至右编号(从1开始编号),则对任何结点 (i)(i > 1)而言,其双亲结点的编号是
。
-
对于任何结点 (i),其左孩子(如果存在)的编号是 (2i),其右孩子(如果存在)的编号是 (2i + 1)。
-
结点编号为奇数(除了根结点,编号为1)的结点是其双亲的右孩子,编号为偶数的结点是其双亲的左孩子。
5. 二叉树的高度和结点数量的关系
-
对于有 (n) 个结点的二叉树,其最小高度
为
。
-
对于高度为 (h) 的二叉树,其最少结点数为 h(形成一条链),最多结点数为
(形成一个满二叉树)。
二叉树的存储
可能大家到这里也明白DS这门学科的套路了,存储喜欢用顺序和链式各来一遍。
在二叉树这种数据结构中,两种存储结构各有优势和劣势。顺序存储结构更适用于完全二叉树或近似完全二叉树,因为这样可以最大限度地减少数组中的空位,节省存储空间。而链式存储结构更加灵活,适用于各种形态的二叉树,但需要额外的空间来存储指针。
顺序存储结构通常使用数组来实现。在顺序存储结构中,二叉树中的每个节点都对应数组中的一个位置,位置的索引可以根据节点在树中的层次位置来确定。下面我们分别用C和python语言来写出顺序存储示例。
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int* array;
int size;
} BinaryTree;
BinaryTree* create_binary_tree(int size) {
BinaryTree* tree = (BinaryTree*)malloc(sizeof(BinaryTree));
tree->array = (int*)malloc(sizeof(int) * size);
tree->size = size;
for (int i = 0; i < size; i++) {
tree->array[i] = -1; // 假设-1代表空节点
}
return tree;
}
void set_root(BinaryTree* tree, int value) {
if (tree != NULL) {
tree->array[0] = value;
}
}
void set_left(BinaryTree* tree, int value, int parent_index) {
int index = 2 * parent_index + 1;
if (index < tree->size) {
tree->array[index] = value;
} else {
printf("Left child index is out of bounds\n");
}
}
void set_right(BinaryTree* tree, int value, int parent_index) {
int index = 2 * parent_index + 2;
if (index < tree->size) {
tree->array[index] = value;
} else {
printf("Right child index is out of bounds\n");
}
}
class BinaryTree:
def __init__(self, size):
self.array = [None] * size # 创建指定大小的数组,初始值都为None
def set_root(self, value):
self.array[0] = value
def set_left(self, value, parent_index):
if 2 * parent_index + 1 < len(self.array):
self.array[2 * parent_index + 1] = value
else:
print("Left child index is out of bounds")
def set_right(self, value, parent_index):
if 2 * parent_index + 2 < len(self.array):
self.array[2 * parent_index + 2] = value
else:
print("Right child index is out of bounds")
链式存储结构通过节点间的指针链接来存储二叉树。每个节点包含数据和指向其左右孩子的指针。
#include <stdio.h>
#include <stdlib.h>
typedef struct TreeNode {
int value;
struct TreeNode *left;
struct TreeNode *right;
} TreeNode;
TreeNode* create_node(int value) {
TreeNode* node = (TreeNode*)malloc(sizeof(TreeNode));
node->value = value;
node->left = NULL;
node->right = NULL;
return node;
}
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
二叉树的遍历
回忆C语言实现的二叉树顺序存储结构,根据访问根结点的先后顺序以及访问方式,我们可以将遍历二叉树的方式分为一下几类。
先序遍历(Pre-order Traversal)
先访问根节点,然后递归地遍历左子树,最后递归地遍历右子树。
void preorder(BinaryTree *tree, int index) {
if (index < tree->size && tree->array[index] != -1) {
printf("%d ", tree->array[index]);
preorder(tree, 2 * index + 1); // 访问左子树
preorder(tree, 2 * index + 2); // 访问右子树
}
}
def preorder(self, index=0):
if index < len(self.array) and self.array[index] is not None:
print(self.array[index], end=' ')
self.preorder(2 * index + 1) # 访问左子树
self.preorder(2 * index + 2) # 访问右子树
中序遍历(In-order Traversal)
先递归地遍历左子树,然后访问根节点,最后递归地遍历右子树。
void inorder(BinaryTree *tree, int index) {
if (index < tree->size && tree->array[index] != -1) {
inorder(tree, 2 * index + 1); // 访问左子树
printf("%d ", tree->array[index]);
inorder(tree, 2 * index + 2); // 访问右子树
}
}
def inorder(self, index=0):
if index < len(self.array) and self.array[index] is not None:
self.inorder(2 * index + 1) # 访问左子树
print(self.array[index], end=' ')
self.inorder(2 * index + 2) # 访问右子树
后序遍历(Post-order Traversal)
先递归地遍历左子树,然后递归地遍历右子树,最后访问根节点
void postorder(BinaryTree *tree, int index) {
if (index < tree->size && tree->array[index] != -1) {
postorder(tree, 2 * index + 1); // 访问左子树
postorder(tree, 2 * index + 2); // 访问右子树
printf("%d ", tree->array[index]);
}
}
def postorder(self, index=0):
if index < len(self.array) and self.array[index] is not None:
self.postorder(2 * index + 1) # 访问左子树
self.postorder(2 * index + 2) # 访问右子树
print(self.array[index], end=' ')
层次遍历(Level-order Traversal)
使用辅助队列来按层遍历树。首先将根节点入队,然后循环地执行以下操作:从队列中取出一个节点,访问它,然后将它的非空左右子节点依次入队。
#include <queue.h>
void level_order(BinaryTree *tree) {
int index = 0;
Queue queue = createQueue(tree->size);
enqueue(&queue, index);
while (!isEmptyQueue(&queue)) {
index = dequeue(&queue);
if (index < tree->size && tree->array[index] != -1) {
printf("%d ", tree->array[index]);
if (2 * index + 1 < tree->size && tree->array[2 * index + 1] != -1) {
enqueue(&queue, 2 * index + 1);
}
if (2 * index + 2 < tree->size && tree->array[2 * index + 2] != -1) {
enqueue(&queue, 2 * index + 2);
}
}
}
}
from collections import deque
def level_order(self):
queue = deque([0]) # 队列中存储的是节点的索引
while queue:
index = queue.popleft()
if index < len(self.array) and self.array[index] is not None:
print(self.array[index], end=' ')
left_index = 2 * index + 1
right_index = 2 * index + 2
if left_index < len(self.array) and self.array[left_index] is not None:
queue.append(left_index)
if right_index < len(self.array) and self.array[right_index] is not None:
queue.append(right_index)
森林与树
在介绍完二叉树后,我们还看看“树”的结构,以及树和二叉树的转化。这部分内容在考试中Coding没有那么重要,因此我们在这里就不展示代码,大家重在理解概念,后续我们可能会单独出一版代码版本。在数据结构中,树的存储结构主要有三种表示方法:双亲表示法、孩子表示法、孩子兄弟表示法。
-
双亲表示法:
双亲表示法通过一个数组来存储每个节点及其父节点的位置。每个节点包含节点本身的数据和一个指向其父节点的指针(通常是数组中的索引)。这种方法使得查找父节点变得非常直接,但查找子节点则需要遍历整个数组。 -
孩子表示法:
在孩子表示法中,每个节点有多个指向其子节点的指针。常见的实现是每个节点有一个指向其第一个子节点的指针,以及其它指针指向兄弟节点,形成一个链表。这样的结构便于遍历节点的所有子节点。 -
孩子兄弟表示法:
孩子兄弟表示法将任意树转换为二叉树的存储方式。在这种表示法中,每个节点存储两个指针:一个指向其第一个子节点,另一个指向其直接右侧的兄弟节点。这种方法的优点是能够用二叉树的遍历算法来遍历普通的树。
树转化为二叉树的方法
将一棵普通树转化为二叉树,可以遵循以下规则:
- 将树中每个节点的第一个子节点保持为二叉树中该节点的左子结点。
- 将树中每个节点的兄弟节点转化为二叉树中该节点的右子结点。
在图中,我们的操作方案是:
(1)在兄弟结点之间加一条线;
(2)对每个结点,只保留第一个孩子连线,删掉与其他孩子连线;
(3)结束!以树根为葡萄藤提起来就好!(相当于旋转45度)
森林转化为二叉树的方法
森林顾名思义,就是多棵树的集合,转化为二叉树的方法如下:
- 将森林中的第一棵树转化为二叉树。
- 将森林中的其余树转化为二叉树,并将这些二叉树依次连接为第一棵树的右子树。
树的遍历
树的遍历主要包括先根遍历和后根遍历:
-
先根遍历(Pre-order Traversal):
在先根遍历中,首先访问树的根节点,然后依次遍历每个子树。这种遍历方式适用于树的任何形式。 -
后根遍历(Post-order Traversal):
在后根遍历中,首先遍历每个子树,然后访问树的根节点。这种方式通常用于需要在子节点完成某些操作后才能执行的场景,如释放内存或计算子树的值。
森林的遍历
森林的遍历可以视为树遍历的扩展,包括先序遍历和中序遍历:
-
先序遍历:
在森林的先序遍历中,按照树的先根遍历的顺序依次遍历森林中的每棵树。 -
中序遍历:
在森林的中序遍历中,先遍历第一棵树的所有左侧的树,然后访问第一棵树的根,再遍历右侧的树。这在转化为二叉树后,即为二叉树的中序遍历。
二叉排序树
二叉排序树(Binary Search Tree, BST)是一种二叉树,它的每个结点都具有以下特性:
- 结点的左子树只包含比当前节点小的数。
- 结点的右子树只包含比当前节点大的数。
- 每个子树自身也都是一个二叉排序树。
- 高效的数据检索:二叉排序树能够提供对数据的快速检索,平均查找时间复杂度 O(
)。
- 动态数据集合管理:二叉排序树支持动态地插入和删除数据项,使得数据集合的管理更加灵活。
插入操作
向二叉排序树中插入一个新的值时,需要保持树的排序特性。这通常通过递归地比较新值与树中节点的值,决定是向左子树还是右子树进行插入来实现。
接下来我们用C语言&Python中定义二叉排序树的结构和实现一个插入操作
#include <stdio.h>
#include <stdlib.h>
typedef struct TreeNode {
int value;
struct TreeNode *left, *right;
} TreeNode;
// 插入操作
TreeNode* insert(TreeNode *node, int value) {
if (node == NULL) {
TreeNode *newNode = (TreeNode*)malloc(sizeof(TreeNode));
newNode->value = value;
newNode->left = newNode->right = NULL;
return newNode;
}
if (value < node->value) {
node->left = insert(node->left, value);
} else if (value > node->value) {
node->right = insert(node->right, value);
}
return node;
}
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
def insert(root, value):
if root is None:
return TreeNode(value)
if value < root.value:
root.left = insert(root.left, value)
elif value > root.value:
root.right = insert(root.right, value)
return root
删除操作
从二叉排序树中删除一个值稍微复杂,需要考虑三种基本情况:
- 要删除的节点是叶子节点,可以直接删除。
- 要删除的节点只有一个子节点,可以让这个子节点直接替换掉要删除的节点。
- 要删除的节点有两个子节点,一般的做法是用其右子树的最小节点或左子树的最大节点来替换它。
#include <stdio.h>
#include <stdlib.h>
typedef struct TreeNode {
int value;
struct TreeNode *left, *right;
} TreeNode;
// 辅助函数:找到以给定节点为根的树中的最小值节点
TreeNode* findMin(TreeNode *node) {
while (node->left != NULL) node = node->left;
return node;
}
// 删除操作
TreeNode* deleteNode(TreeNode *root, int value) {
if (root == NULL) return root; // 如果树为空,直接返回
// 如果删除的值小于根值,则在左子树中删除
if (value < root->value) {
root->left = deleteNode(root->left, value);
}
// 如果删除的值大于根值,则在右子树中删除
else if (value > root->value) {
root->right = deleteNode(root->right, value);
}
// 找到了要删除的节点
else {
// 情况1: 无子节点或只有一个子节点
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;
}
// 情况2: 有两个子节点,找到右子树的最小节点
TreeNode *temp = findMin(root->right);
// 将最小节点的值复制到当前节点
root->value = temp->value;
// 删除右子树中的最小节点
root->right = deleteNode(root->right, temp->value);
}
return root;
}
// 插入操作,为了完整性
TreeNode* insert(TreeNode *node, int value) {
if (node == NULL) {
node = (TreeNode*)malloc(sizeof(TreeNode));
if (node == NULL) {
fprintf(stderr, "Out of memory!\n");
exit(1);
}
node->value = value;
node->left = node->right = NULL;
} else if (value < node->value) {
node->left = insert(node->left, value);
} else if (value > node->value) {
node->right = insert(node->right, value);
}
return node;
}
int main() {
// 示例用法
TreeNode *root = NULL;
root = insert(root, 20);
root = insert(root, 10);
root = insert(root, 30);
root = insert(root, 5);
root = insert(root, 15);
root = insert(root, 25);
root = insert(root, 35);
root = deleteNode(root, 20); // 删除有两个子节点的节点
return 0;
}
哈夫曼树
哈夫曼树(Huffman Tree),也称为最优二叉树,是一种应用于数据压缩的树形结构。其特点是通过构造最优的二叉树来实现数据编码,从而达到压缩数据的目的。接下来我们来介绍哈夫曼树:
哈夫曼树根据数据项出现的频率或权值构造。在哈夫曼树中,权值越大的节点离根越近,这样可以确保整个树的加权路径长度最小。这里的关键概念是路径长度和加权路径长度:
- 路径长度:从树中一个结点到另一个结点之间的边的数量。
- 带权路径长度(WPL,Weighted Path Length):树中所有叶子节点的路径长度与其权值的乘积之和
- 数学表示为 (
),其中 (
) 是节点的权值,(
) 是节点的深度。
哈夫曼树的构建过程是一个重复选择两个最小权值节点并合并的过程,直到只剩下一个节点。这个过程可以分为以下步骤:
- 将数据集中的每个数据项作为一个节点,并根据其权值(如出现频率)排序。
- 每次选择两个权值最小的节点合并为一个新的节点,新节点的权值是两个子节点权值的和。
- 将新节点重新插入到列表中,再次排序。
- 重复上述过程,直到所有节点被合并为一棵树。
大家可以根据自身情况完成以下题目:
有一个电文使用五种字符a,b,c,d,e,出现频率分别为4,7,5,2,9
(1)给出对应的哈夫曼树
(2)求每个字符的哈夫曼编码
(3)翻译出编码序列11000111000101011相对应的电文
(4)求带权路径长度
+