论如何用C语言的数组手撕一棵特殊的完全二叉树----堆

目录

0.前言

1. 用数组表示存储一棵完全二叉树

2. 数组表示的完全二叉树的性质

3. 堆的基本概念

3.1 堆的核心性质

3.2 堆顶的性质

3.3 堆的单支性质

3.4 堆的左右支关系

4.  用代码实现堆

4.1 堆类的实现

4.2  堆的初始化

4.3 堆的销毁

4.4 获取堆顶的数据

4.5 堆判空

4.6 堆的大小

4.7 堆内元素的交换

4.8* 堆的插入

4.8.1 问题引入

4.8.2* 向上调整

4.8.3 堆插入的代码

4.9* 堆的删除

4.9.1 情景引入

4.9.2* 向下调整

4.9.3 堆的删除代码实现


0.前言

5堆的实现 · onlookerzy123456qwq/data_structure_practice_primer - 码云 - 开源中国 (gitee.com)https://gitee.com/onlookerzy123456qwq/data_structure_practice_primer/tree/master/5%E5%A0%86%E7%9A%84%E5%AE%9E%E7%8E%B0本文所有代码都已放到gitee,可以自取。

我们之前在博客当中,知道了如何构建一棵二叉树的方式3. 如何用代码表示一棵树,完全二叉树作为一种特殊的二叉树,我们也是可以用数组表示出来的!那如何表示呢?数据结构 堆 是一棵完全二叉树,那堆又有什么性质,如何在数组中实现堆呢?本文讲带你一一解答!

1. 用数组表示存储一棵完全二叉树

2.3 完全二叉树对于完全二叉树来说,完全二叉树的所有节点在顺序上,是连续的,没有跳跃间隔的!!!

所以我们可以从第一层的第一个节点自上层到下层同一层自最左节点到最右节点到最后一层的最后一个节点,都是连续的,没有跳跃间隔的,如上述节点是1 2 3 4 5 6 7......12。

所以对于一棵完全二叉树所有的节点都是连续的数组就是一个连续的没有跳跃间隔的存储结构,所以对于一棵完全二叉树,我们可以抽象为一个数组给每一个节点数据都设计一个下标index,依次从上层到下层,同一层从最左到最右,连续存储到数组当中,如下图例子:

 我们就是这样用数组表示一棵完全二叉树的。为什么这么说?因为只要你知道了一个节点的在完全二叉树的位置,比如你是第3层的第2个节点,那从第一层的第一个根节点开始数(连续的没有间隔跳跃的),你就是第 2^2 - 1 + 2 == 5,即你是第5个节点。所以就可以快速定位你这个节点在数组当中存储的位置就是第5个元素,下标就是[4]。

也就是说,完全二叉树的所有的节点从上往下,从左到右,是连续的依次的按照下标连续存储的。

2. 数组表示的完全二叉树的性质

我们现在每一个节点存储在数组里,他们的就可以用一个下标index来定位,从下标代表的角度出发,那节点与节点之间的下标有关系吗?尤其是父节点和子节点直接有关系吗?兄弟节点之间有关系吗?我们直接上结论:

1. 根节点的下标始终为0

2. 如果一个节点的下标是parent_i,那么它的左孩子的存储下标left_childparent_i * 2 + 1右孩子的存储下标right_child就是parent_i * 2 + 2

(当然需要保证这个节点有左孩子 / 右孩子,不能超过下标的范围哦!)

3. 如果一个节点的下标是child,那么不管这个节点是左孩子,还是右孩子,它的父亲的下标一定就是(child - 1)/ 2

(当然根节点除外,它没有父亲)

4. 如果一个节点的下标是index,那么在同一层中,它的左边的 亲兄弟 / 堂兄弟,的下标就是index-1,它的右边的 亲兄弟 / 堂兄弟,的下标就是index+1

(当然存在三个特殊情况,一个是index是最后一个节点,右边不存在 亲兄弟/堂兄弟;另一个是我index节点是当前层的第一个节点,index-1就是上一层的最后一个节点;还有一个是我index节点是当前层的最后一个节点,那index+1就是下一层的第一个节点)

随便举个例子,B的存储下标是1,那我们快速定位B的左孩子和右孩子的下标:left_child = 1*2 + 1 == 3,right_child = 1*2 + 2 == 4,图中左孩子D节点的下标就是3,右孩子E节点的下标就是4。

节点D的下标是3,那在同一层中,3+1 = 4,我的右边相邻的 亲兄弟 / 堂兄弟,就是E(下标为4),3 - 1 = 2,那就是上一层的最后一个节点C(下标为2)。

