堆的分析与实现(基于C语言)

一、堆的概念及性质结构

堆是一种非线性数据结构,本质上堆其实是一棵完全二叉树,通常存储在一维数组当中。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆具有的性质:
1.堆总是一棵完全二叉树;
2.堆中某个节点的值总是不大于或不小于其父亲节点的值;

对第二点的理解是:当堆为大根堆时,堆的根节点的值一定大于或等于其两个孩子节点的值;当堆为小根堆时,堆的根节点的值一定小于或等于其两个孩子节点的值
在这里插入图片描述
在这里插入图片描述


二、堆的实现

堆的存储结构是顺序表,堆的实现紧紧围绕着两个核心算法,即向上调整算法与向下调整算法。下面将分别从向上调整算法与向下调整算法展开,借助对堆的功能实现示例来分析这两种算法。首先展示堆的结构定义:

//定义Heap结构体
typedef struct Heap
{
	int* a;
	int size;//顺序表的长度
	int capacity;//顺序表的容量
}HP;

1.向上调整算法

向上调整顾名思义即从下往上调整,从叶子节点开始向根节点的方向进行调整。

1)向上调整算法代码实现

基本的思想: (默认调整小堆)AdjustUp函数中形参child用来接收传入的孩子节点的下标,定义 parent变量来表示当前孩子的父亲节点的下标(由完全二叉树在数组中存储的性质得,parent=(child-1)/2 ,便可得到父亲节点的下标),通过比较 a[parent] 的值与 a[child] 的值,如果调整的是大堆则当
a[parent]>a[child] 时交换两值,如果调整的是小堆则当 a[parent] <a[child] 时交换两值。利用循环不断重复这一步骤,如果符合交换条件的就更新当前的childparent,再将parent的下标更新一下,直到child走到根节点时停止,或者是当交换条件不符合时停止循环。

代码示例如下:

//交换函数
void Swap(int* a, int* b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

//a是存储堆的数组,child接收的是传入的孩子节点的下标
void AdjustUp(int* a, int child)
{
    //完全二叉树的性质
    int parent = (child - 1) / 2;

    while (child > 0)
    {
        //调整小堆    
        if (a[child] < a[parent])
        {
            //交换两值
            Swap(&a[child],&a[parent]);
            
            //继续向上调整
            child = parent;
            parent = (child - 1) / 2;
        }
        else
        {
            break;
        }
    }
}

2)堆的插入

堆的插入运用到的就是向上调整算法。因为堆的插入是从堆最后一个元素后插入,插入元素容易,但同时也要确保堆的结构不被破坏,因此要在插入以后再向上调整以保持堆的结构。

基本思想: 由于堆的存储结构是动态的顺序表,所以当进行插入操作时第一步是需要判断顺序表是否有容量能够插入,如果容量满了,则需要realloc出一个新的空间,进行扩容操作。进行插入操作只需要在顺序表尾部插入元素并且将元素个数size加一即可,需要注意的是,插入的数据可能不符合堆的结构,所以我们需要采用向上调整算法将插入后的结构重新调整为堆。

//以小堆为示例
void HeapPush(HP* php, int x)
{
	assert(php);

    //如果容量满了则进行扩容
	if (php->size == php->capacity)
	{
		int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		int* tmp = realloc(php->a, sizeof(int)* newCapacity);
		if (tmp == NULL)
		{
			printf("realloc failed\n");
			exit(-1);
		}

        //动态开辟内存成功,更新顺序表及顺序表的容量
		php->a = tmp;
		php->capacity = newCapacity;
	}

    //在顺序表尾插入元素x,再将元素个数加一
	php->a[php->size] = x;
	++php->size;

	// 向上调整,控制保持是一个小堆
	AdjustUp(php->a, php->size - 1);
}

2.向下调整算法

向下调整顾名思义即从上往下调整,从根节点开始向叶子节点节点的方向进行调整。

1)向下调整算法代码实现

基本思想:(默认调整小堆)AdjustDown函数中形参parent接收传入的父亲节点的下标,形参size保存数组的元素个数。定义child变量表示当前父亲节点对应的左孩子的下标(由完全二叉树在数组中存储的性质得,child=parent*2+1
,便可得到左孩子节点的下标),由于父亲节点有两个孩子节点,有可能左孩子节点的值比右孩子节点的值要小,也有可能右孩子节点的值比左孩子节点的值要小,所以我们干脆直接让child表示左孩子的下标,然后再加一层判断:如果右孩子的值比左孩子的值要小,则让当前child+1就是右孩子的下标。
确定了孩子下标的值以后,我们再将父亲节点的值与孩子节点的值进行比较,如果a[parent]>a[child]
则交换并继续向下调整,如果不符合这个判断条件则停止循环。当一直向下调整到child>size时也会停止循环。

