堆【概念-实现-应用详解】

堆的概念及性质

堆的本质是一个完全二叉树

大堆:树中所有父亲结点都大于等于孩子结点

小堆:树中所有父亲结点都小于等于孩子结点
在这里插入图片描述

如上图,实际上堆的存储结构,是存在数组中的,即是它的物理结构(在内存中的存储)

孩子和父亲下标的关系:

image-20221120133443439

leftchild=parent*2+1         左孩子一定是奇数
rightchild=parent*2+2        右孩子一定是偶数

而若已知子结点要推父节点:

parent=(child-1)/2

注意:堆不一定有序,上面的有序只是巧合,因为堆的性质中并没有限制左右孩子的大小关系

例题:

image-20221120134128290

思路:任何一个数组都可以看作一个完全二叉树,但不一定是堆,因此可以把数组按照1、2、4…逐个的分解出来,再来检查是否满足堆的性质

堆的函数声明与实现

函数声明

堆的逻辑结构是一个完全二叉树,而它的物理存储结构却是一个数组,我们采用变长数组,用size代表堆中元素个数,用capacity代表数组空间的大小,三者构成堆的结构

typedef int HPDataType;
typedef struct Heap
{
	//堆的存储结构由数组实现
	HPDataType* a;
	int size;
	int capacity;
}HP;

void HeapInit(HP* php);									//初始化
void HeapDestroy(HP* php);								//销毁

void AdjustUp(HPDataType* a, int child);                //向上调整
void AdjustDown(HPDataType* a, int n, int parent);      //向下调整
void HeapPush(HP* php, HPDataType x);					//插入

void HeapPop(HP* php);									//删除根(堆顶)
HPDataType HeapTop(HP* php);							//取堆顶
int HeapSize(HP* php);									//堆大小
bool HeapEmpty(HP* php);								//堆是否为空

函数实现

1.初始化

数组这种存储结构的初始化很简单,我们只用考虑在初始化时是否预先为数组开辟一小段空间,这里我们不开空间

void HeapInit(HP* php){
    assert(php);
    php->a=NULL;
    php->capacity=php->size=0;
}

2.销毁

free掉数组占用的空间,并将数组指针置为空防止野指针造成的非法内存访问

void HeapDestroy(HP* php){
    assert(php);
    free(php->a);
    php->a=NULL;
    php->size=php->capacity=0;
}

3.插入

堆的插入,在物理存储上是存在数组的下一个位置里(尾插),但要满足堆的性质,因此在插入时,插入的值的影响范围是它的所有祖先结点;若不满足堆的性质,我们需要对它向上调整

向上调整

image-20221122130026963

这里以小堆为例:

假设插入的数据是6,堆是小堆,因此要满足父节点小于子节点,因此要让6与8换位置,换位置后,再比较6与9,若还是父节点大于子节点,则再换一次位置,如此循环,直到满足堆的性质,循环结束

此时我们插入的位置是数组的尾部,最后一个元素下标为size-1,用parent=(child-1)/2,这一关系即可定位它的父节点下标,比较、调整(交换)即可;由于这里涉及到多次交换操作,因此我们不妨将它封装到函数里

void Swap(HPDataType* a,HPDataType* b){
    int tmp=*a;
    *a=*b;
    *b=tmp;
}
void AdjustUp(HPDataType* a, int child){
    int parent=(child-1)/2;
    while(child>0){
        //这里我们将它调整为大堆
        if(parent<child){
            Swap(&child,&parent);
            child=parent;
            parent=(child-1)/2;
        }
        //说明此时已经满足堆的性质,调整结束
        else{
            break;
        }
    }
}

此时我们再来考虑插入,①首先插入数据我们需要考虑数组空间是否足够,不够则需要对它进行扩容;②然后再把待插入数据放入堆中;③最后让插入后满足堆的性质进行向上调整

void HeapPush(HP* php, HPDataType x){
    assert(php);
    //检查空间
    if(php->size==php->capacity){
        int newCapacity=php->capacity==0?4:capacity*2;
        HPDataType* tmp=(HPDataType*)realloc(php->a,sizeof(DataType)*newCapacity);
        if(tmp==NULL){
            printf("realloc fail");
            exit(-1);
        }
        php->a=tmp;
        php->capacity=newCapacity;
        //放入数据
        php->a[size]=x;
        php->size++;
        //向上调整
        AdjustUp(php->a,php->size-1);
    }
}

