⭐️本篇博客我要来和大家一起聊一聊数据结构中的树的基本概念和一些相关名词,与之前说的数据结构相比,树是一种非线性的数据结构
⭐️我会主要介绍二叉树的基本性质和他的顺序存储结构及实现(堆)
⭐️博客代码已上传至gitee:https://gitee.com/byte-binxin/data-structure/tree/master/Heap
树(tree)是包含 n(n≥0) [2] 个节点,当 n=0 时,称为空树,非空树中条边的有穷集,在非空树中:
(1)每个元素称为节点(node)。
(2)有一个特定的节点被称为根节点或树根(root)。
(3)除根节点之外的其余数据元素被分为个互不相交的集合,其中每一个集合本身也是一棵树,被称作原树的子树(subtree)(来源:百度百科)
注意:树形结构中,子树之间不能有交集,否则就不是树形结构
节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的为2
叶节点或终端节点:度为0的节点称为叶节点; 如上图:D、F、G、H为叶节点
非终端节点或分支节点:度不为0的节点; 如上图:A、B…等节点为分支节点
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点
兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点
树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为2
节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
树的高度或深度:树中节点的最大层次; 如上图:树的高度为4(根节点的高度记为1)
堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点
节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙
森林:由m(m>0)棵互不相交的树的集合称为森林
树的表示方法有很多,由于树不是一种线性的结构,所以表示起来会显得有些复杂,最常用的就是左孩子右兄弟表示法。
左孩子右兄弟表示法是节点中保存第一个孩子的节点的指针,还有一个指针指向下一个兄弟节点。
typedef int DataType;
struct Node
{
struct Node* firstChild; // 第一个孩子结点
struct Node* pNextBrother; // 指向其下一个兄弟结点
DataType data; // 结点中的数据域
}
这样一种表示方法是十分的精妙,可以很好地遍历到每一个节点。
二叉树是n个有限元素的集合,该集合或者为空、或者由一个称为根(root)的元素及两个不相交的、被分别称为左子树和右子树的二叉树组成,是有序树。当集合为空时,称该二叉树为空二叉树。在二叉树中,一个元素也称作一个结点。
注意
满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是(2^k) -1 ,则它就是满二叉树。
完全二叉树:一棵深度为k的有n个结点的二叉树,对树中的结点按从上至下、从左到右的顺序进行编号,如果编号为i(1≤i≤n)的结点与满二叉树中编号为i的结点在二叉树中的位置相同,则这棵二叉树称为完全二叉树。
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。
可以看出,只有完全二叉树可以很充分地利用空间,普通二叉树会浪费很大的空间。
n个元素的序列{k1,k2,ki,…,kn}当且仅当满足下关系时,称之为堆。
Ki<=K2i且Ki<=K2i+1 或 Ki>=K2i且Ki>=K2i+1
堆的性质:
由于堆是用数组来进行存储的,所以这里的结构和顺序表有些类似,逻辑上是堆,物理上是一种数组的形式。
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int capacity;
int size;
}HP;
堆的初始化和顺序表的很相似基本上什么都不用做,只要指针置空,大小和容量置0即可。
void HeapInit(HP* hp)
{
assert(hp);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
先看动图演示:
代码实现如下:
void AdjustDown(HPDataType* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
//选小孩子
if (child + 1 < n && a[child + 1] < a[child])//< 建小堆 > 建大堆
{
child++;
}
if (a[child] < a[parent])//< 建小堆 > 建大堆
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
向上调整算法就是在插入一个节点后为了使堆依旧保持原来的大堆或者小堆的一个调整算法。先将该节点与父亲节点比较,如果比父亲节点大就交换(原本是大堆)否则就不交换,直到交换到根节点为止。
看一个动图演示:
代码实现如下:
void AdjustUp(HPDataType* a, int child)
{
assert(a);
int parent = (child - 1) / 2;
while (child >= 0)
{
if (a[child] < a[parent])//< 建小堆 > 建大堆
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
对于给定的一个数组,我们如何把他构建成大堆或者小堆呢?
堆的构建有两种方法:
第一种:从第二个节点往后开始向上调整
第二种:从最后一个非叶子节点开始向下调整
第一种代码实现:
void HeapCreate(HP* hp, int n)
{
assert(hp);
int i = 0;
//建小堆 排降序 建大堆 排升序
for (i = 1; i < n; i++)
{
//建大堆 向下调整
AdjustUp(hp->a, i);
}
}
第二种代码实现:
void HeapCreate(HP* hp, int n)
{
assert(hp);
//找到最后一个父亲节点
int parent = (n - 1 - 1) / 2;
int i = 0;
//建小堆 排降序 建大堆 排升序
for (i = parent; i >= 0; i--)
{
//建大堆 向下调整
AdjustDown(hp->a, n, i);
}
}
堆的插入和顺序表的尾插有些相似,要考虑扩容的问题,有一点不同的是堆在插入后要进行向上调整,也就是向上调整算法,保持原来的堆的性状。
代码实现如下:
void HeapPush(HP* hp, HPDataType x)
{
assert(hp);
if (hp->capacity == hp->size)
{
int newCapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
HPDataType* tmp = (HPDataType*)realloc(hp->a, newCapacity * sizeof(HPDataType));
if (tmp == NULL)
{
printf("realloc fail\n");
exit(-1);
}
hp->a = tmp;
hp->capacity = newCapacity;
}
hp->size++;
hp->a[hp->size - 1] = x;
//向上调整
AdjustUp(hp->a, hp->size - 1);
}
我们规定,堆的删除在头部进行,所以堆的删除也和顺序表的头删有些相似,要对大小进行断言,确保堆的大小不为0。但是堆不能直接在头部进行删除,这样会破坏堆的结构,又要重新建堆的时间复杂度是O(n)(后面会证明),这样就显得很麻烦。
于是就有新的一种方法,把堆顶的数据和堆尾的数据先进行交换,然后再把堆尾的数据删除,这样堆的结构就没有完全破坏,因为堆顶的左子树和右子树都是大堆,我们可以进行向下调整就可以恢复堆的形状了,向下调整算法的时间复杂度是堆的高度次,即O(log(h+1))。显然,下面这种算法更优。
代码实现如下:
void HeapPop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
//把最后一个数替换堆顶的数,然后再进行向下调整
Swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
//向下调整
AdjustDown(hp->a, hp->size, 0);
}
直接返回第一个数据即可,但要确保堆不为空。
代码实现如下:
HPDataType HeapTop(HP* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
return hp->a[0];
}
直接返回size的大小即可。
代码实现如下:
int HeapSize(HP* hp)
{
assert(hp);
return hp->size;
}
利用逻辑表达式hp->size == 0进行判断,然后返回其值即可。
代码实现如下:
int HeapEmpty(HP* hp)
{
assert(hp);
return hp->size == 0;
}
堆的销毁就是对动态申请的空间进行释放,防止内存泄漏,其实和顺序表的销毁很相似。
代码实现如下:
void HeapDestory(HP* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
本篇博客就先到这里就结束了,下一篇博客我会跟大家聊一聊有关堆的顺序结构的应用的问题,堆排序和Top-K问题。喜欢的话,欢迎大家点赞支持和指正~