数据结构堆的全解析

Posted 白晨并不是很能熬夜

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构堆的全解析相关的知识,希望对你有一定的参考价值。

😀大家好,我是白晨,一个不是很能熬夜😫,但是也想日更的人✈。如果喜欢这篇文章,点个赞👍,关注一下👀白晨吧!你的支持就是我最大的动力!💪💪💪

文章目录


🍉前言


上一篇文章,我们详细介绍了二叉树的入门知识(如果没有二叉树基础的同学建议先看一下二叉树入门,我在本篇文章也会对其中二叉树中比较重要的点再次提及),在有了二叉树的相关知识后,我们就可以着手实现一些有着更多特性的数据结构了。今天,这篇文章我们要介绍到的数据结构是一种用来排序或者找寻数据中最大、最小的若干数据的树——堆。



上一篇文章我们讲到,二叉树的顺序存储结构一般只适用于完全二叉树,并且顺序存储结构的父子结点之间还有一些数学联系,现在我们来回顾一下。

一棵二叉树中最后一层结点以上结点构成满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树

上图中,第四层以上的前三层构成满二叉树结构,最后一层从左到右结点连续,所以上树为一棵完全二叉树。

完全二叉树有如下特性:

对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有结点从0开始编号,则对于序号为i的结点有:

  1. i>0,i位置结点的双亲序号:(i-1)/2;i=0,i为根结点编号,无双亲结点
  2. i位置结点的孩子序号:
    • 左孩子:2*i+1;
    • 右孩子:2*i+2;
  3. 若2i+1=n,则无左孩子
  4. 若2i+2=n,则无右孩子

🍊堆的定义及结构


如果有一个关键码的集合K = k 0 k_0 k0 k 1 k_1 k1 k 2 k_2 k2 ,…, k n − 1 k_n-1 kn1 ,把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: k i k_i ki <= k 2 i + 1 k_2i+1 k2i+1 k i k_i ki<= k 2 i + 2 k_2i+2 k2i+2 ( k i k_i ki >= k 2 i + 1 k_2i+1 k2i+1 k i k_i ki >= k 2 i + 2 k_2i+2 k2i+2 ),i= 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

翻译一下就是:

  1. 如果一棵完全二叉树树上父节点都小于等于子节点,那么这棵完全二叉树就被称为小堆

下图为逻辑结构:

下表为存储结构:

下标012345
数据123654
  1. 如果一棵完全二叉树树上父节点都大于等于子节点,那么这棵完全二叉树就被称为大堆

下标012345
数据645123

从以上定义中我们可以得到堆的两个性质:

  1. 一定是完全二叉树
  2. 堆中某个节点的值总是不大于或不小于其父节点的值

特别注意:堆只要求父节点与子节点的关系,并没有要求前一层与后一层的关系,更没有要求存储顺序必须是有序的.


🥝堆结构以及🥭简单接口函数的代码实现


前面已经提过,堆的存储结构是顺序存储,所以需要用到顺序表(如果没有顺序表基础的同学建议先去了解一下顺序表<-点击跳转),以下操作均会涉及到顺序表的相关操作。

堆的代码实现与顺序表没有任何区别,所以这里我们就不再赘述:

typedef int HPDataType;
typedef struct Heap

	HPDataType* a;
	int size;
	int capacity;
Heap;

这样一个堆的存储结构就搭建完毕了,现在我们来实现一下几个简单的接口函数。

// 堆的初始化
void HeapInit(Heap* hp);
// 堆的销毁
void HeapDestory(Heap* hp);
// 堆的打印
void HeapPrint(Heap* hp);
// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);
// 堆的判空
bool HeapEmpty(Heap* hp);
  1. 堆的初始化与销毁

由于堆的结构也是顺序表,所以初始化与销毁与销毁与顺序表没有任何区别,我们可以直接实现。

void HeapInit(Heap* hp)

	assert(hp);

	hp->a = NULL;
	hp->size = 0;
	hp->capacity = 0;


void HeapDestory(Heap* hp)

	assert(hp);

	free(hp->a);
	hp->capacity = 0;
	hp->size = 0;

  1. 堆的打印

这个操作只需要遍历一遍堆中元素就可以。

void HeapPrint(Heap* hp)

	assert(hp);

	int i = 0;

	for (i = 0; i < HeapSize(hp); i++)
	
		printf("%d ", hp->a[i]);
	

	printf("\\n");

  1. 取堆顶的数据

堆的堆顶元素在物理上就是顺序表数组下标为0的元素,所以我们只需要保证栈不为空就可以拿到这个数据。