从上面,我们知道,只要你知道了一个节点在数组中存储的下标,那你就可以直接锁定这个节点的父亲,左孩子,右孩子,的存储下标。这也进一步说明我们用一个数组来表示完全二叉树的合理!!!

堆这个数据结构就是一棵完全二叉树,所以我们也是以这样,即数组的方式代表存储一个堆。

3. 堆的基本概念

3.1 堆的核心性质

,是一种特殊完全二叉树,是一种非常实用的数据结构,跟进程地址空间的堆区没有任何的关系。

,分为大堆和小堆,大堆和小堆是相互反着的。

大堆永远保持:上面的父节点大于下面的左右孩子节点,即每一对“三角关系”父亲 左孩子 右孩子,都满足,父亲 > 左孩子 && 父亲 > 右孩子。上克下。

小堆永远保持:上面的父节点小于下面的左右孩子节点,即每一对“三角关系”父亲 左孩子 右孩子,都满足,父亲 < 左孩子 && 父亲 < 右孩子。下克上。

只要满足上述关系的完全二叉树,你就是堆。

 如图,大堆,15节点的左右孩子12 13都是小于父亲15的;12节点的左右孩子9  5都是小于父亲12的;3节点的左孩子11,也是小于父亲13的。

同时,既然所有的三角关系都满足上述性质,那么其实你单拎出来任何一棵大堆/小堆的子树(注意拎出来的一棵完全二叉树),那么其实这棵子树也必然是大堆/小堆。

3.2 堆顶的性质

这里我们可以看到一个很明显的结论,大堆的根节点,我们称之为堆顶永远是所有节点当中最大的。这是因为堆顶大于其左右孩子,而这左右孩子又继续作为父亲,继续往下扩散,继续大于他的左右孩子,这样依次往下扩散,我们很容易得出大堆堆顶的数据就是最大的

同理,我们知道,小堆的堆顶,也永远是所有节点当中最小的

3.3 堆的单支性质

根据堆的基本三角关系性质,然后我们又可以得出一个结论,在大堆当中,一个单支当中上面的永远大于下面的,类比现实就是太太太爷爷永远比太太爷爷大,太太爷爷永远比太爷爷大,太爷爷永远比爷爷大,爷爷比爸爸大。

 同理在小堆的任何一个单支当中,上面的永远小于下面的

3.4 堆的左右支关系

 刚才我们看的都是同一支的上下节点的关系,那同层的左右的节点之间的大小对比关系呢?答:没有任何关系,同层的左右节点之间的大小左节点大还是右节点大,这个是不确定事件。即同级别的分支和分支之间的大小之间没有任何的关系。如果非要说关系,那就是他们这些左右支里的值都比根小,但是事实上,左右支的大小对比关系,就是没有关系。

如下图中的左右支,这两个支没有大小对比关系。因为我们可以把12节点 和 13节点,直接交换位置,那么这其实也是一个大堆,大小对比关系都是不定的!

4.  用代码实现堆

4.1 堆类的实现

我们如何封装表示一个堆呢?事实上,堆这个完全二叉树,我们是用数组结构来实现的,所以我们可以直接封装一个数组就可以了。但是这样就是定长的了,不能够进行动态的增长,所以说我们不选择定义一个静态的数组int a[N]。而是封装一个顺序表即可!(1条消息) 数据结构的起航,用C语言实现一个简约却不简单的顺序表!(零基础也能看懂)_yuyulovespicy的博客-CSDN博客

//范式类型
typedef int HPDataType;
//实现堆(一种完全二叉树,节点在逻辑上一定是连续的),我们采用数组结构进行存储表示比较合适。
typedef struct Heap {
	HPDataType* _a; //数组-存储基本节点
	int _size;		//元素节点个数
	int _capacity;	//数组容量
}Heap;

我们的堆的相关接口,必须要传入的是Heap对象的指针,因为C语言,我们函数进行传参都是传值传参,我们传入的都是传入对象的拷贝!!!

4.2  堆的初始化

在一开始定义出来这个struct Heap对象的时候,它的内部的成员变量,都是随机值,_a是野指针,_size,_capacity都是随机值。所以我们必须要创建一个堆对象之后,就要完成堆的初始化。

void HeapInit(Heap* ph)
{
	//传入有效堆实体(指针非空)
	assert(ph);
	ph->_a = NULL;
	ph->_size = ph->_capacity = 0;
}

4.3 堆的销毁

我们创建一个堆,是在堆区开辟了连续的物理空间,申请的堆区空间需要我们主动释放,不然就会导致内存泄漏。所以在进程退出之前,我们必须要清理这个堆对象的资源。

