堆(超详解)
文章目录
- 前言
- 一、堆的概念及结构
- 二、堆的实现
- 2.1 堆的向下调整算法
- 2.2 堆的向上调整算法
- 2.3 初始化堆
- 2.4 销毁堆
- 2.5 打印堆
- 2.6 堆的插入
- 2.7 堆的删除
- 2.8 获取堆顶的元素
- 2.9 获取堆的数据个数
- 2.10 堆的判空
- 2.11 建堆
- 2.11.1 向上调整建堆(逐个插入建堆:自顶向下)
- 2.11.2 向下调整建堆(直接数组建堆:自底向上)
- 2.11.3 向上调整vs向下调整
前言
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段
由于堆总是一颗完全二叉树,因此数组便成为了用来存储堆的最好方式。数组最大的优点就是支持随机访问,在数组中,只要我们知道其中一个结点的下标,便可以通过完全二叉树父结点和孩子结点的编号关系,快速定位到其父结点或孩子结点
一、堆的概念及结构
堆的性质:
- 堆中某个结点的值总是不大于或不小于其父结点的值
- 堆总是一颗完全二叉树
练习题:
1 .已知小根堆为8,15,10,21,34,16,12,删除关键字 8 之后需重建堆,在此过程中,关键字之间的比较次数是()
A.1
B.2
C.3
D.4
2 .一组记录排序码为(5 11 7 2 3 17),则利用堆排序方法建立的初始堆为
A.(11 5 7 2 3 17)
B.(11 5 7 2 17 3)
C.(17 11 7 2 3 5)
D.(17 11 7 5 3 2)
E.(17 7 11 3 5 2)
F.(17 7 11 3 2 5)
解析:
1.(答案:C)在删除小根堆的根节点8后,将最后一个元素12移动到根位置,堆变为:12, 15, 10, 21, 34, 16。随后进行向下调整以恢复堆性质
调整过程如下:
根节点12有两个子节点15和10。首先比较两个子节点15和10(第一次比较),找到较小值10。然后比较根节点12与较小子节点10(第二次比较),由于12 > 10,交换两者。堆变为:10, 15, 12, 21, 34, 16
现在节点12位于索引3位置,它只有一个左子节点16。比较节点12与子节点16(第三次比较),由于12 < 16,不需要交换,调整结束
在此过程中,关键字之间的比较次数共计3次
2.(答案:C)根据堆排序方法,建立初始堆(大顶堆)的过程如下:
给定记录排序码为 (5, 11, 7, 2, 3, 17),需要将其调整为大顶堆,即每个节点的值都大于或等于其子节点的值。
调整步骤:
①从最后一个非叶子节点(索引 2,值 7)开始调整。比较节点 7 与其子节点(索引 5,值 17),由于 7 < 17,交换两者,数组变为 (5, 11, 17, 2, 3, 7)。
②调整索引 1(值 11)。其子节点为索引 3(值 2)和索引 4(值 3),较大值为 3。比较 11 和 3,11 > 3,无需交换。
③调整索引 0(值 5)。其子节点为索引 1(值 11)和索引 2(值 17),较大值为 17。比较 5 和 17,5 < 17,交换两者,数组变为 (17, 11, 5, 2, 3, 7)。
④由于交换后索引 2(值 5)可能违反堆性质,调整索引 2。其子节点为索引 5(值 7),比较 5 和 7,5 < 7,交换两者,数组变为 (17, 11, 7, 2, 3, 5)。
二、堆的实现
2.1 堆的向下调整算法
现在我们给出一个数组,逻辑上看作一棵完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个小堆
但是,使用向下调整算法需要满足一个前提:
若想将其调整为小堆,那么根结点的左右子树必须都为小堆
若想将其调整为大堆,那么根结点的左右子树必须都为大堆
向下调整算法的基本思想(以建小堆为例):
- 从根结点处开始,选出左右孩子中值较小的孩子
- 让小的孩子与其父亲进行比较
①若小的孩子比父亲还小,则该孩子与其父亲的位置进行交换。并将原来小的孩子的位置当成父亲继续向下进行调整,直到调整到叶子结点为止
②若小的孩子比父亲大,则不需处理了,调整完成,整个树已经是小堆了
//交换函数
void swap(HPDateType* p1, HPDateType* p2)
{int tmp = *p1;*p1 = *p2;*p2 = tmp;
}//向下调整(小堆)
void AdjustDown(HPDateType* a, int n, int parent)
{//child记录左右孩子中值较小的孩子的下标int child = parent * 2 + 1;//假设左孩子的值较小while (child < n){//右孩子存在并且右孩子比左孩子小if (child + 1 < n && a[child + 1] < a[child]){child++;//较小的孩子改为右孩子}//较小的孩子的值比父结点的值小if (a[child] < a[parent]){//将父结点与较小的子结点交换swap(&a[child], &a[parent]);//继续向下调整parent = child;child = parent * 2 + 1;}//已成堆else{break;}}
}
2.2 堆的向上调整算法
当我们在一个堆的末尾插入一个数据后,需要对堆进行调整,使其仍然是一个堆,这时需要用到堆的向上调整算法
向上调整算法的基本思想(以建小堆为例):
- 将目标结点与其父结点比较
- 若目标结点的值比其父结点的值小,则交换目标结点与其父结点的位置,并将原目标结点的父结点当作新的目标结点继续进行向上调整。若目标结点的值比其父结点的值大,则停止向上调整,此时该树已经是小堆了
//向上调整(小堆)
void AdjustUp(HPDateType* 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.3 初始化堆
首先,必须创建一个堆类型,该类型中需包含堆的基本信息:存储数据的数组、堆中元素的个数以及当前堆的最大容量
typedef int HPDateType;//堆中存储数据的类型
typedef struct Heap
{HPDateType* a;//用于存储数据的数组int size;//记录堆中已有元素个数int capacity;//记录堆的容量
}HP;
然后我们需要一个初始化函数,对刚创建的堆进行初始化
//初始化
void HeapInit(HP* php)
{assert(php);php->a = NULL;php->size = 0;php->capacity = 0;
}
2.4 销毁堆
为了避免内存泄漏,使用完动态开辟的内存空间后都要及时释放该空间,所以,一个用于释放内存空间的函数是必不可少的
//销毁
void HeapDestroy(HP* php)
{assert(php);free(php->a);//释放动态开辟的数组php->a = NULL;//及时置空php->size = 0;//元素个数置0php->capacity = 0;//容量置0
}
2.5 打印堆
打印堆中的数据,按照堆的物理结构进行打印,即打印为一排连续的数字
//打印
void HeapPrint(HP* php)
{assert(php);//按照物理结构进行打印int i = 0;for (i = 0; i < php->size; i++){printf("%d ", php->a[i]);}printf("\n");
2.6 堆的插入
数据插入时是插入到数组的末尾,即树形结构的最后一层的最后一个结点,所以插入数据后我们需要运用堆的向上调整算法对堆进行调整,使其在插入数据后仍然保持堆的结构
先插入一个10到数组的尾上,再进行向上调整算法直到满足堆
//插入
void HeapPush(HP* php, HPDateType x)
{assert(php);if (php->size == php->capacity){HPDateType* tmp = (HPDateType*)realloc(php->a, 2 * php->capacity * sizeof(HPDateType));if (tmp == NULL){printf("realloc fail\n");exit(-1);}php->a = tmp;php->capacity *= 2;}php->a[php->size] = x;php->size++;//向上调整AdjustUp(php->a, php->size - 1);
}
2.7 堆的删除
堆的删除,删除的是堆顶的元素,但是这个删除过程可并不是直接删除堆顶的数据,而是先将堆顶的数据与最后一个结点的位置交换,然后再删除最后一个结点,再对堆进行一次向下调整
原因:我们若是直接删除堆顶的数据,那么原堆后面数据的父子关系就全部打乱了,需要全体重新建堆,时间复杂度为O(N)。若是用上述方法,那么只需要对堆进行一次向下调整即可,因为此时根结点的左右子树都是小堆,我们只需要在根结点处进行一次向下调整即可,时间复杂度为O(log(N))
//删除
void HeapPop(HP* php)
{assert(php);assert(!HeapEmpty(php));swap(&php->a[0], &php->a[php->size - 1]);//交换堆顶和最后一个结点的位置php->size--;//删除最后一个结点(也就是删除原来堆顶的元素)AdjustDown(php->a, php->size, 0);//向下调整
}
2.8 获取堆顶的元素
获取堆顶的数据,即返回数组下标为0的数据
//获得堆顶元素
HPDateType HeapTop(HP* php)
{assert(php);assert(!HeapEmpty(php));return php->a[0];
}
2.9 获取堆的数据个数
获取堆的数据个数,即返回堆结构体中的size变量
//获取堆中数据个数
int HeapSize(HP* php)
{assert(php);return php->size;
}
2.10 堆的判空
堆的判空,即判断堆结构体中的size变量是否为0
//判断是否为空
bool HeapEmpty(HP* php)
{assert(php);return php->size == 0;
}
2.11 建堆
2.11.1 向上调整建堆(逐个插入建堆:自顶向下)
核心思想:
- 从空堆开始,逐个往堆的末尾插入元素
- 每次插入后,通过向上调整(AdjustUp)维护堆性质
- 类似于"边建边调整"的策略
2.11.2 向下调整建堆(直接数组建堆:自底向上)
核心思想:
- 一次性将整个数组视为完全二叉树
- 从最后一个非叶子节点开始,通过向下调整(AdjustDown)构建堆
- 采用"先建后调整"的策略
在之前,我们说过使用向下调整算法有一个前提:左右子树必须是个堆。那么我们显然不能从前往后进行调整,因为堆顶的左右子树不是一个堆。为了保证调整某个元素时它的左右子树已经是堆了,我们应该从后往前逐元素进行向下调整
那么,第一个需要调整的元素是谁呢?叶子节点没有左右子树,故不需要进行调整,我们应该从第一个非叶子结点开始向下调整调整,即下标为(n-1-1)/2的元素(n为元素个数)。就这样不断调整到根结点即可完成堆的构建。下面是向下调整构建大堆的过程和代码:
//堆的创建,向下调整
void HeapDownCreate(HP* php, HPDateType* a, int n)
{assert(php);assert(a);HeapInit(php);//初始化堆//将数组的n个元素拷贝到堆中if (php->capacity < n)//容量不足先扩容{HPDateType* tmp = realloc(php->a, n * sizeof(HPDateType));if (tmp)php->a = tmp;elseexit(-1);php->capacity = n;}memcpy(php->a, a, sizeof(HPDateType) * n);php->size = n;//从后往前逐元素向下调整建堆//n-1最后一个叶子结点的下标//(n-1-1)/2最后一个叶子结点的父结点的下标,即第一个非叶子结点for (int i = (n - 1 - 1) / 2; i >= 0; i--){AdjustDown(php->a, n, i);}
}
2.11.3 向上调整vs向下调整
我们分析一下这两种建堆方法的时间复杂度
因为堆是完全二叉树,而满二叉树也是完全二叉树,因此为了简化运算,我们使用满二叉树来分析复杂度
我们明显可以看出“向下调整建堆”优于“向上调整建堆”,具体表现在向下调整随着层数增加每个结点的调整次数会递减,而向上调整正好相反。层数越高需要调整的结点越多,因此总体来看向下调整建堆的总调整次数会更少。我们后面的建堆均会使用向下调整来建堆