【数据结构】二叉树——堆(开篇)
作者:mmseoamin日期:2024-03-04

前言

内容优质的话,那就献上你的三连,在这寒冬里为你继续创造知识的暖阳(˵¯͒〰¯͒˵)

但它如果在某些方面上不太懂事,也希望你能告诉我,让我还有一次获得肯定的机会૮ ºﻌºა站岗

如果在某些方面留有高斯模糊,同样也希望你能告诉我,让我解答你的心中所惑(ง •̀_•́)ง加油

让我知道每一位读者的三连都来之不易

👉话不多说,开讲!👈

树的相关基本概念(利用题目来进行讲解)

        对于度,分为两种——结点的度和树的度。在判断结点的度中,只要把握住结点拥有的子树个数即可,而对于树的度则取决于最大结点的度,如图所示:

【数据结构】二叉树——堆(开篇),第1张

对于E结点,有三个子树——E-I、E-J-P、E-J-Q ,所以其节点的度为3 。

对于这棵树,A拥有最大的结点的度,所以这颗树的度为6。

以上则是对度的初步了解,接下来让我们通过几道例题来加深对它的印象:

相关题目

1.在一颗度为3的树中,度为3的结点有2个,度为2的结点有1个,度为1的结点有2个,则叶子结点有( )个

A.4        B.5        C.6        D.7

2.一颗拥有1000个结点的树度为4,则它的最小深度是( )

A.5         B.6         C.7         D.8

3.一颗完全二叉树有1001个结点,其叶子结点的个数是( )

A.251        B.500        C.501        D.不能确定

4.设一棵二叉树中有3个叶子结点,有8个度为1的结点,则该二叉树中总的结点数为( )个

A.11        B.12​        C.13        D.14

解析环节 

对于第一题:

【数据结构】二叉树——堆(开篇),第2张

对于第二题:

如果这棵树每一层都是满的,则它的深度最小。

        这是因为在一个层次上,如果每个节点都有最大数量的子节点,那么树在该层的宽度最大,因此在达到相同的叶子节点数量时,树的深度会更浅。

        考虑一个例子:如果一颗树的度为4,每一层都是满的,那么树的第一层有4个节点,每个节点都有4个子节点,即第二层有 4×4=164×4=16 个节点,以此类推。这样,通过每一层都充分利用子节点,可以尽量减小树的深度。

【数据结构】二叉树——堆(开篇),第3张

对于第三题:

对于满二叉树:每一层的结点都达到最大值;完全二叉树则“不满”

【数据结构】二叉树——堆(开篇),第4张

对于这道题需要注意的几个点:

1、在任意二叉树中,度为0的节点都比度为2的节点多1个,即 n0 = n2 + 1;

2、在完全二叉树中,如果节点总个数为奇数,则没有度为1的节点,如果节点总个数为偶数,只有一个度为1的节点;

所以根据上述的几个性质,设总结点个数为n,

n=n0+n1+n2=1001,n1=0,所以n=2n0-1,

n0=501;

对于第四题:

利用在任意二叉树中,度为0的节点都比度为2的节点多1个,即 n0 = n2 + 1;这一条性质进行求解,设总结点个数为n,因为n0=3,n1=8,且n2=n0-1=2,所以n=13。

在第三题中,让我们一起来总结一下二叉树中的几个性质

【性质一】 在任意二叉树中,度为0的节点都比度为2的节点多1个,即 n0 = n2 + 1;

【性质二】在任意二叉树中,在第i层中的结点个数最多为2^(i-1)(i>=1);

【性质三】在任意二叉树中,高度为h的二叉树至多有2 ^h -1个节点(h>=1);

【性质四】在有n个结点的完全二叉树中,其深度为h=log(n+1),(log以2为底);

【性质五】这个性质可以结合一道题目来理解

5.在一个堆中,根节点从0开始编号,下标为 i(i > 0) 的结点的左右孩子结点及父结点的下标分别是(  C )

