目录
一、树的概念
二、树的构成
(一)、树的基本组成成分
(二)、树的实现方法
三、树的特殊结构------二叉树
(一)、二叉树的概念
(二)、二叉树的性质
(三)、特殊二叉树
1、满二叉树
2、完全二叉树
四、二叉树的结构------顺序存储------堆
(一)、二叉树的顺序存储
(二)、堆的概念
(三)、堆的实现
1、堆的构建
2、堆的插入
3、堆的删除
4、获取堆顶元素、堆的有效元素个数和堆的判空
(四)、堆排序
1、堆排序的概念
2、堆排序的实现
(五)、TOP-K问题
在数据结构的广阔领域中,树与堆犹如两颗璀璨的明星,散发着独特的魅力。它们不仅是计算机科学的基础,更是解决各种复杂问题的有力武器。树,以其独特的层次结构,模拟了现实世界中的众多场景,如文件系统的目录结构、公司的组织架构等,让我们能够清晰地梳理和管理复杂的数据关系。而堆,作为一种特殊的完全二叉树,凭借其高效的插入、删除和查找操作,在优先队列、排序算法等领域发挥着关键作用 。那树究竟是什么呢?
一、树的概念
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。逻辑结构如图所示:
树有一个特殊的结点,称为根结点,根节点没有前驱结点
任何一颗树都是由根和子树构成。
树中,子树是不相交的,除了根节点外,每个节点有且仅有一个父节点;
一颗有N个节点的树有N-1条边(根节点无前驱节点,故少一条);
除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继 因此,树是递归定义的。
二、树的构成
(一)、树的基本组成成分
让我们来了解一下一颗树的组成结构吧。
- 节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的为6
- 叶节点或终端节点:度为0的节点称为叶节点; 如上图:B、C、H、I...等节点为叶节点
- 非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G...等节点为分支节点
- 双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点
- 孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点
- 兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点
- 树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6 节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
- 树的高度或深度:树中节点的最大层次; 如上图:树的高度为4
- 堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点 节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先
- 子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙
- 森林:由m(m>0)棵互不相交的树的集合称为森林(也即我们后续需要学到的并查集);
(二)、树的实现方法
我们通过上述已经了解到树是递归式的,那我们如何实现上图所示的树呢?
看了上图后是不是一下子就想到了链表,链表是由一个一个的节点链接构成,上图所示的树也是由一个一个的节点构成,那我们能不能按实现链表的思想来实现树呢?
但问题是,链表是线性结构,每个节点只需要与一个节点链接即可,但树可不同,有的节点和一个节点链接,有的和几个节点链接,构成节点的结构体可不能随情况不同而变化。那我们能不能对链接不同个节点分别构建节点结构体呢?链接节点个数的情况可以是无限的,真这样做只能说吃力不讨好。这就不得不提到一个非常巧妙的方法--------左孩子右兄弟表示法。
先来看看它的概念:
树中的每个节点除了自身的数据域,还设置两个指针域,分别指向该节点的第一个孩子节点和它的右兄弟节点。节点的左指针指向其第一个孩子,右指针指向其紧邻的右兄弟。若节点没有孩子或右兄弟,则相应指针为 None 或 NULL 。
代码如下:
typedef struct TreeNode
{
//存储数据
int data;
//存储左孩子指针
struct TreeNode* left;
//存储右兄弟地址
struct TreeNode* right;
}TreeNode;
通过这个表示法,上图的树就变成了这样:
它将这样一棵普通树转化为一棵二叉树。这一转化意义非凡,因为二叉树的算法和操作相对成熟,我们可以借助二叉树的处理方式来解决普通树的问题,大大简化了操作难度。而且左孩子右兄弟表示法在空间利用上表现出色。它不再需要为每个节点预留大量不确定的子节点指针,节省了内存空间。在操作效率方面,基于二叉树的遍历算法,如前序遍历、中序遍历和后序遍历,都能直接应用到由左孩子右兄弟表示法转化而来的二叉树上,使得树的遍历、查找、插入和删除等操作都能以相对较低的时间复杂度完成。
对于当前的我们来说,我们对于树,只需要做到了解即可,我们的侧重点是树的一种特殊结构二叉树。
那什么是二叉树呢?重点在于二叉两个字。
三、树的特殊结构------二叉树
(一)、二叉树的概念
一棵二叉树是结点的一个有限集合,该集合:
1. 或者为空
2. 由一个根节点加上两棵别称为左子树和右子树的二叉树组成
二叉树并不是每一个节点都与另外两个节点链接。二叉树只是不存在度大于2的节点。且二叉树是有左右之分的,它的次序不能颠倒,故二叉树是有序的。如图所示:
上图所示皆为二叉树,因此在对待二叉树时,一定不要忘记NULL,且对于任意的二叉树都是由以下几种情况复合而成的:
(二)、二叉树的性质
- 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有(2^(i-1)) 个结点。
- 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是(2^h-1)。
- 对任何一棵二叉树, 如果度为0其叶结点个数为 n, 度为2的分支结点个数为n-1。
- 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= log(n+1)。(log(n+1)是以2为底,(n+1)为对数)。
- 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对 于序号为i的结点有:
- 1. 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点。
- 2. 若2i+1=n否则无左孩子。
- 3. 若2i+2=n否则无右孩子。
(三)、特殊二叉树
在二叉数中,有一些特殊的二叉树,分别是满二叉树和完全二叉树。同时,满二叉树也可以说是一种特殊的二叉树。
1、满二叉树
满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是 说,如果一个二叉树的层数为K,且结点总数是(2^k-1),则它就是满二叉树。
为什么说满二叉树的节点总数为(2^k-1)呢?
也即第k层节点个数为(2^(k-1)),构成了一个等比数列。使用等比数列求和公式可得(2^k-1)。
2、完全二叉树
完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。
完全二叉树其实就是前n-1层都是满的,最后一层要从左到右是连续的。
完全二叉树的节点范围为[2^(n-1),2^n-1],高度范围为[logN+1,log(N+1)]。
四、二叉树的结构------顺序存储------堆
(一)、二叉树的顺序存储
二叉树有两种结构,分别是顺序存储和链式存储。从字面上看,我们就可以知道,分别是在数组上和链表上来存储二叉树。
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是 链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所 在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面 如红黑树等会用到三叉链。
二叉树在数组上存储就构成了我们这次博客的主题------堆。
二叉树顺序存储的逻辑结构和物理结构是不一样的,我们需要通过数组去看到存储的二叉树是不容易的,因此,要想学好,还是要多画图。二叉树的顺序存储逻辑结构如下图所示:
二叉树顺序存储的物理结构如下图所示:
若想要学好堆,就不得不对父节点和子节点在顺序存储中的关系记忆深刻:
我们假设parent为父节点在数组中的下标,children为子节点在数组中的下标,则有:
- parent=(children-1)/2;
- leftchildren=parent*2+1;
- rightchildren=parent*2+2;
注意:使用数组存储二叉树会不合适,因为会浪费很多空间。数组存储表示二叉树只适合完全二叉树。如图所示:
现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统 虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
(二)、堆的概念
堆是一种特殊的完全二叉树,它满足以下两个重要特性:
- 结构性:堆是一棵完全二叉树,这意味着除了最后一层外,每一层的节点都是满的,且最后一层的节点从左到右依次排列 。这种规整的结构使得堆可以高效地使用数组进行存储。在一个包含 10 个元素的堆中,其完全二叉树结构可以清晰地映射到数组中,根节点存储在数组的第一个位置,根节点的左子节点存储在数组的第二个位置,右子节点存储在第三个位置,以此类推。通过这种方式,利用数组下标就能快速定位节点的父子关系,大大提高了操作效率。
- 有序性:堆分为大顶堆(大根堆或大堆)和小顶堆(小根堆或小堆) 。在大顶堆中,每个节点的值都大于或等于其左右子节点的值,堆顶元素是堆中的最大值 。而在小顶堆中,每个节点的值都小于或等于其左右子节点的值,堆顶元素是堆中的最小值 。以一个存储学生成绩的大顶堆为例,堆顶元素就是所有学生中的最高成绩,通过这种有序性,在需要获取最大值或最小值时,能够快速定位到目标元素,节省查找时间。
堆的功能:
- 堆的构建 void HeapCreate(Heap* hp, int* a, int n);此处堆的构建是对于一个存满数据的数组进行堆的构建,它是先申请空间,然后使用堆的插入函数将数组中的元素逐一插入来构建堆。
- 堆的销毁 void HeapDestory(Heap* hp);
- 堆的插入 void HeapPush(Heap* hp, HPDataType x);
- 堆的删除 void HeapPop(Heap* hp);
- 取堆顶的数据 HPDataType HeapTop(Heap* hp);
- 堆的数据个数 int HeapSize(Heap* hp);
- 堆的判空 int HeapEmpty(Heap* hp);
(三)、堆的实现
我们需要先定义一个结构体,结构体和顺序表一样,毕竟都是在数组上进行存储,代码如下:
typedef struct Heap
{
int* data;//数组,方便扩容
int size;//计数器
int capicity;//容量大小
}Heap;
1、堆的构建
我们先来看如何对一个存满数据的数组进行堆的构建,也即建堆。代码如下:
//两数交换函数
void Swap(int* a, int* b)
{
int c = *a;
*a = *b;
*b = c;
}
void HeapCreate(int* a, int n)
{
//这里建的是大根堆,故父节点一定要比子节点大
//循环遍历要建堆的数组的每一个数
for (int i = 0; i < n; i++)
{
int children = i;
int parent = (children - 1) / 2;
//当子节点都小于0时,父节点一定小于0,如果用父节点>=0,来做
//循环结束条件,会导致越界访问或者缺失比较数据
while (children > 0)
{
//建的是大根堆
if (a[parent] < a[children])
{
//父节点比子节点小,就互换,让大的来当父节点
Swap(&a[parent], &a[children]);
//循环的递近条件,让大的节点互换后与当前节点的父节点再度比较,直到小于
children = parent;
parent = (children - 1) / 2;
}
else
{
break;
}
}
}
}
int main()
{
int a[10] = { 1,0,5,6,3,2,4,9,8,7 };
HeapCreate(a, 10);
for (int i = 0; i < 10; i++)
{
printf("%d ", a[i]);
}
return 0;
}
看图解释:
上述代码结果为:
教你一个简便的方法快速判断是否为堆:
开始时:
运用画图功能将其拼成一个二叉树,根据大根堆或者小根堆的概念进行判断,如下图就是一个大根堆。如果没有电脑这种方法就不可行吗?当然可行。我们可以直接看数组,运用堆的性质比较即可。
而另一种堆的构建,堆的初始化、堆的扩容,堆的销毁和顺序表一样。代码如下:
//堆的初始化
void HeapInte(Heap* hp)
{
assert(hp);
hp->data = (int*)malloc(sizeof(int) * 4);
if (hp->data == NULL)
{
perror("Inte::malloc");
return;
}
hp->size = 0;
hp->capicity = 4;
}
//动态空间扩容
void HeapCapicity(Heap* hp)
{
assert(hp);
if (hp->capicity == hp->size)
{
hp->data = (int*)realloc(hp->data, hp->capicity * 2 * sizeof(int));
if (hp->data == NULL)
{
perror("Capicity::realloc");
return;
}
hp->capicity *= 2;
printf("扩容成功\n");
}
}
//堆的销毁
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->data);
hp->data = NULL;
hp->capicity =hp->size=0;
printf("销毁成功\n");
}
2、堆的插入
堆的插入类似于顺序表的尾删,只是再插入后还需通过向上调整建堆使得数组上的数据始终符合堆的性质。
什么是向上调整建堆呢?
向上调整建堆是一种构建堆的方法,通常用于在已有部分堆结构的基础上插入新元素时维护堆的性质,也可以用来从零开始构建堆。对于大顶堆,堆中每个节点的值都大于或等于其子节点的值;对于小顶堆,堆中每个节点的值都小于或等于其子节点的值。
向上调整的核心思想是:从当前节点开始,将其与父节点比较,如果不满足堆的性质(例如在大顶堆中当前节点的值大于父节点的值),则交换当前节点和父节点,然后继续向上比较,直到满足堆的性质或者到达根节点。
向上调整建堆的时间复杂度为O(N*logN)。
而上述我们直接对数组进行建堆操作,而不另开辟空间的代码中使用的就是向上调整建堆。代码如下:
void HeapPush(Heap* hp, int x)
{
assert(hp);
//检查空间是否充足,不足则扩容
HeapCapicity(hp);
//插入数据
hp->data[hp->size] = x;
//我们不能确定插入后是否符合,故采用向上调整法。
int chile = hp->size;
int parent = (chile - 1) / 2;
//和之前直接对数组操作建堆一样
while (chile > 0)
{
if (hp->data[chile] > hp->data[parent])
{
Swap(&hp->data[chile], &hp->data[parent]);
chile = parent;
parent = (chile - 1) / 2;
}
else
{
break;
}
}
hp->size++;
}
看到这,你是不是有一个疑惑,既然有向上调整建堆,那是不是还有向下调整建堆呢?
当然是有的,而且向下调整建堆比向上调整建堆效率更高。那向下调整建堆又该如何做呢?
向下调整建堆是一种高效的建堆算法,其核心思想是从最后一个非叶子节点开始,依次对每个节点进行向下调整操作,使得每个子树都满足堆的性质,最终整个树成为一个堆。
对于一个包含 n个元素的数组表示的完全二叉树,最后一个非叶子节点的索引是 (n-2)/2。也可以直接找最后一个元素的父节点,也即最后一个非叶子节点。从这个节点开始,依次向前遍历每个非叶子节点,对其进行向下调整操作,将以该节点为根的子树调整为堆。
向下调整操作是指:比较当前节点与其左右子节点的值,如果不满足堆的性质(对于大顶堆,当前节点的值应大于等于其子节点的值;对于小顶堆,当前节点的值应小于等于其子节点的值),则将当前节点与值最大(大顶堆)或最小(小顶堆)的子节点交换,然后继续对交换后的子节点进行向下调整,直到满足堆的性质或者到达叶子节点。
向下调整建堆的时间复杂度为O(N)。
以下图为例:
代码如下:
//n-1为最后一个元素的下标,(下标-1)/2得到父节点
for (int i = (n - 2) / 2; i >= 0; i--)
{
int parent = i;
//因为父节点的左右子节点始终在一起,故右子节点下标为左子节点下标加1
//因此只需一个变量
int child = parent * 2 + 1;
//向下调整操作,左孩子下标不要超过数组元素个数并且右孩子下标也不要超过
while (child<n)
{
//因为是建大堆,故父节点要和子节点大的比较
//如果是建小堆,则父节点要和子节点小的比较
if ((child + 1) < n && a[child] < a[child + 1])
{
child++;
}
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
3、堆的删除
堆的删除如果只是删除堆底的话那就没什么好说的,只需要计数器size减一即可,且不会破坏堆的结构,但如果是删除堆顶元素呢?
如果删除堆顶元素,那么不但要将堆顶元素后的所有元素往前移一下,而且堆的结构也被破坏了,需要重新构建堆。这样的话过于麻烦。有没有什么更好的办法呢?
我们可以让堆顶元素和堆底元素互换,然后像删除堆底元素一样删除,最后使用向下调整法重构堆,以此来达到删除堆顶元素的操作,且效率很高。代码如下:
void HeapPop(Heap* hp)
{
assert(hp);
if (hp->size == 0)
{
printf("无数据\n");
return;
}
//先交换堆顶元素和堆底元素
Swap(&hp->data[0], &hp->data[hp->size - 1]);
hp->size--;
int parent = 0;
int leftchile = parent * 2 + 1;
//向下调整建堆
while ((leftchile+1)<hp->size && leftchile < hp->size)
{
if (hp->data[leftchile] > hp->data[leftchile + 1])
{
if (hp->data[parent] < hp->data[leftchile])
{
Swap(&hp->data[parent],&hp->data[leftchile]);
parent = leftchile;
leftchile = leftchile * 2 + 1;
}
else
{
return;
}
}
else
{
if (hp->data[parent] < hp->data[leftchile+1])
{
Swap(&hp->data[parent], &hp->data[leftchile+1]);
parent = leftchile+1;
leftchile = (leftchile+1) * 2 + 1;
}
else
{
return;
}
}
}
}
4、获取堆顶元素、堆的有效元素个数和堆的判空
这三个功能的实现比较简单,因为堆就是存储在数组上的。因此代码如下:
int HeapTop(Heap* hp)
{
assert(hp);
return hp->data[0];
}
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size;
}
自此,一个堆就完成了。那么堆有什么用处呢?毕竟我们不会学一个没有用处的结构。我们或许都听过在排序中有一个堆排序。堆可以用来排序吗?
当然可以,而且它的速度还是比较快的。那我们如何去实现堆排序呢?
(四)、堆排序
1、堆排序的概念
堆排序(Heap Sort)是一种基于堆这种数据结构设计的高效排序算法,它利用堆的特性进行排序,时间复杂度为 O(N*longN),且是一种不稳定的排序算法。
堆排序的基本思想是利用堆的特性,先将待排序的数组构建成一个堆,然后不断地将堆顶元素(最大值或最小值)与堆的最后一个元素交换,再对剩余的元素重新调整为堆,重复这个过程直到整个数组有序。
它的排序步骤只有两步:
- 建堆:将待排序的数组构建成一个堆。可以使用向上调整建堆或向下调整建堆的方法,其中向下调整建堆的时间复杂度为O(N),更为常用。
- 排序:将堆顶元素与堆的最后一个元素交换,然后对剩余的元素重新调整为堆,重复这个过程直到整个数组有序。
注意:排升序建大堆,排降序建小堆。
2、堆排序的实现
代码如下:
建大堆排升序,使用向下调整建堆法。(这是指下图代码)
//堆排序
void Swap(int* a, int* b)
{
int c = *a;
*a = *b;
*b = c;
}
void HeapSortdown(int* a, int n)
{
//先建堆(向下调整建堆)
//n-1为最后一个元素的下标,(下标-1)/2得到父节点
for (int i = (n - 2) / 2; i >= 0; i--)
{
int parent = i;
//因为父节点的左右子节点始终在一起,故右子节点下标为左子节点下标加1
//因此只需一个变量
int child = parent * 2 + 1;
//向下调整操作,左孩子下标不要超过数组元素个数并且右孩子下标也不要超过
while (child<n)
{
//因为是建大堆,故父节点要和子节点大的比较
//如果是建小堆,则父节点要和子节点小的比较
if ((child + 1) < n && a[child] < a[child + 1])
{
child++;
}
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//向下调整
for (int i = n-1; i >0; i--)
{
Swap(&a[0], &a[i]);
int parent = 0;
int child = parent * 2 + 1;
while ( child <i)
{
if ((child + 1) < i && a[child] < a[child + 1])
{
child++;
}
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
}
建小堆排降序,使用向上调整建堆法。(这是指下图代码)
void HeapSortup(int* a, int n)
{
//先建堆(向上调整建堆,建小堆)
for (int i = 1; i < n; i++)
{
int chile = i;
int parent = (chile - 1) / 2;
while (chile > 0)
{
if (a[chile] < a[parent])
{
Swap(&a[chile], &a[parent]);
chile = parent;
parent = (chile - 1) / 2;
}
else
{
break;
}
}
}
//向下调整
for (int i = n-1; i>0;i--)
{
Swap(&a[0], &a[i]);
int parent = 0;
int child = parent * 2 + 1;
while (child <i)
{
if ((child + 1) < i && a[child] > a[child + 1])
{
child++;
}
if (a[parent] > a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
}
同时,堆不仅仅只用于排序,它还涉及到一个问题------TOP-K问题。
(五)、TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。 比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决。
我们怎么利用堆来解决TOP-K问题呢?
我们知道大堆堆顶是最大的元素,小堆堆顶是最小元素,我们如果要求n个数的前50,那我们就建一个小堆,先存储n个数中的50个数,此时堆顶元素就是这50个元素中最小的,我们再遍历除这50个数外的所有数,让这些数都与堆顶元素比较,如果大于,则让大于的数和堆顶元素互换,并使用向下调整法保持堆为小堆状态,当遍历结束后,堆中的元素就为n个数中前50的数。
同理,求n个数中后50的数,只需建大堆重复上述步骤即可。这里代码就不给出了,大家可以自己尝试一下,如果逻辑依旧想不通,一定要画图,一定要画图,一定要画图,重要的事说三遍!!!
如果代码出现了问题,一定要灵活的使用调试功能!!!