堆(数据结构)


  • 点击表格内对应链接跳转对应内容⬇️⬇️⬇️
作者主页 吃透C语言专栏 数据结构 Gitee仓库

文章目录

  • 一,堆的概念
    • [1 大根堆(大堆)](#1 大根堆(大堆))
    • [2 小根堆(小堆)](#2 小根堆(小堆))
  • 二,堆的实现
    • [1 Test.c](#1 Test.c)
    • [2 Heap.h](#2 Heap.h)
    • [3 Heap.c](#3 Heap.c)
    • [1 Test.c解析](#1 Test.c解析)
    • [2 Heap.h解析](#2 Heap.h解析)
    • [3 Heap.c解析](#3 Heap.c解析)
      • [1 初始化](#1 初始化)
      • [2 销毁](#2 销毁)
      • [3 插入(保持堆结构)](#3 插入(保持堆结构))
      • [4 删除(堆顶)](#4 删除(堆顶))
      • [5 取堆顶](#5 取堆顶)
      • [6 判空](#6 判空)
      • [7 交换](#7 交换)
      • [8 向上调整算法(小堆)(重点)](#8 向上调整算法(小堆)(重点))
      • [9 向上调整算法(大堆)(重点)](#9 向上调整算法(大堆)(重点))
      • [10 向下调整算法(小堆)(重点)](#10 向下调整算法(小堆)(重点))
      • [11 向下调整算法(大堆)(重点)](#11 向下调整算法(大堆)(重点))
      • [12 向上调整建堆](#12 向上调整建堆)
      • [13 向下调整建堆](#13 向下调整建堆)
  • 三,堆排序
  • [四,TOP - K 问题](#四,TOP - K 问题)

一,堆的概念

  • 堆逻辑上就是是特殊的完全二叉树,堆分为大根堆和小根堆

1 大根堆(大堆)

  • 性质:任意节点的值 ≥ 它的左右子节点的值(所以根,也就是堆顶是整个堆最大值)

2 小根堆(小堆)

  • 性质:任意节点的值 ≤ 它的左右子节点的值(所以根,也就是堆顶是整个堆最小值)

  • 总结:只要满足大堆和小堆的性质任意节点大于或小于左右孩子节点,就是堆。所以从堆的性质中可以得出,堆只要求了父亲和左右孩子之间的大小关系,而左右孩子之间,也就是兄弟之间的大小关系是没有任何要求的,只要在满足性质的条件下,左右孩子,也就是兄弟之间的大小相差多少都没事,因为兄弟之间在堆的性质中是没有要求的,所以如果堆中一个节点的改变最终影响的是它的祖祖辈辈和子孙,并不会影响旁系的亲戚

二,堆的实现

  • 堆的物理存储结构使用一维数组,完全二叉树的结构让数组存储没有空间浪费,且可以通过下标直接计算父子节点位置,无需指针。

节点 节点左孩子 节点右孩子 节点父亲
i 2*i+1 2*i+2 (i-1)/2
  • 堆本质上就是完全二叉树,只不过规定了父子之间的大小关系,所以也就是一个特殊的完全二叉树,而这个节点公式就适用于数组物理结构的完全二叉树,所以堆中节点也可以适用

1 Test.c

c 复制代码
#include"Heap.h"
int main()
{
	HP hp;
	HPInit(&hp);//初始化
	//下面为堆操作
	HPDeaTroy(&hp);//销毁
	return 0;
}

2 Heap.h

c 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<stdbool.h>
#include<string.h>
//堆结构体
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;

}HP;
//初始化
void HPInit(HP* php);

//销毁
void HPDesTroy(HP* php);

//插入(插入之后依然保持堆结构)
void HPPush(HP* php,HPDataType x);

//删除(删除堆顶节点)
void HPPop(HP* php);

//判空
bool HPEmpty(HP* php);

//取堆顶
HPDataType HPTop(HP* php);

//交换
void Swap(HPDataType* x, HPDataType* y);

//向上调整算法
void AdjustUp(HPDataType* a, int child);

//向下调整算法
void AdjustDown(HPDataType* a, int parent, int size);

//向上调整建堆
void HPInitArrayy(HP* hp, HPDataType* arr, int n);

//向下调整建堆
void HPInitArray(HP* hp, HPDataType* arr, int n);

//堆排序(升序)(建大堆)
void HPsort(int* a, int n);

3 Heap.c

c 复制代码
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
//交换
void Swap(HPDataType* x, HPDataType* y)
{
	HPDataType tmp = *x;
	*x = *y;
	*y = tmp;
}
//向上调整算法
void AdjustUp(HPDataType* a,int child)
{
	int parent = (child - 1) / 2;
	while (child>0)
	{
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (parent - 1) / 2;
		}
		else
		{
			break;
		}
	}

}
//向下调整算法
void AdjustDown(HPDataType* 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[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = child * 2 + 1;
		}
		else
		{
			break;
		}
	}


}
//初始化
void HPInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->capacity = php->size = 0;
}

//销毁
void HPDesTroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}
//插入(保持堆结构)
void HPPush(HP* php, HPDataType x)
{
	assert(php);
	if (php->size == php->capacity)
	{
		int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			perror("realloc:");
			return;
		}
		php->a = tmp;
		php->capacity = newcapacity;
	}
	php->a[php->size] = x;
	php->size++;
	AdjustUp(php->a, php->size - 1);
}
//删除(删除堆顶节点)
void HPPop(HP* php)
{
	assert(php);
	assert(php->size > 0);

	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	AdjustDown(php->a, 0, php->size);
}
//取堆顶
HPDataType HPTop(HP* php)
{
	assert(php);
	return php->a[0];
}
//判空
bool HPEmpty(HP* php)
{
	assert(php);
	return php->size == 0;//
}
//向上调整建堆
void HPInitArrayy(HP* hp,HPDataType* arr, int n)
{
	assert(hp);
	HPDataType* tmp = (HPDataType*)malloc(n * sizeof(HPDataType));
	if (tmp == NULL)
	{
		perror("malloc faile:");
		return;
	}
	hp->a = tmp;
	hp->size = hp->capacity = n;
	memcpy(hp->a, arr, n * sizeof(HPDataType));
	for (int i = 0; i < hp->size; i++)
	{
		AdjustUp(hp->a, i);
	}

}

//向下调整建堆
void HPInitArray(HP* hp,HPDataType* arr, int n)
{

	assert(hp);
	HPDataType* tmp = (HPDataType*)malloc(n * sizeof(HPDataType));
	if (tmp == NULL)
	{
		perror("malloc faile:");
		return;
	}
	hp->a = tmp;
	hp->size = hp->capacity = n;
	memcpy(hp->a, arr, n * sizeof(HPDataType));
	for (int i = (hp->size-1-1)/2;i>=0;i--)
	{
		AdjustDown(hp->a, i, hp->size);
	}
}

//堆排序(升序)(建大堆)
void HPsort(int* a, int n)
{
	for (int i = (n - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, i, n);
	}
	for (int i = n; i > 0; i--)
	{
		Swap(&a[0], &a[n - 1]);
		n--;
		AdjustDown(a, 0, n);
	}

}

1 Test.c解析

c 复制代码
#include"Heap.h"
int main()
{
	HP hp;
	HPInit(&hp);//初始化
	//下面为堆操作
	HPDeaTroy(&hp);//销毁
	return 0;
}
  • ⬆️堆的本质是一个完全二叉树,而顺序二叉树是存储堆最好的形式,所以堆使用一个结构体封装,HP为堆结构体类型,hp创建一个结构体变量,然后使用HPInit函数对堆结构体初始化,进行堆操作以后使用HPDeaTroy销毁堆即可

2 Heap.h解析

c 复制代码
//堆结构体
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* a;
	int size;
	int capacity;
}HP;
  • 声明各种函数,创建堆的结构体类型,用一个指针变量来接收realloc开辟的动态内存的空间,用这个指针变量来控制realloc开辟的空间,所以指针变量的类型就相当于,数组元素的类型,因为我们使用指针变量来控制和接收realloc开辟的动态空间,指针变量的类型决定了访问这个连续的动态内存空间的权限,不同的类型访问动态内存空间的权限不同,为了方便整体调整数组的元素类型,所以int 重命名为HPDataType,int size为元素有效个数,capacity为数组空间大小。

3 Heap.c解析

1 初始化

c 复制代码
//初始化
void HPInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->capacity = php->size = 0;
}
  • ⬆️解析:接收堆结构体变量的地址,把指针部分初始化为NULL,capacity 和 size 初始化为0,size作为下标代表着数组最后一个有效元素的下一个位置。

2 销毁

c 复制代码
//销毁
void HPDesTroy(HP* php)
{
	assert(php);
	free(php->a);
	php->a = NULL;
	php->size = php->capacity = 0;
}
  • ⬆️解析:销毁把a释放以后,将a置为NULL,再把size 和 capacity 置为0即可

3 插入(保持堆结构)

c 复制代码
//插入(保持堆结构)
void HPPush(HP* php, HPDataType x)
{
	assert(php);
	if (php->size == php->capacity)
	{
		int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->a, newcapacity * sizeof(HPDataType));
		if (tmp == NULL)
		{
			perror("realloc:");
			return;
		}
		php->a = tmp;
		php->capacity = newcapacity;
	}
	php->a[php->size] = x;
	php->size++;
	AdjustUp(php->a, php->size - 1);
}
  • ⬆️在堆中进行插入操作,是在堆最后一个叶子节点进行插入,也就是数组的最后一个有效元素的后面进行插入,堆的插入操作只要保证插入节点之后依然保持堆结构即可

  • ⬆️形参部分:堆结构体的地址 + 需要插入的元素。堆的存储结构是数组的形式,所以和顺序表的存储形式一样,区别在于堆和顺序表的逻辑结构不一样,以及最后堆插入完成以后要对插入元素进行向上调整,确保插入以后堆结构不变。所以插入部分分3块。1:判断空间大小是否充足,不够就扩容,扩容时先判断是否是capacity是否为0,如果为0代表是第一次插入,还未开辟空间,按2倍扩容无效,直接赋予4个元素空间即可,把capacity作为realloc的部分进行扩容。2:扩容完成之后进行插入,在数组的size位置插入x并且记得size++。3:调整插入的元素,插入的元素经过向上调整和自己的祖先进行比较,在这个枝上调整到合适的位置维持堆结构,AdjustUp向上调整的参数部分就是:需要调整的堆数组 + 堆中最后一个元素的有效位置。在向上调整算法中,就会把堆中最后一个元素的有效位置进行向上调整,向上调整的前提是,调整前数组的逻辑结构以及是堆了,向上调整无法对非堆的结构进行调整,因为插入操作本身就是对堆进行插入,所以可以直接对插入的元素进行向上调整。


4 删除(堆顶)

c 复制代码
//删除(删除堆顶节点)
void HPPop(HP* php)
{
	assert(php);
	assert(php->size > 0);

	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	AdjustDown(php->a, 0, php->size);
}
  • ⬆️堆中的删除操作是对堆顶的元素进行删除,大堆的话,就是删除最大元素,小堆的话就是删除最小元素,删除之前需要断言size>0确保堆中是堆的删除操作三步骤:1 :交换,把堆顶元素和堆中最后一个元素的位置进行交换,使用Swap函数,两个元素的地址作为参数。堆顶的元素被换到的最后面,最后一个元素被换到了堆顶的位置。2:size--删除最后一个元素。把堆顶的元素删除了。3:使用向下调整对堆顶的元素调整,因为堆顶的元素被替换之后,整个堆结构已经被破环了,需要将破坏结构的元素,也就是刚刚被换上来作为堆顶的元素进行位置调整,通过向下调整到合适的位置保持堆结构不变。向下调整的前提是,节点的子树都是完整的堆结构,也就是节点的左子树和右子树都是堆结构的前提下,才能通过向下调整算法来调整该节点,让该节点和左右孩子比较,大堆就让左右孩子中大的和节点比较,大的往上走,小的往下走,小堆就让左右孩子中小的和节点比较,小的往上走,大的往下走,在左右子树都是堆结构的前提下不断调整该节点的位置,最终走到叶子节点,不存在左右孩子,则停止调整,或者在途中走到一半,如果是大堆最终让第二大的走到堆顶,如果是小堆就让第二小的走到堆顶。参数部分:需要调整的堆数组 + 需要调整的节点的位置 + 堆数组的大小(因为一次调整结束的条件调整到合适的位置,这个位置可能是叶子节点,所以知道数组大小就可以在调整到叶子节点的时候及时的停下来,不造成越界访问)


5 取堆顶

c 复制代码
//取堆顶
HPDataType HPTop(HP* php)
{
	assert(php);
	return php->a[0];
}
  • ⬆️堆顶的元素在数组中就是首元素,所以直接返回数组首元素即可

6 判空

c 复制代码
//判空
bool HPEmpty(HP* php)
{
	assert(php);
	return php->size == 0;//
}
  • ⬆️空的意思是数组中实际元素个数为0,所以返回size == 0 判断表达式的结果就行,size == 0代表有效元素个数为0,表达式为真,转化为true,若size != 0 表达式为假,转化为false,a == NULL无法判断是否为空,因为有效元素个数为0的时候,a != NULL,a == NULL,只在堆结构体被销毁的时候。

7 交换

c 复制代码
//交换
void Swap(HPDataType* x, HPDataType* y)
{
	HPDataType tmp = *x;
	*x = *y;
	*y = tmp;
}
  • ⬆️交换函数是适配于 向上调整算法 或者 向下调整算法中的插件,在堆排序中也会用到,所以单独封装成一个交换函数。交换函数形参部分为两个元素类型的指针,通过指针来定位到元素所在的位置完成交换,形参是实参的临时拷贝,只有通过传地址加上对地址的解引用才能找到需要交换的元素进行交换 ,不然形参的交换和实参不产生联系。

8 向上调整算法(小堆)(重点)

c 复制代码
//向上调整算法
void AdjustUp(HPDataType* a,int child)
{
	int parent = (child - 1) / 2;
	while (child>0)
	{
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (parent - 1) / 2;
		}
		else
		{
			break;
		}
	}

}
  • ⬆️代码解析:参数部分传入:数组(堆存储结构)+ 需要调整位置的下标。命名为孩子节点是因为向上调整解决的是和自己父亲的矛盾,所以需要通过该节点找到自己的父亲节点,所以变量命名为孩子节点。两步骤:1:找父节点 2:和父节点比较 1:通过公式parent = (child-1)/2得出节点对应的父节点的下标,2:在循环中反复执行和父节点比较,如果小于父节点(小堆)的话,就把该节点的内容和父节点的内容进行交换,如果大于父节点说明向上调整到了合适的位置则break跳出循环结束调整。当进行完交换以后,把原本父节点的下标赋值给节点,让节点走到父节点的位置,再通过公式找到父节点的父节点。一直循环比较,交换,然后下标往上走,到原本父节点的位置继续找父节点一直循环,停下来的条件只有一个就是调整到了自己合适的位置,而调整到合适的位置的情况有两种一种是在支脉中找到了合适的位置,节点的父节点小于节点了,则直接进入else中的break跳出循环,另一种是最终所有的父节点都大于节点,节点要一直调整到堆顶,成为新的堆顶,这时候成为堆顶的节点是没有父节点的,而到达堆顶的时候该节点就已经完成了调整,不需要再进行调整,这时候需要跳出循环,所以到达堆顶停下来靠的是while循环的判断条件child>0,因为到达堆顶的时候child为0,所以child>0则说明未到达堆顶,一旦child=0则说明节点调整到了堆顶,不需要进行调整了,则结束循环,停止调整。

  • ⬆️向上调整算法原理是该节点和父节点比较大小,如果是小堆的话,则该节点小于父节点则与父节点进行交换,反之不交换,如果是大堆的话,则该节点大于父节点则于父节点进行交换,反之不交换。上的意思指该节点和父节点进行比较。


  • ⬆️向上调整的逻辑是仅从当前节点出发,向根节点方向逐层比对父节点、交换,全程不会修改当前节点的子树,也不会触碰其他分支节点。


  • ⬆️向上调整算法适用的条件:在堆中对节点使用向上调整算法,只会影响该节点这一条支脉,只有除了节点出现问题,整个堆的结构都是完整的情况下,才能使用向上调整算法,而且节点影响的是只能是父节的情况下才能使用向上调整算法,我们以小堆为例,每一个节点的左右孩子节点都必须小于该节点,也就是每一个节点都必须大于自己的父亲节点,也就是在这一条支脉上,小堆呈现是从上到下逐渐变大的趋势,因为小堆是孩子节点大于父亲节点。如果有节点出现问题,原本应该大于父亲节点的节点,变成了小于父亲节点,而其他的节点都依然保持堆结构,只有该节点和自己父亲节点发生了冲突(重点:该节点只和自己的父节点发生了冲突,破坏了堆结构,和其他任何节点都保持原来的堆结构这时候才适配向上调整),原本应该大于父亲节点,变成了小于父亲节点,该节点比自己父亲小了,小堆应该小的节点往上走,这时候使用向上调整,和父节点进行比较比自己的父节点小,该节点就进行交换,最终交换到比自己父节点大的位置或者如果一直比自己的父节点小就直接交换到堆顶,成为新的堆顶。这时候我们来观察,交换的时候别的节点的变化。该节点的冲突是和自己父节点产生的,本来父节点要比自己小,但是自己比父节点还要小所以才交换,比自己的父节点小的话,该节点是不可能和自己的子节点产生冲突的,因为原本从上到下就是升序,下面一层比上面一层要大,有一个节点变成了比上面小,这一层的关系就变成了下面比上面小,原本这个节点的下一层就比该节点和该节点上面的所有的节点都要大,该节点变得比自己的父节点还要小了,该节点的下一层节点本来就大于该节点和该节点的父节点,该节点比父节点还要小了,那该节点的下一层节点依然比该节点要大,而且相对之前变得更大了,比如该节的是老二节点,下一层是老大节点,上一层是老三节点,老大节点本来就比老二节点和老三节点都要大,老二节点比老三节点大,结果老二节点变得比老三节点小了,完全不影响老大节点大于老二节点和老三节点,只会在老大节点的眼中老二和老三就像是被调换了顺序罢了,所以老二节点和老三节点之间的矛盾,不会去影响别的节点和分支。理解了该支脉上的逻辑关系以后,当该节点和父节点之间冲突,不会影响原来的为堆结构的下一层的子节点,原本从上往下就是层层递增,节点变得比上一层下了,下一层只会比节点更大,不影响下一层节点的结构和关系。所以理解了向上调整中节点和父节点产生矛盾需要调整的时候,不会影响下一层。而和父节点产生矛盾的节点的兄弟节点也是一样的道理,兄弟节点本来就比上一层的父节点大,而节点变得比父亲节点还要小了,兄弟节点原本就比父亲节点大,节点比父亲节点小了,换上去以后,兄弟节点的肯定大于被换上去的节点,所以一个节点的兄弟节点和子节点在原本都保持堆结构的基础上,节点和父亲节点发生矛盾需要进行向上调整,把小的节点换到父节点小于自己为止,如果没有父节点小于自己就一直往上调整,最后调整到没有父节点为止,此时就到了堆顶。

  • ⬆️向上调整的前提就是必须是该节点和自己的父节点产生矛盾,如果是该节点和孩子节点产生矛盾呢,这时候我们就要用到节点和孩子节点产生矛盾需要用到的向下调整法

  • 重点:在堆结构中,只存在一个节点出现问题的时候,这个节点要么和自己的父节点发生矛盾,要么和孩子节点发生矛盾,不可以同时发生矛盾,以小堆举例:一条支脉上是从上到下是递增的,1:该节点和父亲发生矛盾的话,则小于父节点,而该节点的子节点的既大于该节点,也大于该节点的父节点,如果节点小于自己的父节点的话,那节点的子节点只会比该节点更大。所以节点和父节点产生矛盾不会影响子节点,产生矛盾就应该换位,和子节点没任何关系。2:该节点和孩子节点发生矛盾的话,原本要小于孩子节点,变成了大于孩子节点,这时候就应该向下调整,和两个孩子中最大的比较,往下走。该节点原本就大于自己的父亲节点,现在和自己的孩子节点发生了矛盾,大于了自己的孩子节点,孩子节点原本就大于该节点和该节点的父亲节点,现在该节点比孩子节点还大了,那只会比该节点的父亲节点更大,所以当该节点和自己的孩子节点发生矛盾的时候是不会和自己的父亲节点发生矛盾的,所以当节点发生矛盾,要么就是和父亲节点发生矛盾,要么就是和孩子节点发生矛盾。不可能同时发生矛盾。就像12345一样,当3节点变成了6,比后面的节点大了,就需要排在后面的节点的后面,不会和前面的节点有矛盾,当3节点变成0了,比前面的节点小了,就需要排在前面节点的前面,不会和后面的节点有矛盾,要么往前排要么往后排,往一个方向走,就不会和反方向的有矛盾,向上和向下调整也是一样的道理在这一个支脉上是有顺序大小的,要么往下走,要么往上走,往下走说明变大了,不会和原本比自己小的有矛盾,往上走说明变小了,不会和原本比自己大的有矛盾。
    ----

  • ⬆️堆中非近亲节点(非父子,非兄弟)是没有任何联系的,这也是节点的改变影响的是父子关系的原因之一,理由如下:以小堆为例:堆顶的节点是整个堆最小的节点,整个堆只需要满足父节点大于左右孩子即可,每个节点只要满足整个条件就是小堆,左右孩子之间的大小关系是没有要求的,只要满足大于父节点即可,所以堆顶元素就像一个起始点,无论多大多小都没关系,(以数轴的角度:)就和一个数轴中的0点一样是一个起始的位置,从堆顶这个起始点开始,右边的两个孩子节点都比堆顶大就行,但是两个孩子的谁大谁小是没有规定的,两个孩子比堆顶大多少也没有规定,大很多,大一点都可以。所以就像上图一样,100 和 11 比 10 大就行,11比10大1 ,100比10大90,只要大于10都可以,而从堆顶的根节点开始,第一次分支的11 和 100都比根节点大,11的两个孩子节点又都比11大,100的两个孩子节点又都比100大,所以堆顶的元素就是最小的元素,在数轴中更好理解,从10为起点开始,右边的值都是比起点大的。第二大的值呢,也是在根节点左右两个孩子中产生的,因为,以10为起点以后,我我们忽略10就到了11 和 100 比大小,也就是根节点的左右两个孩子比大小,无论谁大,小的那个节点的左右两个孩子肯定大于小的那个节点,大的那个节点本身就比小的那个节点大了,大的那个节点的两个孩子,肯定大于大的那个节点,所以小堆根节点的两个孩子中小的那个节点就是整个堆第二小的节点,但是第三小的节点不是第二小的节点的兄弟节点,因为:当根节点分出左右两个孩子节点以后,每个节点的孩子节点的要求只要大于父节点即可,第二小的节点的孩子节点也就是11的孩子节点只要满足大于11 节点即可,可以只大一点点变成12,可以大很多很多变成1000,而11的兄弟节点100和11是没有大小关系的,只不过根节点的两个孩子节点中肯定会产生一个大一点的节点一个小一点的节点(也可能相同),但是两个兄弟节点之间是没有大小关系的,只要满足比自己的父亲大即可,所以11的孩子节点12和1000,到100的孩子节点,只要满足比100大就行,可能是101可能是102,这符合堆的性质,101和102都还没有11的孩子1000大,但是这都是符合堆的性质的,因为都满足大于父节点,1000比100都大,所以兄弟是没有大小关系的这一条件加上只要比父节点大就行的性质,让堆结构,只有在一个连续的支脉上面才存在升序降序的大小关系,一旦涉及兄弟节点或者别的支脉就不存在确定的升序和降序,因为兄弟节点之间没有任何大小关系,子节点也只要满足大于父节点即可。

9 向上调整算法(大堆)(重点)

c//向上调整算法 复制代码
void AdjustUp(HPDataType* a,int child)
{
	int parent = (child - 1) / 2;
	while (child>0)
		if (a[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (parent - 1) / 2;
		}
		else
		{
			break;
		}
	}

}
  • ⬆️原理同上


10 向下调整算法(小堆)(重点)

c 复制代码
//向下调整算法
void AdjustDown(HPDataType* 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[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = child * 2 + 1;
		}
		else
		{
			break;
		}
	}


}
  • ⬆️向下调整参数需要:数组(堆结构) + 父节点(出问题节点下标)+ 数组大小,第一步通过父节点找出左孩子节点,while循环中,通过假设法找出最小的孩子,假设法是:我们要和父节点交换的孩子节点是小孩子节点,所以就在if的判断条件中加上如果左孩子比右孩子大,那说明小孩子是右孩子,就让child下标++变成右孩子,如果左孩子比右孩子小,则说明小孩子是左孩子,则跳过++这一步,我们开始得到的孩子节点是左孩子节点,我们做出假设默认小孩子是左孩子节点,如果左孩子节点大于右孩子节点的话,就让左孩子节点下标++变成右孩子下标,这样就完成了假设。如果左孩子小于右孩子就没完成假设,孩子下标依然是左孩子下标,小孩子就是左孩子。这一步得到了小孩子下标以后,就让小孩子和节点下标对应的元素对比,如果节点大于孩子则进行交换,让小的元素:孩子元素走上来,节点元素往下走,元素交换完以后,让父节点下标变成交换的孩子节点下标,继续找孩子节点的孩子左孩子节点。这样子的话就把有问题的节点换下来以后,继续到新的父子结构中判断是否影响了新的孩子节点,而整个判断结束的情况只有找到了合适的位置才会停下来,比如节点到了新的位置,这个位置中的两个孩子节点都大于节点,这时候堆的结构完整,节点的小孩子节点不再小于节点,if语句到else中break直接跳出循环,这时候到了合适的位置,但是如果这个节点一直大于自己的孩子节点,需要往下走,最后要走到叶子节点才能停下来,因为这时候节点为叶子节点,叶子节点没有孩子节点,节点能交换到这里来,则说明刚刚又大于自己的孩子节点,这时候节点是大于自己的父节点且没有孩子节点,成为了新的叶子节点也就是节点合适的位置,这时候也是要停下来的。而叶子节点如何判断呢,就是while的判断条件,叶子节点是没有孩子节点的,所以一旦节点的孩子节点存在,也就是在size数组有效元素个数之内,就说明节点存在,但是如果节点>size说明节点的下标大于有效元素个数的下标,该节点不存在,这时候节点通过计算得出孩子节点>元素有效个数下标则说明该孩子节点不存在,说明这个节点没有孩子节点,就代表了这个节点是叶子节点。同时,在第一个if中寻找小孩子的时候需要判断右孩子存不存在所以加上了child +1 < size 的条件,在右孩子存在的情况下才能去让左右孩子进行对比寻找小的那个孩子,如果右孩子不存在那么child+1解引用的时候就会越界访问,因为我们计算的一直都是左孩子,因为左孩子存在的时候右孩子不一定存在,堆是一个完全二叉,一个节点如果存在孩子的情况下,一定有左孩子,不一定有右孩子。所以在while判断条件中,如果我们计算的是右孩子,在不存在右孩子的情况下,只有左孩子,那我们连节点和左孩子比较的机会都没有。所以计算必须是左孩子,但是有了左孩子不一定有右孩子,所以我们可以在if条件中判断是否有右孩子,在有右孩子的前提下才让左右孩子比较大小,不然如果没有右孩子,得出的下标就会造成越界访问。

  • ⬆️向下调整的前提就是只有节点和自己的两个孩子节点都发生了冲突,这时候该节点肯定不会和自己的父节点发生冲突的,因为和孩子节点发生冲突只能是大于孩子节点,那肯定就比自己的父节点大,所以不会和自己的父节点发生冲突。所以当节点和自己孩子节点发生冲突的时候,无论是和一个孩子发生冲突还是和两个孩子发生冲突都是一样的解决方法,因为当节点和自己的孩子节点发生矛盾的时候,也就是大于了自己的孩子节点,需要换位,但是如果只是和其中一个孩子发生了矛盾,也就是只是大于了自己的其中一个孩子,那个孩子只能是小的孩子节点,因为如果,节点和自己的孩子发生矛盾时,也就意味着大于了自己的孩子节点,需要和自己的孩子交换,需要交换的就是最小的那个孩子,因为两个孩子中小的孩子比大孩子小,如果节点比大孩子还要大了,那肯定比小的孩子大,这时候就和两个孩子有矛盾,所以只有可能比小的孩子大,比大的孩子小,才出现节点和其中一个孩子有矛盾的情况,如果比大的孩子大了,那就一定和两个孩子有矛盾了,知道了这个前提以后,交换的时候,为了符合小堆的性质,只有把两个孩子中小的那个孩子换上来,符合堆的性质,如果把大的那个换上来,大孩子比小孩子大,那依然不合符堆的性质,必须把小孩子换上来,而向下调整是节点和有问题的孩子节点交换,刚刚我们就解释过了,如果需要用到向下调整,说明两个孩子中要么其中一个和父亲有矛盾,要么两个孩子都和父亲有矛盾,而一定有矛盾的就是小孩子节点,如果大孩子节点有矛盾,那节点比大孩子大,肯定比小孩子大,就都有矛盾了,所以矛盾要么就是节点和小孩子要么就是节点和两个孩子,所以出现矛盾的话,节点和小孩子节点肯定是有矛盾的,所以小的那个节点换上去就再合理不过了,原因1:节点的两个孩子节点都发生了矛盾,只有最小的那个孩子节点上去,才能恢复堆结构,小的那个节点比节点小,比大孩子小,换上去就恢复堆结构了 原因2:如果只有一个孩子节点发生了矛盾,那只能是小孩子节点,直接和小孩子节点比较把小孩子节点换上去,小孩子节点一定比节点和大孩子节点小,找小孩子节点这个步骤就必须和大孩子节点对比,所以无论哪一种情况,都是要找出小孩子节点换上去的。都需要两个孩子节点对比找出来小孩子节点这一步骤。循环进行往下调整即可

11 向下调整算法(大堆)(重点)

c 复制代码
//向下调整算法
void AdjustDown(HPDataType* 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[child] > a[parent])
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = child * 2 + 1;
		}
		else
		{
			break;
		}
	}


}
  • ⬆️原理同上

12 向上调整建堆

c 复制代码
//向上调整建堆
void HPInitArrayy(HP* hp,HPDataType* arr, int n)
{
	assert(hp);
	HPDataType* tmp = (HPDataType*)malloc(n * sizeof(HPDataType));
	if (tmp == NULL)
	{
		perror("malloc faile:");
		return;
	}
	hp->a = tmp;
	hp->size = hp->capacity = n;
	memcpy(hp->a, arr, n * sizeof(HPDataType));
	for (int i = 0; i < hp->size; i++)
	{
		AdjustUp(hp->a, i);
	}

}

  • ⬆️向上调整建堆就是使用向上调整算法进行建堆,参数部分需要把传:堆 + 数组 + 数组元素个数 ,原理很简单,将一个数组和数组元素个数传入函数中,任何一个有效元素连续的数组都可以看成是一个完全二叉树,仅此而已,要想变成堆,就要通过向上调整算法把每一个节点都调成堆。1:用malloc开辟在堆内开辟和数组一样大的空间,并将堆初始化 2:把数组的内容拷贝到堆中 3:最重要的。一步,设置for循环向上调整,我们已经了解了,向上调整就是在该节点上面的所有节点都是堆结构的前提下对错误节点进行调整,让与父节点发生矛盾的错误节点向上调整,走到适合自己的位置。所以显然要进行和使用向上调整的话必须满足,堆中只和父节点有矛盾,而且上面的所有节点都保持好堆结构,而整个数组都是乱的,要满足这个条件的话,只能从堆的第一个元素开始调整,让整个堆缩小成只有一个元素,调整好一个元素,再继续调整下一个元素,我们通过for循环,从第一个元素开始向上调整,在数组中从左往右依次调整,这样的话,每一次调整的元素的时候前面的所有元素都是被调整过了的,这就符合了向上调整中调整的节点前面的节点必须都为堆的结构的条件(每天节点都必须要调整所以时间复杂度为:O(nlogn))


13 向下调整建堆

c 复制代码
//向下调整建堆
void HPInitArray(HP* hp,HPDataType* arr, int n)
{

	assert(hp);
	HPDataType* tmp = (HPDataType*)malloc(n * sizeof(HPDataType));
	if (tmp == NULL)
	{
		perror("malloc faile:");
		return;
	}
	hp->a = tmp;
	hp->size = hp->capacity = n;
	memcpy(hp->a, arr, n * sizeof(HPDataType));
	for (int i = (hp->size-1-1)/2;i>=0;i--)
	{
		AdjustDown(hp->a, i, hp->size);
	}
}
  • ⬆️向下调整建堆的话:前面的malloc 和 memcpy 和向上调整建堆是一样的,需要给堆初始化,并且把数组拷贝在堆中,进行向下调整的时候,我们需要遵循向下调整的算法生效的规则。向下调整算法调整的节点必须要求节点的左右子树都是堆结构,所以如果从堆顶,也就是数组的第一个元素开始调整的话,整个堆都是乱的,无法进行调整,而最后一个非叶子节点才是第一个需要调整的位置,因为最后一个非叶子节点中左孩子和右孩子都是叶子节点,而两个叶子节点没有孩子节点,就是天然的堆结构,相当于堆顶。所以最后一个非叶子节点才是向下调整的起始节点,所以i的初始值为(size -1 -1 ) / 2,szie-1得到是最后一个叶子节点的下标,-1/2是计算公式计算最后一个叶子的父节点,也就是最后一个非叶子节点,从这个节点开始往前遍历调整,这样子的话,每一次调整的节点的左右子树都是堆结构,都可以进行向下调整。最后调整到堆顶即可。(向下调整建堆不是调整所有节点,是从最后一个非叶子节点开始的,所以对比向上调整建堆的话,调整的节点少了最后一层的节点,所以时间复杂度为O(N),所以建堆的话,选用向下调整建堆)

  • 为什么要「从后往前」倒序调整?(最关键考点)
条目 说明
向下调整 前置要求 当前节点的左右子树必须已经是合法堆,向下调整才能把当前子树修复为合法堆。
推导1 树最底层:全是叶子,天然合法堆;
推导2 倒数第二层结点:它们的孩子都在最底层(合法堆),可以执行 siftDown;
推导3 调整完倒数第二层,往上一层,此时它的左右子树(下层)已经是合法堆,调整有效;
推导4 一路向上,直到根节点。

三,堆排序

  • 堆排序的本质就是利用堆的性质进行排序:而堆能做到的只能选出最值(大堆堆顶是最大值,小堆堆顶是最小值)

  • 通过建堆可以选出最值,把最值拿走,重新建堆又可以选出最值,把最值一个个排好,这就是堆排序的思路

c 复制代码
//堆排序(升序)(建大堆)
void HPsort(int* a, int n)
{
	for (int i = (n-1-1)/2;i>=0;i--)
	{
		AdjustDown(a, i, n);

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

}

  • ⬆️传入数组和数组的大小,第一个for循环目的是使用向下调整算法建堆,从最后一个非叶子节点开始循环向下调整建堆,经过了第一个for循环以后已经建好大堆了,第二个for循环的目的就是把每一个堆顶的值和数组末尾的值交换,这样子就把堆顶的值放在了末尾,通过n--就把刚刚放在末尾的堆顶值排出了堆结构中,这时候通过向下调整算法将堆顶的元素向下调整保持堆结构,这时候新堆的堆顶又是最值,所以一直重复交换,--缩小数组大小,向下调整,最后调整到数组只剩下一个元素的时候,停止调整,n-1=0刚好是第一个元素,n=0则n-1=-1则越界。最后for循环结束,数组排序完成如果建的是大堆,大的数先被放在后面,排出来是升序,如果建的是小堆,小的数被放在后面,拍出来是降序

  • ⬆️堆排序第一步先建堆,建堆使用向下调整法建堆,时间复杂度更低,所以我们之后谈到的建堆都是使用向下调整法建堆。而使用向下调整法建堆可以建大堆和小堆,建大堆的话堆顶的值就是最大值,建小堆的话堆顶的值就是最小值,我们以建大堆为例:建好大堆了以后,此时堆顶的是是最大值,我们需要把最大值拿走,这时候就可以用到删除的操作,把堆顶的值和末尾的值进行交换,如何把n--,这时候相当于把数组的有效元素个数缩小了,把刚刚的最大值不计入堆内,这时候新的堆中,除了堆顶,堆顶的左右子树都保持着完整的堆结构,所以使用向下调整法维持建堆,建堆后就选出了第二大的,因为之前就说过,堆顶的左右孩子中存在第二大的值,将堆顶的元素进行向下调整重新保持堆结构的话,堆顶的值就是除了刚刚去掉的最大值外次大的值,所以新堆中的堆顶就是第二大的值,牢牢记住只要是堆,堆顶就是最值,所以也可以这样了解,使用向下调整法把堆顶的错误节点调整到合适的位置以后,整个堆又恢复了堆结构,这时候堆顶的值又是最值了,所以一直循环换下最值,再循环把堆顶的值向下调整建堆就可以完成排序

四,TOP - K 问题

  • TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素

一般情况下数据量都比较大。

比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。

对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能

数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决


  • 思路:堆的性质就是提供最值,大堆提供最大值,小堆提供最小值,但是如果把所有数据建堆的话,庞大的数据加载在内存中是不现实的,如果能全部加载在内存中那通过别的方式更好排序了,
  • 我们的目的是通过堆中的最值建立分界线1:求前K个最大元素,我们建K个元素的小堆,分界线是K个元素中最小值,只有比分界线大的元素才能进来,所有前K大的元素一定能进堆,而且前K个最大元素进来了不可能是小堆顶分界线,最后直到第K大的数进堆以后,彻底封锁堆,只要前K大的元素才能进堆,最后堆中剩下的就是前K大的元素 2:求前K个最小元素,建K个元素的大堆,分界线是K个元素中的最大值,只有比分界线小的元素才能进堆,所有前K小的元素一定能进堆,而且前K最小元素进来了不可能是大堆顶分界线,最后直到第K小的元素进堆以后,彻底封锁堆,只有前K小的元素才能进堆,最后堆中剩下的就是前K小的元素

  • 前K个最大元素建小堆,前K个最小元素建大堆,思路就是控制范围,前K最大元素只有在小堆中设置最低分界线,才能最后让前K大元素进来,前K最小元素只有在大堆中设置最高分界线,才能让前K小的元素进来。如果出错,前K个最大元素建大堆,设置最高分界线,只要有一个前K元素进来,如果遇到了比相邻的前K元素,大的前K元素提前进来就会把小的前K袁术挡在外面。

  • 总结:前K大元素设下限建小堆,前K小元素设上限建大堆

  • 点击表格内对应链接跳转对应内容⬇️⬇️⬇️
作者主页 吃透C语言专栏 数据结构 Gitee仓库

相关推荐
wabs6667 小时前
关于图论【卡码网100.最大岛屿的面积的思考】
数据结构·算法·图论
yuannl109 小时前
图的最短路径Dijkstra
数据结构
zander25810 小时前
138. 随机链表的复制
数据结构·算法·链表
过期动态10 小时前
【LeetCode 热题 100】找到字符串中所有字母异位词
java·数据结构·算法·leetcode·职场和发展·rabbitmq
来一碗刘肉面10 小时前
栈在递归中的应用
数据结构·算法
皓月斯语10 小时前
程序设计语言的特点
开发语言·数据结构·c++
邪修king11 小时前
C++ 进阶终章:异常机制与智能指针全解 —— 从错误处理到 RAII 资源管理,打通现代 C++ 的核心命脉
android·数据结构·c++
Adios79419 小时前
设置交集大小至少为2
数据结构·算法·leetcode
来一碗刘肉面1 天前
栈的应用(表达式求值)
数据结构·算法