A.2 i、2 i + 1、i /2        B.2i、2i + 1、(i - 1)/2        C.2i + 1、2i + 2、(i - 1)/2

        D.2i + 1、2i + 2、i/2-1

注意根结点是从0开始编号的

【数据结构】二叉树——堆(开篇),275f189a8ee4473a8a7a557f87dbeffa.png,第5张

对于assert函数出现在文章此处,也是基于后面的代码都会运用到这一函数,同时也是自己不太清楚它的完整概念,所以在这个地方介绍一下assert函数在代码中的作用

assert函数:它允许程序员在代码中插入断言,以验证在程序执行过程中的某个点上是否满足了预期的条件。如果条件不满足,assert会中断程序执行并输出错误信息,有助于识别问题的根本原因。

堆的实现

堆的初始化

void HeapInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->size = 0;
	php->capacity = 0;
}

将数组初始化为堆

一些运用的场景:        

        将数组初始化为堆,我们可以将堆类比为一个待办事项列表,其中每个任务都有一个紧急程度。初始化堆的过程可以看作是将一组任务按照紧急程度组织成一个有序的任务列表。

        例如,想象你是一位学生,你有许多不同科目的作业需要完成。每个作业都有一个截止日期,而这个截止日期可以表示作业的紧急程度。通过初始化堆,你可以确保待办事项列表按照截止日期有序排列,使得你能够更有效地处理最紧急的作业。

        在下面中,我们将提到分别运用向下调整与向上调整的方式直接对数组进行建堆,并实现排序的功能。

示例代码:

【数据结构】二叉树——堆(开篇),第6张

效果验证:

【数据结构】二叉树——堆(开篇),第7张

【数据结构】二叉树——堆(开篇),第8张

在接下来的内容当中,需要用到HeapSort函数中的建堆部分,来验证代码执行效果

向下调整建堆

void HeapSort(int* a,int n)
{
    for(int i=(n-1-1)/2;i>=0;i--)
    {
        AdjustDown(a,n,i);
    }
}

        在使用向下调整的时候,前提得是堆结构,面对乱序得数组建成堆(左右子树并非对堆的结构),不能从头就开始调。当然,对于叶子结点,它符合大堆的规则又符合小堆的规则,不需要进行调整,则从它的父结点开始调整。

储存结构int a[ ]={5,3,7,2,8,6,4,70,65,32,100,60,50}

下图则是逻辑结构,将代码的执行过程结构具象化一下:

【数据结构】二叉树——堆(开篇),第9张

【数据结构】二叉树——堆(开篇),第10张

向下调整示例代码:

【数据结构】二叉树——堆(开篇),第11张

利用test.c部分的代码,对向上调整建堆的代码进行调用,通过调试得到;

【数据结构】二叉树——堆(开篇),第12张

效果验证

【数据结构】二叉树——堆(开篇),第13张

【数据结构】二叉树——堆(开篇),第14张

向上调整建堆

void HeapSort(int* a, int n)
{
	// 向上调整建堆 (大堆)or  (小堆)
	// O(N*logN)
	for (int i = 1; i < n; i++)
	{
		AdjustUp(a, i);
	}
}

向上调整示例代码:

【数据结构】二叉树——堆(开篇),第15张

效果验证:

利用test.c部分的代码,对向上调整建堆的代码进行调用,通过调试得到;

【数据结构】二叉树——堆(开篇),第16张对于代码中出现的HeapSort函数,是在堆排序部分中的,在这里只是运用了函数里头的建堆部分,来进行更好的讲解。

【数据结构】二叉树——堆(开篇),第17张

【数据结构】二叉树——堆(开篇),第18张

数据有点尬尴,只需要调整一次。

向上调整与向下调整建堆,究竟谁更胜一筹?

在这里我们假设利用两种调整方式建一个满二叉树,如下图(相关的计算过程和图解)

当然根据前面中,我们了解到了二叉树中相关性质:

