数据结构:树与二叉树
📌目录
- 🌳 一,树与二叉树的定义
- (一)树的定义
- (二)树的基本术语
- (三)二叉树的定义
- 🌰 二,案例引入
- 场景1:文件系统的目录结构
- 场景2:决策树模型
- 📋 三,树与二叉树的抽象数据类型定义
- 树的ADT定义
- 二叉树的ADT定义
- 🔑 四,二叉树的性质和存储结构
- (一)二叉树的性质
- (二)二叉树的存储结构
- 1. 顺序存储结构
- 2. 链式存储结构(二叉链表)
- 🔍 五,遍历二叉树与线索二叉树
- (一)遍历二叉树
- 1. 先序遍历(Pre-order)
- 2. 中序遍历(In-order)
- 3. 后序遍历(Post-order)
- 4. 层序遍历(Level-order)
- (二)线索二叉树
- 🌳 六,树与森林
- (一)树的存储结构
- 1. 双亲表示法
- 2. 孩子表示法
- 3. 孩子兄弟表示法(二叉树表示法)
- (二)森林与二叉树的转换
- (三)树和森林的遍历
- 📊 七,哈夫曼树及其应用
- (一)哈夫曼树的基本概念
- (二)哈夫曼树的构造算法
- 算法步骤
- 示例(权值3、4、5、6)
- 代码实现(基于最小堆)
- (三)哈夫曼编码
- 编码规则
- 示例(字符A(3)、B(4)、C(5)、D(6))
- 优势
- 解码过程
- 🛠️ 八,案例分析与实现
- 案例:哈夫曼编码压缩系统
- 实现步骤
- 核心代码
- 效果分析
- 📝 章结
🌳 一,树与二叉树的定义
树是一种非线性数据结构,它通过层次化的关系组织数据,广泛应用于文件系统、数据库索引、人工智能等领域。与线性表的“一对一”关系不同,树的核心是“一对多”的层级关系。
(一)树的定义
树(Tree)是由n(n≥0)个节点组成的有限集合。当n=0时,称为空树;当n>0时,满足:
- 有且仅有一个特定的节点称为根(Root),它没有前驱节点;
- 其余节点可分为m(m≥0)个互不相交的有限集合T₁,T₂,…,Tₘ,每个集合本身又是一棵树,称为根的子树(Subtree)。
示例:一个家族树中,祖父是根节点,父亲和叔叔是祖父的子节点,而父亲的子女是其下的子树节点。树的定义具有递归性——子树也是树。
(二)树的基本术语
- 节点的度(Degree):节点拥有的子树数量。例如,根节点若有3个子树,则度为3。
- 叶子节点(Leaf):度为0的节点(无子女),又称终端节点。
- 分支节点:度不为0的节点,又称非终端节点(根节点除外的分支节点称为内部节点)。
- 树的度:所有节点中度的最大值。
- 层次(Level):从根开始计数,根为第1层,根的子女为第2层,以此类推。
- 深度(Depth):树中节点的最大层次数(从根到最远叶子的层数)。
- 有序树与无序树:若子树的顺序有意义(如左子树≠右子树),则为有序树;否则为无序树。
- 森林:m(m≥0)棵互不相交的树的集合(可理解为“去掉根的树”)。
(三)二叉树的定义
二叉树(Binary Tree)是一种特殊的树,它的每个节点最多有两棵子树,且子树有左右之分(左子树和右子树,顺序不可颠倒)。
二叉树的递归定义:
- 空树是二叉树;
- 若存在一个节点,其左子树和右子树都是二叉树,则该结构为二叉树。
特殊二叉树类型:
- 满二叉树:除叶子节点外,每个节点都有左右两棵子树,且所有叶子节点在同一层次(如深度为3的满二叉树有7个节点)。
- 完全二叉树:深度为k的二叉树,前k-1层为满二叉树,第k层的节点从左到右连续排列(缺一不可),适合顺序存储。
🌰 二,案例引入
场景1:文件系统的目录结构
电脑中的文件系统是典型的树结构:
- 根目录(如Windows的
C:\
)是根节点; - 文件夹是分支节点,包含子文件夹或文件;
- 具体文件是叶子节点,没有子节点。
通过树结构,用户可高效地层级化管理文件(如“文档→2023→报告.docx”)。
场景2:决策树模型
在AI分类任务中,决策树通过层层判断实现分类:
- 根节点是初始特征(如“天气是否晴朗”);
- 分支节点是中间判断条件(如“温度是否高于25℃”);
- 叶子节点是分类结果(如“适合户外活动”或“不适合”)。
树的层次结构模拟了人类的决策逻辑。
这些案例表明,树结构擅长表达具有层级关系或决策流程的数据,其效率远高于线性表。
📋 三,树与二叉树的抽象数据类型定义
树的ADT定义
ADT Tree {数据:节点集合,根节点唯一,其余节点分属子树,存在层次关系。操作:1. InitTree(&T):初始化空树T。2. CreateTree(&T):按指定方式创建树T。3. Root(T):返回树T的根节点。4. Parent(T, x):返回节点x的父节点。5. Child(T, x, i):返回节点x的第i个子节点。6. InsertChild(&T, x, i, c):将子树c插入为x的第i个子树。7. DeleteChild(&T, x, i):删除x的第i个子树。8. TraverseTree(T):遍历树T的所有节点。9. DestroyTree(&T):销毁树T。
}
二叉树的ADT定义
ADT BinaryTree {数据:节点集合,每个节点最多有左、右两棵子树,子树有序。操作:1. InitBiTree(&T):初始化空二叉树T。2. CreateBiTree(&T):创建二叉树T。3. BiRoot(T):返回二叉树T的根节点。4. LeftChild(T, x):返回节点x的左子节点。5. RightChild(T, x):返回节点x的右子节点。6. InsertLeftChild(&T, x, c):为x插入左子树c。7. InsertRightChild(&T, x, c):为x插入右子树c。8. PreOrderTraverse(T):先序遍历二叉树。9. InOrderTraverse(T):中序遍历二叉树。10. PostOrderTraverse(T):后序遍历二叉树。11. LevelOrderTraverse(T):层序遍历二叉树。12. DestroyBiTree(&T):销毁二叉树T。
}
🔑 四,二叉树的性质和存储结构
(一)二叉树的性质
性质1:在二叉树的第i层上,最多有2ⁱ⁻¹个节点(i≥1)。
- 示例:第1层最多1个节点(2⁰),第2层最多2个节点(2¹),第3层最多4个节点(2²)。
性质2:深度为k的二叉树最多有2ᵏ - 1个节点(k≥1)。
- 满二叉树是该性质的特例(如深度为3的满二叉树有2³ - 1 = 7个节点)。
性质3:对任意二叉树,若叶子节点数为n₀,度为2的节点数为n₂,则n₀ = n₂ + 1。
- 推导:总节点数n = n₀ + n₁ + n₂(n₁为度1的节点数),总边数n-1 = n₁ + 2n₂,联立得n₀ = n₂ + 1。
性质4:具有n个节点的完全二叉树的深度为⌊log₂n⌋ + 1(⌊x⌋表示向下取整)。
- 示例:n=7时,深度为⌊log₂7⌋ + 1 = 2 + 1 = 3。
性质5:对完全二叉树中编号为i的节点(从1开始):
- 若i>1,则父节点编号为⌊i/2⌋;
- 左子节点编号为2i(若2i≤n);
- 右子节点编号为2i+1(若2i+1≤n)。
(二)二叉树的存储结构
1. 顺序存储结构
适合完全二叉树,用数组按层序存储节点,通过性质5的公式计算父子节点位置。
-
存储表示(C语言):
#define MAXSIZE 100 typedef char TElemType; typedef struct {TElemType data[MAXSIZE]; // 存储节点数据int length; // 节点总数 } SqBiTree;
-
优势:空间紧凑,访问父子节点高效(O(1));
-
劣势:非完全二叉树会浪费大量空间(需用特殊值填充空缺节点)。
2. 链式存储结构(二叉链表)
每个节点包含数据域和两个指针域(分别指向左、右子节点),适合任意二叉树。
-
存储表示(C语言):
typedef char TElemType; typedef struct BiTNode {TElemType data; // 数据域struct BiTNode *lchild, *rchild; // 左、右子指针 } BiTNode, *BiTree;
-
示例:创建一个简单二叉树节点:
BiTree CreateNode(TElemType x) {BiTNode *node = (BiTNode*)malloc(sizeof(BiTNode));node->data = x;node->lchild = NULL;node->rchild = NULL;return node; }// 构建一棵二叉树: A // / \ // B C BiTree BuildBiTree() {BiTree root = CreateNode('A');root->lchild = CreateNode('B');root->rchild = CreateNode('C');return root; }
-
优势:空间利用率高,插入删除灵活;
-
劣势:访问父节点需遍历(可扩展为三叉链表,增加父指针域)。
🔍 五,遍历二叉树与线索二叉树
(一)遍历二叉树
遍历是按某种规则访问二叉树的所有节点,且每个节点仅访问一次。二叉树的遍历基于递归定义,核心有4种方式:
1. 先序遍历(Pre-order)
顺序:根节点 → 左子树 → 右子树(根左右)。
递归实现:
void PreOrderTraverse(BiTree T) {if (T == NULL) return;printf("%c ", T->data); // 访问根节点PreOrderTraverse(T->lchild); // 遍历左子树PreOrderTraverse(T->rchild); // 遍历右子树
}
示例:对树 A(B(D,E),C(,F))
先序遍历结果为:A B D E C F。
2. 中序遍历(In-order)
顺序:左子树 → 根节点 → 右子树(左根右)。
递归实现:
void InOrderTraverse(BiTree T) {if (T == NULL) return;InOrderTraverse(T->lchild); // 遍历左子树printf("%c ", T->data); // 访问根节点InOrderTraverse(T->rchild); // 遍历右子树
}
示例:上述树的中序遍历结果为:D B E A C F。
3. 后序遍历(Post-order)
顺序:左子树 → 右子树 → 根节点(左右根)。
递归实现:
void PostOrderTraverse(BiTree T) {if (T == NULL) return;PostOrderTraverse(T->lchild); // 遍历左子树PostOrderTraverse(T->rchild); // 遍历右子树printf("%c ", T->data); // 访问根节点
}
示例:上述树的后序遍历结果为:D E B F C A。
4. 层序遍历(Level-order)
顺序:按层次从左到右访问节点(需借助队列实现)。
算法步骤:
- 根节点入队;
- 队不空时,出队一个节点并访问;
- 若该节点有左子节点,入队;
- 若该节点有右子节点,入队;
- 重复步骤2-4直至队空。
实现代码:
void LevelOrderTraverse(BiTree T) {if (T == NULL) return;BiTNode *queue[MAXSIZE]; // 用数组模拟队列int front = 0, rear = 0;queue[rear++] = T; // 根节点入队while (front != rear) {BiTNode *p = queue[front++]; // 出队printf("%c ", p->data); // 访问节点if (p->lchild) queue[rear++] = p->lchild; // 左子节点入队if (p->rchild) queue[rear++] = p->rchild; // 右子节点入队}
}
示例:上述树的层序遍历结果为:A B C D E F。
(二)线索二叉树
二叉链表中存在大量空指针(n个节点有n+1个空指针),线索二叉树利用这些空指针存储遍历序列中的前驱/后继信息,提高遍历效率。
- 线索:将空的左指针改为指向遍历前驱,空的右指针改为指向遍历后继,称为线索。
- 线索二叉树结构:在二叉链表基础上增加两个标志位(ltag/rtag):
- ltag=0:lchild指向左子树;ltag=1:lchild指向前驱;
- rtag=0:rchild指向右子树;rtag=1:rchild指向后继。
typedef struct ThreadNode {TElemType data;struct ThreadNode *lchild, *rchild;int ltag, rtag; // 线索标志位
} ThreadNode, *ThreadTree;
- 优势:无需递归或栈即可实现遍历,节省空间,适合频繁遍历的场景。
🌳 六,树与森林
(一)树的存储结构
1. 双亲表示法
用数组存储节点,每个节点记录数据和父节点下标,适合查询父节点的场景。
#define MAX_TREE_SIZE 100
typedef struct {TElemType data;int parent; // 父节点下标(-1表示无父节点)
} PTNode;typedef struct {PTNode nodes[MAX_TREE_SIZE];int r; // 根节点下标int n; // 节点总数
} PTree;
2. 孩子表示法
数组+链表结合:数组存储节点,每个节点通过链表指向所有子节点,适合查询子节点的场景。
3. 孩子兄弟表示法(二叉树表示法)
每个节点包含数据、第一个孩子指针和右兄弟指针,可将树转换为二叉树(左孩子右兄弟)。
typedef struct CSNode {TElemType data;struct CSNode *firstchild, *nextsibling; // 第一个孩子、右兄弟
} CSNode, *CSTree;
(二)森林与二叉树的转换
森林与二叉树可相互转换,核心规则:
- 树→二叉树:根节点的左子树为第一个孩子,右子树为兄弟节点;
- 森林→二叉树:第一棵树转换为二叉树,其余树的根节点作为前一棵树的右子树依次连接;
- 二叉树→森林:根节点的左子树为第一棵树,右子树递归转换为其余森林。
(三)树和森林的遍历
- 树的遍历:
- 先根遍历:先访问根节点,再依次先根遍历各子树;
- 后根遍历:先依次后根遍历各子树,再访问根节点。
- 森林的遍历:
- 先序遍历:先序遍历第一棵树,再先序遍历其余森林;
- 中序遍历:中序遍历第一棵树,再中序遍历其余森林。
📊 七,哈夫曼树及其应用
(一)哈夫曼树的基本概念
哈夫曼树(Huffman Tree)又称最优二叉树,是一类带权路径长度(WPL)最小的二叉树。
- 关键术语:
- 路径:从一个节点到另一个节点的分支序列(如根节点到叶子节点的路径);
- 路径长度:路径上的分支数(根到第k层节点的路径长度为k-1);
- 节点的权:赋予节点的数值(如字符出现的频率);
- 带权路径长度(WPL):树中所有叶子节点的权值与路径长度的乘积之和,公式为:
WPL = Σ(ωᵢ × lᵢ)
(ωᵢ为第i个叶子节点的权值,lᵢ为其路径长度)。
示例:
假设有4个叶子节点,权值分别为3、4、5、6,两种二叉树结构的WPL计算如下:
- 结构1(不平衡):WPL = 3×3 + 4×3 + 5×2 + 6×1 = 9 + 12 + 10 + 6 = 37;
- 结构2(哈夫曼树):WPL = 3×2 + 4×2 + 5×2 + 6×2 = 6 + 8 + 10 + 12 = 36。
显然结构2的WPL更小,为最优结构。
(二)哈夫曼树的构造算法
哈夫曼树的构造遵循贪心策略,核心是通过反复合并权值最小的子树,最终形成最优树。
算法步骤
- 初始化:将所有权值节点视为独立的单节点树(只有根节点,无左右子树),组成森林F;
- 合并子树:从F中选择两棵权值最小的树T₁和T₂,创建新节点作为它们的父节点,新节点的权值为T₁和T₂的权值之和;
- 更新森林:将T₁和T₂从F中移除,将新树加入F;
- 重复步骤2-3:直到F中只剩下一棵树,该树即为哈夫曼树。
示例(权值3、4、5、6)
- 第1次合并:选择3和4,新节点权值7,森林变为{5,6,7};
- 第2次合并:选择5和6,新节点权值11,森林变为{7,11};
- 第3次合并:选择7和11,新节点权值18,森林只剩{18},构造完成。
代码实现(基于最小堆)
#include <stdio.h>
#include <stdlib.h>typedef struct {int weight; // 节点权值int parent, lchild, rchild; // 父节点、左/右子节点下标
} HTNode, *HuffmanTree;// 构建哈夫曼树
void CreateHuffmanTree(HuffmanTree &HT, int n) {if (n <= 1) return;int m = 2 * n - 1; // 哈夫曼树总节点数HT = (HuffmanTree)malloc((m + 1) * sizeof(HTNode)); // 下标从1开始for (int i = 1; i <= m; i++) { // 初始化HT[i].parent = 0;HT[i].lchild = 0;HT[i].rchild = 0;}for (int i = 1; i <= n; i++) { // 输入叶子节点权值scanf("%d", &HT[i].weight);}// 合并子树for (int i = n + 1; i <= m; i++) {// 选择两个权值最小的无父节点的节点int s1, s2;// 找到第一个最小节点s1for (int j = 1; j < i; j++) {if (HT[j].parent == 0) {s1 = j;break;}}for (int j = 1; j < i; j++) {if (HT[j].parent == 0 && HT[j].weight < HT[s1].weight) {s1 = j;}}// 找到第二个最小节点s2(s2≠s1)for (int j = 1; j < i; j++) {if (HT[j].parent == 0 && j != s1) {s2 = j;break;}}for (int j = 1; j < i; j++) {if (HT[j].parent == 0 && j != s1 && HT[j].weight < HT[s2].weight) {s2 = j;}}// 合并s1和s2HT[s1].parent = i;HT[s2].parent = i;HT[i].lchild = s1;HT[i].rchild = s2;HT[i].weight = HT[s1].weight + HT[s2].weight;}
}
(三)哈夫曼编码
哈夫曼编码是哈夫曼树的典型应用,用于数据压缩,通过变长编码实现无损压缩(频率高的字符用短编码)。
编码规则
- 从哈夫曼树的根节点出发,左分支标记为“0”,右分支标记为“1”;
- 每个叶子节点(对应字符)的编码为从根到该节点的路径上的标记序列。
示例(字符A(3)、B(4)、C(5)、D(6))
- A的路径:根→7→3(左→左),编码为“00”;
- B的路径:根→7→4(左→右),编码为“01”;
- C的路径:根→11→5(右→左),编码为“10”;
- D的路径:根→11→6(右→右),编码为“11”。
优势
- 编码唯一:哈夫曼树中叶子节点的编码互不前缀(前缀编码特性),避免解码歧义;
- 压缩高效:频率高的字符编码短,总编码长度最短。
解码过程
根据哈夫曼树,从根节点开始,按编码序列遍历:遇到“0”走左子树,遇到“1”走右子树,到达叶子节点即完成一个字符的解码,重复直至所有编码解析完毕。
🛠️ 八,案例分析与实现
案例:哈夫曼编码压缩系统
功能:对输入的文本进行哈夫曼编码压缩,并能通过编码和解码表还原文本。
实现步骤
- 统计文本中每个字符的出现频率(权值);
- 用频率构建哈夫曼树;
- 生成每个字符的哈夫曼编码;
- 用编码表将文本转换为二进制压缩串;
- 保存编码表和解码表,用于解压还原。
核心代码
#include <string.h>// 生成哈夫曼编码
void CreateHuffmanCode(HuffmanTree HT, char **&HC, int n) {HC = (char**)malloc((n + 1) * sizeof(char*)); // 存储每个字符的编码char *cd = (char*)malloc(n * sizeof(char)); // 临时存储编码cd[n - 1] = '\0'; // 编码结束符for (int i = 1; i <= n; i++) {int start = n - 1; // 编码从后往前存储int c = i;int f = HT[i].parent;while (f != 0) { // 从叶子节点回溯到根start--;if (HT[f].lchild == c) {cd[start] = '0'; // 左子树为0} else {cd[start] = '1'; // 右子树为1}c = f;f = HT[f].parent;}HC[i] = (char*)malloc((n - start) * sizeof(char)); // 分配编码空间strcpy(HC[i], &cd[start]); // 复制编码}free(cd);
}// 示例:压缩文本"ABACCD"
int main() {int n = 4; // 字符数:A、B、C、DHuffmanTree HT;CreateHuffmanTree(HT, n); // 构建哈夫曼树(权值3,4,5,6对应A,B,C,D)char **HC;CreateHuffmanCode(HT, HC, n); // 生成编码// 输出编码表printf("哈夫曼编码表:\n");printf("A: %s\n", HC[1]); // A: 00printf("B: %s\n", HC[2]); // B: 01printf("C: %s\n", HC[3]); // C: 10printf("D: %s\n", HC[4]); // D: 11// 压缩文本"ABACCD"char text[] = "ABACCD";printf("原文本:%s\n", text);printf("压缩编码:");for (int i = 0; i < strlen(text); i++) {switch (text[i]) {case 'A': printf("%s", HC[1]); break;case 'B': printf("%s", HC[2]); break;case 'C': printf("%s", HC[3]); break;case 'D': printf("%s", HC[4]); break;}}// 输出:压缩编码:000100101011// 释放内存free(HT);for (int i = 1; i <= n; i++) free(HC[i]);free(HC);return 0;
}
效果分析
原文本“ABACCD”共6个字符,若用ASCII编码需6×8=48位;
哈夫曼编码后为“000100101011”,共12位,压缩率达75%,体现了哈夫曼编码的高效性。
📝 章结
树与二叉树作为非线性数据结构的核心,以层次化关系突破了线性表的“一对一”限制,为表达复杂数据关系提供了强大工具:
-
树与二叉树的本质:树通过“一对多”的层级关系建模现实中的层级结构(如文件系统),而二叉树通过限制子树数量(最多两棵)简化了操作实现,成为树结构的研究重点。二叉树的性质(如节点数与深度的关系)为存储和遍历提供了理论基础,链式存储(二叉链表)则是实现二叉树的主流方式。
-
遍历的核心价值:先序、中序、后序遍历通过递归思想实现对二叉树的完整访问,层序遍历借助队列实现层次化访问,不同遍历方式适用于不同场景(如中序遍历可用于二叉搜索树的有序输出)。线索二叉树通过利用空指针存储前驱/后继信息,优化了遍历效率。
-
树与森林的扩展:树的存储结构(双亲表示法、孩子兄弟表示法)需根据操作需求选择,而森林与二叉树的转换则架起了树与二叉树的桥梁,使得二叉树的算法可迁移到树和森林中。
-
哈夫曼树的应用:哈夫曼树通过贪心策略构造最优二叉树,其衍生的哈夫曼编码利用变长编码实现高效数据压缩,体现了数据结构与算法结合解决实际问题的典型思路。
从层级建模到高效编码,树与二叉树的思想渗透在计算机科学的各个领域。理解它们的定义、性质和算法,不仅能解决具体问题,更能培养“层次化思维”和“优化意识”——这正是非线性数据结构的核心价值所在。