欢迎各位大佬光临本文章!!!
还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。
本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。
我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog
我的gitee:冰冰棒 (BingbingSuperEffort) - Gitee.comhttps://gitee.com/BingbingSuperEffort
系列文章推荐
目录
前言
在前面的章节中我们学习了数据结构中的链表,顺序表,以及栈和队列。这些数据结构都是线性排列的。从今天开始,我们要学习非线性的数据结构:树。相比于线性结构,树比较复杂,但是树结构的应用非常广泛,也是我们学习的重点。下面我们进入主题。
一、树与二叉树
1.1什么是树?
1.1.1树的定义
树是一种非线性的数据结构,它是由n(n>=0)个有限的结点组成的一个具有层次关系的集合。之所以将此种数据结构称为树,是因为它看起来像一颗倒挂的树,根朝上,叶朝下。
1.1.2树的特点
树有一个特殊的结点,称为根节点,根节点没有前驱节点。除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继。因此,树是递归定义的。
在树的结构中,子树之间不能有交集,否则就不是树型结构。
1.1.3树的相关概念
1.1.4树的表示方法
树的表示方法比较复杂,因为我们并不知道一个结点包含几个孩子结点,无法将其一一创建。实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。最常用的是兄弟孩子表示法。结点结构为下图所示。
兄弟孩子表示法是在表示树的结点创建中包含两个结点指针,一个指向该节点的第一个孩子的结点,另一个为指向其兄弟的结点。例如下面的树形结构,用代码链接起来的结构就可以是这样。
1.2二叉树
1.2.1二叉树的定义
一颗二叉树不存在度大于2的情况,二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树。任何一颗二叉树都是由以下几种情况复合而成的。
1.2.2二叉树的特点和性质
二叉树有两种特殊情况,即满二叉树与完全二叉树。
二叉树具有下列性质:
(1)若规定根节点的层数为1,则一棵非空二叉树的第 i 层上最多有2^(i-1)个结点。
(2)若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2^h-1。
(3)对任何一棵二叉树, 如果度为0其叶结点个数为n0 , 度为2的分支结点个数为n2 ,则有n0=n2+1。
(4)若规定根节点的层数为1,具有n个结点的满二叉树的深度,
(5)对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对 于序号为i的结点有:
1.2.3二叉树的存储结构
二、堆的实现
说了这么多,那二叉树具体实现是什么样的呢?接下来我们使用顺序结构来实现一种完全二叉树,堆。
2.1堆的定义
什么是堆?堆就是,有一组数据的集合,其按照完全二叉树的顺序结构进行排列存储在一个一维数组中,并满足每一个子结点的数据都不大于(或者不小于)父亲节点的数据,堆顶数据为这一组数据中最大(或最小)的数据,称为大根堆(或小根堆)。堆总是一颗完全二叉树。
2.2堆的实现
2.2.1堆的构建、初始化、打印
既然堆存储在顺序表中,因此我们搭建堆的结构与顺序表的结构一样。结点结构中依旧包含三个元素,数据类型指针,元素个数,空间大小。
初始化函数也与顺序表无异,开始将存储数据的指针初始化为NULL指针,size和capacity为0。
打印函数与顺序表也一致,遍历打印即可,因此代码如下所示。
函数代码:
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;
}
//打印
void HeapPrint(Heap* hp)
{
assert(hp);
int i = 0;
for ( i = 0; i < hp->size; i++ )
{
printf("%d ", hp->a[i]);
}
printf("\n");
}
2.2.2堆的数据插入
既然是顺序表结构,堆的数据插入也和顺序表一致。开辟新节点然后判断空间是否够用,不够就扩容,够就直接放入到size指向的空间中。
堆的难点是将数据放入后,整体结构是否还是堆,是否还满足堆的性质,如下图所示小根堆,插入数据10后怎样才能让堆再次满足每一个子节点都不小于父亲节点的性质,从新构建为小根堆。
此种情况下我们就需要将数据10进行更换,重新整理数据构成的堆,使其变为小根堆。
想要完成数据更换就需要使用向上调整算法。
我们发现,在插入数据10 后,只有数据10的祖先结点这条路线需要更改,其他结点并不会因为数据的插入发生改变。因此我们只需要将数据10 的这条祖先结点依次进行比对更换即可。
因此我们只需要将数据10 的父亲节点算出来,然后看对应的数据是否小于10,不小于则进行更换,然后继续寻找上一个祖先,直到没有祖先可以找寻。根据前面的性质,我们知道,任何一个结点的父亲结点都与该节点具有联系,即parent=(child-1)/ 2。所以代码如下所示。
函数代码:
//交换函数
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整
void AdjustUp(HPDataType* hp, int child)
{
assert(hp);
int parent = (child - 1) / 2;
while (child > 0 )
{
if ( hp[child] < hp[parent] )//小堆
//if ( hp[child] > hp[parent] )//大堆
{
Swap(&hp[child], &hp[parent]);
child = parent;
parent= (child - 1) / 2;
}
else
{
break;
}
}
}
//插入数据
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
if ( hp->size == hp->capacity )
{
int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity);
if ( tmp == NULL )
{
perror("HeapPush::realloc");
exit(-1);
}
hp->a = tmp;
hp->capacity = newcapacity;
}
hp->a[hp->size] = x;
hp->size++;
AdjustUp(hp->a,hp->size-1);
}
向上调整代码分析:
2.2.3堆的数据删除
删除堆顶元素我们也不能直接运用顺序表的头删做法来挪动数据进行删除,因为这样删除会导致堆的结构完全破坏,如果想重新变为堆,只能重新构建。
那应该怎样删除才能保证结构不会改变呢。堆的删除采用的是交换删除,再调整的思想。即在堆不为空的情况下,将堆顶的数据与最后一个数据先进行交换,然后将最后一个数据删除。删除数据后,堆的其他地方还是满足性质,除了堆顶的数据,此时我们采用向下调整的方法,将堆重新进行调整。
向下调整的具体做法为找到左右孩子中较小的孩子与父亲进行比较,如果满足孩子小于父亲,则进行交换,然后继续找寻下一个孩子结点中较小的孩子,直到不满足条件退出循环。
函数代码:
void AdjustDown(HPDataType* hp, int size, int parent)
{
assert(hp);
int child = parent * 2 + 1;
while ( child<size )
{
if ( child + 1 < size && hp[child] > hp[child + 1] )//小堆
//if ( child + 1 < size && hp[child] < hp[child + 1] )//大堆
{
child++;
}
if ( hp[child] < hp[parent] )//小堆
//if ( hp[child] > hp[parent] )//大堆
{
Swap(&hp[child], &hp[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapPop(Heap* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
Swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
AdjustDown(hp->a, hp->size, 0);
}
代码分析:
2.2.4堆的其他功能函数
在完成堆的数据删除和插入函数的构建后,堆的难点也相应的解决了,剩下的函数就是堆基于顺序表结构的一些操作方式,与顺序表的结构无异,书写起来相对简单。
返回堆顶的元素首先要对其进行判空处理,当堆不为空时返回顺序表中size为0的空间存储的元素。
堆判空就是结构中的size是否为0,如果size为0说明并没有数据存储。
堆的存储元素个数就是size的大小,销毁堆就直接将存储数据的数组进行释放,然后置空即可。
//返回堆顶元素
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(!HeapEmpty(hp));
return hp->a[0];
}
//堆判空
bool HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
//堆元素个数
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
//堆销毁
void HeapDestroy(Heap* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
三、堆的应用
上面介绍了堆的性质和堆的实现,那么堆有什么用呢?堆的最大的两个用处就是进行堆排序和TOP-K问题。下面我们介绍这两种具体应用。
3.1堆排序
堆排序即用堆的思想来进行排序,建成堆后,如果是小堆,那么这组数据中的最小值就在堆顶,将其取出,堆进行调整,次小的数据又在堆顶。经过若干次取出后,堆中数据为空时,堆中的数据从小到大就依次排列完毕。
但是堆排序并没有这么简单,我们要注意堆排序中首先要进行建堆,然后才能排序。并且建大堆排升序,建小堆排降序。
例如将下列数组中的数据利用堆排序,排成升序,我们并不能直接建立小堆,小堆虽然可以找到最小的数据,但是取出最小的数据后,其余数据和堆删除时一样,结构乱掉,必须重新将其建成堆,然后再找出堆顶数据。
既然是排升序,那我们就需要先建成大堆。建堆的方式有两种,向上调整建堆和向下调整建堆。
3.1.1向上调整建堆
向上调整建堆使用的是类似于堆数据插入的方式进行建堆。将数组arr中的元素看成需要调整的一个堆中的元素。从第二个元素50开始进行调整,直到最后一个数据14。具体做法为,首先将首元素20看作堆顶的数据,50看作新插入的数据,插入数据后,对20,50两个元素进行向上调整成大堆。调整完毕后在去访问41,将41看作新插入的数据,对20,50,41进行向上调整,调整成大堆,然后继续遍历数组,直到最后一个元素14。
当调整完最后一个数据14后,数组便被更改为大堆存储的方式。
3.1.2向下调整建堆
向下调增建堆即利用堆删除的思想,从数组最后一个非叶子结点开始向下调整,然后不断向前移动,直到数组第一个元素为止。向下调整有一个前提,即调整该节点的左右子树必须为堆,否则不能使用该方式。因此需要从后向前调整。
因此我们在调整之前,传递过去的是第一个具有孩子结点的父亲节点的位置,最后一个元素下标减1然后除以2便可以得到。具体调整方式如下所示:
3.1.3两种建堆方式的复杂度分析
既然有两种建堆方式,那么哪种建堆方式才是最优的呢?下面我们来计算一下两种建堆方式的时间复杂度。
(1)向上调整建堆时间复杂度
我们以满二叉树为例子来验证。
如图所示,高度为h的二叉树使用向上调整算法的总的移动节点步数为:
那么:
错位相减可得:
化简可得:
所以向上调整算法的时间复杂度为O(N*logN)。
(2)向下调整建堆时间复杂度
与上面的算法类似:
最终算法时间复杂度为O(N)。综上所述,向下调整的时间复杂度更优,因此推荐采用向下调整建堆。
3.1.4堆排序的过程分析
堆建立完成后就要开始堆排序了。例如我们排升序,那我们建立的为大堆。大堆建立完成后,数组中arr[0]的位置保存的是这组数据中最大的数,那我们将其与最后一个数据arr[size-1]进行替换,然后将重新调整的范围变为arr[0]到arr[size-2]的位置,重新调整堆,找出次大的数据,然后再次替换,循环往复直到数组只有一个数据为止。
最终代码如下所示:
void Heapsort(int*pa,int size)
{
int i = 0;
//1.向上调整建堆//0(n*log(n))
//for ( i = 1; i < size; i++ )
//{
// AdjustUp(pa, i);
//}
//print(pa, size);
//2.向下调整建堆//O(n)
for ( i = (size - 1 - 1) / 2; i >= 0; i-- )
{
AdjustDown(pa, size, i);
}
//print(pa, size);
int end = size - 1;
while (end>0 )
{
Swap(&pa[0], &pa[end]);
AdjustDown(pa, end, 0);
end--;
}
}
3.2TOP-K问题
什么是TOP-K问题呢?举个例子,如果我们想知道重庆的所有火锅店中哪几个评分排名前10,我们打开美团就可以看到推荐的前10的火锅店,也就是说假设重庆有2000家火锅店,从2000家店中选出前10名的问题就是一种TOP-K问题。
求解TOP-K问题我们一般选择的就是排序,将2000家火锅店评分从大到小排列,排完序就可以得到前10名,此种方式可以,但不是最优,使用堆排序排列N个数据的时间复杂度为O(N*logN)。还有一种方式是将N个数据建堆,然后取K次堆顶的数据,时间复杂度为O(N+K*logN)。但是这两种办法都无法解决N非常大的问题,如果N达到100亿,空间根本无法创建N个数据来构成堆,因此就需要采用其他方式解决。
最优情况下就是采用下列这种方式,我们创建K个元素的堆,一般情况下,N非常大但是K并不大,内存空间足够容纳K个元素的堆。求前K个最大数需要创建K个元素的小堆,前K个最小数,则需要创建K个元素的大堆。堆创建完毕后,将剩余的N-K个元素依次与堆顶的数据进行比较,比堆顶数据大则进堆,直到遍历结束。此时堆中的K个元素即为这组数据中的前K个最大的数据。
函数代码:
void PrintTopK(int* a, int n, int k)
{
// 1. 建堆--用a中前k个元素建堆
int i = 0;
int* pa = (int*)malloc(sizeof(int)*k);
for ( i = 0; i < k; i++ )
{
pa[i] = a[i];
}
//建堆
for ( i = (k - 1 - 1) / 2; i >= 0; i-- )
{
AdjustDown(pa, k, i);
}
// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则替换
for ( i = k; i < n; i++ )
{
if ( pa[0] < a[i] )
{
pa[0] = a[i];
AdjustDown(pa, k, 0);
}
}
for ( i = 0; i < k; i++ )
{
printf("%d ", pa[i]);
}
printf("\n");
}
总结
堆在逻辑上是一个树形结构,但在物理存储空间上是顺序存储结构。堆最难理解的部分就是两种调整方式,向上调整在增加节点时使用。需要通过孩子结点计算父亲节点,然后比对二者的数据关系,满足条件进行数据交换,直到计算出来的孩子结点不大于0为止。向下调整在删除结点时使用,向下调整算法需要满足左右子树必须为堆,否则不能使用,通过父亲节点来计算孩子结点,然后进行数据比对,满足条件进行数据交换,直到计算的孩子结点超出size的范围。堆排序构建堆的方式有两种,两种方式的时间复杂度不一样,向下调整构建堆更优。TOP-K问题中构建的堆的大小为K个数据,是一种空间复杂度与时间复杂度都达到最优的解法,最终得出的前K个数据都在堆中。
最后附上堆与堆排序、TOP-K等代码实现链接:堆排序,堆的搭建 · 49a5ceb · 冰冰棒/数据结构仓库 - Gitee.com