【性质二】在任意二叉树中,在第i层中的结点个数最多为2^(i-1)——涉及到我们下面的计算。

【性质四】在有n个结点的完全二叉树中,其深度为h=log(n+1),(log以2为底); 

同时,我们也要知道向上调整于向下调整的时间复杂度上都是一样的——O(logN)。

向上调整建堆 :                                                 

【数据结构】二叉树——堆(开篇),第19张

向下调整:

【数据结构】二叉树——堆(开篇),第20张

根据上述分析,即可得出对于向上调整建堆的时间复杂度为O(N*logN),而向下调整建堆的时间复杂度则为O(N),所以最后向下调整赢得了这场较量!

插入

介绍示例代码:

【数据结构】二叉树——堆(开篇),第21张

对于realloc函数:有原地扩容和异地扩容两种扩容方式

对于原地扩容:

【数据结构】二叉树——堆(开篇),第22张

【数据结构】二叉树——堆(开篇),第23张

效果验证:

【数据结构】二叉树——堆(开篇),第24张

根据结果显示,建立了一个大堆;

删除

介绍示例代码

【数据结构】二叉树——堆(开篇),第25张

效果验证:

test.c

【数据结构】二叉树——堆(开篇),第26张

【数据结构】二叉树——堆(开篇),第27张

打印、摧毁、判空、获取堆顶数据

heap.c

打印:

【数据结构】二叉树——堆(开篇),第28张

摧毁:

【数据结构】二叉树——堆(开篇),第29张

判空:

【数据结构】二叉树——堆(开篇),第30张

获取堆顶数据

【数据结构】二叉树——堆(开篇),第31张

堆排序——两种方法的一较高下

第一种方法:

逻辑步骤:

1、前提要拥有一个堆,这个堆为小堆;

【数据结构】二叉树——堆(开篇),第32张

2、以判空函数HeapEmpty作为循环里的条件;

【数据结构】二叉树——堆(开篇),第33张

3、利用HeapTok取顶函数,将堆顶最小的数存入数组a[0]当中;之后重复之前的操作,将次小的数存了首个元素地址的相邻位置当中,以此类推;

【数据结构】二叉树——堆(开篇),第34张

4、现存入a[i]当中,之后,i在++;对于++i与i++,顺便在这里在提一下,++ i 是先加后赋值;i ++ 是先赋值后加;++i和i++都是分两步完成的。

5、利用HeapPop删除函数,将堆顶的数删除,之后,调用AdjustDown向下调整函数,重新建立小堆,以便完成后续的操作;

【数据结构】二叉树——堆(开篇),第35张

最后,完成升序排序。

整体代码:

test.c

【数据结构】二叉树——堆(开篇),第36张

heap.c——涉及到前面 堆的初始化、堆的销毁、堆的打印、堆的判空与取顶数据等代码。

第二种方法:

对于上述的第一种方法上,我们得先要有一个堆的结构,而对于建立一个堆结构所需要花费的成本过于大,这个是第一种方法的主要缺陷,第二个缺点就是过程中对空间复杂度的消耗。

逻辑步骤:

对于我们即将要写的第二种方法上,我们利用向下调整的建堆方式(在上述内容中,有具体的分析介绍)。

那么对于第二种方法——升序建大堆还是小堆?

升序建大堆。对于小堆来说,最小的数可以利用HeapTop取顶函数来实现,但这也意味着剩下的数成为新的“堆”,这种情况存在偶然性——升序建小堆,父子关系全乱

升序的思路:【数据结构】二叉树——堆(开篇),第37张

整体代码:

test.c

【数据结构】二叉树——堆(开篇),第38张

        第三个注释是对AdjustDown函数中,中间的参数不是“n”,而是“end”的解释——但在Swap函数中,最大的数。“100”已经被放入a[end]的位置上了,最大的数已经被选出,接下来只要重复步骤,将次大的放入到数组的倒数第二位置即可。

【数据结构】二叉树——堆(开篇),第39张

