引言
在计算机科学中,二叉树是一种重要的数据结构,广泛应用于各种算法和程序设计中。本文将探讨二叉树的顺序实现,特别是堆的实现方式。
一、树
1.1树的概念与结构
树是⼀种⾮线性的数据结构,它是由 n(n>=0) 个有限结点组成⼀个具有层次关系的集合。把它叫做
树是因为它看起来像⼀棵倒挂的树,也就是说它是根朝上,⽽叶朝下的。
• 有⼀个特殊的结点,称为根结点,根结点没有前驱结点。
• 除根结点外,其余结点被分成 M(M>0) 个互不相交的集合 T1、T2、……、Tm ,其中每⼀个集合 Ti(1 <= i <= m) ⼜是⼀棵结构与树类似的⼦树。每棵⼦树的根结点有且只有⼀个前驱,可以有 0 个或多个后继。因此,树是递归定义的。
树形结构中,⼦树之间不能有交集,否则就不是树形结构
⾮树形结构:
总结:
• ⼦树是不相交的(如果存在相交就是图了,图以后得课程会有讲解)
• 除了根结点外,每个结点有且仅有⼀个⽗结点
• ⼀棵N个结点的树有N-1条边
1.2树的基本术语
- ⽗结点/双亲结点:若⼀个结点含有⼦结点,则这个结点称为其⼦结点的⽗结点; 如上图:A是B的⽗结点
- ⼦结点/孩⼦结点:⼀个结点含有的⼦树的根结点称为该结点的⼦结点; 如上图:B是A的孩⼦结点
- 结点的度:⼀个结点有⼏个孩⼦,他的度就是多少;⽐如A的度为6,F的度为2,K的度为0
- 树的度:⼀棵树中,最⼤的结点的度称为树的度; 如上图:树的度为 6
- 叶⼦结点/终端结点:度为 0 的结点称为叶结点; 如上图: B、C、H、I... 等结点为叶结点
- 分⽀结点/⾮终端结点:度不为 0 的结点; 如上图: D、E、F、G... 等结点为分⽀结点
- 兄弟结点:具有相同⽗结点的结点互称为兄弟结点(亲兄弟); 如上图: B、C 是兄弟结点
- 结点的层次:从根开始定义起,根为第 1 层,根的⼦结点为第 2 层,以此类推;
- 树的⾼度或深度:树中结点的最⼤层次; 如上图:树的⾼度为 4
- 结点的祖先:从根到该结点所经分⽀上的所有结点;如上图: A 是所有结点的祖先
- 路径:⼀条从树中任意节点出发,沿⽗节点-⼦节点连接,达到任意节点的序列;⽐如A到Q的路径为: A-E-J-Q;H到Q的路径H-D-A-E-J-Q
- ⼦孙:以某结点为根的⼦树中任⼀结点都称为该结点的⼦孙。如上图:所有结点都是A的⼦孙
- 森林:由 m(m>0)棵互不相交的树的集合称为森林;
1.3树的表示法
孩⼦兄弟表⽰法:
树结构相对线性表就⽐较复杂了,要存储表⽰起来就⽐较⿇烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表⽰⽅式如:双亲表⽰法,孩⼦表⽰法、孩⼦双亲表⽰法以及孩⼦兄弟表⽰法等。我们这⾥就简单的了解其中最常⽤的孩⼦兄弟表⽰法
struct TreeNode { struct Node* child; // 最左边的孩子结点 struct Node* brother; // 指向其右边的下一个兄弟结点 int data; // 结点中的数据域 };
1.4树形结构实际运⽤场景
⽂件系统是计算机存储和管理⽂件的⼀种⽅式,它利⽤树形结构来组织和管理⽂件和⽂件夹。在⽂件系统中,树结构被⼴泛应⽤,它通过⽗结点和⼦结点之间的关系来表⽰不同层级的⽂件和⽂件夹之间的关联。
二、二叉树
2.1二叉树的概念与结构
二叉树(binary tree) 是一种非线性数据结构,代表“祖先”与“后代”之间的派生关系,体现了“一分为二” 的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含值、左子节点引用和右子节点引用。
总结:
1. ⼆叉树不存在度⼤于 2 的结点
2. ⼆叉树的⼦树有左右之分,次序不能颠倒,因此⼆叉树是有序树
注意:对于任意的⼆叉树都是由以下⼏种情况复合⽽成的
2.2二叉树的基本术语
- 根节点(root node) :位于二叉树顶层的节点,没有父节点。
- 叶节点(leaf node):没有子节点的节点,其两个指针均指向 None 。
- 节点所在的 层(level) :从顶至底递增,根节点所在层为 1 。
- 节点的度(degree) :节点的子节点的数量。在二叉树中,度的取值范围是 0、1、2 。
- 二叉树的高度(height) :从根节点到最远叶节点所经过的边的数量。
- 节点的深度(depth) :从根节点到该节点所经过的边的数量。
- 节点的高度(height) :从距离该节点最远的叶节点到该节点所经过的边的数量。
2.3特殊二叉树
1.完美二叉树(满二叉树)
完美二叉树(perfect binary tree) 所有层的节点都被完全填满。在完美二叉树中,叶节点的度
为 0 ,其余所有节点的度都为 2 ;若树的高度为 ℎ ,则节点总数为 2 ℎ+1 − 1 ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。
2. 完全二叉树
完全二叉树(complete binary tree) 只有最底层的节点未被填满,且最底层节点尽量靠左填充。
2.4二叉树的存储结构
⼆叉树⼀般可以使⽤两种结构存储,⼀种顺序结构,⼀种链式结构。
2.4.1顺序结构
顺序结构存储就是使⽤数组来存储,⼀般使⽤数组只适合表⽰完全⼆叉树 ,因为不是完全⼆叉树会有空间的浪费,完全⼆叉树更适合使⽤顺序结构存储。
2.3.2 链式结构
⼆叉树的链式存储结构是指,⽤链表来表⽰⼀棵⼆叉树,即⽤链来指⽰元素的逻辑关系。 通常的⽅法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别⽤来给出该结点左孩⼦和右孩⼦所在的链结点的存储地址 。链式结构⼜分为 ⼆叉链 和 三叉链 。(红⿊树等会⽤到三叉链。)
三、实现顺序结构二叉树
⼀般堆使⽤顺序结构的数组来存储数据,堆是⼀种特殊的⼆叉树,具有⼆叉树的特性的同时,还具备其他的特性。
3.1堆的概念与结构
堆是一种完全二叉树,具有以下性质:
- 最大堆:每个节点的值都大于或等于其子节点的值。
- 最小堆:每个节点的值都小于或等于其子节点的值。
堆常用于实现优先队列,因为它能有效地支持插入和删除操作。
总结
• 堆中某个结点的值总是不⼤于或不⼩于其⽗结点的值;
• 堆总是⼀棵完全⼆叉树。
3.2堆的实现
我们以小堆为例进行实现:
3.2.1存储结构
二叉树的顺序存储通常使用数组来实现。对于一个节点在数组中的索引
i
,可以通过以下方式找到其父节点和子节点的索引:
- 父节点:
(i - 1) / 2
(取整)- 左子节点:
2 * i + 1
- 右子节点:
2 * i + 2
typedef struct Heap { DataType *arr; int size; int capacity; }HP;
这里可以看到堆的底层结构和动态顺序表一样。
3.2.2相关操作
1.初始化
//初始化 void HPInit(HP* p) { assert(p); p->arr = NULL; p->size = p->capacity = 0; }
2.销毁
//销毁 void HPDestory(HP* p) { assert(p); if (p->arr) { free(p->arr); } p->arr = NULL; p->size = p->capacity = 0; } Swap(int* x, int* y) { int tmp = *x; *x = *y; *y = tmp; }
3.向上调整
💡 向上调整算法
• 先将元素插⼊到堆的末尾,即最后⼀个孩⼦之后
• 插⼊之后如果堆的性质遭到破坏,将新插⼊结点顺着其双双亲往上调整到合适位置即可
//堆的向上调整 void AdjustUp(DataType* arr, int child) { int parent = (child - 1) / 2;//父节点 while(child>0)//注意child可能会被调整到根节点,这时候就不能再调整了 { if (arr[child] < arr[parent])//如果条件语句不成立,就说明堆已经成型 { Swap(&arr[child], &arr[parent]); child = parent; parent = (child - 1) / 2;//循环以上步骤 } else { break; } } }
4.堆的插⼊
将新数据插⼊到数组的尾上,再进⾏向上调整算法,直到满⾜堆。
//插入 void HPPush(HP* p, DataType x) { assert(p); if (p->size == p->capacity)//判断空间是否足够 { //扩容 int NewCap = p->capacity == 0 ? 4 : 2 * p->capacity; DataType* tmp = (DataType*)realloc(p->arr, NewCap * sizeof(DataType)); if (tmp == NULL) { perror("realloc fail!"); exit(1); } p->arr = tmp; p->capacity = NewCap; } p->arr[p->size] = x; AdjustUp(p->arr, p->size); ++p->size; }
5.向下调整法
💡 向下调整算法
• 将堆顶元素与堆中最后⼀个元素进⾏交换
• 删除堆中最后⼀个元素
• 将堆顶元素向下调整到满⾜堆特性为⽌
向下调整算法有⼀个前提:左右⼦树必须是⼀个堆,才能调整。
//堆的向下调整 void AdjustDwon(DataType* arr, int parent, int n) { int child = parent * 2 + 1;//左孩子 while (child < n) { //寻找左右孩子最小的那个 if (child + 1 < n && arr[child] > arr[child + 1]) { child++; } //这里和向上调整就一样了,比较,交换,循环 if (arr[child] < arr[parent]) { Swap(&arr[child], &arr[parent]); parent = child; child = parent * 2 + 1; } else { break; } } }
6.堆的删除
删除堆是删除堆顶的数据,将堆顶的数据根最后⼀个数据⼀换,然后删除数组最后⼀个数据,再进⾏向下调整算法。
//删除,出堆顶数据 void HPPop(HP* p) { assert(p && p->size); Swap(&p->arr[0], &p->arr[p->size - 1]); --p->size; AdjustDwon(p->arr, 0, p->size); }
7.判空
//判空 bool HPEmpty(HP* p) { assert(p); return p->size == 0; }
8.返回堆顶元素
//返回堆顶元素 DataType HPTop(HP* p) { assert(p && p->size); return p->arr[0]; }
四、完整代码
Heap.h
#pragma once #include<stdio.h> #include<assert.h> #include<stdlib.h> #include<stdbool.h> //定义堆的结构--数组 typedef int DataType; typedef struct Heap//小堆为例 { DataType *arr; int size; int capacity; }HP; //初始化 void HPInit(HP* p); //销毁 void HPDestory(HP* p); //插入 void HPPush(HP* p,DataType x); //删除,出堆顶数据 void HPPop(HP* p); //判空 bool HPEmpty(HP* p); //返回堆顶元素 DataType HPTop(HP* p);
Heap.c
#define _CRT_SECURE_NO_WARNINGS #include"Heap.h" //初始化 void HPInit(HP* p) { assert(p); p->arr = NULL; p->size = p->capacity = 0; } //销毁 void HPDestory(HP* p) { assert(p); if (p->arr) { free(p->arr); } p->arr = NULL; p->size = p->capacity = 0; } Swap(int* x, int* y) { int tmp = *x; *x = *y; *y = tmp; } //堆的向上调整 void AdjustUp(DataType* arr, int child) { int parent = (child - 1) / 2;//父节点 while(child>0)//注意child可能会被调整到根节点,这时候就不能再调整了 { if (arr[child] < arr[parent])//如果条件语句不成立,就说明堆已经成型 { Swap(&arr[child], &arr[parent]); child = parent; parent = (child - 1) / 2;//循环以上步骤 } else { break; } } } //插入 void HPPush(HP* p, DataType x) { assert(p); if (p->size == p->capacity)//判断空间是否足够 { //扩容 int NewCap = p->capacity == 0 ? 4 : 2 * p->capacity; DataType* tmp = (DataType*)realloc(p->arr, NewCap * sizeof(DataType)); if (tmp == NULL) { perror("realloc fail!"); exit(1); } p->arr = tmp; p->capacity = NewCap; } p->arr[p->size] = x; AdjustUp(p->arr, p->size); ++p->size; } //堆的向下调整 void AdjustDwon(DataType* arr, int parent, int n) { int child = parent * 2 + 1;//左孩子 while (child < n) { //寻找左右孩子最小的那个 if (child + 1 < n && arr[child] > arr[child + 1]) { child++; } //这里和向上调整就一样了,比较,交换,循环 if (arr[child] < arr[parent]) { Swap(&arr[child], &arr[parent]); parent = child; child = parent * 2 + 1; } else { break; } } } //删除,出堆顶数据 void HPPop(HP* p) { assert(p && p->size); Swap(&p->arr[0], &p->arr[p->size - 1]); --p->size; AdjustDwon(p->arr, 0, p->size); } //判空 bool HPEmpty(HP* p) { assert(p); return p->size == 0; } //返回堆顶元素 DataType HPTop(HP* p) { assert(p && p->size); return p->arr[0]; }
main.c
#define _CRT_SECURE_NO_WARNINGS #include"Heap.h" void test01() { HP hp; HPInit(&hp); int arr[] = { 17,20,10,13,19,15 }; for (int i = 0; i < 6; i++) { HPPush(&hp, arr[i]); } //HPPop(&hp); while (!HPEmpty(&hp)) { printf("%d-", HPTop(&hp)); HPPop(&hp); } HPDestory(&hp); } int main() { test01(); return 0; }
五、总结
通过顺序实现的方式,我们可以高效地存储和操作二叉树。堆作为一种特殊的二叉树,提供了快速的插入和删除操作,非常适合优先队列的实现。在许多应用场景中,如任务调度、图算法等,堆结构都发挥着重要作用。
希望本文能够帮助你理解二叉树的顺序实现及堆的基本操作。继续探索数据结构的世界,会发现更多有趣的应用和挑战!