树的基本概念
树是一种重要的数据结构,在计算机科学中广泛应用。以下是树的基本概念:
-
节点(Node):
- 树的基本单元。每个节点包含数据,可能还有指向其他节点的连接。
-
根节点(Root Node):
- 树的最顶层节点,只有一个。其他所有节点都直接或间接从它派生。
-
子节点(Child Node):
- 一个节点的直接下层节点称为它的子节点。
-
父节点(Parent Node):
- 一个节点的直接上层节点称为它的父节点。
-
叶子节点(Leaf Node):
- 没有子节点的节点,即树的最底层节点。
-
兄弟节点(Sibling Node):
- 具有相同父节点的节点互为兄弟节点。
-
路径(Path):
- 从一个节点到另一个节点的连接序列。路径的长度通常以路径中连接的数量来表示。
-
深度(Depth):
- 一个节点的深度是从根节点到该节点路径中边的数量。
-
高度(Height):
- 树的高度是根节点到最远叶子节点的最长路径的长度。也可以定义为某个节点的高度是从该节点到最远叶子节点的最长路径的长度。
-
度(Degree):
- 一个节点的度是其子节点的数量。树的度是树中节点的最大度。
-
子树(Subtree):
- 由一个节点及其所有后代节点构成的树。
-
二叉树(Binary Tree):
- 每个节点最多有两个子节点的树,通常被称为左子节点和右子节点。
树的性质
-
节点数和边数关系:
- 对于一个有 nnn 个节点的树,边的数量为 n−1n - 1n−1。这是因为树是一个无环的连通图,每增加一个节点只需要一条边与已有树连接。
-
路径与深度:
- 从根节点到某个节点的路径唯一,路径的长度等于该节点的深度。
-
高度和深度的关系:
- 树的高度是指根节点的高度,也就是从根节点到最远叶子节点的最长路径的长度。
- 树中任意节点的深度和其子树的高度是相关的。叶子节点的深度等于树的高度。
-
叶子节点与内部节点:
- 树中,叶子节点是没有子节点的节点,内部节点(非叶子节点)是有子节点的节点。
- 一棵树至少有一个叶子节点,如果树的节点数为 nnn,内部节点的数量总是比叶子节点少一个。
-
二叉树的性质:
- 对于一个二叉树,如果高度为 hhh,则其最多包含 2h+1−12^{h+1} - 12h+1−1 个节点。
- 在二叉树中,度为 0 的节点(叶子节点)的数量总是比度为 2 的节点多一个。
-
满二叉树(Full Binary Tree):
- 如果一棵二叉树的所有非叶子节点都有两个子节点,那么它就是满二叉树。
- 在满二叉树中,节点的数量 nnn 与叶子节点的数量 lll 关系为 l=n+12l = \frac{n + 1}{2}l=2n+1。
-
完全二叉树(Complete Binary Tree):
- 一棵完全二叉树的特点是每一层的节点都是满的,只有可能在最后一层不满,并且所有节点都集中在左侧。
- 对于一棵高度为 hhh 的完全二叉树,其节点总数至少为 2h2^h2h,最多为 2h+1−12^{h+1} - 12h+1−1。
-
树的递归性质:
- 树具有递归性质,可以把每棵子树看作是一棵独立的树。因此,许多树操作(如遍历、查找、插入、删除等)可以递归地定义和实现。
-
树的平衡性:
- 一棵树的平衡性指的是它的子树高度差的大小。如果子树的高度差不超过某个范围(如 AVL 树中的 1),则称树是平衡的。
- 平衡树通常用于提高查找和插入的效率。
二叉树
二叉树的定义及主要特征
二叉树是一种特殊的树结构,其中每个节点最多有两个子节点,通常称为左子节点和右子节点。二叉树广泛应用于计算机科学的各种领域,如搜索、排序、表达式解析等。
二叉树的定义
- 二叉树是一个由节点组成的集合,这个集合要么是空集(即没有任何节点),要么是满足以下条件的非空集合:
- 有一个称为根的节点。
- 根节点有两个子二叉树,分别称为左子树和右子树,这两个子树也是二叉树。
二叉树的主要特征
-
节点度的限制:
- 在二叉树中,每个节点的度(子节点的数量)最多为 2。一个节点可以有 0、1 或 2 个子节点。
-
递归性质:
- 二叉树具有递归性质,任一节点的左子树和右子树都是二叉树。许多关于二叉树的操作都可以通过递归来实现。
-
子树的有序性:
- 二叉树中的子树是有序的。即使一个节点的左子树和右子树交换位置,结构仍然是二叉树,但它们表示的是不同的二叉树。
-
树的高度和节点数量:
- 对于高度为 hhh 的二叉树,节点的最大数量为 2h+1−12^{h+1} - 12h+1−1,最小数量为 h+1h + 1h+1(当二叉树为线性结构时)。
-
二叉树的类型:
- 满二叉树(Full Binary Tree):每个节点要么是叶子节点,要么有两个子节点,且所有叶子节点都在同一层。
- 完全二叉树(Complete Binary Tree):所有层次都是满的,只有最后一层可能不满,并且节点都集中在左侧。
- 平衡二叉树(Balanced Binary Tree):左右子树的高度差不超过某个常数(如 AVL 树中为 1)。
- 斜树(Skewed Tree):所有节点都只有一个子节点,形成线性结构。分为左斜树(只有左子节点)和右斜树(只有右子节点)。
-
树的遍历:
- 二叉树有多种遍历方式,其中最常用的有三种:
- 前序遍历(Preorder Traversal):先访问根节点,然后遍历左子树,最后遍历右子树。
- 中序遍历(Inorder Traversal):先遍历左子树,然后访问根节点,最后遍历右子树。
- 后序遍历(Postorder Traversal):先遍历左子树,然后遍历右子树,最后访问根节点。
- 二叉树有多种遍历方式,其中最常用的有三种:
-
表达式树:
- 二叉树常用于表达式解析和计算,其中叶子节点表示操作数,内部节点表示运算符。例如,表达式树可以用于解析和计算数学表达式。
二叉树的顺序存储结构和链式存储结构
二叉树的存储方式主要有两种:顺序存储结构和链式存储结构。这两种存储结构各有优缺点,适用于不同的应用场景。
1. 顺序存储结构
顺序存储结构是用一维数组来存储二叉树的节点。它适用于完全二叉树或接近完全二叉树的情况。
顺序存储结构的特点:
-
节点编号:假设二叉树的根节点存储在数组的第 1 个位置(通常编号为 1 或 0),则对于数组中编号为 iii 的节点:
- 左子节点的位置为 2i2i2i(如果存在)。
- 右子节点的位置为 2i+12i + 12i+1(如果存在)。
- 父节点的位置为 i2\frac{i}{2}2i(向下取整)。
-
存储效率:对于完全二叉树或接近完全二叉树,这种结构的存储效率很高,因为节点的位置可以直接通过数组下标计算得到,不需要额外的指针。
-
空间浪费:如果二叉树不是完全二叉树或接近完全二叉树,顺序存储可能会浪费大量空间。因为数组的大小是由二叉树的高度决定的,即使某些位置没有节点,也会预留空间。
举例:
例如,对于下图所示的完全二叉树:
A
/ \
B C
/ \ \
D E F
其顺序存储结构数组表示为:[A, B, C, D, E, None, F]
。
2. 链式存储结构
链式存储结构是为每个节点设置一个结构体或类,包含节点的数据域和两个指针域,分别指向左子节点和右子节点。它适用于各种类型的二叉树,尤其是不完全二叉树。
链式存储结构的特点:
-
节点结构:每个节点包含三个部分:
- 数据域:存储节点的数据。
- 左子节点指针域:指向左子节点。
- 右子节点指针域:指向右子节点。
-
灵活性高:链式存储不需要预留额外的空间,只为实际存在的节点分配存储空间,因此更节省空间,尤其是对于稀疏二叉树或非完全二叉树。
-
复杂性:链式存储结构需要处理指针的管理,增加了算法实现的复杂性,例如遍历、插入和删除操作需要维护指针关系。
举例:
以链式存储结构表示上面的二叉树时,节点 A 的结构如下:
class Node:
def __init__(self, data):
self.data = data # 数据域
self.left = None # 左子节点指针域
self.right = None # 右子节点指针域
A 节点的 left
指针指向节点 B,right
指针指向节点 C,依此类推。
总结
- 顺序存储结构适用于完全二叉树或接近完全二叉树,具有直接计算节点位置的优势,但对不完全二叉树来说空间浪费较大。
- 链式存储结构适用于任意形态的二叉树,存储灵活且不浪费空间,但实现和操作相对复杂,需要处理指针。
二叉树的遍历
二叉树的遍历是指按照某种顺序访问二叉树中的每一个节点,常见的遍历方式有以下四种:
-
前序遍历(Pre-order Traversal):
- 遍历顺序:根节点 -> 左子树 -> 右子树
- 递归实现的代码示例(Java):
void preOrderTraversal(TreeNode node) { if (node == null) { return; } System.out.print(node.val + " "); preOrderTraversal(node.left); preOrderTraversal(node.right); }
-
中序遍历(In-order Traversal):
- 遍历顺序:左子树 -> 根节点 -> 右子树
- 递归实现的代码示例(Java):
void inOrderTraversal(TreeNode node) { if (node == null) { return; } inOrderTraversal(node.left); System.out.print(node.val + " "); inOrderTraversal(node.right); }
-
后序遍历(Post-order Traversal):
- 遍历顺序:左子树 -> 右子树 -> 根节点
- 递归实现的代码示例(Java):
void postOrderTraversal(TreeNode node) { if (node == null) { return; } postOrderTraversal(node.left); postOrderTraversal(node.right); System.out.print(node.val + " "); }
-
层次遍历(Level-order Traversal):
- 遍历顺序:按层次从上到下,从左到右访问节点
- 常用队列(Queue)实现的代码示例(Java):
void levelOrderTraversal(TreeNode root) { if (root == null) { return; } Queue<TreeNode> queue = new LinkedList<>(); queue.add(root); while (!queue.isEmpty()) { TreeNode node = queue.poll(); System.out.print(node.val + " "); if (node.left != null) { queue.add(node.left); } if (node.right != null) { queue.add(node.right); } } }
线索二叉树的基本概念和构造
线索二叉树(Threaded Binary Tree)是一种特殊的二叉树结构,它利用原本为空的左、右指针来存储前驱和后继节点的指针,目的是为了提高二叉树遍历的效率,特别是在没有栈或递归的情况下实现中序遍历。
基本概念
1. 线索
- 前驱线索:如果一个节点的左孩子为空,则左孩子指针指向该节点在中序遍历中的前驱节点,这称为前驱线索。
- 后继线索:如果一个节点的右孩子为空,则右孩子指针指向该节点在中序遍历中的后继节点,这称为后继线索。
2. 线索二叉树的分类
根据线索的类型,线索二叉树可以分为以下几类:
- 单线索二叉树:每个节点只包含前驱线索或后继线索。
- 双线索二叉树:每个节点同时包含前驱线索和后继线索。
#include <stdio.h>
#include <stdlib.h>
typedef enum { Link, Thread } PointerTag; // 枚举类型定义,Link表示普通指针,Thread表示线索
typedef struct ThreadNode {
int data;
struct ThreadNode *left, *right;
PointerTag lTag, rTag; // 左右孩子指针标志域
} ThreadNode, *ThreadTree;
ThreadNode *pre = NULL; // 全局前驱节点指针
// 中序遍历构造线索二叉树
void inThreading(ThreadTree T) {
if (T) {
inThreading(T->left); // 递归左子树线索化
// 左子树为空,建立前驱线索
if (!T->left) {
T->lTag = Thread;
T->left = pre;
}
// 前驱节点的后继线索
if (pre && !pre->right) {
pre->rTag = Thread;
pre->right = T;
}
pre = T; // 保持前驱指针
inThreading(T->right); // 递归右子树线索化
}
}
// 创建一棵线索二叉树
void createInThread(ThreadTree T) {
pre = NULL;
if (T) {
inThreading(T); // 中序遍历进行线索化
if (pre->right == NULL) { // 处理遍历的最后一个节点
pre->rTag = Thread;
}
}
}
// 中序遍历线索二叉树(非递归)
void inOrderTraverse(ThreadTree T) {
ThreadNode *p = T;
while (p) {
// 找到最左下节点(不一定是根节点)
while (p->lTag == Link) {
p = p->left;
}
printf("%d ", p->data); // 访问节点
// 按后继线索访问节点
while (p->rTag == Thread && p->right) {
p = p->right;
printf("%d ", p->data); // 访问节点
}
p = p->right; // 移到下一个节点
}
}
// 示例:构造一个简单的二叉树并进行线索化和遍历
int main() {
ThreadNode n1 = {1, NULL, NULL, Link, Link};
ThreadNode n2 = {2, NULL, NULL, Link, Link};
ThreadNode n3 = {3, NULL, NULL, Link, Link};
ThreadNode n4 = {4, NULL, NULL, Link, Link};
ThreadNode n5 = {5, NULL, NULL, Link, Link};
n1.left = &n2;
n1.right = &n3;
n2.left = &n4;
n2.right = &n5;
ThreadTree T = &n1;
createInThread(T); // 线索化二叉树
printf("中序遍历线索二叉树:\n");
inOrderTraverse(T); // 中序遍历线索二叉树
return 0;
}
代码说明:
-
ThreadNode 结构体:
data
:节点的数据域。left
和right
:左右孩子指针。lTag
和rTag
:标志左右孩子指针是否是线索(Link表示指向子节点,Thread表示指向前驱或后继节点的线索)。
-
inThreading 函数:
- 通过递归的方式进行中序遍历并为每个节点创建前驱和后继线索。
-
createInThread 函数:
- 这是构造线索二叉树的入口函数。
-
inOrderTraverse 函数:
- 非递归方式遍历线索化的二叉树,利用线索高效地进行中序遍历。
-
main 函数:
- 构造一个简单的二叉树,进行线索化,然后遍历输出结果。
树、森林
树的存储结构
树的存储结构主要有以下几种形式:
-
双亲表示法(Parent Representation):
- 定义:使用一组顺序存储的节点,其中每个节点包含一个指针(或索引),指向它的双亲节点。
- 结构:
- 每个节点通常包含数据和指向其双亲的索引。
- 适用于查找双亲较为方便的情况,但查找孩子需要遍历整个结构。
- 示例:
- 如果节点i的父节点是j,则
parent[i] = j
。根节点的父指针通常设置为-1或NULL。
- 如果节点i的父节点是j,则
- 优缺点:
- 优点:存储简单,适合表达通用的树结构。
- 缺点:查找子节点较慢,效率低。
typedef struct { int data; int parent; // 指向父节点的索引 } PTNode; PTNode tree[MAX_SIZE]; // 树的顺序存储表示
-
孩子表示法(Child Representation):
- 定义:每个节点保存一个指向其孩子的指针,孩子们组织成一个线性表(通常使用链表)。
- 结构:
- 每个节点保存一个指向其孩子链表的指针,链表中每个节点保存一个指向实际孩子节点的指针。
- 示例:
- 每个节点的孩子链表可能包含多个节点,表示当前节点的所有孩子。
- 优缺点:
- 优点:快速访问子节点。
- 缺点:额外空间开销大,复杂性高。
typedef struct CTNode { int child; // 子节点的位置 struct CTNode *next; // 下一个子节点指针 } CTNode; typedef struct { int data; CTNode *firstChild; // 第一个孩子指针 } CTBox; CTBox tree[MAX_SIZE]; // 树的孩子链表表示法
-
孩子兄弟表示法(Child-Sibling Representation):
- 定义:每个节点保存两个指针,一个指向第一个孩子节点,另一个指向右兄弟节点。
- 结构:
- 每个节点都具有两个指针,一个指向第一个孩子,另一个指向右兄弟,这样可以利用二叉树的结构来表示多叉树。
- 示例:
- 适合树结构表示为二叉树,方便转换和存储。
- 优缺点:
- 优点:易于转换为二叉树,便于遍历。
- 缺点:相对复杂,需要处理两个指针。
typedef struct CSNode { int data; struct CSNode *firstChild, *nextSibling; // 孩子指针和兄弟指针 } CSNode, *CSTree;
-
顺序存储表示法(Sequential Storage Representation):
- 定义:适合完全二叉树,节点顺序存储在数组中,父子关系通过数组下标计算。
- 结构:
- 对于节点i,其左孩子在
2*i + 1
,右孩子在2*i + 2
,父节点在(i - 1) / 2
。
- 对于节点i,其左孩子在
- 示例:
- 完全二叉树的情况最适合使用顺序存储表示,能够高效地利用数组下标来确定节点关系。
- 优缺点:
- 优点:节省空间,直接索引,效率高。
- 缺点:适用于完全二叉树,不适合一般树或稀疏树。
int tree[MAX_SIZE]; // 用数组表示树结构
选择存储结构的考虑因素
- 树的类型:比如,完全二叉树适合顺序存储,通用的树适合孩子兄弟表示法。
- 操作需求:需要高效的查找孩子节点、兄弟节点或父节点时,选择合适的存储结构。
- 空间复杂度:考虑内存使用情况,简单结构可能占用较少空间。
森林与二叉树的转换
基本概念
- 森林:由多棵互不相交的树组成的集合称为森林。
- 二叉树:每个节点最多有两个孩子的树结构。
转换步骤
森林转二叉树的转换基于以下两条规则:
- 左孩子:树中每个节点的第一个孩子,作为该节点的左孩子。
- 右兄弟:树中每个节点的右边第一个兄弟节点,作为该节点的右孩子。
通过这两条规则,森林可以转化为二叉树,其中每棵树的根节点相连构成一个整体的二叉树。
转换过程的详细步骤
- 处理每棵树:对于森林中的每棵树,从根节点开始,将根节点的第一个孩子作为左孩子,将其他兄弟节点依次作为右孩子。
- 处理整个森林:将森林中第一棵树的根节点作为二叉树的根节点,然后依次将其他树的根节点作为前一棵树的根节点的右孩子,形成一个完整的二叉树。
#include <stdio.h> #include <stdlib.h> // 定义二叉树节点 typedef struct Node { char data; struct Node *left, *right; } Node; // 创建新节点 Node* createNode(char data) { Node* newNode = (Node*)malloc(sizeof(Node)); newNode->data = data; newNode->left = newNode->right = NULL; return newNode; } // 森林转换为二叉树的核心函数 Node* forestToBinaryTree(Node** forest, int n) { if (n == 0) return NULL; Node* root = forest[0]; // 将第一棵树的根作为二叉树的根 Node* current = root; for (int i = 1; i < n; i++) { current->right = forest[i]; // 连接右兄弟 current = current->right; } return root; } // 中序遍历二叉树 void inOrderTraversal(Node* root) { if (root) { inOrderTraversal(root->left); printf("%c ", root->data); inOrderTraversal(root->right); } } int main() { // 创建三个树根节点,表示森林中的三棵树 Node* T1 = createNode('A'); T1->left = createNode('B'); T1->left->right = createNode('C'); T1->left->right->right = createNode('D'); Node* T2 = createNode('E'); T2->left = createNode('F'); T2->left->right = createNode('G'); Node* T3 = createNode('H'); T3->left = createNode('I'); T3->left->right = createNode('J'); Node* forest[] = {T1, T2, T3}; // 将森林转换为二叉树 Node* binaryTree = forestToBinaryTree(forest, 3); // 遍历并打印二叉树 printf("Converted Binary Tree (In-order Traversal):\n"); inOrderTraversal(binaryTree); printf("\n"); return 0; }
代码解释
- createNode:用于创建新节点。
- forestToBinaryTree:核心函数,按转换规则将森林转化为一棵二叉树。
- inOrderTraversal:对二叉树进行中序遍历,以验证转换结果。
- main:创建森林中的树,调用转换函数并输出结果。
树和森林的遍历
一、树的遍历
树的遍历主要包括以下几种:
1. 先序遍历(Preorder Traversal)
先序遍历的顺序是:根节点 → 左子树 → 右子树。
递归实现
void preorder(TreeNode *root) {
if (root != NULL) {
printf("%c ", root->data); // 访问根节点
preorder(root->left); // 先序遍历左子树
preorder(root->right); // 先序遍历右子树
}
}
2. 中序遍历(Inorder Traversal)
中序遍历的顺序是:左子树 → 根节点 → 右子树。
递归实现
void inorder(TreeNode *root) {
if (root != NULL) {
inorder(root->left); // 中序遍历左子树
printf("%c ", root->data); // 访问根节点
inorder(root->right); // 中序遍历右子树
}
}
3. 后序遍历(Postorder Traversal)
后序遍历的顺序是:左子树 → 右子树 → 根节点。
递归实现
void postorder(TreeNode *root) {
if (root != NULL) {
postorder(root->left); // 后序遍历左子树
postorder(root->right); // 后序遍历右子树
printf("%c ", root->data); // 访问根节点
}
}
4. 层次遍历(Level-order Traversal)
层次遍历是按层次从上到下、从左到右依次访问各节点。
队列实现
#include <stdio.h>
#include <stdlib.h>
typedef struct TreeNode {
char data;
struct TreeNode *left, *right;
} TreeNode;
void levelOrder(TreeNode *root) {
if (root == NULL) return;
TreeNode *queue[100]; // 简单队列实现
int front = 0, rear = 0;
queue[rear++] = root;
while (front < rear) {
TreeNode *node = queue[front++];
printf("%c ", node->data);
if (node->left) queue[rear++] = node->left;
if (node->right) queue[rear++] = node->right;
}
}
二、森林的遍历
森林可以看作是多棵树的集合,对森林的遍历可以通过对每棵树进行遍历来实现。
1. 先序遍历森林
先序遍历森林中的每棵树,按照根节点 → 左子树 → 右子树的顺序。
递归实现
void preorderForest(TreeNode *forest[]) {
for (int i = 0; forest[i] != NULL; i++) {
preorder(forest[i]); // 对每棵树进行先序遍历
}
}
2. 后序遍历森林
后序遍历森林中的每棵树,按照左子树 → 右子树 → 根节点的顺序。
递归实现
void postorderForest(TreeNode *forest[]) {
for (int i = 0; forest[i] != NULL; i++) {
postorder(forest[i]); // 对每棵树进行后序遍历
}
}
3. 层次遍历森林
层次遍历森林中的每棵树,按层次依次访问各节点。
队列实现
void levelOrderForest(TreeNode *forest[]) {
for (int i = 0; forest[i] != NULL; i++) {
levelOrder(forest[i]); // 对每棵树进行层次遍历
}
}
三、示例代码
假设有一个森林包含三棵树,我们可以按如下方式对其进行遍历:
int main() {
TreeNode *T1 = createNode('A');
T1->left = createNode('B');
T1->left->right = createNode('C');
T1->left->right->right = createNode('D');
TreeNode *T2 = createNode('E');
T2->left = createNode('F');
T2->left->right = createNode('G');
TreeNode *T3 = createNode('H');
T3->left = createNode('I');
T3->left->right = createNode('J');
TreeNode *forest[] = {T1, T2, T3, NULL};
printf("Preorder Traversal of Forest:\n");
preorderForest(forest);
printf("\nPostorder Traversal of Forest:\n");
postorderForest(forest);
printf("\nLevel-order Traversal of Forest:\n");
levelOrderForest(forest);
return 0;
}
运行结果
Preorder Traversal of Forest:
A B C D E F G H I J
Postorder Traversal of Forest:
B D C A F G E I J H
Level-order Traversal of Forest:
A B C D E F G H I J
总结
- 树的遍历:可以采用先序、中序、后序和层次遍历。
- 森林的遍历:通过对每棵树进行遍历来实现,可以采用先序、后序和层次遍历。
树与二叉树的应用
哈夫曼树和哈夫曼编码
哈夫曼树和哈夫曼编码是信息论中的重要概念,主要用于数据压缩。通过构造哈夫曼树,可以生成哈夫曼编码,这是一种最优前缀编码,能够有效地减少编码后的数据长度。
一、哈夫曼树的基本概念
1. 定义
- 哈夫曼树(Huffman Tree):是一种带权路径长度最短的二叉树,又称最优二叉树。它是基于贪心算法构造的,常用于无损数据压缩。
2. 权重和带权路径长度
- 权重:每个节点的权重通常是其对应字符的频率或概率。
- 路径长度:从树的根节点到某一节点的路径上的边的数量。
- 带权路径长度:树中所有叶子节点的路径长度与其权重的乘积之和。哈夫曼树的目标是使得带权路径长度最小。
3. 哈夫曼树的构造方法
构造哈夫曼树的过程可以通过以下步骤实现:
- 初始化:将所有待编码的字符及其频率视为一棵只有一个节点的树。
- 构造新节点:每次从森林中选出两个权重最小的树,作为新树的左右子树,合并为一棵新的树,新树的根节点的权重为其左右子树的权重之和。
- 重复:重复上述步骤,直到森林中只剩下一棵树,这棵树就是哈夫曼树。
二、哈夫曼编码
1. 定义
- 哈夫曼编码(Huffman Coding):是一种前缀编码,即任何一个编码都不是其他编码的前缀。它利用字符的频率,通过哈夫曼树生成变长编码,频率高的字符用短编码,频率低的字符用长编码,从而减少总编码长度。
2. 编码过程
- 构造哈夫曼树:按照前述方法构造哈夫曼树。
- 生成编码:从根节点开始,对每个左分支标记为
0
,右分支标记为1
,一直到叶子节点,从根到叶子节点的路径就是对应字符的编码。
3. 示例
假设我们有以下字符及其频率:
字符 | 频率 |
---|---|
A | 5 |
B | 7 |
C | 10 |
D | 15 |
E | 20 |
F | 45 |
构造哈夫曼树的步骤:
- 选择频率最小的两个节点A和B,合并为新节点(A+B),权重为12。
- 选择频率最小的两个节点C和(A+B),合并为新节点(C+(A+B)),权重为22。
- 选择频率最小的两个节点D和E,合并为新节点(D+E),权重为35。
- 选择频率最小的两个节点(F)和(C+(A+B)),合并为新节点(F+(C+(A+B))),权重为67。
- 最后合并剩下的两个节点(F+(C+(A+B)))和(D+E),形成哈夫曼树。
哈夫曼树如下:
(124)
/ \
(F)45 (79)
/ \
(E+D)35 (C+(A+B))22
/ \ / \
E20 D15 C10 (A+B)12
/ \
A5 B7
哈夫曼编码:
- A:1100
- B:1101
- C:111
- D:101
- E:100
- F:0
4. 应用
哈夫曼编码广泛应用于文件压缩,如ZIP文件、JPEG图片、MP3音乐文件中,通过减少数据的冗余信息,达到压缩的目的。
#include <stdio.h>
#include <stdlib.h>
#define MAX_TREE_HT 100
// 哈夫曼树节点
typedef struct MinHeapNode {
char data;
unsigned freq;
struct MinHeapNode *left, *right;
} MinHeapNode;
// 最小堆(用于构造哈夫曼树)
typedef struct MinHeap {
unsigned size;
unsigned capacity;
struct MinHeapNode **array;
} MinHeap;
// 创建新节点
MinHeapNode* newNode(char data, unsigned freq) {
MinHeapNode* temp = (MinHeapNode*)malloc(sizeof(MinHeapNode));
temp->left = temp->right = NULL;
temp->data = data;
temp->freq = freq;
return temp;
}
// 创建最小堆
MinHeap* createMinHeap(unsigned capacity) {
MinHeap* minHeap = (MinHeap*)malloc(sizeof(MinHeap));
minHeap->size = 0;
minHeap->capacity = capacity;
minHeap->array = (MinHeapNode**)malloc(minHeap->capacity * sizeof(MinHeapNode*));
return minHeap;
}
// 交换两个最小堆节点
void swapMinHeapNode(MinHeapNode** a, MinHeapNode** b) {
MinHeapNode* t = *a;
*a = *b;
*b = t;
}
// 堆化
void minHeapify(MinHeap* minHeap, int idx) {
int smallest = idx;
int left = 2 * idx + 1;
int right = 2 * idx + 2;
if (left < minHeap->size && minHeap->array[left]->freq < minHeap->array[smallest]->freq)
smallest = left;
if (right < minHeap->size && minHeap->array[right]->freq < minHeap->array[smallest]->freq)
smallest = right;
if (smallest != idx) {
swapMinHeapNode(&minHeap->array[smallest], &minHeap->array[idx]);
minHeapify(minHeap, smallest);
}
}
// 检查大小是否为1
int isSizeOne(MinHeap* minHeap) {
return (minHeap->size == 1);
}
// 从堆中取出最小值
MinHeapNode* extractMin(MinHeap* minHeap) {
MinHeapNode* temp = minHeap->array[0];
minHeap->array[0] = minHeap->array[minHeap->size - 1];
--minHeap->size;
minHeapify(minHeap, 0);
return temp;
}
// 插入节点
void insertMinHeap(MinHeap* minHeap, MinHeapNode* minHeapNode) {
++minHeap->size;
int i = minHeap->size - 1;
while (i && minHeapNode->freq < minHeap->array[(i - 1) / 2]->freq) {
minHeap->array[i] = minHeap->array[(i - 1) / 2];
i = (i - 1) / 2;
}
minHeap->array[i] = minHeapNode;
}
// 构建最小堆
void buildMinHeap(MinHeap* minHeap) {
int n = minHeap->size - 1;
for (int i = (n - 1) / 2; i >= 0; --i)
minHeapify(minHeap, i);
}
// 创建并构建最小堆
MinHeap* createAndBuildMinHeap(char data[], int freq[], int size) {
MinHeap* minHeap = createMinHeap(size);
for (int i = 0; i < size; ++i)
minHeap->array[i] = newNode(data[i], freq[i]);
minHeap->size = size;
buildMinHeap(minHeap);
return minHeap;
}
// 构建哈夫曼树
MinHeapNode* buildHuffmanTree(char data[], int freq[], int size) {
MinHeapNode *left, *right, *top;
// 创建最小堆
MinHeap* minHeap = createAndBuildMinHeap(data, freq, size);
// 合并过程
while (!isSizeOne(minHeap)) {
left = extractMin(minHeap);
right = extractMin(minHeap);
top = newNode('$', left->freq + right->freq);
top->left = left;
top->right = right;
insertMinHeap(minHeap, top);
}
return extractMin(minHeap);
}
// 打印哈夫曼编码
void printCodes(MinHeapNode* root, int arr[], int top) {
if (root->left) {
arr[top] = 0;
printCodes(root->left, arr, top + 1);
}
if (root->right) {
arr[top] = 1;
print
并查集及其应用
并查集(Union-Find Set)是一种用于处理不相交集合(disjoint sets)合并和查询问题的数据结构。它在计算机科学中有广泛的应用,尤其是在图的连通性问题中。
一、并查集的基本概念
1. 并查集的基本操作
并查集支持两个基本操作:
- 查找(Find):查找元素所属的集合,通常返回集合的代表元素(根节点)。
- 合并(Union):将两个不相交的集合合并为一个集合。
2. 树型结构表示
- 并查集通常使用树型结构表示,每个集合被表示为一棵树,树的根节点作为该集合的代表元素。每个节点指向其父节点,根节点指向自身。
- 路径压缩:在查找操作中,将树的深度尽可能压缩,使得后续查找操作更加高效。
- 按秩合并:在合并操作中,优先将秩(即树的高度)较小的树合并到秩较大的树上,以防止树的高度过高。
二、并查集的实现
下面是并查集的基本实现(带路径压缩和按秩合并):
#include <stdio.h>
#define MAX_SIZE 100
// 并查集结构
typedef struct {
int parent[MAX_SIZE];
int rank[MAX_SIZE];
} UnionFind;
// 初始化并查集
void init(UnionFind *uf, int n) {
for (int i = 0; i < n; i++) {
uf->parent[i] = i; // 每个元素指向自己
uf->rank[i] = 0; // 初始秩为0
}
}
// 查找操作(带路径压缩)
int find(UnionFind *uf, int x) {
if (uf->parent[x] != x) {
uf->parent[x] = find(uf, uf->parent[x]); // 路径压缩
}
return uf->parent[x];
}
// 合并操作(按秩合并)
void unionSets(UnionFind *uf, int x, int y) {
int rootX = find(uf, x);
int rootY = find(uf, y);
if (rootX != rootY) {
// 按秩合并
if (uf->rank[rootX] > uf->rank[rootY]) {
uf->parent[rootY] = rootX;
} else if (uf->rank[rootX] < uf->rank[rootY]) {
uf->parent[rootX] = rootY;
} else {
uf->parent[rootY] = rootX;
uf->rank[rootX]++;
}
}
}
int main() {
UnionFind uf;
int n = 10; // 假设有10个元素
// 初始化并查集
init(&uf, n);
// 合并一些集合
unionSets(&uf, 1, 2);
unionSets(&uf, 3, 4);
unionSets(&uf, 2, 3);
unionSets(&uf, 5, 6);
// 查询两个元素是否属于同一个集合
if (find(&uf, 1) == find(&uf, 4)) {
printf("1 and 4 are in the same set.\n");
} else {
printf("1 and 4 are in different sets.\n");
}
return 0;
}
代码解释
- init:初始化并查集,每个元素初始指向自己,秩为0。
- find:查找元素的根节点,并使用路径压缩优化查找路径。
- unionSets:合并两个集合,使用按秩合并策略优化合并过程。
运行结果
1 and 4 are in the same set.
三、并查集的应用
1. 动态连通性问题
并查集常用于解决动态连通性问题,即判断在一系列操作后,两个元素是否属于同一个连通分量。例如,判断图中的两个节点是否连通。
2. Kruskal算法
并查集在Kruskal算法中用于最小生成树的构造。Kruskal算法通过将边按权值排序,然后逐步加入生成树,使用并查集判断是否会形成环。
3. 处理等式和不等式
并查集可以用于处理一组等式和不等式的矛盾性。例如,判断一组变量的等式和不等式是否能同时成立。
4. 图的连通分量
并查集可以用来求解无向图的连通分量,即通过逐步合并图中的边,最终得到所有连通分量。
5. 网络连接问题
在计算机网络中,可以用并查集来处理动态的网络连接和断开操作,快速判断网络中两个节点是否连通。
四、总结
- 并查集是一种高效的数据结构,特别适合处理不相交集合的合并和查询问题。
- 通过路径压缩和按秩合并优化,并查集的查询和合并操作的时间复杂度接近于常数级别。
- 并查集在图论算法、动态连通性、等式处理等多个领域有着广泛的应用。