自学内容网 自学内容网

【数据结构】堆的概念、结构、模拟实现以及应用

        本篇我们来介绍一下数据结构中的堆。

  1.堆的概念及结构

1)堆是一颗完全二叉树

2)堆又分为大堆小堆,大堆就是树里面任何一个父节点都大于子节点,所以根节点是最大值;小堆就是树里面任何一个父节点都小于子节点,所以根节点也是最小值

大堆和小堆只要求父节点与子结点之间的关系,并没有要求兄弟节点之间的关系。 所以说,小堆不一定是降序,大堆不一定是升序。

2.父节点和子节点的对应关系

假设父节点在数组的下标为i:

左孩子在数组的下标:2*i + 1右孩子在数组的下标:2*i + 2

假设子节点在数组中的下标为j:

父节点在数组中的下标:(j - 1) / 2

 3.小堆的实现

3.1 准备工作

建立一个头文件Heap.h,两个源文件Heap.ctest.c,存放内容如下。

Heap.h中实现堆的结构。因为堆的底层是数组,所以堆的底层实现和顺序表的一样。

#include <stdio.h>
#include <assert.h>
#include <stdlib.h>

typedef int HpDateType;
typedef struct Heap
{
HpDateType* a;
int size;
int capacity;
}Heap;

3.2 初始化和销毁

Heap.h中进行函数声明。

void HPInit(Heap* hp);//初始化
void HPDestroy(Heap* hp);//销毁

Heap.c中进行函数实现。记得包含头文件 #include "Heap.h"