HPDataType HeapTop(Heap* hp)

	assert(hp);
	assert(!HeapEmpty(hp));

	return hp->a[0];

  1. 堆的数据个数及堆的判空
    • 堆的数据个数就是返回堆的size大小
    • 堆的判空就是判断size是否为0,如为0,返回真;如不为0,返回假
int HeapSize(Heap* hp)

	assert(hp);

	return hp->size;


bool HeapEmpty(Heap* hp)

	assert(hp);

	return hp->size == 0;

  1. 堆的查找

    • 遍历堆数组,如果找到返回下标,找不到返回-1
    int HeapSearch(Heap* hp, HPDataType x)
    
    	assert(hp);
    
    	for (int i = 0; i < hp->size; i++)
    	
    		if (hp->a[i] == x)
    		
    			return i;
    		
    	
    
    	return -1;
    
    
    

🍎堆的创建


向下调整算法


我们通过上面学习了解到堆对父子结点的大小关系是有一定要求的,如果现在给我们一个完全二叉树,我们该如何将其变为大堆呢?(从这里开始,我们全都使用大堆来举例子,小堆的情况只需要根据大堆的思想类比就可以)

先放置这个问题,我们先来看一种情况:

我们可以发现这个完全二叉树除了根结点以外,其余的结点都满足大堆的结构,我们如何调整这个完全二叉树可以使其成为一个大堆呢?

  1. 先让根结点与其孩子中比较大的交换

这时候我们发现,根结点已经是全部结点中最大的了,这就说明根结点已经调整好了,不用再换了。

  1. 我们现在再比较03这两个结点的大小,发现0<3,不满足大堆的要求,所以让03

调整完毕,我们得到了一个小堆。

通过上面的过程,我们得到了以下经验:

  • 向下调整时,被调整节点的左右子树必须都是大堆(或者小堆,根据所调整的堆而定)。

如果左右子树不是大堆,那会出现什么情况呢?

这个堆的左右子树就全不为大堆,我们现在来向下调整根结点。

  1. 根结点与较大的孩子3

  1. 由于0<5,05交换

我们发现0结点已经调整完毕,但是这个完全二叉树仍然不是大堆。

所以,我们可以得出结论,向下调整必须要求根结点的左右子树为大堆(或者小堆)

  • 向下调整只会对二叉树从根结点到最后调整到的位置产生影响,不会对二叉树的全部结点产生影响

所以,我们可以根据以上过程抽象出一个向下调整的通法(大堆):

  1. 保证要调整结点N的左右子树都是大堆。
  2. 比较N与孩子结点的大小关系。如果N大于等于两个孩子结点,调整结束;不然就让N与较大的孩子交换。
  3. 重复2过程,直到N调整结束或者被调整到叶子结点。

小堆类比大堆思想就可以:

  1. 保证要调整结点N的左右子树都是小堆。
  2. 比较N与孩子结点的大小关系。如果N小于等于两个孩子结点,调整结束;不然就让N与较小的孩子交换。
  3. 重复2过程,直到N调整结束或者被调整到叶子结点。

上述过程N结点始终不变,向下调整的意思就是,向下调整N结点到应在的位置

根据以上思想,我们可以实现向下调整:

// 大堆向下调整
// 向下调整结束条件
// 1. 被调整结点 >= 孩子
// 2. 调整到叶子节点
void AdjustDown_big(HPDataType* a, int n, int parent)

	assert(a);

	int child = parent * 2 + 1;
	//孩子必须在堆的范围内
	while (child < n)
	
		//选出两个孩子中最大的进行交换,并且要保证右孩子存在
		if (a[child + 1] > a[child] && child + 1 < n)
		
			child++;
		

		//孩子大于父亲,则交换
		if (a[child] > a[parent])
		
			Swap(&a[child], &a[parent]);
		
		else
		
			break;
		

		parent = child;
		child = parent * 2 + 1;
	


// 小堆向下调整
// 向下调整结束条件
// 1. 被调整结点 <= 孩子
// 2. 调整到叶子节点
void AdjustDown_little(HPDataType* a, int n, int parent)

	assert(a);

	int child = parent * 2 + 1;
	//孩子必须在堆的范围内
	while (child < n)
	
		//选出两个孩子中最小的进行交换,并且要保证右孩子存在
		if (a[child + 1] < a[child] && child + 1 < n)
		
			child++;
		

		//孩子小于父亲,则交换
		if (a[child] < a[parent])
		
			Swap(&a[child], &a[parent]);
		
		else
		
			break;
		

		parent = child;
		child = parent * 2 + 1;
	

这里我们假设我们堆就是满二叉树,向下调整一次最坏情况的时间复杂度为 O ( l o g 2   n ) O(log_2~n) O(log2 n)

我们现在回到最开始提出的问题,一个完全二叉树怎么才能调整为一个大堆?

