【数据结构入门】堆

发布于:2025-08-13 ⋅ 阅读:(11) ⋅ 点赞:(0)

目录

1.堆的概念

2.堆的实现(小堆)

2.1 堆的结构体

2.2 堆的初始化&建堆(逻辑)

2.3 建堆(物理数组)

2.3.1 向下调整算法

2.3.2 满足向下调整算法的条件

2.3.3 建堆的时间复杂度分析

2.4 堆排序


1.堆的概念

堆本质上是完全二叉树使用数组存储的一个数据结构。

堆分为大根堆、小根堆:

大根堆

每个父亲结点都大于等于孩子结点

        

小根堆

每个父亲结点都小于等于孩子结点。

        堆可以用来选出这个堆中最大或者最小的值,只需要不断维护这个堆即可,那么后面的堆排序的原理其实就是通过维护堆来实现的。

我们做一下下面的题目,巩固一下堆的概念:

可以直接还原为完全二叉树,也可以目测,首先判断第一个数和第二个数的大小,如果第一个数大于第二个数那么就是大根堆,如果后面的数字有比第一个数字大的直接排除,小根堆同理,选A。

2.堆的实现(小堆)

2.1 堆的结构体

        这里使用数组实现,由于后续可能考虑到堆的插入会涉及到扩容的问题,所以这里需要一个size表示实际的元素个数,以及capacity堆的容量。

typedef int dataType;
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
typedef struct Heap
{
	dataType* data;
	int size;
	int capacity;
}Heap;

2.2 堆的初始化&建堆(逻辑)

        对于堆中的data来说,需要创建一个固定大小的空间,此时外界传入空间的大小,大小赋给size和capacity,需要注意的一点是,第一次建堆的时候,数据是从外界获取的,可以是在数组开辟完毕之后拷贝过来。

void heapInit(Heap* p, dataType* data, int size) 
{
	p->data = (dataType*)malloc(sizeof(dataType)*size);
	memcpy(p->data,data, sizeof(dataType) * size);// 提供的数组数据拷贝到堆内数组
	p->size = size;
	p->capacity = size;
	
	// 建堆
}

        接下来就要开始建堆了,假如外界传入一组数据,我们如何构建小堆呢?

这里举一个非常特殊的例子,当一个完全二叉树的根结点的左右子树都是小堆,只有根结点需要进行调整,那么我们调整的过程如下:

①27首先和自己的左右孩子进行比较,选出最小的孩子和自己交换,即27和15交换。

②接下来,只需要调整左子树即可,因为右子树本身就是一个小堆,我们需要找出27的左右孩子中较小的那一个进行交换,也就是和18进行交换。

        

③最后选出25和27交换,最终形成了小根堆。

上面调整的过程就是所谓的向下调整算法,这个算法的前提是左右子树都是小堆(大堆)

2.3 建堆(物理数组)

①首先需要找到根结点的左右孩子,其实就是根节点的下标*2+1(左孩子),根节点的下标*2+2(右孩子)。

②找到左右孩子之后,需要判断自己是否是小堆,即自己是否比孩子要小,如果不是需要和孩子交换。

③选出孩子中较小的一个进行交换,交换完毕之后,新的根结点就是那个被交换的孩子,以此类推继续判断自己是不是小堆。

④每次计算孩子下标的时候,需要判断下标是否越界,如果越界说明,本节点就是叶子结点。

2.3.1 向下调整算法

        首先函数需要三个参数,第一个是需要调整的堆,第二个是堆中数组的长度,第三个是指定需要调整的堆的根结点的下标。

        然后我们假设根结点的左孩子是最小的孩子,如果判断右孩子比左孩子还小,那么更新右孩子为左孩子。

        其次,若根节点比最小的孩子还要大,那么就进行交换,交换完毕之后需要更新最新的根结点是最小的孩子,同时需要计算最新的孩子。

        最后,我们发现这个过程是一个持续的过程,所以写成循环,退出循环的条件是当孩子节点计算的值大于或等于数组的长度,说明这个结点就是叶子结点需要终止循环;

        然而我们发现循环还是有问题,因为只有头结点交换了才会更新头结点和孩子节点的下标,这样就永远无法退出循环,所以如果没有发生交换,也就是说头结点本身比最小的孩子节点还要小,说明就不需要进行堆调整了,直接退出循环