void HeapDestroy(Heap* ph)
{
	//传入有效堆实体(指针非空)
	assert(ph);
	//释放所有堆区空间
	free(ph->_a);
	//置空内部
	ph->_a = NULL;
	ph->_size = ph->_capacity = 0;
	//外部置空ph
}

但是这里,我们传入的是这个Heap对象的指针的拷贝!并不是Heap对象指针实体。所以这里我们需要在外部对这个Heap对象指针实体进行主动的置空。

4.4 获取堆顶的数据

我们的堆,是一棵完全二叉树,并按照数组结构进行依次存储的。

 所以我们可以很容易知道,堆顶的数据,就是根节点,它在数组当中的存储下标就是index=0处的位置。

这里我们进一步强调堆顶的数据的重要性:根据我们的刚刚讨论的堆顶的性质,大堆的堆顶是所有元素当中的最大值,小堆的堆顶是所有元素当中的最小值,所以实际上堆顶代表着这整个堆的“脸面”,我们后面的TOPK问题就是通过堆顶来进行实现的。

HPDataType HeapTop(Heap* ph)
{
	//传入有效堆实体(指针非空)
	assert(ph);
	//非空有有效数据才可以返回
	assert(ph->_size > 0);
	//返回栈顶元素
	return ph->_a[0];
}

4.5 堆判空

我们的这个对是以顺序表进行架构的,这个_size成员的大小就代表了这个顺序表的数据个数,所以判堆是否为空,直接返回_size==0即可。

bool HeapEmpty(Heap* ph)
{
	return ph->_size == 0;
}

4.6 堆的大小

即返回当前堆,即完全二叉树的有效数据的个数。

int HeapSize(Heap* ph)
{
	assert(ph);
	return ph->_size;
}

4.7 堆内元素的交换

就是交换堆内的两个有效数据,我们这里实现出来,后面的向上调整/向下调整,会有大用。

void Swap(HPDataType* pa, HPDataType* pb)
{
	//交换两个元素的大小
	HPDataType tmp = *pa;
	*pa = *pb;
	*pb = tmp;
}

4.8* 堆的插入

4.8.1 问题引入

我们可以往堆里直接插入一共新元素,我们的堆首先是一棵完全二叉树,所有的数据不能有跳跃间隔,故我们的插入不能随便找一个空位置插入,应该所有的元素紧凑连接起来,所以现在插入的位置就先在尾插入了,如果我们直接尾插,那么就会变成下面这样:

 现在这个大堆,就不再是大堆了!大堆中,所有的三角关系,都是父亲大于左右孩子,而我们新插入的17这个节点元素,就会导致不再满足大堆的性质了。父亲13不再大于孩子17了。

我们出现问题的点就在于这个17的位置是不合法的。所以说我们接下来所做的应该是调整这个17的位置,使得这个大堆,还满足大堆的性质。这就是我们堆中的AdjustUp向上调整!

4.8.2* 向上调整

大堆满足的是一个上下分支,上面的节点数据大于下面的节点数据;同级的左右分支之间,大小是没有关系。我们AdjustUp向上调整,所针对的对象,是这个新插入节点往上到根的这个上下分支(如下图圈中的分支)。

 我们向上调整的逻辑过程是:如果我child比parent更大,那child和parent的值就交换,把child这个大值交换上去,然后继续循环迭代如果我child比parent更小,那就停止迭代循环,向上调整更新完成。

 我们根据思路得出代码:

然后我们再结合的数组结构实现的完全二叉树的结构,知道如何从父亲节点下标到左右孩子节点下标的转变,以及如何从左右孩子下标到父亲节点下标的转变。

//在完全二叉树a中,对pos位置的元素进行向上调整 为堆
void AdjustUp(int* a, int pos)
{
	assert(a);
	assert(pos >= 0);
	//完全二叉树(堆)中,parent=(child-1)/2,left_child=parent*2+1,right_child=parent*2+2 
	int child = pos;
	int parent = (child - 1) / 2;
	//向上调整的最坏情况是从pos尾调整到root[0]才调整完毕,即child[pos,0)
	while (child > 0)
	{
		//小堆调整主要使用<,大堆调整主要使用>。(统一用小堆实现)
		//小者往上调整
		if (a[child] < a[parent])
		{
			Swap(a + child, a + parent);
		}
		else //大小结构合理,调整完毕
		{
			break;
		}
		//往上层更新迭代父子
		child = parent;
		parent = (child - 1) / 2;
	}
}

4.8.3 堆插入的代码

所以堆的插入代码就很简单,就是插入之后,对这个新插入的节点进行向上调整。(当然啦,插入就要涉及扩容,所以我们需要检查扩容)。

