在理解堆之前,我们知道,普通的二叉树只有完全二叉树适合使用数组这种顺序结构进行存储。如果普通的二叉树进行顺序存储会极大的浪费空间。所以我们在现实中把顺序存储的这种完全二叉树称为堆。值得一提的是,尽管我们在逻辑上处理堆的时候是树的结构,但是在物理上堆就是一个一维数组,物理上就是对一个数组进行控制。
对于堆而言,可以分为两种堆——大根堆和小根堆。顾名思义大根堆就是根总是大于左右孩子。小根堆就与之相反。
对于上图中的这种结构就是一个小根堆,可以看到的是,二叉树的各种性质对于堆也是使用的。
对于这种顺序结构,第i个节点的左孩子下标就是2*i+1;右孩子的下标就是2*i+2。对于堆这种数据结构,这个性质是用的最多的。
了解了堆的基本结构之后,先给出它的定义。
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
这里事实上就是一个静态的数组。因为堆的底层实际上就是一个一维数组。
紧接着我们给出堆各个功能的接口
void HeapInit(HP* php);//初始化
void HeapPush(HP* php,HPDataType x);//charu
void HeapPop(HP* php);//shanchu
HPDataType HeapTop(HP* php);//取堆顶元素
bool HeapEmpty(HP* php);//判空
int HeapSize(HP* php);//计算堆中元素个数
void AdjustUp(HPDataType* a, int child);//向上调整建堆
void AdjustDown(HPDataType* a, int n, int parent);//向下调整建堆
1.初始化
这里的初始化就是类似于顺序表的初始化,动态申请空间即可
void HeapInit(HP* php)
{
assert(php);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (php->a == NULL)
{
perror("malloc");
return;
}
php->size = 0;
php->capacity = 4;
}
2.插入
堆的插入其实就是顺序表的插入,只是插入位置需要符合堆的限定。这里涉及到向上调整和向下调整两种方式,其实就是类似于顺序表的头插和尾插,那么为什么可以这么认为呢?其实就是因为堆尽管我们在逻辑上按照树的结构理解,但是他在内存中是按照顺序表的方式存储的。那么我们也可以类比的看,在顺序表插入时并不会选择头插这种方式,因为这样需要移动所有的元素,效率极低,我们通常会选择尾插这种方式进行插入,因为这样不需要移动数据。
首先来看堆的向上调整,向上调整建堆事实上就可以类比尾插来看,在数组末尾插入元素并且根据堆的限定调整数据。下面给出向上调整的代码,为了便于理解,这里直接写成硬编码,默认构建大根堆,如果需要小根堆修改其中的比较关系即可。
void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;//这里是找到孩子的父亲节点
while (child > 0)
{
if (a[parent] < a[child])//建造大根堆,如果父亲小于孩子就交换并且更新孩子和父亲
{
Swap(&a[parent], &a[child]);
child = parent;
parent = (child - 1) / 2;
}
else//如果父亲大于等于孩子,就无需调整直接跳出循环即可
{
break;
}
}
}
这里的交换函数就不过多说明直接给出代码了。
void Swap(HPDataType* p1, HPDataType* p2)
{
int temp = *p1;
*p1 = *p2;
*p2 = temp;
}
接下来看插入功能的实现,这里的插入与顺序表几乎一致,重点就是这里堆如何调整,在插入时注意扩容即可。
void HeapPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
//扩容
HPDataType* temp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * php->capacity * 2);
if (temp == NULL)
{
perror("relloc");
return;
}
php->a = temp;
php->capacity *= 2;
}
//插入
php->a[php->size] = x;
php->size++;
//向上调整
AdjustUp(php->a, php->size - 1);
}
3.删除
堆的删除只有一点需要注意的是,我们如果直接删除堆顶元素的话,这会导致堆的结构被破坏,我们不希望只是删除一个元素就要去重建整个堆。那么用什么方式解决这个问题呢?需要注意的是,堆的物理上就是顺序表,删除堆顶的元素就是删除第一个元素,那么理所当然的需要移动剩下的数据,这里与顺序表也是一致的,那么顺序表如何删除效率高呢?答案显而易见,尾删,那么我们可以交换堆顶和尾部的元素最后尾删向下调整堆即可,就不需要重建整个堆,效率就显而易见提升。
解决这个问题之前我们需要先实现向下调整,这里也是类比顺序表的头插来看,找到父亲节点的较大的孩子,如果这个较大的孩子大于父亲,那么就交换更新节点调整即可。下面给出代码
void AdjustDown(HPDataType* a,int n,int parent)
{
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;
}
}
}
实现了向下调整之后,删除就很简单了,这里直接给出代码
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);
}
4.取堆顶
取堆顶元素其实就是取数组的首元素即可,这里直接给出代码。
HPDataType HeapTop(HP* php)
{
assert(php);
return php->a[0];
}
5.判空
判空也很容易,只需要看size是否为0即可
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
6.计算元素个数
这里也很简单直接返回size就行,直接给出代码
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
8.排序(排升序建大堆,排降序建小堆)
首先在进行堆排序的时候我们需要知道,如果我么需要排升序,那么需要建大堆。这是为什么呢?首先我们需要了解堆排序的思想,堆排序的思想是:首先先将一个无序的数组建堆,然后交换堆顶元素和最后一个元素的位置,这样就能缩小堆的范围,再重复上面的过程直到只剩下了一个元素。
了解了思想之后我么来解决为什么排升序的时候建造大堆而不建造小堆,如果建小堆,每次只能拿到数组中的最小值,无法找到数组中的最大值也就意味着和最后一个元素交换后,无法的到升序的序列,而且还需要额外的空间来实现,效率低。那么建大堆的话,每次找到数组中的最大值,将最大值与最后一个位置的元素交换,那么这个最大值的位置就确定了,我就可以重新向下调整其余元素构成的堆,逐步缩小建堆的范围直到数组中剩一个元素。这里的时间复杂度:建堆的复杂度是O(N),排序时的复杂度是O(NlogN),所以堆排序的时间复杂度是O(NlogN)。
虽然思想或许稍微复杂,但是代码并不难写,这里给出实现
这里唯一需要注意的点在于父亲节点的位置计算,size是元素个数size-1是末尾元素的下标,下标-1再除以2才是父亲节点的位置
void HeapSort(int* arr,int sz)
{
//建堆,向下调整,这里的sz-1是下标位置,下标再-1之后除以2才是父亲节点的位置
for (int i = (sz - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(arr, sz, i);
}
int end = sz - 1;//记录最后一个元素下标
while (end > 0)
{
Swap(&arr[end],&arr[0]);
AdjustDown(arr, end, 0);
end--;
}
}