void HPInit(Heap* hp)
{
assert(hp);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
void HPDestroy(Heap* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL;
hp->capacity = hp->size = 0;
}

3.3 push 插入数据

实现之前我们先来分析一下。

假如我们现在实现一个小堆,在下面的小堆里插入一个10。

 此时已经它既不是大堆也不是小堆,就不是一个堆,所以我们需要将这个10向上调整,让它变成小堆。 如下是逻辑结构上的变化。

物理结构上这个10,应该是插入在数组的结尾

 我们按照前面说过的父子关系来通过子节点的下标找父节点

 找到父节点之后,与此时的子节点10对比一下,父节点比子节点10大,两个交换位置

然后再用同样的方法继续找父节点。

 找到父节点之后,与此时的子节点10对比一下,父节点比子节点10大,两个交换位置

然后再用同样的方法继续找父节点。 

 找到父节点之后,与此时的子节点10对比一下,父节点比子节点10大,两个交换位置。 

所以插入的数据要和它所有的“亲”祖先比,直到它大与等与自己的父亲,或者自己成了根节点,没有比它更小的数了,就结束。

3.3.1 交换

因为交换函数用的地方很多,包括push,所以我们封装一下交换的代码,以便后续使用。

Heap.h中进行函数声明。

void Swap(HpDateType* p1, HpDateType* p2);//交换

 在Heap.c中进行函数实现。

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

3.3.1 向上调整

我们将向上调整的代码同样封装成一个函数。

Heap.h中进行函数声明。

void AdjustUp(HpDateType* x, int child);//向上调整

第一个参数是要调整的数组,第二个参数是这个数在数组中的下标。

Heap.c中进行函数实现。

void AdjustUp(HpDateType* a, int child)
{
int parent = (child - 1) / 2;
while (child >= 0 && a[child] < a[parent])
{
Swap(&a[child], &a[parent]); //交换
child = parent;
parent = (child - 1) / 2;
}
}

3.3.3 push

Heap.h中进行函数声明。

void HPPush(Heap* hp, HpDateType x);//插入

 在Heap.c中进行函数实现。

void HPPush(Heap* hp, HpDateType x)
{
assert(hp);

if (hp->size == hp->capacity) //空间不够扩容
{
int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
HpDateType* tmp = (HpDateType*)realloc(hp->a, newcapacity * sizeof(HpDateType));
if (tmp)
{
perror("malloc fail!");
return;
}
hp->a = tmp;
hp->capacity = newcapacity;
}


hp->a[hp->size] = x;//插入数据
hp->size++;//更新size

//向上调整
AdjustUp(hp->a, hp->size - 1);//size-1才是子节点的下标
}

test.c中对前面实现的所有函数进行测试。

#include "Heap.h"
void test1()
{
int a[] = { 5,2,4,7,9,1,3,8 };
Heap hp;
HPInit(&hp); //初始化
for (int i = 0; i < sizeof(a) / sizeof(int); i++)
{
HPPush(&hp, a[i]); //插入数据
}
HPDestroy(&hp);//销毁
}
int main()
{
test1();
return 0;
}

运行结果和我们分析的一样。 

3.4 pop 删除数据

堆里面的删除,要求的是删除堆顶的数据,也就是根位置,堆里最小的数。在小堆里,这样删除可以找到第二小的数,再删除,可以找到第三小的数..;在大堆则可以找出最大的数、第二大的数、第三大的数...这样删除才有意义。

分析一下,这里删除的就是数组最开始的数据,直接删除首元素可以吗? 不可以,因为直接删除的话,父子关系就全乱了

兄弟变父子,父子变兄弟,也会导致这不是一个堆了。

所以删除的方法就是,将根节点最后一个叶子节点交换,删除调整后的尾节点,然后采用向下调整的算法重新排序。

这样,我们就把第二小的放到了堆顶,删除之后依旧是一个小堆。

3.4.1 向下调整

我们将向下调整的代码同样封装成一个函数,实现pop时可直接复用。

Heap.h中进行函数声明。

void AdjustDown(HpDateType* x, int size, int parent);//向下调整

第一个参数是要调整的数组,第二个参数是数组的大小,第三个参数是父节点的下标。

Heap.c中进行函数实现。

void AdjustDown(HpDateType* x, int size, int parent)
{
int child = parent * 2 + 1;//假设左孩子小

while (child < size && x[parent] > x[child])
{
//如果右孩子小,child为右孩子下标
if (child + 1 < size && x[child] > x[child + 1]) 
{
child++;
}

Swap(&x[parent], &x[child]);//交换
parent = child;
child = parent * 2 + 1;
}
}

3.4.2 pop

Heap.h中进行函数声明。

void HPPop(Heap* hp); //删除

Heap.c中进行函数实现。

void HPPop(Heap* hp)
{
assert(hp);
assert(hp->size);
Swap(&hp->a[0], &hp->a[hp->size - 1]);//首尾交换
hp->size--; //删除末节点
AdjustDown(hp->a, hp->size, 0); //向下调整
}

test.c中对前面实现的所有函数进行测试。

void test2()
{
int a[] = { 3,4,5,8,9,7,6,10 };
Heap hp;
HPInit(&hp); //初始化
for (int i = 0; i < sizeof(a) / sizeof(int); i++)
{
HPPush(&hp, a[i]); //插入数据
}
for (int i = 0; i < hp.size; i++)
{
printf("%d ", hp.a[i]);
}
printf("\n");
HPPop(&hp);
for (int i = 0; i < hp.size; i++)
{
printf("%d ", hp.a[i]);
}
printf("\n");
HPDestroy(&hp);//销毁
}

这个运行结果和我们前面分析的一样。 

3.5 获取根节点数据、判空

Heap.h中进行函数声明。

HpDateType HPTop(Heap* hp); //获取堆顶数据
bool HPEmpty(Heap* hp);//判空

 在Heap.c中进行函数实现。

HpDateType HPTop(Heap* hp)
{
assert(hp);
assert(hp->size);
return hp->a[0];
}
bool HPEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}

test.c中对前面实现的函数进行测试。

void test3()
{
int a[] = { 3,4,5,8,9,7,6,10 };
Heap hp;
HPInit(&hp); //初始化
for (int i = 0; i < sizeof(a) / sizeof(int); i++)
{
HPPush(&hp, a[i]); //插入数据
}
while (!HPEmpty(&hp))
{
printf("%d ", HPTop(&hp));//把堆顶元素打印出来
HPPop(&hp); //删除堆顶数据,此时堆顶为第二小的数 
}
}

