作者主页
lovewold少个r博客主页
➡️栈和队列博客传送门
参天大树充满生命力,其根深叶茂,分枝扶疏,为我们展示了数据分治的生动形态
树
树的常见概念
树的表示
二叉树
一棵二叉树是结点的一个有限集合,该集合:
二叉树的基本类型
满二叉树(完美二叉树)
完全二叉树
二叉树的性质
二叉树的存储方式
顺序存储
链式存储
堆
堆的定义
堆的常用操作
堆的初始化
堆的构建
堆的向上调整的堆化算法
堆的向下调整的堆化算法
编辑
时间复杂化分析
堆的插入
堆的删除
获取堆顶元素
获取堆有效元素个数
堆的判空
堆的销毁
完整代码
堆的应用
️Top-K问题
堆实现逻辑
️堆排序
✒️总结
树木随处可见,对于树的形容,我们总是以枝繁叶茂,树木丛生等来形容它。一颗树的主干能长出许多的分支,每一颗分支上又可以有更多分支。而这和我们要学的新结构抽象成的逻辑结构极为相似。
前面我们学习的数据结构大多数都是一对一的关系,前面接触的单链表,栈等无外乎是对于数据的存储和查找。可是现实中还存在着一对多的关系,也就需要研究这种一对多的数据关系——树(Tree)。同时树中有特殊的完全二叉树,还有特殊的完全二叉树——堆。
树是一种非线性的数据结构,代表这祖先和后代之间的派生关系,树是一种由n(n>=0)个节点组成的集合,其中:
树在我们的计算机中主要应用为文件的管理,这里我们来用Linux更加直观的展示树在文件管理中的应用。
我们进入Linux系统的根目录,这就好比是文件管理的的主干。
在这个根目录下我们还可以看见许多的文件以及文件夹,而我们知道的是文件夹中还可以套文件夹和文件
我们利用tree指令就可以看见整个树形结构的文件系统,观察
,我们就能看见文件就像一颗树一样,分支纵横,枝繁叶茂,而tmp也只为根的一个分支。 比如在根目录的树形展示中,通过记数,我们发现拥有17008个文件夹和115225个文件。而文件却多而不乱,通过访问各个分支就能访问到想要的文件,这就是树的魅力所在。
树结构相对与以前的数据结构要更加复杂,既要保存节点的值域,也要保存节点和节点之间的关系。树在实际中有多种表示方式如:双亲表示法,孩子表示法,孩子双亲表示法以及孩子兄弟表示法。这里我们简单的用孩子兄弟表示法。即我们预想并不能考虑到树的根节点到底有多少分支,但是我们可以不去考虑根节点去链接所有孩子节点。我们通过根管老大,老大管老二的链接方式,让父节点链接左孩子,让左孩子去链接他的兄弟节点。
逻辑上这一棵树的概念如图所示,在代码上我们通过孩子兄弟表示法进行连接。
typedef int DataType;
struct Node
{
struct Node* firstChild1; // 第一个孩子结点
struct Node* pNextBrother; // 指向其下一个兄弟结点
DataType data; // 结点中的数据域
};
二叉树是一种特殊的树,在日常操作和解决问题的过程中我们更常使用二叉树。什么是二叉树呢,顾名思义,即每一颗节点有两个分支。“一分二支”即作为二叉树操作中主要思想。与链表类似,二叉树的基本单元为节点,每一个节点包含值,左节点,右节点。每一个根节点都通过指针分别指向他的左右节点,在二叉树中,除叶子节点外,其他所有节点都包含子节点和非空子树。
一棵二叉树是结点的一个有限集合,该集合:
- 或者为空
- 由一个根节点加上两棵别称为左子树和右子树的二叉树组成
从上图可以看出:
- 二叉树不存在度大于2的结点
- 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
满二叉树所有层的节点都被完全填满的二叉树。
完美二叉树的除了最底层未被填满,其余层都被填满,且叶子节点是从左往右的填充。
- 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有 2^(i-1) 个结点。
- 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是 2^h-1。
- 对任何一棵二叉树, 如果度为0其叶结点个数为 n0,度为2的分支结点个数为 n2,则有 n0=n2+1。
- 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= log_2(n+1)。 (ps: log_2 是以2为底,n+1为对数)
- 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
- 若i>0,i位置节点的双亲序号:(i-1)/2;若i=0,i为根节点编号,无双亲节点
- 若2i+1
2i+1;否则无左孩子 - 若2i+2
2i+2;否则无右孩子
关于数据结构的存储方式,我们一般使用两种方式,即顺序存储和链式存储,二叉树我们同样使用这两种方式。这时候不免要考虑了,链式存储能理解,顺序存储又怎么能实现这个逻辑呢。首先需要清楚的是,顺序存储即数组更适合连续存储,这就比较适合对完全二叉树的存储。
我们通过对一颗完美二叉树建立节点索引,按照层序遍历的方式进行存储,就会发现孩子节点和双亲节点可以通过映射公式建立逻辑联系。
- 即通过任意一个孩子节点的索引值可以通过 (n-1)/2(向下去整)找到其唯一的双亲节点索引。
- 通过一个双亲节点的索引值 2*n+1 ;可以找到唯一的左孩子节点索引,再通过左孩子节点+1即可访问右孩子节点。
非完全二叉树由于后续节点之间不联系,存在空的情况,因此不太适合用数组去存储。数组对于完全二叉树的最优体现就是对堆的实现。
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是 链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所,在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面到高阶数据结构如红黑树等会用到三叉链,因此不做过多铺垫。
不是完全二叉树的篇章么,怎么又跳到了堆。别急,堆是基于完全二叉树的升华体现,要了解树不妨先看看堆。
堆是一种特殊的完全二叉树,它可以用数组来实现,数组中的每个元素对应一个结点。
堆的存储上并不是有序的,但在在每一棵子树都存在根节点相对于左右子树为最大值(大堆),最小值(小堆)。
//堆的初始化 void HeapInit(Heap* hp); // 堆的构建 void HeapCreate(Heap* hp, HPDataType* 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 int HPDataType; typedef struct Heap { HPDataType* a; int size; int capacity; }Heap;
void HeapInit(Heap* hp) { assert(hp); hp->a = NULL; hp->capacity = hp->size = 0; }
- 通过任意一个孩子节点的索引值可以通过 (n-1)/2(向下去整)找到其唯一的双亲节点索引。
- 通过一个双亲节点的索引值 2*n+1 ;可以找到唯一的左孩子节点索引,再通过左孩子节点+1即可访问右孩子节点。
堆的构建简单来讲就是插入数据进堆,由于插入的val并不能保证大小堆的根节点和左右子节点大小关系,因此需要修复从插入节点到根节点路径上的每一节点。这个进行堆调节的一个过程也叫堆化。一般来讲建堆操作通常是对一个已经存在的数组进行堆化,通过建堆利用堆的大小堆根节点的最大和最小进行排序等操作。因此在入堆过程中我们通常有两种堆化方式,那么这两种方式的思维逻辑和时间复杂度怎么样呢?
· 在建堆的过程中首先创建一个空堆,然后遍历列表对每个元素依次入堆操作,这种方式就是在构建堆的时候,新元素进入堆末,再通过对其祖先路径上元素进行比较,如果子节点元素小于父节点,则交换(构建大堆相反),进行"从底到顶"的堆化.
void AdjustDown(HPDataType* a, int n, int parent) { assert(a); 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 = child * 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 = (parent - 1) / 2; } else { break; } } }
这里是以图示的大堆向下调整算法代码
在构建堆的过程中,通过这两种不同的算法,我们有不同的建堆方式。
向上调整建堆:
首先创建空堆,遍历待插入的列表,依次将每一个元素入堆,即先将元素添加到堆末,然后对该元素向上调整。元素数量为n,每个元素入堆的时间复杂度为logn,因此建堆整体时间在nlogn。这种建堆方式,是上层先达到大小堆关系,因此是从上至下的创建的堆。
向下调整建堆:
和向上不同的是先将所有元素原封不动入堆,然后按照倒序层次遍历堆,依次对每一个非叶子节点进行向下调整,当调整到叶子节点的时候调整完毕。这种建堆方式是下层先有序,然后依次向上遍历建堆,因此是从下至上创建的堆。
这里理解起来有点麻烦,为什么向下调整是倒序建立的堆呢。答案其实很简单,要对一个根节点进行如图所示的调整,得先保证左右子树是一定的大小堆,然后才能向下调整,否则调整是没有意义的。而要保证每一个根节点的左右子树是堆,只需要从倒数第二层即叶节点上一层开始向下调整,保证左右子树和根节点的大小关系。往上遍历的过程中,自然一直能保证左右子树是大堆或者小堆。
这种方式的时间复杂度是多少呢,假设完全二叉树的节点数量为n,则节点数量为(n+1)/2(向下整除),因此需要堆化的数量为(n-1)/2。从顶至底部堆化的过程中,每个节点最多堆化到叶子节点,因此最大高度为二叉树高度logn,所有时间复杂度为nlogn么?
一个节点从底到上的一个堆化中,最大需要去调整的次数为该节点到叶子节点的距离,而该距离为节点高度,因此我们可以得到公式 节点数量*节点高度。
叶子节点不用去调整,因此只需要计算到高度1即可。
Tn = 2⁰ · h + 2¹ · (h - 1) + 2² · (h - 2) + ...... + 2ʰ⁻² · 2 + 2ʰ⁻¹ · 1 + 2ʰ · 0
把首尾两个元素简化,记为①式:
①: Tn = h + 2¹ · (h - 1) + 2² · (h - 2) + ...... + 2ʰ⁻² · 2 + 2ʰ⁻¹
对①等于号左右两边乘以2,记为②式:
②: 2Tn = 2¹ · h + 2² · (h - 1) + 2³ · (h - 2) + ...... + 2ʰ⁻¹ · 2 + 2ʰ
那么用②式减去①式,其中②式的操作数右移一位使指数相同的部分对齐,错位相减法。
得到
Tn = n - log₂(n + 1)约等于n
即向下调整的时间复杂度仅O(n),非常高效,因此这里我们建堆过程中采用向下调整的方法。
void HeapCreate(Heap* hp, HPDataType* a, int n)//对已有数组可以直接构建堆
{
assert(hp);
assert(a);
hp->a = (HPDataType*)malloc(sizeof(HPDataType) * n);//开辟空间,如果已有数组,之间开辟等大空间
if (hp->a == NULL)
{
perror("malloc fail");
exit(-1);
}
hp->capacity = n;
hp->size = n;
memcpy(hp->a, a, sizeof(HPDataType) * n);
for (int i = 0; i < n; i++)
{
AdjustUp(hp->a, i);
}
}
堆插入新元素,即先入堆末,在对此进行向上调整即可。
void HeapPush(Heap* hp, HPDataType x) { assert(hp); if (hp->size == hp->capacity) { int newcapacity = hp->capacity==0 ? 4 : hp->capacity * 2; HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity); if (tmp == NULL) { perror("realloc fail"); exit(-1); } hp->a = tmp; hp->capacity = newcapacity; } hp->a[hp->size] = x; hp->size++; AdjustUp(hp->a, hp->size - 1); }
删除元素我们要考虑的是怎么去删除比较有意义,不论是大堆小堆,我们能直接获取就是堆顶元素,而且堆的性质能保证这个元素为堆最大或者最小。因此很显然直接删堆首元素嘛。但是当我们删除堆顶元素后,怎么样能保证他继续是一个堆呢。
这个时候我们先将堆首和堆末替换,扶小弟上位,后面对小弟进行向下调整即可。
void HeapPop(Heap* hp) { assert(hp); assert(hp->size > 0); Swap(&hp->a[0], &hp->a[hp->size - 1]); hp->size--; AdjustDown(hp->a, hp->size, 0); }
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
return hp->a[0];
}
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
//头文件Heap.h
#pragma once
#include
#include
#include
#include
#include
#include
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}Heap;
//自上而下调整
void AdjustUp(HPDataType* a, int child);
//自下而上调整
void AdjustDown(HPDataType* a, int n, int parent);
//堆的初始化
void HeapInit(Heap* hp);
// 堆的构建
void HeapCreate(Heap* hp, HPDataType* 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);
//交换
void Swap(HPDataType* a, HPDataType* b);
//打印
void HeapPrint(Heap* hp);
//实现源文件 Heap.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
void HeapInit(Heap* hp)
{
assert(hp);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
void HeapCreate(Heap* hp, HPDataType* a, int n)//对已有数组可以直接构建堆
{
assert(hp);
assert(a);
hp->a = (HPDataType*)malloc(sizeof(HPDataType) * n);//开辟空间,如果已有数组,之间开辟等大空间
if (hp->a == NULL)
{
perror("malloc fail");
exit(-1);
}
hp->capacity = n;
hp->size = n;
memcpy(hp->a, a, sizeof(HPDataType) * n);
for (int i = 0; i < n; i++)
{
AdjustUp(hp->a, i);
}
}
void Swap(HPDataType* a, HPDataType* b)
{
HPDataType tmp = *a;
*a = *b;
*b = tmp;
}
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 = (parent - 1) / 2;
}
else
{
break;
}
}
}
void AdjustDown(HPDataType* a, int n, int parent)
{
assert(a);
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 = child * 2 + 1;
}
else
{
break;
}
}
}
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
if (hp->size == hp->capacity)
{
int newcapacity = hp->capacity==0 ? 4 : hp->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity);
if (tmp == NULL)
{
perror("realloc fail");
exit(-1);
}
hp->a = tmp;
hp->capacity = newcapacity;
}
hp->a[hp->size] = x;
hp->size++;
AdjustUp(hp->a, hp->size - 1);
}
void HeapPop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
Swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
AdjustDown(hp->a, hp->size, 0);
}
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
return hp->a[0];
}
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
void HeapPrint(Heap* hp)
{
assert(hp);
for (size_t i = 0; i < hp->size; i++)
{
printf("%d ", hp->a[i]);
}
printf("\n");
}
在日常生活中,我们能看见各种各样的榜单,或者是你微信运动的步数,亦或者是美团的餐馆评分前五名,又或者是自己游戏中的排行榜。很多情况下,这些排行榜并不会展示所有的数据,这样的数据反而没有任何参考意义。很多情况下我们总是盯这Top-100,Top-10。因为这些根据某种评分后决定的数据更有含金量,是大多数人的选择或者想知道的信息。
在实现中其实就可以利用堆完成这样子的Top-K问题。
整个建堆的过程时间复杂度就很低,相比于多次排序找最值,时间复杂度在k值最小时间复杂度O(n);k较大时,时间复杂度也不会超过O(nlogn)。这种方式在开辟空间也只用开辟K个节点的空间,空间复杂度也很低。
这种方式适用于动态的数据流变换,在不断加入数据时,堆内元素始终只需要维护其K个,时刻可以保证K个元素及时更新。
这里我们通过文件操作进行写入随机值,并且利用这些随机值解决Top-K问题来演示。
void PrintTopK(const char* filename, int k)
{
// 1. 建堆--用a中前k个元素建堆
FILE* fout = fopen(filename, "r");
if (fout == NULL)
{
perror("fopen fail");
return;
}
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc fail");
return;
}
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minheap[i]);
}
// 前k个数建小堆
for (int i = (k - 2) / 2; i >= 0; --i)
{
AdjustDown(minheap, k, i);
}
// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换
int x = 0;
while (fscanf(fout, "%d", &x) != EOF)
{
if (x > minheap[0])
{
// 替换你进堆
minheap[0] = x;
AdjustDown(minheap, k, 0);
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", minheap[i]);
}
printf("\n");
free(minheap);
fclose(fout);
}
// fprintf fscanf
void CreateNDate()
{
// 造数据
int n = 10000000;//造了一个千万级别的数据
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");//打开文件
if (fin == NULL)
{
perror("fopen error");
return;
}
for (int i = 0; i < n; ++i)
{
int x = (rand() + i) % 10000000;//随机值
fprintf(fin, "%d\n", x);//写入文件
}
fclose(fin);//关闭文件
}
int main()
{
CreateNDate();
PrintTopK("data.txt", 100);//Top-100
return 0;
}
可以看见这个数据量还是相当大的,要是用排序等去实现得跑到天昏地暗。我们改变了数据的几个值,观察他能否找出来,可以看见8133211323这个数据能很快被找出来。
堆排序一个指定的数列,首先我们要确定的是排升序还是降序。升序构建大堆,降序构建小堆。
但是我们还要思考的是对于一个已有的数列,堆是需要额外开辟一块空间进行打印操作么。很显然,我们需要将一个数组转变为一个有序数组,我们可以直接用这个数组本身作为一个堆,然后对数组依次入堆并向下调整。升降序只需要大小堆构建控制即可。
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
堆(Heap)是二叉树和数组的一种抽象数据结构
堆的基本概念:
- 堆是一种树状数据结构,通常是一个完全二叉树。
- 堆分为两种主要类型:最大堆(Max Heap)和最小堆(Min Heap),具体取决于根节点的值与其子节点的关系。
- 在最大堆中,父节点的值大于或等于子节点的值,最大值位于根节点。
- 在最小堆中,父节点的值小于或等于子节点的值,最小值位于根节点。
堆常用的应用:
- 堆排序:堆排序是一种高效的排序算法,通过使用堆数据结构,可以将数组以O(n log n)的时间复杂度进行原地排序。
- 堆可以用于Top-K算法问题。
堆的难点和理解难点:
- 堆的插入和删除操作需要维护堆的性质,这涉及到向下调整和向上调整操作。
- 确保堆的性质在插入或删除元素后仍然得到维护,需要深刻理解堆的特性。
- 堆排序算法的实现相对复杂,需要理解堆的建立和维护。
- 在实际应用中,选择最大堆还是最小堆取决于问题的性质。
作者个人水平有限,文章难免出错,如有错误欢迎指正!