void HeapPush(Heap* ph, HPDataType x)
{
	//传入有效堆实体(指针非空)
	assert(ph);
	//检查扩容
	if (ph->_size == ph->_capacity)
	{
		int newcapacity = (ph->_capacity == 0) ? 8 : ph->_capacity * 2;
		//扩容
		HPDataType* ptmp = (HPDataType*)realloc(ph->_a,sizeof(HPDataType) * newcapacity);
		if (ptmp == NULL)
		{
			perror("realloc");
			exit(1);
		}
		ph->_a = ptmp;
		ph->_capacity = newcapacity;
	}
	//先进行尾插
	ph->_a[ph->_size] = x;
	ph->_size++;
	//对尾插的数据进行向上调整
	AdjustUp(ph->_a, ph->_size-1);
}

4.9* 堆的删除

4.9.1 情景引入

堆的删除,是删除堆顶节点。可是我们不能直接删除堆顶的数据,因为我们既要维持完全二叉树的结构(即在数组存储上是连续的没有间隔的),同时我们也要维持堆的性质:父亲大于左右孩子 / 父亲小于左右孩子 的关系。

我们不能直接删除堆顶的数据,因为堆顶的数据的下标是[0],我们使用的是数组形式组织的完全二叉树,直接头删就是O(N)的时间复杂度,效率过低。但是顺序表的尾删效率是极高的O(1)。

所以说,堆,采用的是替换法删除,我们是把要删除的堆顶节点数据,与该完全二叉树的最后一个节点的数据进行交换,然后我们直接删除最后一个节点即可。这样我们就的确删除了这个堆顶数据15

 然后使用这个但是删除之后呢?删除之后,虽然还是一棵完全二叉树,但是就不再是一个堆了!如图中,现在大堆顶替换成了11,而堆顶的左右孩子是12,13,大堆的parent(11)居然是小于下面的左右孩子12,13。这肯定是不对的,所以这里我们就请出AdjustDown向下调整来解决这个问题。

4.9.2* 向下调整

对于大堆来说,向下调整的思路就是:在这个parent,leftchild,rightchild这个三角关系当中,我们向下调整肯定是让这三角当中的最大值作为parent,两个较小值作为左右孩子child

所以我们第一步是选取左右孩子当中的较大值节点child,当然也存在右孩子不存在的情况,我们需要特殊讨论,当然有不存在左右孩子的情况,那就说明我们已经向下调整到底,即调整完毕了。

第二步是,将parent和选出来的较大的child孩子,进行比较如果child大孩子比parent父亲还大,那么我们就交换大孩子child和parent父亲的值,完成一次向下调整,然后继续迭代向下。而如果child大孩子比parent父亲还小,也即parent更大,符合大堆性质,那此时就调整结束

 所以就产生代码:

//对pos位置的元素 在大小为sz的完全二叉树a中向下调整 为堆
void AdjustDown(int* a, int sz, int pos)
{
	assert(a);
	//小堆调整主要使用<,大堆调整主要使用>。(统一用小堆实现)
	/*parent向下调整,把parent child_left child_right中最小的放在parent的位置,发生交换则继续向下调整直至到最后一层*/
	int parent = pos;
	//使用child首先默认表示左孩子
	int child = parent * 2 + 1;
	//调整到最后一层停止向下调整
	while (child <= sz - 1)
	{
		//找出左右孩子中较小<的孩子去交换调整(当然首先右孩子要存在)
		if ((child + 1) <= sz - 1 && a[child + 1] < a[child])
		{
			//此时child代表右孩子去交换
			++child;
		}
		//如果parent小于<其中孩子,为了维护小堆结构需交换继续向下调整
		if (a[child] < a[parent])
		{
			Swap(a + parent, a + child);
		}
		else //parent可以作为左右孩子父亲,大小结构合理,则停止调整。
		{
			break;
		}
		//继续向下迭代
		parent = child;
		child = parent * 2 + 1;
	}
}

4.9.3 堆的删除代码实现

按照我们刚刚的思路,先替换法删除,然后,对新的换到堆顶的数据进行向下调整。

void HeapPop(Heap* ph)
{
	//传入有效堆实体(指针非空)
	assert(ph);
	//有有效数据存在才可以删除
	assert(!HeapEmpty(ph));
	/*栈的删除是对栈顶元素a[0]的删除*/
	//采用首尾置换删除法
	//1.交换首尾元素,删除尾部
	ph->_a[0] = ph->_a[ph->_size - 1];
	ph->_size--;
	//2.对新栈顶数据进行向下调整
	AdjustDown(ph->_a, ph->_size, 0);
}
  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值