目录
注:本文logN表示以2为底N的对数
一、堆的概念引入
普通的二叉树不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树(满二叉树属于完全二叉树的一种)更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
堆的概念及结构:
大堆(大根堆):1、完全二叉树 2、每个父亲>=孩子
小堆(小根堆):1、完全二叉树 2、每个父亲<=孩子
将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆就像一个完全二叉树+数组来存储,只是在这个基础上分大根堆和小根堆(简称大堆和小堆)
而关于左右孩子谁大谁小完全无关
堆的意义:
用来选数,因为比如大堆,父亲就是最大的,以后讲的堆排序就是用的这个特征,大堆特点:根(堆顶)就是最大值,小堆特点:根(堆顶)就是最小值。实际应用比如选出哪个地方好评最好的食品,就用到了堆。
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树。
例题:
下列关键字序列为堆的是(A)
A、100,60,70,50,32,65
B、60,70,65,50,32,100
C、65,100,70,32,50,60
D、70,65,100,32,50,60
思路:因为堆的完全二叉树按照数组来存储,则从第一个往后每一层的节点数为1、2、4、6......
比如A:
二、堆的实现(主要用小堆实现)
本质就是一个完全二叉树用数组来存,我们用一个动态数组就好
分为三个文件:Heap.h Heap.c test.c
关键:
逻辑结构是完全二叉树,物理结构是数组,本质上逻辑结构只是想象出来的,物理结构才是我们真实操作的,所以针对数组,我们真实的是操作它的下标。
如果想构建小堆(大堆一样的思路):
首先,我们会跟线性表一样建立一个动态数组来存堆的数据
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
①、向下调整法的实现
向下调整法(大小堆都适用):
- 前提:如果一棵树中左右子树都是小堆(或大堆),只有根节点不满足,那么就可使用向下调整算法。(这个方法很重要,讲的堆排序都要用到这个算法)数组建堆主要依赖的就是向下调整法
- 基础知识:对于完全二叉树而言,已知父亲节点的下标为i,则他的左孩子下标为2*i+1,右孩子下标为2*i+2
- 思路:找出左右孩子中小的那一个,然后与父亲节点交换,然后再找下一个父亲和孩子,再次找出左右孩子中小的那一个,不断比较并交换,直到最后的下标超出了数组的范围。
下面是只有一个左子树的情况(以小堆为例),元素个数n=10,此时数值为37的下标为9,9+1会造成越界
综上,调整终止条件有两个:
- 当二叉树是满二叉树:child < n循环(调整)才会继续
- 当二叉树的最后某一节点只有左子树(不可能只有右子树,因为是完全二叉树),child+1<n循环(调整)才会继续,否则这种情况会越界
- 交换过程中满足孩子大于父亲了,说明此次无需交换了
下面是小堆的向下调整法:
//向下调整算法。前提:左右子树都是小堆
void AdjustDown(HPDataType* a, int n, int root)
{// root说明从root开始调,root不是每次都是0
//找出左右孩子中小的那一个,默认认为左孩子是小的那一个,否则就加以调整即可
int parent = root;
int child = parent * 2 + 1;//先默认child是左孩子,我们的目的是让child成为小的那一个
while (child < n)
{//当孩子的下标<n的时候才会一直比较交换,越界就说明堆构建完了
if (child + 1 < n && a[child + 1] < a[child])//判断还要有一个只有左子树的情况
{//如果右孩子比左孩子还小,就让child变成右孩子,即下标+1即可
child++;
}
//如果孩子小于父亲就交换
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;//进行下一次的比较判断
child = parent * 2 + 1;
}
else
{
break;//因为孩子已经>=父亲了,满足小堆的条件了,就无须继续往下判断,
//因为在调整的过程中可能就存在在越界之前,孩子>=父亲的情况
//谨记向下调整法用于只有堆顶不满足,而左右子树满足堆的性质的时候使用
}
//仅交换一次还不能够满足小堆,应该持续比较并交换,所以应该是个循环
}
}
相反,大堆的向下调整法只要变一下符号即可
//向下调整算法。前提:左右子树都是大堆
void AdjustDown(HPDataType* a, int n, int root)
{// root说明从root开始调,root不是每次都是0
//找出左右孩子中大的那一个,默认认为左孩子是大的那一个,否则就加以调整即可
int parent = root;
int child = parent * 2 + 1;//先默认child是左孩子,我们的目的是让child成为大的那一个
while (child < n)
{//当孩子的下标<n的时候才会一直比较交换,越界就说明堆构建完了
if (child + 1 < n && a[child + 1] > a[child])//判断还要有一个只有左子树的情况
{//如果右孩子比左孩子还大,就让child变成右孩子,即下标+1即可
child++;
}
//如果孩子大于父亲就交换
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;//进行下一次的比较判断
child = parent * 2 + 1;
}
else
{
break;//因为孩子已经<=父亲了,满足大堆的条件了,就无须继续往下判断,
//因为在调整的过程中可能就存在在越界之前,孩子<=父亲的情况
//谨记向下调整法用于只有堆顶不满足,而左右子树满足堆的性质的时候使用
}
//仅交换一次还不能够满足大堆,应该持续比较并交换,所以应该是个循环
}
}
向下调整法的时间复杂度:
因为对于堆来说,按最糟糕的情况来算,那就是每一层就要交换一次节点,那么整个堆就要交换高度次,即log(N+1) (以2为底N+1的对数),故时间复杂度:O(logN)
②、建堆的实现
因为向下调整法是构建在左右子树都是小堆(或大堆)的前提下,那怎么使左右子树是小堆(或大堆)呢?
只有一个节点时既可看作小堆也可看作大堆,如果以小堆为例,那对于一个完全二叉树,只看叶子结点肯定保证是小堆了(可看做是小堆),那只需从叶子结点的父亲开始调整为小堆,其下标为(n-1-1)/2(因为向下调整法只要在左右子树是小堆(或大堆),根节点不保证是小堆(或大堆)的下用,那么叶子结点可以看做是左右子树),然后在判断它的前一个元素(只需要下标-1就找到上一个元素了),不断往上调整,直到调完最上面的根节点就结束了,最上面的根节点的下标为0
//数组构建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{//从最后的叶子结点的父节点开始向下调整
AdjustDown(php->_a, php->_size, i);
//为什么每次都可传堆的大小?本质上,size可以看为终止条件
//当child<size时循环继续,否则循环终止
//那不同节点用向下调整法只是终止的快慢的问题,但最后都会终止
}
那分析一下向下调整法建堆的时间复杂度:
③、堆的初始化--HeapInit
①、数组应该存堆的数据,而数据是源于你传入的数据,用动态数组拷贝数据,才方便后续操作
②、数组建堆要用向下调整法,满足树中所有父亲节点均小于孩子。
void HeapInit(Heap* php, HPDataType* a, int n)
{
//php: hp代表heap,前面多个p表示指针
php->_a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->_a == NULL)
{//一般malloc很少会失败,为了严谨最好检查一下
printf("malloc fail!\n");
exit(-1);
}
memcpy(php->_a, a, sizeof(HPDataType) * n);
//把需要的数据拷贝到堆中,拷贝后才方便动态进行
//因为传给我们的数组a,它是静态的,不便于后续操作
php->_size = n;//堆的特点就是本来堆中就有n个数据
php->_capacity = n;
//数组构建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{//从最后的叶子结点的父节点开始向下调整
AdjustDown(php->_a, php->_size, i);
//为什么每次都可传堆的大小?本质上,size可以看为终止条件
//当child<size时循环继续,否则循环终止
//那不同节点用向下调整法只是终止的快慢的问题,但最后都会终止
}
}
当然也可以用递归来写,但是一般除了练习,最好不要用递归,最好用迭代
④、堆排序的实现(假设排为降序)
若利用向下调整法建小堆可以找出最小的一个,但为了排序,如何找到次小的?正常逻辑是除了这个最大的其他节点再次建小堆,因为再次建小堆就可以再次找到最小的,但是这个方法时间复杂度比较大,为O(N*N)【因为相当于等差数列】
【排降序】:建小堆
【排升序】:建大堆
那么如何减少时间复杂度?
思路:
已知建堆的时间复杂度为O(N),而在排序过程中,由于要每次选出剩余数中最小的数,并保存到每次最后的节点,并要再执行一次向下调整算法,总共需要进行N-1(≈N)次,则向下调整算法的时间复杂度:O(NlogN),再加上建堆的时间复杂度,则=N*logN+N,综上,堆排序的时间复杂度:O(NlogN)
void HeapSort(int* a, int n)
{
//1、数组建堆
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
//当然i也可初始化为n-1,即从叶子结点开始调,但是这么做肯定没有从叶子结点
//的父节点开始调高效
}
//2、找次小,排序
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
//再继续选次小的
AdjustDown(a, end, 0);
--end;
//没有真的删除最后一个数据,只是说我下次再找小交换,最后一个数据
//不被看作堆里面的,不造成影响
}
}
⑤、堆的插入和向上调整法
插入数据,为了保持堆(小堆或大堆)的性质,它与普通线性表的插入还不完全相同,因为它插入的数据可能会使原来是小堆变成不是小堆,即它比他的父亲还小,就需要重新调整。
头插可以吗?第一、头插会导致关系变乱,原来是父子关系的两节点,会出现父子变兄弟,第二,头插需要挪动数据(因为用的是数组),会导致效率低下,所以用尾插。尾插需要用到向上调整法。
还是以小堆为例
向上调整法思路如下:
也就是从最后插入的数据跟它的父亲比大小,比父亲小就交换,比父亲大就说明不用交换了,满足小堆了
void AdjustUp(HPDataType* a, int n, int child)//child表示从哪里开始往上调,他被视为孩子
{
int parent = (child - 1) / 2;
while (child > 0)
{//这里的循环判断条件不应该看父节点,父节点不管怎么都可以>=0,比如child=0,(0-1)/2=0
//如果以父亲<0作为循环终止条件是判断不出来的
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;//孩子比父亲大,就无须调整了
}
}
}
那么尾插的代码如下:
void HeapPush(Heap* php, HPDataType x)
{
assert(php);
if (php->_capacity == php->_size)
{
php->_capacity *= 2;
HPDataType* tmp = (HPDataType*)realloc(php->_a, sizeof(HPDataType) * php->_capacity);
if (tmp != NULL)
{
php->_a = tmp;
}
}
php->_a[php->_size++] = x;//先插入x
//然后利用向上调整法再调节为小堆
AdjustUp(php->_a, php->_size, php->_size - 1);
}
⑤、删除堆顶数据
直接删堆顶不行,因为关系会变乱,比如原来的父子关系会变成兄弟,那这里和堆排序的思路差不多,第一个数据与最后一个交换,但是这里最后一个数据是真删,然后再向下调整法,就可以把堆顶删掉了。这里不需要尾删,因为简单且没意义,我们一般都会删除堆顶,不断找次小的数。
void HeapPop(Heap* php)
{
assert(php);
assert(php->_size > 0);
Swap(&php->_a[0], &php->_a[php->_size - 1]);//堆顶与最后一个元素交换
php->_size--;//删除堆顶
AdjustDown(php->_a, php->_size, 0);//再次向下调整法,使其为小堆
}
⑥、获取堆顶
HPDataType HeapTop(Heap* php)
{
assert(php);
assert(php->_size > 0);
return php->_a[0];
}
Heap.h:
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
//堆的初始化
void HeapInit(Heap* php, HPDataType* a, int n);
//堆的销毁
void HeapDestroy(Heap* php);
//数据插入
void HeapPush(Heap* php,HPDataType x);//与线性表不同的是插入数据后,要保持堆的特性
//删除堆顶的数据
void HeadPop(Heap* php);//并不是删除那么简单,删除完数据还是要保持堆的特性
//获得堆顶的数据
HPDataType HeapTop(Heap* php);
Heap.c:
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向下调整算法。前提:左右子树都是小堆
void AdjustDown(HPDataType* a, int n, int root)
{
//找出左右孩子中小的那一个,默认认为左孩子是小的那一个,否则加以调整即可
int parent = root;
int child = parent * 2 + 1;//先默认child是左孩子的下标,我们的目的是让child成为小的那一个
while (child < n)
{//当孩子的下标(每一轮循环child下标都会被更新为左孩子的下标)<n的时候才会一直比较交换,越界就说明堆构建完了
if (child + 1 < n && a[child + 1] < a[child])//判断还要有一个只有左子树的情况
{//如果右孩子比左孩子还小,就让child变成右孩子,即小标+1即可
child++;
}
//如果孩子小于父亲就交换
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;//进行下一次的比较判断
child = parent * 2 + 1;//再次假设小的孩子是左孩子,进行下一次循环
}
else
{
break;//满足小堆的条件了,无须继续往下判断,
//因为在调整的过程中,可能出现孩子>=父亲的情况
}
//仅交换一次还不能够满足小堆,应该持续比较并交换,所以应该是个循环
}
}
//向上调整法
void AdjustUp(HPDataType* a, int n, int child)//child表示从最后底开始的下标,因为是从下往上调
{
int parent = (child - 1) / 2;
while (child > 0)
{//这里的循环判断条件不应该看父节点,父节点不管怎么都可以>=0,比如child=0,(0-1)/2=0
//如果以父亲<0作为循环终止条件是判断不出来的
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;//孩子比父亲大,就无须调整了
}
}
}
void HeapInit(Heap* php, HPDataType* a, int n)
{
//php: hp代表heap,前面多个p表示指针
php->_a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->_a == NULL)
{//一般malloc很少会失败,为了严谨最好检查一下
printf("malloc fail!\n");
exit(-1);
}
memcpy(php->_a, a, sizeof(HPDataType) * n);
//把需要的数据拷贝到堆中,拷贝后才方便动态进行
//因为传给我们的数组a,它是静态的,不便于后续操作
php->_size = n;//堆的特点就是本来堆中就有n个数据
php->_capacity = n;
//构建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{//从最后的叶子结点的父节点开始向下调整
AdjustDown(php->_a, php->_size, i);
}
}
//堆的销毁
void HeapDestroy(Heap* php)
{
assert(php);
free(php->_a);
php->_a = NULL;
php->_capacity = php->_size = 0;
}
//堆排序的实现
void HeapSort(int* a, int n)
{
//1、数组建堆
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
//当然i也可初始化为n-1,即从叶子结点开始调,但是这么做肯定没有从叶子结点
//的父节点开始调高效
}
//2、找次小,排序
int end = n - 1;
while (end > 0)
{//最后临界条件是end=0,即只剩下一个元素,就无须再排了
Swap(&a[0], &a[end]);//把建好的堆的最小元素换到和最后的一个元素换
//再继续选次小的
AdjustDown(a, end, 0);//传入了n-1个元素,相当于把最后的一个元素除去了
--end;
}
}
//从堆尾部插入数据
void HeapPush(Heap* php, HPDataType x)
{
assert(php);
if (php->_capacity == php->_size)
{
php->_capacity *= 2;
HPDataType* tmp = (HPDataType*)realloc(php->_a, sizeof(HPDataType) * php->_capacity);
if (tmp != NULL)
{
php->_a = tmp;
}
}
php->_a[php->_size++] = x;//先插入x
//然后利用向上调整法再调节为小堆
AdjustUp(php->_a, php->_size, php->_size - 1);
}
//从删除堆顶数据
void HeapPop(Heap* php)
{
assert(php);
assert(php->_size > 0);
Swap(&php->_a[0], &php->_a[php->_size - 1]);//堆顶与最后一个元素交换
php->_size--;//删除堆顶
AdjustDown(php->_a, php->_size, 0);//再次向下调整法,使其为小堆
}
//获取堆顶
HPDataType HeapTop(Heap* php)
{
assert(php);
assert(php->_size > 0);
return php->_a[0];
}
test.c:
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
int main()
{
int a[] = { 27,15,19,18,28,34,65,49,25,37 };
Heap hp;
HeapInit(&hp, a, sizeof(a) / sizeof(a[0]));
HeapPush(&hp, 19);
printf("堆顶为%d\n", HeapTop(&hp));
HeapDestroy(&hp);
return 0;
}
三、时间复杂度总结:
向上调整法复杂度=向下调整法时间复杂度=O(logN)
向下建堆的时间复杂度:O(N)
堆排序的时间复杂度:O(N*logN)