一、堆的概念和结构
堆是一棵完全二叉树,对于这棵二叉树,要求某个节点的值总是不大于或不小于其父节点的值。若某个节点的值总是不大于其父节点的值,我们称这样的堆为大堆或大根堆;若某个节点的值总是不小于其父节点的值,我们称这样的堆为小堆或小根堆。
堆是使用数组存储的。
堆在逻辑上是一棵完全二叉树,在物理上是使用数组存储的,那么堆这棵完全二叉树的数据是怎么存储到数组中的呢?
很简单,堆中的数据是从第一层开始,一层一层放到数组中的。
二、堆的实现
2.1定义堆的结构
指针a指向动态开辟的数组空间,数组用来存放堆的数据;size记录堆的有效数据个数;capacity记录堆的容量大小。
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
2.2堆的初始化
void HeapInit(HP* php)
{
//php是指向结构体的指针,php不能为空
assert(php);
//先为堆开辟能存放4个数据的空间
HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (tmp == NULL)
{
perror("malloc fail!");
return;
}
php->a = tmp;
//刚开始堆没有数据,所以size初始化为0
php->size = 0;
//因为开辟了4个空间,所以堆的容量大小是4,把capacity初始化为4
php->capacity = 4;
}
2.3 堆的插入
大堆插入数据后必须仍是大堆,小堆插入数据后必须仍是小堆。
给堆插入一个数据之后,堆的结构可能被破坏,如下图:
当3插入之后,3的父亲是15,而15大于3,不满足小堆的定义,因此需要对3进行向上调整,使树的结构满足小堆的定义。
向上调整有一个前提:左右子树都满足是一个堆。在上面的堆中,除了新插入的数据不满足是一个堆,其他都满足是一个堆,因此要从3这个位置开始向上调整。
向上调整的基本思路如下:
通过下标找到要向上调整的结点(child为下标)及其父节点(parent为下标),将child下标的数据和parent下标的数据进行比较,若不满足堆,则将child下标的数据和parent下标的数据进行交换,然后让child走到parent的位置,parent通过计算走到当前parent的parent位置,然后再进行新一轮的调整,直到满足堆。
3的下标是size-1,那么如何通过3的下标找到3的父亲呢?根据上一篇文章提到的父子结点的下标关系,parent = (child - 1)/2,就可以找到3的父亲的下标。当孩子和父亲都找到之后,就对孩子和父亲进行比较,若孩子大于父亲,则证明满足小堆结构;若孩子小于父亲,则不满足小堆结构。若不满足小堆结构,则要把孩子和父亲进行交换,交换后,孩子和父亲下标要进行相应的变换,再进行比较,再判断是否满足小堆结构,一直调整到满足小堆结构为止。
向上调整过程如下:
向上调整的代码实现(以小堆为例):
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 = (child - 1) / 2;
}
else
{
break;
}
}
}
Swap函数:
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
堆的插入:
void HeapPush(HP* php, HPDataType x)
{
//php是指向结构体的指针,php不能为空
assert(php);
//判断堆还有没有剩余的空间插入数据,有就直接插入,没有就先扩容,再插入数据
if (php->size == php->capacity)
{
//2倍扩容
HPDataType* tmp = (HPDataType*)realloc(php->a, 2 * sizeof(HPDataType) * php->capacity);
if (tmp == NULL)
{
perror("realloc fail!");
return;
}
php->a = tmp;
//扩容后,capacity的值要进行相应的修改
php->capacity *= 2;
}
//插入数据
php->a[php->size] = x;
//插入数据后,堆的有效数据变多了。要进行相应的修改
php->size++;
//向上调整
AdjustUp(php->a, php->size - 1);
}
总的来说,在给堆插入数据时,有以下2个步骤:
1.把数据插入在堆的末尾;
2.判断堆的结构有没有被破坏,若堆的结构被破坏了,则要通过向上调整算法,把这个数据调整的合适的位置。
2.4堆的删除
堆的删除指的是删除堆顶元素。若是挪动删除堆顶元素,会有两个弊端:1.效率低下;2.父子兄弟关系全乱了,再要去调整成堆,会很麻烦。删除堆顶元素,可以先把堆顶元素和堆最后一个元素交换,然后通过size--来控制堆的有效数据个数,这样堆顶元素就被删除了。但是交换数据过后,还要保持是一个堆,这时可以通过向下调整算法来调整成堆。
需要注意的是,向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
在把堆顶元素和堆最后一个元素交换后,左右子树仍是一个堆,只有堆顶元素不满足堆,因此把堆顶元素向下调整到合适的位置形成堆即可。
如要对下图的小堆进行删除操作:
交换元素过后:
交换元素之后,要从20开始向下调整。通过20的下标算20的左右孩子下标,让20和它左右孩子中小的那个进行交换,然后再调整chid和parent的值,不断循环,直到调成小堆。
向下调整过程如下:
向下调整代码实现(以小堆为例):
void AdjustDown(HPDataType* a, int n, int parent)
{
//假设左孩子为左右孩子中小的那个
int child = parent * 2 + 1;
//最坏情况下调整到叶子
while (child <= n-1)
{
//child + 1 < n是为了防止越界
//a[child]>a[child+1]说明左孩子比右孩子大,不满足假设
if (child + 1 < n && a[child] > a[child + 1])
{
child = parent * 2 + 2;//右孩子为左右孩子中小的那个
}
if (a[parent] > a[child])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;//形成堆,循环结束
}
}
}
堆的删除:
void HeapPop(HP* php)
{
assert(php);
assert(php->size != 0);//堆为空的时候不能删除
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0);
}
总的来说,堆的删除有以下3个步骤:
1.将堆顶元素与堆中最后一个元素进行交换;
2.删除堆中最后一个元素(size--即可);
3.将堆顶元素向下调整,直到满足堆的特性为止。
2.5取堆顶的数据
取堆顶元素,直接把堆顶元素返回即可。
HPDataType HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));//堆为空不能取堆顶元素
return php->a[0];
}
2.6判断堆是否为空
若堆为空,则返回非0结果;若堆不为空,则返回0。
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
2.7获取堆的数据个数
直接返回size即可获得堆的数据个数。
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
2.8堆的销毁
由于堆是一块动态开辟出来的空间,因此为防止内存泄漏,在使用堆之后要把堆销毁。
void HeapDestroy(HP* php)
{
assert(php);
//释放指针a指向的空间
free(php->a);
//把a置空
php->a = NULL;
//堆被销毁之后,要把有效数据个数size和容量大小capacity置为0
php->size = php->capacity = 0;
}
三、堆的应用
3.1建堆
在讲解堆的应用之前,我们先来看看两个建堆的方法。
下面我们给出一个数组,这个数组逻辑上可以看做一棵完全二叉树,但是还不是一个堆,现在我们要把它构建成一个堆,如何建堆?
也许有人会想到用堆这个数据结构建堆,但是这存在两个问题:1.当没有堆这个数据结构的时候,就需要自己手搓堆,比较麻烦。2.如果用堆这个数据结构,就要把原数组的数据push到堆里面,建成堆之后又要把堆里面的数据拷贝回原数组,这样也很麻烦。因此,我们可以考虑脱离堆这个数据结构自己建堆。
建堆有如下两个方法:
1.向上调整建堆(模拟插入的过程)
向上调整建堆就是模拟插入的过程,最先开始默认数组的第一个数据就是堆中的元素。
第一次循环:将数组的第二个元素插入堆中,从第二个元素开始向上调整成堆。
第二次循环:将数组的第三个元素插入堆中,从第三个元素开始向上调整成堆。
……
第n - 1次循环:将数组的第n个元素插入堆中,从第n个元素开始向上调整成堆。
循环结束。
当循环结束的时候,堆就建成了。
//向上调整建堆
//n是数组长度
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
2.向下调整建堆
由于向下调整有一个前提:左右子树必须是一个堆,才能调整。
因此向下调整是从最后一个结点的父亲开始调整的。只有从最后一个结点的父亲开始调整,下面的子树才能形成一个一个堆。下面的子树形成堆时,才能从这些子树的根节点开始向下调整。
n-1是最后一个结点的坐标,(n-1-1)/2是最后一个结点的父亲的下标。
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
向上调整建堆的时间复杂度是O(N*logN),向下调整建堆的时间复杂度是O(N),故向下调整建堆的效率比较高。
3.2堆排序
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
1.建堆。若是排升序,则建大堆;若是排降序,则建小堆。
2.利用堆删除思想来进行排序。
假设我们要排升序,则我们要建的是大堆。当大堆建好后,堆顶元素就是最大的那一个,定义一个end指针指向数组最后一个位置,然后将堆顶元素和堆中的最后一个元素进行交换,这样就把最大的元素沉到堆的最后,接着通过end调节数组长度,使已经排好序的数据不参与向下调整(相当于删除堆中的元素)。每进行一次循环,就有一个数被排好序,当循环结束的时候,排序也完成了。
void HeapSort(int* a, int n)
{
//建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, n, i);
}
//排序
int end = n - 1;
while (end > 0)
{
Swap(&a[end], &a[0]);
AdjustDown(a, end, 0);
end--;
}
}
int main()
{
int a[10] = { 2,1,5,7,6,8,0,9,4,3 };
HeapSort(a, 10);
return 0;
}
3.3TOP-K问题
简单来说,TOP-K问题就是找N个数里最大/最小的前k个。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等,这些都是TOP-K问题。
解决TOP-K问题的基本思路如下:
1.用数据集合中前K个元素来建堆;
找前k个最大的元素,则建小堆;
找前k个最小的元素,则建大堆;
2.用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素。
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
比如找N个数里最大的前k个:
(1).建一个K个数的小堆。
(2).遍历剩下的N-K个数据,如果比这个数据比堆顶的数据大,就替代它进堆(把这个数据赋值给堆顶数据,然后向下调整)。
(3).最后这个小堆的数据就是最大的前K个。
代码示例:
void PrintTopK(const char* file, int k)
{
//建堆——用前k个元素建小堆
int* topk = (int*)malloc(sizeof(int) * k);
//检查malloc是否失败
assert(topk);
//打开文件
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
//读k个数据,建堆
for(int i = 0 ; i < k ; i++)
{
fscanf(fout, "%d", &topk[i]);
}
for (int i = (k - 2) / 2; i >= 0; i--)
{
AdjustDown(topk, k, i);
}
//将剩余的n-k个元素依次与堆顶元素交换
int val = 0;
int ret = fscanf(fout, "%d", &val);
while (ret != EOF)
{
if (val > topk[0])
{
topk[0] = val;
AdjustDown(topk, k, 0);
}
ret = fscanf(fout, "%d", &val);
}
for (int i = 0; i < k; i++)
{
printf("%d ", topk[i]);
}
printf("\n");
free(topk);
fclose(fout);
}
void TestTopk()
{
int n = 10000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
//向文件里面写随机数
for (int i = 0; i < n; i++)
{
int x = rand() % 10000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
PrintTopK(file, 10);
}
int main()
{
TestTopk();
return 0;
}