目录
一.树与二叉树
1.树的概念与相关术语:
概念
树是⼀种非线性的数据结构,它是由 n(n>=0) 个有限结点组成⼀个具有层次关系的集合。把它叫做 树是因为它看起来像⼀棵倒挂的树,也就是说它是根朝上,而叶朝下的。
有一个特殊的结点,称为根结点,根结点没有前驱结点。
除根结点外,其余结点被分成 M(M>0) 个互不相交的集合 T1、T2、……、Tm ,其中每⼀个集合 Ti( 又是⼀棵结构与树类似的子树。每棵⼦树的根结点有且只有⼀个前驱,可以 有 0 个或多个后继。因此,树是递归定义的
相关术语
父结点/双亲结点:若⼀个结点含有子结点,则这个结点称为其子结点的父结点
子结点/孩子结点:⼀个结点含有的⼦树的根结点称为该结点的⼦结点
结点的度:⼀个结点有⼏个孩⼦,他的度就是多少
树的度:⼀棵树中,最⼤的结点的度称为树的度
叶子结点/终端结点:度为 0 的结点称为叶结点
分支结点/非终端结点:度不为 0 的结点
兄弟结点:具有相同父结点的结点互称为兄弟结点(亲兄弟)
结点的层次:从根开始定义起,根为第 1 层,根的⼦结点为第 2 层,以此类推
树的高度或深度:树中结点的最大层次
路径:⼀条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列
2.二叉树:
(1)定义:
二叉树满足以下两个特点:
1. 二叉树不存在度大于 2 的结点
2. 二叉树的子树有左右之分,次序不能颠倒,因此⼆叉树是有序树
(2)特殊的二叉树:
满二叉树
⼀个二叉树,如果每⼀个层的结点数都达到最⼤值,则这个二叉树就是满二叉树。也就是说,如果⼀ 个二叉树的层数为 K ,且结点总数是2的k次方-1 ,则它就是满二叉树。
(3)完全二叉树
完全二叉树是效率很高的数据结构,完全二叉树是由满⼆叉树而引出来的。
对于深度为 K 的,有 n 个 结点的⼆叉树,当且仅当其每⼀个结点都与深度为K的满⼆叉树中编号从 1 至 n 的结点⼀⼀对应时称 之为完全⼆叉树。要注意的是满⼆叉树是⼀种特殊的完全⼆叉树
简单来说:完全二叉树和满二叉树的区别·就在于那它们的区别主要在于结构上的差异,满二叉树要求每一层都填满,而完全二叉树只要求前几层填满,最后一层可以填到最左边但不一定全部填满
(4)二叉树的存储结构:
顺序存储(即下面我要说的堆)与链式结构
(我在这篇文章主要说明的是二叉树的顺序存储,链式结构的介绍我会详细再另外写一篇文章)
二.堆的概念与性质
1.堆的概念:
堆(Heap)是计算机科学中的一种特殊数据结构,它可以被视为一棵完全二叉树的数组表示形式。堆的特点是节点的值总是不大于或不小于其父节点的值,这种性质使得堆的根节点总是该数据结构中的最大值或最小值
其中,堆的根节点是该数据结构中的最大值则称该堆为大根堆;
堆的根节点是该数据结构中的最小值则称该堆为小根堆。
堆满足以下特性:
*堆中某个结点的值总是不大于或不小于其父结点的值
*堆总是⼀棵完全二叉树
2.堆的重要性质:
对于具有 n 个结点的完全⼆叉树,如果按照从上至下从左至右的数组顺序对所有结点从 0 开始编号,则对于序号为 i 的结点有:
1. 若 i>0 , i 位置结点的双亲序号: (i-1)/2 ; i=0 , i 为根结点编号,无双亲结点
2. 若 2i+1,左孩子序号: 2i+1 , 2i+1>=n 否则无左孩子
3. 若 2i+2,右孩子序号: 2i+2 , 2i+2>=n 否则无右孩子
三.堆的具体代码实现
1.堆的结构:
//堆的结构
typedef int HPDataType;
typedef struct Heap
{
HPDataType* arr;
int size;//有效数据个数
int capacity;//空间大小
}HP;
2.堆的初始化与销毁:
//堆的初始化
void HPInit(HP* php)
{
assert(php);
php->arr = NULL;
php->size = php->capacity = 0;
}
//堆的销毁
void HPDestroy(HP* php)
{
assert(php);
if (php->arr)
{
free(php->arr);
}
php->arr = NULL;
php->capacity = php->size = 0;
}
3.向上调整算法和向下调整算法:
(1)向上调整算法:
目标:
将新插入的节点或被修改的子节点调整到合适位置,使其满足堆性质
步骤:
从当前节点开始,比较其与父节点的值
如果违反堆性质(如最小堆中子节点值更小),则交换两者
重复上述过程,直到到达根节点或满足堆性质
void AdjustUp(HP* hp, int child)//传入堆和调整的起始孩子
{
int parent = (child - 1) / 2;//公式
while (child > 0)//注意退出条件
{
//建小根堆
if (hp->arr[parent] > hp->arr[child])
{
swap(&hp->arr[parent], &hp->arr[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;//如果不用换了。说明这个堆正常了,和adjustdown的退出原因一样
}
}
}
(2)向下调整算法:
目标:
将删除根节点后的最后一个元素调整到堆顶,或修复被修改的根节点
步骤:
找到当前节点的子节点(左、右),选出符合堆性质的子节点(如最小堆中较小的子节点)
如果当前节点违反堆性质(如比子节点更大),则与子节点交换
重复上述过程,直到叶子节点或满足堆性质
void AdjustDown(HP* hp, int parent, int n)//n是向下调整的下界范围
{
int child = parent * 2 + 1;//公式
while (child < n)
{
//建小根堆
if (child + 1 < n && hp->arr[child + 1] < hp->arr[child])//child+1的目的是确保两个孩子结点都是有意义的结点
{
child++;
}
if (hp->arr[parent] > hp->arr[child])
{
swap(&hp->arr[parent], &hp->arr[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
//我最小的孩子都比父节点大,那么这个堆就是正常堆了,直接退出
break;
}
}
}
(3)总结两种算法的区别:
1. 方向不同
向上调整:从子节点向根节点方向调整
例如,插入新节点时,将其放在堆末尾,然后逐层与父节点比较并交换
向下调整:从父节点向叶子节点方向调整
例如,删除根节点后,将末尾节点移到根位置,逐层与子节点比较并交换
2. 应用场景不同
向上调整:
插入新元素:新元素被添加到堆的末尾,可能破坏堆性质,需向上调整
节点值增大:在最大堆中,若某节点的值变大,可能需要向上调整
向下调整
删除根节点:删除堆顶后,将末尾节点移到堆顶,需向下调整
节点值减小:在最大堆中,若某节点的值变小,可能需要向下调整
构建堆:从最后一个非叶子节点开始,逐个向下调整,时间复杂度为 O(n)
3. 操作步骤不同
向上调整:
比较当前节点与父节点
若违反堆性质(如最大堆中当前节点值更大),则交换两者
重复直到到达根节点或满足堆性质
向下调整:
比较当前节点与左右子节点
选择最大(或最小)的子节点(根据堆类型)
若子节点违反堆性质,则交换两者
重复直到到达叶子节点或满足堆性质
4. 时间复杂度
常数因子差异:向下调整需比较两个子节点,操作略复杂;向上调整仅需比较父节点以下是时间复杂度的具体算法
(4)堆元素的插入:
void HeapPush(HP* hp, int x)
{
assert(hp);
//判断空间是否已满
if (hp->capacity == hp->size)
{
//注意capacity的单位是个,不是字节
int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;//第一次开就开四个
//空间开辟
//注意这里是realoc不是malloc
//所有链式结构的用malloc,咱们开辟新节点
//所有线性结构,底层是数组的,咱们就realoc
//因为咱们要保留数组之前的数据,所以必须要扩容而不是重新开辟
HP* newspace = (HP*)realloc(hp->arr, newcapacity * sizeof(int));
if (newspace == NULL)
{
perror("malloc wrong!");
exit(1);
}
hp->arr = newspace;
hp->capacity = newcapacity;
}
//直接插入
//size是当前有效元素个数,对应的也是下一个该存放元素位置的下标
hp->arr[hp->size] = x;
hp->size++;
//插入元素之后配套进行向上调整
AdjustUp(hp, hp->size - 1);//注意传入的是下标
}
(5)堆元素的删除:
void HeapPop(HP* hp)
{
//几乎所有的数据结构,在插入元素是要断言这个数据结构是否存在
//在删除元素的时候断言这个数据结构中要含有元素
assert(!empty(hp));
//删除根元素
swap(&hp->arr[0], &hp->arr[hp->size - 1]);
hp->size--;
//配套使用向下调整算法
AdjustDown(hp, 0, hp->size);
}
(6)取堆顶元素:
int HeapTop(HP* hp)
{
assert(!empty(hp));
//empty里面判断了这个堆必须存在,所以说不用再在这判断了、
return hp->arr[0];
}
(7)堆的判空:
bool empty(HP* hp)
{
assert(hp);
return hp->size == 0;
}
四.堆的具体应用
1.基于已有堆结构的堆排序(通过不断取堆顶):
// 1、需要堆的数据结构
// 2、空间复杂度 O(N)
void HeapSort(int* a, int n)
{
HP hp;
for(int i = 0; i < n; i++)
{
HPPush(&hp,a[i]);
}
int i = 0;
while (!HPEmpty(&hp))
{
a[i++] = HPTop(&hp);
HPPop(&hp);
}
HPDestroy(&hp);
}
2.数组建堆,先向下调整建堆,再向上调整:
// 升序,建⼤堆
// 降序,建⼩堆
// O(N*logN)
void HeapSort(int* a, int n)
{
// a数组直接建堆 O(N)
for (int i = (n-1-1)/2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
// O(N*logN)
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
欧克,到这里就差不多把数组结构的堆介绍完了,更多的就是堆的Top-K问题了,感兴趣的可以先去了解,我后续会写关于它的解析
那就这样吧
全文终