一 什么是堆?
1.问题引入
当我们需要不断从一个有序数列中插入元素,取出最大值(或者最小值)时,通常会先将元素存放进有序数组,有序链表,查找树中,
但这三种存储方式都有缺点
有序数组插入值的时间复杂度为O(n),删除最值的时间复杂度为O(1)
有序链表插入值时间复杂度为O(n),删除最值时间复杂度为O(1)
查找树的插入值的时间复杂度为O(logn)(平衡二叉树树高)删除最值时间复杂度为O(logn),但随着不断删除元素,树也越来越倾斜,使得树高不再是logn,增大了时间复杂度
综上我们可以用树的结构来存储数据,只不过要避免删除数据使得树越来越倾斜这个问题。
2.最大堆
我们将最大值放在一棵完全二叉树的树根,而对于其他元素来说,都是以其为根的子树的最大值(左右节点的值都小于根节点)。
3.堆的两个特性
1.结构性:用数组表示的完全二叉树。
2.有序性:任一节点的关键字是其子树所有节点的最大值(或最小值)
最大堆(MaxHeap)根节点存放最大值
最小堆(MinHeap)根节点存放最小值
最大堆 最小堆
不是完全二叉树 对于堆来说 树中的每一条路径都应该是有序的
最大堆从大到小 最小堆从小到大
二 堆的操作
1.最大堆的创建
struct _MaxHeap
{
int *data; //堆使用数组实现的所以元素都存入数组中
int size; //堆的当前元素个数
int capacity; //堆的最大容量
};
typedef struct _MaxHeap* MaxHeap;
MaxHeap maxheap_creat(int MaxSize)
{
MaxHeap heap = (MaxHeap)malloc(sizeof(struct _MaxHeap));
heap -> data = (int*)malloc(sizeof(int) * (MaxSize + 1)); //零号节点为哨兵节点 不存放元素
heap -> data[0] = MAXDATA;
heap -> capacity = MaxSize;
heap -> size = 0;
return heap;
}
将数组元素设置为MAXSIZE+1,是因为零号元素不存放值,存放哨兵,哨兵元素会在接下来的插入操作中起到作用
2.最大堆的插入
对于树来说,我们插入一个节点,最自然的想法就是将元素插入树的最后一个位置,以保证堆仍然是一个完全二叉树
假如我们插入20在最后一个位置,可以看到每个节点都是以当前节点为根的子树的最大值,保持着最大堆的有序性
但插入58时,58>31,破坏了最大堆有序性,我们就要把58往上调,那么上调如何实现呢,见下代码。
void maxheap_insert(MaxHeap heap, int item)
{
if(maxheap_isfull(heap))
{
printf("the maxheap is full\n");
return ;
}
int i;
// 当前节点的父节点
for(i = ++heap -> size; heap -> data[i/2] < item; i /= 2)
// 注意要将哨兵节点 data[0] 设为足够大的值防止死循环
// 或者限制 i > 1
// 若父节点小于要插入的节点 将父节点下移
heap -> data[i] = heap -> data[i/2];
// 知道父节点大于要插入的节点 将要插入的值赋予当前节点
heap -> data[i] = item;
}
在for循环中,将i的初始值设置为树的元素个数即数组最后一个下标加一,同时数组个数加一,判断父节点与要插入元素的大小,如果父节点(数组中父节点下标为左右子节点除以二)小于item,说明item还要向上挪动,就将父节点下移,之后 i 进入父节点在比较item与父节点之间的大小关系。
最后当父节点的值大于要插入的值的时候,就意味着当前 i 就是要插入的位置,直接赋值即可
需要注意的是,如果我们插入的值大于当前树中的任意一个值,for循环就会比较item与零号元素的大小,这时我们将零号元素设置为一个足够大的数,就可以退出循环,而将item放入根节点
或者可以设置循环条件 i>1,保证了插入元素与根节点(下标为1)比较完成之后就能退出循环
3.最大堆的删除
对于堆的删除操作,我们只删除根节点,然后将最后一个位置的元素(记为temp)移入根节点中,但根的有序性就被破坏了,这时我们要从根节点开始,选取两个子节点中值大的那一个上调,在进入被上调的那一个子树中,重复操作直到item的值大于左右子节点,或者当前位置没有左右子节点。
int maxheap_delete(MaxHeap heap)
{
int parent, child;
if(maxheap_isempty(heap))
{
printf("the heap is empty\n");
return 0;
}
int maxitem = heap -> data[1]; //存放要删除的最大值
int temp = heap -> data[heap->size--]; //存放替换最大值的数组中的最后一个元素同时数组大小减一
for(parent = 1; parent * 2 <= heap -> size; parent = child)//循环步进条件 进入左右的大节点的子树
{// 进入循环的条件为当前节点存在子节点
child = 2 * parent; //定义左儿子节点
if(child != heap -> size && heap -> data[child+1] > heap -> data[child])
child++; //判断右儿子节点是否存在并存放左右儿子中的较大者
if(heap -> data[child] <= temp)
break; //当左右儿子都小于要替换根节点的元素时 退出循环
heap -> data[parent] = heap -> data[child];
// 将根结点的值替换为子节点中的较大值 进入下一次循环
}
heap -> data[parent] = temp; //退出循环后将根节点值赋为数组总的最后一个元素
return maxitem;
}
先将堆中的第一个元素也就是要返回的最大值存起来,然后再用temp代表堆中的最后一个元素
定义parent child两个变量,分别表示当前节点 和左右节点中较大的那一个。for循环中,parent从1开始,先判断是否存在左子节点(数组中左子节点下标为父节点下标 * 2)如果有,将下标赋给child,接下来的 if 语句来判断是否存在右子节点,有的话右子节点的值是不是大于左子节点,是的话就将child变为右子节点下标,总之一句话:第一个 if 就是取出左右子节点中的较大者。
第二个 if 来判断较大子节点与temp的大小关系,如果较大子节点大于temp,将较大子节点赋给当前位置,循环步进条件parent=child,下一层循环就会进入较大子节点的树中。否则的话说明temp都大于两个子节点,退出循环即可。
循环推出条件:没有左右子节点。
4.最大堆的建立
堆的建立有两种方法
第一种 不断将元素插入堆中,共有n个元素插入一次的时间复杂度为O(logn),总的时间复杂度为O(nlogn),下面是一种时间复杂度为O(n)的方法。
第二种,回顾删除操作,删除是将堆顶的值删除将最后一个元素放到堆顶,这时虽然整棵树不是最大堆但左右子树都是最大堆,我们就可以用parent,child循环的方法重新调整成一个堆,对于一个无序二叉树,我们可以从最后一个有子树的节点开始不断向上调整(最后一个有子树的节点,左右子树只有一个节点或者没有,必是一个最大堆)。
void maxheap_perdown(MaxHeap heap, int p)
{
int parent, child;
int temp = heap -> data[p];
for(parent = p; parent * 2 <= heap -> size; parent = child)
{
child = parent * 2;
if(child != heap -> size && heap -> data[child+1] > heap -> data[child])
child++;
if(heap -> data[child] <= temp)
break;
heap -> data[parent] = heap -> data[child];
}
heap -> data[parent] = temp;
}
void maxheap_build(MaxHeap heap)
{
int i;
for(i = heap -> size / 2; i > 0; i--) //找到最后一个节点的父节点 依次进入下滤函数
maxheap_perdown(heap, i);
}
堆的建立与堆的删除思路是一样的,堆的删除就是将最后一个元素放于堆顶之后的堆的建立。
三 堆排序
堆常见的一个应用就是对数据进行排序
堆排序的第一个很容易想到的方法就是,构建一个最大堆,每次都把最大值弹出来,在重新构造堆,直到所有元素都被弹出。这种方法有个缺点就是,必须将弹出的元素另外放入一个新开辟的空间中,再按从大到小的顺序从新排入原本的数组中,这就造成了空间上的浪费,并且来回赋值元素也会造成时间上的浪费
第二种方法 仍然构建最大堆,但每次不弹出元素,而是把最大值放到堆的最后一个位置,再将堆大小减一
本质来看两种方法很相似,只不过第二种方法是将堆空间和新开辟的临时空间合并到了一起。
#include <stdio.h>
void swap(int* a, int* b)
{
int t = *a;
*a = *b;
*b = t;
}
void percdown(int a[], int i, int n)
{
int parent, child;
int temp = a[i];
for(parent = i; parent * 2 + 1 <= n - 1; parent = child)
{
child = parent * 2 + 1;
if(child != n - 1 && a[child + 1] > a[child])
child++;
if(a[child] < temp)
break;
a[parent] = a[child];
}
a[parent] = temp;
}
void heap_sort(int a[], int n)
{
int i;
for(i = n / 2; i >= 0; i--)
percdown(a, i, n);
for(i = n - 1; i > 0; i--)
{
swap(&a[0], &a[i]);
percdown(a, 0, i);
}
}
int main()
{
int a[] = {81, 94, 11, 96, 12, 35, 17, 95, 28, 58, 41, 75, 15};
heap_sort(a, sizeof(a) / sizeof(int));
for(int i = 0; i < sizeof(a) / sizeof(int); i++)
printf("%d ", a[i]);
return 0;
}
在heap_sort函数中,第一个循环是堆的建立,第二个循环一开始就是交换堆顶的最大值与数组最后一个元素,在重新构建最大堆。
需要注意的是:在堆排序中,元素下标是从零开始的,没有堆结构中的哨兵位置,这时父节点与子节的关系就发生了改变 leftchild = parent * 2 + 1, rightchild = parent * 2 + 2,parent = leftchild / 2, parent = rightchild / 2 - 1。
同样要注意这时数组最后元素的下标不再是n了而是n - 1。