4.删除

由于堆的结构性质,堆顶代表整个堆中最大或最小的数据,因此我们删除只考虑对堆顶的删除;若直接删除堆顶,即相当于数组的头删,删除后数组元素向前移动,那么会打破原有堆结点间的关系,所以我这里不是直接删除;我们用堆顶的元素与堆尾的元素交换位置,此时尾删即可删除堆顶数据;交换后由于不再满足堆原有的性质,所以这里我们需要用到向下调整

向下调整

image-20221122132905922

向下调整算法基本思想(以建成大堆为例):

①从根节点开始,选出左右孩子节点中值较大的一个

②让父亲与较大的孩子比较,若父亲小于此孩子,那么交换;若父亲大于此孩子,则不交换;

③当父亲大于等于较大的孩子则停止,或调整到叶子节点也要停止(叶子节点特征为没有左孩子,就是数组下标超出了范围,就不存在了)

需要用到的关系:**leftchild=parent*2+1,rightchild=parent*2+2 **

对于向上调整,当调整到根节点就不能在继续循环下去了,而向下调整最后的停止条件是最后一个叶子结点,因此在函数传参时我们还需要传递结结点的个数,方便控制循环

void AdjustDown(HPDataType* a, int n, int parent) {
    //左右孩子中较大的于父节点作比较
    int child=parent*2+1;
    while(child<n){
        //这里我们将它调整为大堆
        //child代表左右孩子中较大的那一个
        if(child+1<n&&a[child+1]>a[child]){
            child++;
        }
        //比较、交换
        if(a[parent]<a[child]){
            Swap(&a[parent],&a[child]);
            parent=child;
            child=parent*2+1;
        }
        //若已满足堆性质,提前结束循环
        else{
            break;
        }
    }
}

此时我们再来考虑删除,删除前我们首先要检查数组是否为空,再考虑删除的步骤:①首先交换堆顶和堆尾数据,②然后尾删,③最后再向下调整即可

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);
}

5.取堆顶

HPDataType HeapTop(HP* php) {
	assert(php);
	assert(php->size > 0);
	return php->a[0];
}

对于大堆来说,堆顶的元素最大,因此我们可以采用【取堆顶-删除堆顶】(删除堆顶后会调整为新的大堆)的循环方式,来取出这个数组的前k个最大的数据:

int main(){
    int array[] = { 27,15,19,18,28,34,65,49,25,37 };
	HP hp;
	HeapInit(&hp);
	int size = sizeof(array) / sizeof(HPDataType);
	//插入
	for (int i = 0; i < size; i++) {
		HeapPush(&hp, array[i]);
	}
	//取最大的前5个元素
	for (int i = 0; i < 5; i++)
	{
		//取堆顶-删除堆顶
        printf("%d ", HeapTop(&hp));
		HeapPop(&hp);
	}
}

6.堆的大小

int HeapSize(HP* php) {
	assert(php);
	return php->size;
}

7.堆是否为空

bool HeapEmpty(HP* php) {
	assert(php);
	return php->size == 0;
}

堆函数的应用

通过对前面堆的函数的复用,我们可以实现①堆的创建、②堆排序以及③解决TOP—K问题

void HeapCreat1(HP* php, HPDataType* a, int size);		//堆的创建(复用函数)
void HeapCreat2(HP* php, HPDataType* a, int size);		//堆的创建(更高的效率)

void HeapSort(int* a, int size);                        //堆排序/建堆(向上调整)

void HeapTOPK(int k);                                   //TOP-K问题

1.堆的创建

方案1

我们可以循环复用插入函数,插入k次即可创建一个含有k个数据的堆

void HeapCreat1(HP* php,HPDataType* a,int size){
    assert(php);
    HeapInit(php);
    for(int i=0;i<size;i++){
        HeapPush(php,a[i]);
    }
}
方案2

建堆算法1:向下调整

对于向下调整算法,需要满足条件:这个结点的左孩子和右孩子都要满足堆的性质,然后从这个结点开始使用向下调整算法即可建堆;因此这里我们不妨将最后一个结点看成一个堆,那么这个结点的父结点就可以执行向下调整算法,因此在建堆时,我们从最后一个结点的父节点开始,依次向上执行向上调整算法,那么即可将数组建立为堆

