本文的学习任务:关于堆的实现以及相关的基础操作,包括向上调整算法和向下调整算法,同时利用该算法解决常见的topk问题,之后再对两种算法的时间复杂度进行分析,加深理解。
前面提到过,堆总是一个完全二叉树,那么可以在逻辑上看成一棵二叉树会更加容量理解堆是如何存储数据的,在物理上,我们用一个数组来进行存储。
#pragma once
#define _CRT_SECURE_NO_WARNINGS 1
#include
#include
#include
#include
#include
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
// 堆的构建
void HeapCreate(Heap* hp);
// 堆的销毁
void HeapDestory(Heap* hp);
// 堆的插入
void HeapPush(Heap* hp, HPDataType x);
// 堆的删除
void HeapPop(Heap* hp);
// 取堆顶的数据
HPDataType HeapTop(Heap* hp);
// 堆的数据个数
int HeapSize(Heap* hp);
// 堆的判空
bool HeapEmpty(Heap* hp);
//向上调整
void Adjustup(HPDataType* a, int child);
//向下调整
void AdjustDown(HPDataType* a, int size, int parent);
//堆排序
void HeapSort(int* a, int n);
既然堆是一个数组,它的创建与顺序表非常类似,这里我们重点讲解堆的插入和删除
数组的插入一般是在末尾插入,但插入后要考虑一个问题:就是它插入后,整个数组还是不是一个堆?大堆还是小堆?我们需要进行调整
以大堆为例子,小堆反过来就可以。
大堆顾名思义就是根节点最大的堆,其次任意一个孩子结点的值都小于父亲结点,当在数组末尾插入值时,我们肯定是在二叉树的最下层插入,当插入后,如果不满足上述情况的话,就得进行调整。
如何调整?交换,和谁交换?自己的父节点。当我们的值插入时,它必定会成为一个孩子结点,如果它的值大于父亲结点,那它就要和父亲结点进行交换,以此类推,直到它到达比所有孩子都大的位置,完成调整。
void Adjustup(HPDataType* a, int child)
{
assert(a);
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;
}
}
}
顺序表的删除更加简单,直接将size--就可以了,但是如果在堆也这样使用删除的话,未免有些过于简单且没有意义(和顺序表一样的删法还用堆干嘛),所以一般规定,删除的都是堆顶元素。
这样有一个好处,在建立大堆和小堆时,将需要删除的堆顶元素一返回就是最大值或最小值(后面提到的堆排序就是基于这种思想),和插入一样,在删除元素后,也需要进行调整使它再次成为一个堆。这里就要用到向下调整算法。
所谓向下调整算法,就是找自己的孩子结点的值与其进行比较,(以大堆例)找出最大的孩子结点,如果比父亲大就交换,直到所有结点都完成交换,调整完毕
void AdjustDown(HPDataType* a, int size, int parent)
{
int child = parent * 2 + 1;//根据孩子求父亲
while (child < size)
{
if (child+1
ps:小堆找最小的孩子,大堆就找最大的孩子
利用堆的思想来进行排序,分两个步骤进行
1.建堆:
升序建大堆,降序建小堆
原理是什么?
我们以大堆为例,一般来说,大堆如果要排的话一定是从大到小,但问题的关键在于任何一个堆都不会涉及到孩子结点之间的关系(例如5,4,都比6小,但有可能会出现6,4,5这样的排法),所以孩子结点之间的大小关系是不能简单的建堆来考虑,我们必须将此情况考虑在内。
2.利用堆删除思想来进行排序
还是以大堆为例,既然已经知道大堆的根结点就是最大值,那我们可以利用堆删除的思想,将堆顶元素取出与数组末尾交换,交换完后,这个值固定不动,将剩下的值进行建堆,之后再次与末尾交换,这里的末尾是剩下的值的最后一个数,以此类推,这就是堆排序
关于建堆的说法。我们将数组的第一个值看成一个堆,然后往里放值,每次都进行向上调整或向下调整即可完成建堆。
void HeapSort(int* a, int n)
{
for (int i=(n-2)/2;i>=0;i--)
{
//建堆
AdjustDown(a, n,i);//采用向下调整算法进行建堆
}
int end = n - 1;//从数组的最后一个数开始,n-1就是
while (end > 0)
{
Swap(&a[0], &a[end]);//交换函数
AdjustDown(a, end, 0);
--end;//每次调整完后,end--
}
}
即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能
数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
1. 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
2.用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
原理:以找最大的前k个元素为例,先将前k个元素建堆,如果最大值还没有进堆,那么当他进堆时,它一定大于堆里的所有值,与栈顶元素交换后,进行调整成为新的堆,最大值就会沉到堆底下
最后。堆里只剩下要求求出的最大值了。
void CreateNDate()
{
// 造数据
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() + i) % 100000;//求随机数函数
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
void PrintTopK(const char* file, int k)
{
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
// 建一个k个数小堆
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
perror("malloc error");
return;
}
// 读取前k个,建小堆
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &minheap[i]);
Adjustup(minheap, i);
}
int x = 0;
while (fscanf(fout, "%d", &x) != EOF)
{
if (x > minheap[0])//与栈顶元素交换
{
minheap[0] = x;
AdjustDown(minheap, k, 0);//同时向下调整
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", minheap[i]);
}
printf("\n");
free(minheap);
fclose(fout);
}
前面一直说向上调整算法用来建堆,向下调整算法用来删除,其实有点过于局限,Topk问题和堆排序我也采用向下调整算法来进行建堆是有原因的。
首先先基于一个给定的数组来讨论如何采用两种算法来进行建堆
之前在堆排序使用过,以数组的第一个数看成一个堆,将数组剩下的数一次插入并进行调整
void HeapSort(int* a, int n)
{
for (int i=1;i 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
需要注意的是,堆的最后一层是不需要调整的,所以为了方便控制循环,我们从数组尾到头开始,所以最开始的结点就是最后一个结点的父亲结点开始,怎么求,我们知道最后一个结点的下标是n-1,而前面提到过根据孩子结点求父亲结点的公式。
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[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
可以看到,通过时间复杂度,向下调整的效率会优于向上调整。
堆是一种数据结构,这里提到的堆和内存里提到的堆不一样。堆的核心算法是向上调整算法和向下调整算法,通过这两种算法来解决堆排序问题和TopK问题,由于堆总是一棵完全二叉树,用数组来进行存储会非常方便,也有有益于接下来对于普通二叉树的理解。