//交换函数
void Swap(int* a, int* b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

//parent接收传入的父亲节点的下标,size保存数组的元素个数
void AdjustDown(int* a, int parent,int size)
{
    int child = parent * 2 + 1;
 
    while (child<size)
    {
        //选出左右孩子中最小的那个
        if (child+1<size&&a[child] > a[child + 1])
        {
            child++;
        }

        //如果父亲比孩子的值大则交换,继续向下调整
        if (a[parent] > a[child])
        {
            //交换两数
            Swap(&a[parent], &a[child]);
            
            //继续向下调整
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

2)删除堆顶数据

删除堆顶数据运用的就是向下调整算法,因为栈是存储在顺序表当中,如果删除表头数据,那么新的表头做根,很可能会导致堆原本的结构被破坏,所以不能够直接进行删除,而是应该将堆顶元素与堆最后一个元素交换,再删除最后一个元素,并且进行向下调整保持原有的堆结构。

//交换函数
void Swap(int* a, int* b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

// 删除堆顶的数据。(最小/最大)
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);
}

3.堆的其他功能实现

返回堆顶数据

int HeapTop(HP* php)
{
	assert(php);
	assert(php->size > 0);

	return php->a[0];
}

返回堆的元素个数

int HeapSize(HP* php)
{
	assert(php);

	return php->size;
}

判断堆是否为空

bool HeapEmpty(HP* php)
{
	assert(php);

	return php->size == 0;
}

三、堆的应用

1.堆排序的实现

1)方法一:额外建新堆进行排序

前面我们实现了堆的基本操作,包括向上调整算法和向下调整算法。因此我们可以借助堆的基本操作来实现堆排序。假设有一个n个元素的待排序的数组a,我们可以创建一个堆,将数组元素从头到尾插入到新建的堆中,由于堆的插入是向上调整,所以如果建的是大堆,那么堆顶数据一定是最大的数据,如果建的是小堆,那么堆顶数据一定是最小的数据。因此我们可以根据排序的需要来选择建大堆还是建小堆。
我们以排升序为例,建立小堆,再每次重复取堆顶数据并且删除堆顶数据,将堆顶数据一一存入原先的数组当中,从而实现第一次存入数组的数据是最小的数据,第二次存入的是次小的,以此类推,最终数组中的数据按升序排序。

代码示例如下:

void HeapSort(int* a, int size)
{
	//创建一个堆并对其进行初始化
	Heap hp;
	HeapCreate(&hp);
	
	//插入建堆
	for (int i = 0; i < size; i++)
	{
		HeapPush(&hp, a[i]);
	}

	//每次选出堆顶数据插入到数组中
	for (int i = 0; i < size; i++)
	{
		a[i]=HeapTop(&hp);
		HeapPop(&hp);
	}
}

下面我们分析一下这种排序方法的时间复杂度:
首先是遍历数组,那么时间复杂度就是O(N),遍历数组以后是建堆,建堆使用的是向上调整算法,下面分析向上调整算法的时间复杂度:
在这里插入图片描述
所以综上所述时间复杂度是O(N*logN),而这种排序方法的空间复杂度是O(N),因为额外创建了一个新的堆,所以这种排序方法是存在缺点的。另外,如果每次使用堆排序我们都要写一个堆的基本操作会显得非常低效,因此一般不会使用这种方法进行排序。

2)方法二:在原数组建堆排序

针对方法一存在的缺陷,我们进行改进优化,方法二是在原数组上直接建堆,使得空间复杂度从O(N)变为O(1),而且不需要再运用到堆的插入与删除等繁琐的操作。

首先第一步是建堆,建堆可以采用向上调整建堆也可以采用向下调整建堆。

向上调整建堆:向上调整算法的前提是当前节点往上的结构必须已经是一个堆,所以如果我们从下往上开始建堆就不合适了,因为无法保证上面的节点已经是一个堆。所以我们从根节点开始向下进行向上调整建堆。举例子说明,在一个待排序的数组中,我们从数组的第一个元素开始向上调整,由于第一个元素就只有一个节点,可以认为是堆,所以继续将数组的第二个元素与第一个元素向上调整,使其成为堆,再将第三个元素与前两个已经处理完成的堆进行向上调整,以此类推,就可以将一个数组向上调整为一个堆了。