void HeapCreat2(HP* php,HPDataType* arr,int size){
    assert(php);
    HeapInit(php);
    //由于初始化堆时并没有对数组开空间,因此需要我们为堆开辟空间
    php->a=(HPDataType*)malloc(sizeof(HPDataTYpe)*size);
    if(php->a==NULL){
        perror("malloc fail");
        exit(-1);
    }
    //将待建堆数组复制到堆结构的数组中
    memcpy(php->a,arr,sizeof(HPDataType)*size);
    php->size=php->capacity=size;
    
    for(int i=(size-1-1)/2;i>=0;i--){
        AdjustDown(php->a,size,i);
    }
}

建堆算法2:向上调整

向上调整算法的使用时,需要满足原先是一个堆,在堆尾插入通过调整使其成为一个新的堆;因此这里我们不妨假设数组首元素是一个堆,从第二个开始依次插入,并执行向上调整

for(int i=1;i<size;i++){
    AdjustUp(a,i);
}

2.堆排序

我们对一个数组进行堆排序,首先要把数组建堆,然后再排序;虽然排序前也有【建堆】的操作,然而与建堆不同的是,只建堆需要我们开辟空间,将提供的数组中的内容看作数据,把这些内容memcpy到我们开辟的空间中,然而堆排序的目标即是提供的数组,因此不用再开辟空间,直接对提供的数组进行操作即可**(不用单独开辟空间)**

建堆后为什么就能排序了呢,因为对于大堆来说,堆顶的元素是最大的,我们可以运用这一性质:每次让堆顶和堆底数据交换,然后对除了堆底位置执行向下调整,那么第二大的数据就到了堆顶,在与倒数第二个位置数据交换,再对除最后两个位置执行向下调整,依次循环直到堆顶

void HeapSort(int* a,int size){
    //建堆
    for(int i=1;i<size;i++){
        AdjustUp(a,i);
    }
    //排序
    int end=size-1;
    while(end>0){
        Swap(&a[0],&a[end]);
        AdjustDown(a,end,0);
        end--;
    }  
}

这样的堆排序时间复杂度极低,仅为:O(N*logN)

3.TOP-K问题

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大

对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决

方案1

建立一个N个数的大堆,Pop K次,依次取堆顶

缺点:假设N很大,那么建立N个数的大堆,需要开辟一片非常大的空间(100亿的整数需要40G的空间,内存空间不够,而在磁盘中不能建堆)——存在空间问题

方案2

①用前k个数建立K个数的小堆,②依次遍历数据,比堆顶数据大,就替换堆顶,再向下调整;③最后在数组中的最大的前k个数即存在堆中(数据存在文件——硬盘中,解决了空间问题)

分析:由于是小堆,每一个进入的数,如果是相对较大的数都会向堆底移动,而堆顶是堆中元素相对较小的那一个,因此到最后遍历完数组,堆顶保存的即是整个数组中的第K大的数(大数在堆中下沉

每一次替换,都用较大的存入堆中,最后遍历完后即得到的就是最大的前K个值

因为TOP-K问题的数据量很大,因此我们将待排序的数据存到文件中,因此这里涉及到对文件的操作

void HeapTOPK(int k) {
	int* minHeap = (int*)malloc(sizeof(int) * k);
	if (minHeap == NULL) {
		perror("malloc fail");
		exit(-1);
	}

	//①读文件
	FILE* fout = fopen("data.txt", "r");
	if (fout == NULL)
	{
		perror("fopen fail");
		return;
	}
	//②取文件的前k个数据
	for (int i = 0; i < k; ++i)
	{
		fscanf(fout, "%d", &minHeap[i]);
	}

	//③将这k个数据建小堆
	for (int i = (k - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(minHeap, k, i);
	}

	//④读取文件所有数据,与堆顶作比较
	int val = 0;
	while (fscanf(fout, "%d", &val) != EOF)
	{
		if (val > minHeap[0])
		{
			minHeap[0] = val;
			AdjustDown(minHeap, k, 0);
		}
	}

	//⑤打印排序好的前K个大数
	for (int i = 0; i < k; ++i)
	{
		printf("%d ", minHeap[i]);
	}
	printf("\n");

	fclose(fout);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值