因为向下调整要保证左右子树都是大堆,所以我们不可能从根结点开始调整,但是我们可以倒着调整。

  1. 我们可以从倒数第一个非叶子结点开始向下调整,如上图3结点

  1. 再向下调整倒数第二个非叶子结点5

3.这时根结点的左右子树就是大堆了,我们现在来调整它

调整完毕,这时我们就得到了一个大堆。

具体调整过程如下:

现在我们就可以抽象出一种向下调整建堆的通法:

  • 从最后一个非叶子结点开始向下调整,一直调整到根结点

最后,再补充一点向下建堆的时间复杂度(了解即可)


向上调整算法


来看这样一种情况,现在在一个大堆后面插入一个数9,由于9>6,所以现在构不成大堆了,要怎么调整才能将其再变为大堆呢?

  1. 由于9>6,所以我们交换69结点。

  1. 比较89,8 < 9,所以根据大堆规则,交换89

  1. 9已经被调整到根结点,调整结束。

同样我们可以上述过程中得到一些经验:

  • 被调整的结点以前的结点必须构成一个大堆
  • 该调整也只会影响从被调整结点到最后调整到的位置的结点这一条路径,完全二叉树其余结点都不受影响

从以上我们可以抽象出向上调整的过程(大堆):

  1. 保证被调整的结点N以前的结点构成一个大堆。
  2. 被调整的结点N与其父节点比较大小,如果N大于父节点,则交换这两个结点;如果N小于等于父节点,则结束调整。
  3. 重复2过程,直到N被调整调整完毕或者调整到根结点。

小堆的向上调整类比大堆可得:

  1. 保证被调整的结点N以前的结点构成一个小堆。
  2. 被调整的结点N与其父节点比较大小,如果N小于父节点,则交换这两个结点;如果N大于等于父节点,则结束调整。
  3. 重复2过程,直到N被调整调整完毕或者调整到根结点。
// 大堆向上调整
// 调整结束条件:
// 1.父节点 >= 被调整结点
// 2.被调整结点已经调整到根结点
void AdjustUp_big(HPDataType* a, int child)

	assert(a);

	while (child > 0)
	
		int parent = (child - 1) / 2;

		if (a[child] > a[parent])
		
			Swap(&a[child], &a[parent]);
		
		else
		
			break;
		
		child = parent;
	


// 小堆向上调整
// 调整结束条件:
// 1.父节点 <= 被调整结点
// 2.被调整结点已经调整到根结点
void AdjustUp_little(HPDataType* a, int child)

	assert(a);

	//当child为0时,结束循环
	while (child > 0)
	
		int parent = (child - 1) / 2;

		//孩子小于父亲时交换,否则退出
		if (a[child] < a[parent])
		
			Swap(&a[child], &a[parent]);
		
		else
		
			break;
		
		child = parent;
	


那么向上调整可不可以将一个完全二叉树调整成堆呢?

答案是可以的,但是向上调整前提是被调整的结点以前的结点必须构成一个堆,所以必须要从上到下向上调整,具体要从第二个结点开始调整,保证前面的都是大堆,一直要调整到最后一个结点。

所以向上调整建堆所花费的时间要比向下调整建堆花费的时间长,一般调整一棵完全二叉树成堆我们使用向下调整法。

向上调整的真正用处在于在堆后面插入数据,我们就要用向上调整将这个新插入的数据调整到正确的位置上,使这棵完全二叉树再建成堆。这个用处我们后面还会提到。


堆的插入


上文提到了向上调整一般用来插入数据,现在我们就来具体探讨一下堆的插入。

  • 当堆中插入数据时,首先要判断堆中是否已经满了,如果满了的话,就要扩容;
  • 然后将数据插入到堆的最后;
  • 最后将插入的数据向上调整。

具体代码实现:

// 大堆数据的插入
void HeapPush_big(Heap* hp, HPDataType x)

	assert(hp);

	if (hp->capacity == hp->size)
	
		int newCapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(hp->a, newCapacity*sizeof(HPDataType));
		if (tmp == NULL)
		
			printf("relloc fail");
			exit(-1);
		
		hp->capacity = newCapacity;
		hp->a = tmp;
	

	hp->a[hp->size] = x;
	AdjustUp_big(hp->a, hp->size);
	hp->size++;


// 小堆数据的插入
void HeapPush_little(Heap* hp, HPDataType x)

	assert(hp);

	if (hp->capacity == hp->size)
	
		int newCapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
		HPDataType* tmp = realloc(hp->a, newCapacity * sizeof(HPDataType));
		if (tmp == NULL前K个高频元素

数据结构之基于堆的优先队列

树--07---堆的实现

堆的简单学习

堆排序

数据结构-堆的概念及实现