void Swap(dataType* p1, dataType* p2)
{
	dataType tmp = *p1;
	*p1 = *p2;
	*p2 = tmp;
}

// 向下调整算法,默认左右子树都是小堆
void adjustDown(dataType* a, int n,int root)// 堆,数组长度,需要调整的根结点
{
	// 选出最小的孩子
	int parent = root;
	// 最小孩子下标
	int childIndex = 2 * parent + 1;// 默认左孩子是最小的孩子
	while (childIndex < n ) //左右孩子都不能越界
	{
		// 右孩子更小,那么就调整最小孩子的下标为右孩子
		if ( childIndex + 1 < n && a[childIndex] > a[childIndex + 1])
		{
			++childIndex;
		}
		if (a[parent] > a[childIndex]) // 父结点比子结点大,不是小堆,需要交换
		{
			Swap(&a[parent], &a[childIndex]);
			parent = childIndex;
			childIndex = 2 * parent + 1;
		}
		else 
		{
			// 小的孩子已经大于父亲,直接退出循环即可
			break;
		}
	}
}

        BUG:仔细阅读代码会发现,这个代码存在越界问题,若当前根节点是绿色方框框选的结点,那么我们势必要访问它的孩子节点,此时循环判断左孩子节点(默认最小孩子赋值为左孩子)的下标小于n,但是要访问它的右孩子一定会发生越界的问题,我们该如何处理呢?

while (childIndex < n)
{
// 右孩子更小,那么就调整最小孩子的下标为右孩子
		if (a[childIndex] > a[childIndex + 1])
		{
			++childIndex;
		}
......
}

这里只有一个左孩子,根结点还是需要和左孩子交换的,所以只需要在判断语句中判断,如果右节点的下标小于数组长度 并且右节点小于左节点,才能更新最小孩子节点为右节点。

while (childIndex < n ) //左右孩子都不能越界
{
	// 右孩子更小,那么就调整最小孩子的下标为右孩子
	if ( childIndex + 1 < n && a[childIndex] > a[childIndex + 1])
	{
		++childIndex;
	}
    .......
}

        到了这里还是有一个致命的问题,我们无法保证传过来的根结点的左右子树都是小堆,这个条件太苛刻了,如果不满足这个条件,就无法使用向下调整算法。

2.3.2 满足向下调整算法的条件

        已知每一个单独的叶子节点都可以看做一个堆,那么我们只需要从叶子结点所处的最小二叉树入手,即找到叶子结点的父结点,(下标-1)/2即可;

        找到父结点之后,例如下图的72,由于两个叶子结点都是单独的节点也就是单独的堆,是满足向下调整算法的条件的,那么我们只需要将72作为根节点进行向下调整算法,使其变成一个堆,以此类推,调完72之后,再去调它的兄弟节点53,只需要下标-1即可,两个子树都符合向下调整的算法,53所在的子树调整完毕之后,下标继续-1,就是48所在的子树进行调整

调整的顺序如下:

所以堆的初始化已经完成,这里可以打印数组看看。

// 堆初始化
void heapInit(Heap* p, dataType* data, int size)
{
	p->data = (dataType*)malloc(sizeof(dataType) * size);
	memcpy(p->data, data, sizeof(dataType) * size);// 提供的数组数据拷贝到堆内数组
	p->size = size;
	p->capacity = size;

	// 建堆
	// 最后一个叶子结点的下标是size - 1,找父亲节点就是,(孩子节点-1)/2
	for (int i = (size-1-1)/2; i >= 0; --i)
	{
		adjustDown(p->data,p->size,i); // 最后一个叶子节点的父结点开始每次下标-1,依次进行向下调整
	}
}

将调整后的数组直接打印,发现能够正常输出小堆。

2.3.3 建堆的时间复杂度分析

        树的高度是\log_2 N,那么最坏情况是要遍历整个堆,一共调整的次数是N/2次,所以可能读者会认为建堆的过程的时间复杂度是N*\log_2 N,但是实际上并不是这样。

for (int i = (size-1-1)/2; i >= 0; --i)
{
	adjustDown(p->data,p->size,i); // 最后一个叶子节点的父结点开始每次下标-1,依次进行向下调整
}

        因为我们的调整是从堆的最后一个节点的父结点开始的,如果是从0开始那么时间复杂度才是N*\log_2 N,       。

未完待续......

2.4 堆排序

        学会向下调整、建堆之后,我们就可以尝试完成堆排序了。