我们可以通过pop和top的配合,按顺序打印出这个堆。

 这里也是更加体现出pop的价值。

4.大堆的实现

前面我们已经实现好了小堆,大堆的实现只要稍微改动两个函数即可。

大堆的向上调整。

void AdjustUp(HpDateType* a, int child)
{
int parent = (child - 1) / 2;
//while (child > 0 && a[child] < a[parent])//小堆
while (child > 0 && a[child] > a[parent])//大堆
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
}

大堆的向下调整。

void AdjustDown(HpDateType* x, int size, int parent)
{
int child = parent * 2 + 1;//假设左孩子小

//while (child < size && x[parent] > x[child])//小堆
while (child < size && x[parent] < x[child])//大堆
{
//if (child + 1 < size && x[child] > x[child + 1]) //小堆
if (child + 1 < size && x[child] < x[child + 1]) //大堆
{
child++;
}

Swap(&x[parent], &x[child]);//交换
parent = child;
child = parent * 2 + 1;
}
}

其他一律不变。

test.c中测试一下,就用前面的测试样例。

此时,大堆的pop和top结合,就可以将这个堆倒序打印出来。 

5.堆的应用

5.1 top-k问题

找出一段数据最大的前k个,或者最小的前k个。有了堆,我们不需要对整个数据排序,就能做到。

比如,找出数组a最大的前5个。

void test4()
{
int a[] = { 32,41,55,38,9,71,6,10, 11, 29, 90, 103 };
Heap hp;
HPInit(&hp); //初始化
for (int i = 0; i < sizeof(a) / sizeof(int); i++)
{
HPPush(&hp, a[i]); //插入数据
}
int k = 0;
scanf("%d", &k);
while (k--)
{
printf("%d ", HPTop(&hp));
HPPop(&hp);
}
printf("\n");
}

并且效率也是非常高的。假设树的节点是N,pop的时间复杂度最坏的情况都是\log_{2}N

5.2 建堆

现在我们有一个数组,我们要快速对这个数组建堆,怎么实现?把我们刚刚实现的小堆或者大堆再全部实现一次吗?不是的。我们只需要用到一个函数,就是向上调整,或者向下调整。

int a[] = { 32,41,55,38,9,71,6,10, 11, 29, 90, 103 };
int n = sizeof(a) / sizeof(int);
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
for (int i = 0; i < n; i++)
{
printf("%d ", a[i]);
}
printf("\n");

把这个数组a看作是一个完全二叉树,从下标为1的开始,下标为0的就默认已经是堆了。

 这个就是建堆。这里建的是大堆。

5.3 堆排序

堆排序就使用堆的思想来完成排序。

升序:建大堆!

降序:建小堆!

如果降序建大堆,就跟前面实现pop遇到的问题一样了,会导致关系全乱套。所以,降序我们建小堆。

建小堆,我们就可以得到最小的数

然后把首位节点一交换。

 

交换之后,我们把4忽视,假装它不是这个堆里面的数据。然后不包括4在内的其他数,会向下调整,继续调整为小堆。

调整为小堆之后又得到了第二小的数,第二小的数和不包括4在内的尾节点交换,也就是倒数第二个数交换。

重复上面的步骤,最小的数,第二小的数,第三小的数...这个升序就实现了。

堆排序的效率是O(N*\log_{2}N)

代码实现如下。

void HeapSort(int* a, int n)
{
//降序,建小堆
for (int i = 1; i < n; i++)//建堆
{
AdjustUp(a, i);
}
int end = n - 1; //控制“视为堆内”的数据
while (end > 0)
{
Swap(&a[0], &a[end]);//交换
AdjustDown(a, end, 0);//向下调整为小堆
end--;
}
}

升序则相反。 

本次分享就到这里,我们下篇再见~


原文地址:https://blog.csdn.net/2402_82757055/article/details/144302139

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!