//a是待排序的数组,size是数组的元素个数
void HeapSort(int* a, int size)
{
	//向上调整建堆
	for (int i = 0; i < size; i++)
	{
		AdjustUp(a, i);
	}

}

向上调整建堆的代码实现非常简单,下面我们通过画图来理解向上调整建堆的过程。假设待排序数组如下图:
在这里插入图片描述

那么我们从第一个元素9开始进行向上调整,然后调整前两个元素,再调整前三个元素,以此类推,直到最后一个元素调整完成,堆就建成了。
在这里插入图片描述
在这里插入图片描述

向下调整建堆:向下调整算法的前提是当前节点往下的结构必须已经是一个堆,所以如果我们从上往下建堆就不合适了,因为无法保证下面的节点已经是一个堆。所以我们从下往上开始向下调整建堆。我们从一棵完全二叉树的最后一个非叶子节点开始向下调整(不从叶子节点开始的原因是叶子节点下面已经没有节点了,没必要再调整),数组中当前的元素调整完成以后再将前一个元素进行向下调整,以此类推,直到调整到根节点为止,这样就可以将一个数组向下调整为一个堆了。

//a是待排序的数组,size是数组的元素个数
void HeapSort(int* a, int size)
{
	//向下调整建堆
	//n-1代表的是最后一个节点的下标,再减一除二是完全二叉树的公式求父亲节点
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, i, n);
	}

}

我们同样通过画图的方式,来理解向下调整建堆的过程,假设待排序数组如下图:
在这里插入图片描述
那么根据我们前面的推导,从倒数第一个非叶子节点开始向下调整,即该点在数组中的下标为i=(n-1-1)/2=2,(n代表数组元素的个数),从该点开始向下调整,当这一点调整完成以后下标减一继续调整另一棵树,一直到根节点调整完成为止,这样堆就建成了。
在这里插入图片描述
堆建好了以后,此时数组仍然是无序的,只是保证了父亲节点的值一定小于(或大于)左右孩子节点的值,所以我们还需要对数组进行进一步的排序。

在建完堆以后,堆排序的核心思想就是利用堆顶元素一定是最小(或最大)
的元素,每次选出最小(或最大)的数,再选出次小(或次大)的数,这样选出来的数就是有序的。这个思想类似于上面提到的删除堆顶数据,我们是将堆顶数据与堆最后一个数据进行交换,然后删除最后一个数据(即原本的堆顶数据),再对堆进行向下调整使之仍然是一个堆。但我们要的是将数据进行排序,虽然可以每次利用删除堆顶元素的操作选出数据并且保存在另外一个数组当中,但这样空间复杂度还是O(N),因此我们在删除堆顶元素的思想上做一些改进,我们同样是将堆顶元素与堆最后一个元素进行交换,但我们不去删除最后一个数据,而是利用一个尾指针去指向最后一个数据,每一次完成交换以后就向下调整,再将尾指针向前移一个单位,从而更新最后一个数据的位置,直到所有数据被调整完为止。

//交换函数
void Swap(int* a, int* b)
{
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

void HeapSort(int* a, int size)
{
	//向下调整建堆
	//n-1代表的是最后一个节点的下标,再减一除二是完全二叉树的公式求父亲节点
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, i, n);
	}

    int end=n-1;
    while (end>=0)
    {
        Swap(&a[0], &a[end]);
        AdjustDown(a, 0, end);
        end--;
    }
}

四、小结

以上是对于堆的有关内容的总结和梳理,写博客的目的是为了梳理所学的知识点,更方便日后的复习和学习。堆的应用其实还是很多的,不止是堆排序的应用,只不过堆排序应该是堆的最基础的应用。堆的结构特点还会应用到优先队列的实现当中,即保持队列是先入先出的特性不变,将出队列的顺序做一定的调整,使得符合条件的元素优先出队列,优先队列的底层原理也是应用堆来实现。同时还有Top K问题,即在数据量特别庞大的情况下要求统计出前K个最大的或者是最小的元素,就好比如在游戏里统计所有玩家中经验值、能力值排名前10的玩家,就是典型的Top K问题,这种问题也是依靠堆来解决。堆的应用还有很多方面,在后续的学习中我也会继续总结补充。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

JJP1124

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值