堆的整体代码

heap.h

#pragma once
#include
#include
#include
#include
#include
#include
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;
void AdjustUp(HPDataType* a, int child);
void AdjustDown(HPDataType* a, int n, int parent);
void Swap(HPDataType* p1, HPDataType* p2);
void HeapPrint(HP* php);
void HeapInit(HP* php);
void HeapInitArray(HP* php, int* a, int n);
void HeapDestroy(HP* php);
void HeapPush(HP* php, HPDataType x);
void HeapPop(HP* php);
HPDataType HeapTop(HP* php);
bool HeapEmpty(HP* php);

test.c

#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
//堆排序方法一
void HeapSort(int* a, int n)
{
	HP hp;
	HeapInit(&hp);
	for (int i = 0; i < n; i++)
	{
		HeapPush(&hp, a[i]);
	}
	
	int i = 0;
	while (!HeapEmpty(&hp))
	{
		//printf("%d ", HeapTop(&hp));
		a[i++] = HeapTop(&hp);
		HeapPop(&hp);
	}
	HeapDestroy(&hp);
}
//方法二
void HeapSort(int* a, int n)
{
	 //向上调整建堆 (大堆)or  (小堆)——升序建大堆,降序建小堆
	 //O(N*logN)
	//for (int i = 1; i < n; i++)
	//{
	//	AdjustUp(a, i);
	//}
	// 向下调整建堆
	//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;
	}
}
int main()
{
    int a[] = { 5,3,7,2,8,6,4,70,65,32,100,60,50 };
    HeapSort(a, sizeof(a) / sizeof(int));
    return 0;
}

 根据不同的验证需求,在主函数中调用不同的子函数,以体验更直面的功能效果。

int main()
{
	int a[] = { 5,3,7,2,8,6,4,70,65,32,100,60,50 };
	HP hp;
	HeapInit(&hp);
    //数组初始化建堆
	HeapInitArray(&hp, a, sizeof(a) / sizeof(int));
    return 0;
}

 【数据结构】二叉树——堆(开篇),275f189a8ee4473a8a7a557f87dbeffa.png,第5张

heap.c

#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
void HeapInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->size = 0;
	php->capacity = 0;
}
void HeapInitArray(HP* php, int* a, int n)
{
	assert(php);
	assert(a);
	php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if (php->a == NULL)
	{
		perror("malloc fail");
		exit(-1);
	}
	php->size = n;
	php->capacity = n;
	memcpy(php->a, a, sizeof(HPDataType) * n);
	// 建堆
	for (int i = 1; i < n; i++)
	{
		AdjustUp(php->a, i);
	}
}
void HeapDestroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}
void Swap(HPDataType* p1, HPDataType* p2)
{
	HPDataType tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}
void AdjustUp(HPDataType* a, int child)
{
	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)
{
	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 HeapPush(HP* php, HPDataType x)
{
	assert(php);
	// 扩容
	if (php->size == php->capacity)
	{
		int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(-1);
		}
		php->a = tmp;
		php->capacity = newCapacity;
	}
	php->a[php->size] = x;
	php->size++;
	AdjustUp(php->a, php->size - 1);
}
void HeapPrint(HP* php)
{
	assert(php);
	for (size_t i = 0; i < php->size; i++)
	{
		printf("%d ", php->a[i]);
	}
	printf("\n");
}
void HeapPop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	Swap(&php->a[0], &php->a[php->size - 1]);
	--php->size;
	AdjustDown(php->a, php->size, 0);
}
HPDataType HeapTop(HP* php)
{
	assert(php);
	assert(php->size > 0);
	return php->a[0];
}
bool HeapEmpty(HP* php)
{
	assert(php);
	return (php->size == 0);
}

收尾or待续

        到这里对于堆的构建、向上调整、向下调整等系列操作,相信大伙一定不会在陌生了,其实还有一个点没讲到就是Topk问题——堆的另一种运用,让我们在下一篇中在再次见面吧!