文章目录
- 前言
- 1.堆的相关介绍
- 1.什么是堆
- 2.堆的结构
- 2.堆的相关接口具体实现
- 1.堆的声明和堆的初始化
- 2.堆插入数据和删除数据
- 3.堆的其他函数接口
- 3.堆的实际运用
- 1.建堆算法
- 2.堆的应用之堆排序
- 3.堆解决Top k问题
- 4.总结
前言
之前对树的相关知识概念进行了简单介绍,本文将实现一种树相关的数据结构——堆。堆的本质是二叉树,下面将会对堆进行讲解。
1.堆的相关介绍
1.什么是堆
如果有一个关键码的集合K = { , , ,…, },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: <= 且 <= ( >= 且 >= ) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:堆中某个节点的值总是不大于或不小于其父节点的值;
堆总是一棵完全二叉树。
简单来说,堆是一颗特别完全二叉树,堆有大小堆之分,大堆的根节点一定是比孩子节点要大的,兄弟节点之间无所谓大小顺序,以上图为例 大堆也可以写成 3 1 2,小堆反之。也就是说大堆的根节点一定是这个堆中最大的数据,小堆的根节点一定是堆中最小的数据。
2.堆的结构
堆是一颗完全二叉树,完全二叉树可以由数组和链表实现,堆的实现方式通常是使用数组,普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费,而完全二叉树更适合使用顺序结构存储。其实二叉树这种结构最大的作用是用来搜索查找,之前的线性结构主要是实现增删查改。数组实现堆的物理结构比较简单,直接在数组空间填数即可,前提是要维持住堆的结构特性,由此数组实现堆最大的难度在于维持堆的逻辑结构。由于数组可以随机访问的特性,所以在维持堆的逻辑特性,也比较好处理。
用数组实现堆最重要的一点在于通过数组下标确双亲节点和孩子节点。我们画图分析一下。
2.堆的相关接口具体实现
这里堆采用数组实现,堆的的相关接口大致就是的创建和初始化,插入数据 ,删除数据,堆的销毁。
1.堆的声明和堆的初始化
这个堆的实现刚才提到是用数组来实现的。所以定义结构的时候和顺序表基本上是一样的。
代码示例
typedef int HPDataType;
typedef struct heap
{
HPDataType* data;
int sz;
int capacity;
}Heap;
这里采用申请动态内存方式来实现动态数组存储数据,结构体中的sz记录数组元素个数,capacity记录数组空间容量,如果数组空间不够了,需要及时申请空间。
堆的初始化
代码示例
void HeapInit(Heap* hp)//堆初始化
{
assert(hp);
hp->data = (HPDataType*)malloc(sizeof(HPDataType) * 5);
hp->sz = 0;
hp->capacity = 5;
return;
}
堆的初始化就是对动态数组的初始化,先申请5个整型大小的空间,没有存储数据sz初始化为0,容量大小就是5。
2.堆插入数据和删除数据
堆是一个特别的二叉树,以大堆为例,要保证每层的根节点都比孩子节点要大。当插入一个数据到堆中时,为了保证插入数据后堆还是堆,这势必会对堆进行调整,这个调整算法叫做向上调整。向上的调整的意思是插入的数据会因为大小关系,从而导致这个数据不是孩子节点而是成为其他节点的双亲节点。从底层开始往上爬,直到找到适合位置。
向上调整代码示例
void AdjustUp(HPDataType* arr,int child)
{
int parents = (child - 1) / 2;
while (child > 0)
{
if (arr[child] > arr[parents])
{
Swap(&arr[child], &arr[parents]);
child = parents;
parents = (child - 1) / 2;
}
else
{
break;
}
}
return;
}
我们看到这个33插入到堆后和它的双亲节点进行比较,如果这个节点比它的双亲节点大,就交换位置,然后继续往上走比较交换,直到找到合适的位置为止。这个过程就相当于是孩子取代祖先。
分析代码这个向上调整是先将数据插入到堆中在进行调整,这个过程可以抽象的理解为孩子要当祖先,是一个以下犯上谋朝篡位的过程.在这个过程需要交换,可以写一个交换函数。
我们之前分析了孩子节点和双亲节点之间的下标关系,所以通过孩子节点找到父节节点就很容易,当chilid比所有的双亲节点大时,那么这个child就成为了根节点。由此while循环条件是child,当child为0时就不用比较交换了。
交换函数
void Swap(HPDataType* a,HPDataType* b)
{
HPDataType tem = *a;
*a = *b;
*b = tem;
return;
}
这个交换函数很简单,要注意是这个参数是址传递,这样才能起到交换的作用
插入数据代码示例
void HeapPush(Heap* hp,HPDataType x)// 堆的插入
{
assert(hp);
if (hp->sz == hp->capacity)
{ hp->data =
(HPDataType*)realloc(hp->data, (sizeof(HPDataType))*hp->capacity * 2);
if (hp->data == NULL)
{
perror("malloc fail");
exit(-1);
}
hp->capacity = hp->capacity * 2;
}
hp->data[hp->sz] = x;
hp->sz++;
AdjustUp(hp->data, hp->sz-1);
return;
}
这个插入数据的增容还是和以前实现顺序表类似没啥好说的。
当实现了插入数据之后紧接着就是堆中数据的删除,堆中数据删除规定必须删除堆顶元素,也就是删除根节点。那么有又一个问题了,原堆顶数据删除后那么剩下的元素谁当新的根节点呢,又怎么继续维持剩余节点之前的关系呢?那么这就有需要调整了,这个调整被为向下调整。向下调整简单来说就是将先将堆的根节点和堆中最后一个元素交换,让最后一个节点暂时充当根节点,在让这个节点依次向下比较,交换位置,直到找到适合位置为止。
向下调整代码示例
void AdjustDown(HPDataType* arr, int n,int parents)//向下调整
{
//n的作用只是用来约束孩子和双亲防止越界
int child = parents * 2 + 1;
while (child < n)
{ //保证child是指向大孩子的
if (child + 1 < n && arr[child + 1] > arr[child])
{
child++;
}
if (arr[parents] < arr[child])
{
Swap(&arr[parents], &arr[child]);
parents = child;
child = parents * 2 + 1;
}
else
{
break;
}
}
return;
}
堆的数据删除
void HeapPop(Heap* hp)//删除堆顶元素
{
assert(hp);
assert(hp->sz >0);
Swap(&hp->data[0], &hp->data[hp->sz - 1]);
hp->sz--;
//删除后堆要重新进行调整保证是一个堆
AdjustDown(hp->data, hp->sz, 0);
//向上调整 双亲变孩子
return;
}
这里我们将重点放在向下调整上,我们来好好分析一下这个向下调整
如果说向上调整是孩子变祖先的过程,那么向下调整就是祖先变孩子的过程。因为一个双亲节点有两个孩子节点,我们要选取最大的孩子节点进行比较交换,这样才能保证每个双亲节点大于孩子节点。这个过程需要不断循环,直到中途直到适合的位置,或者将这个堆的所有较大的孩子节点比较完毕。这个调整过程是祖先变孩子是从上至下的,因此为了防止数组越界访问这样的话就必须要多一个参数n进行限定。比较的过程中child和parents是要不断更新的,这样才能将这个过程循环起来。
我们看到这个不管是向上调整还是向下调整while中的约束条件都是child,child的范围就是0到n-1,当只有一个节点时孩子节点和双亲节点都是它本身,这样的约束条件具有普适性。
3.堆的其他函数接口
堆比较重要的核心点就是向上向下调整算法,其他的接口实现都是很简单的,和之前的顺序表类似。这里简单介绍一下堆的其他接口函数
堆顶元素获取
HPDataType HeapTop(Heap* hp)//获取堆顶数据
{
assert(hp);
assert(hp->sz > 0);
return hp->data[0];
}
堆判空
int HeapEmpty(Heap* hp)//堆判空
{
return hp->sz == 0;
}
堆中元素个数
int HeapSize(Heap* hp)//获取堆元素个数
{
assert(hp);
return hp->sz;
}
堆数据打印
void HeapPrint(Heap* hp)//打印显示
{
assert(hp);
assert(!HeapEmpty);
for (int i = 0; i < hp->sz; i++)
{
printf("%d ", hp->data[i]);
}
printf("\n");
return;
}
堆的销毁
void HeapDestory(Heap* hp)// 堆的销毁
{
assert(hp);
free(hp->data);
hp->data = NULL;
hp->sz = 0;
hp->capacity = 0;
return;
}
这些接口都很简单没必要做过多的解释说明
3.堆的实际运用
堆一般可以用来选数,选比如从某堆数据中选取前5大的数,堆还可以用来解决Tok问题,所谓TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等.
关于堆的运用下面将会详细介绍,我们现在要做的就是实现了解建堆算法,只有先有将堆建立起来才能进行后续操作。所以首先介绍的就是建堆算法。
1.建堆算法
建堆算法有两种,分别是向上建堆和向下建堆。这两种算法本质都是之前提到的向上调整和向下调整。
向上建堆代码示例
void HeapCreate(Heap* hp, HPDataType* a, int n)//堆的创建
{
assert(hp);
//用HeapPush建堆 实际就是向上建堆
HeapInit(hp);
for (int i; i < n; i++)
{
HeapPush(hp, a[i]);
}
return ;
}
调用插入数据接口HeapPush本质就是通过AdjustUp对数组中数据进行调整使得数组成为一个堆,数组只有一个数据时就认为它是一个堆,往后插入的数据再挨个进行调整,当所有数据插入完后,这些数据就按照堆的特性在数组中排列好了。
这个向上调整也就是相当于堆已经存,在这个存在的堆中进行相应的调整。哪怕只有一个数据也默认它就是堆。
向下调整建堆
void HeapCreate(Heap* hp, HPDataType* a, int n)//堆的创建
{
assert(hp);
//向下建堆
hp->data = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (hp->data == NULL)
{
perror("malloc fail");
exit(-1);
}
memcpy(hp->data, a, sizeof(HPDataType) * n);
hp->sz = hp->capacity = n;
for (int i = (n - 1 - 1) / 2; i--; i >=0)
{
AdjustDown(hp->data, n, i);
}
return;
}
这个向下调整建堆,我们可以先回忆之前pop删除堆中的数据的时候,当时进行向下调整的时候是除了根节点,其余节点都是维持着堆的特性的,在之前的博客关于树的知识铺垫中提到了树的子树的概念的,所以对于堆来说,pop时左右子树还是堆,在这个前提条件下才进行的向下调整。现在向下调整建对实际上是将整个二叉树分成一个个独立的子树,将这些子树建成一个堆,在最后整体调整建成一个堆。
其实,向下调整是对堆每层节点进行调整,最后一层的叶子节点都可以单独看作只有一个节点的堆,但是只有一个节点的堆调整后也是原样,所以调不调都无所谓。这也就是代码种i是从(n-1-1)/2开始调整的原因。
这种建堆算法就是从局部最后到整体,向上调整是数据进来一次就进行整体调整,向下调整是将数据从底往上开始整体,相当于先解决第k-1层到k层,在解决第k-2层到第k层,最后是解决第1层到第k层。在实际种通常采用向下调整进行建堆,效率更好。
我们来分析一下向上调整建堆和向下调整建堆的时间复杂度
向上调整时间复杂度
向下调整时间复杂度
由此得到结论 向上建堆的时间复杂度是O(N*logN),向下建堆的时间复杂度是O(N).所以采用向下调整进行建堆。
2.堆的应用之堆排序
堆的应用第一个就是堆排序,利用堆对数据进行排序。思路大概是:以升序排布为例,我们利用建堆算法先将原数组建成一个大堆,大堆建好后,将堆顶元素和堆中末尾元素进行交换,然后从堆顶开始到堆中倒数第二个节点为止,在对原来的堆进行调整,在将堆顶元素和堆的倒数第二个元素交换。再次,重复上述操作,直到将每个节点都交换调整后,数组便是升序排列了。
画图分析
代码示例
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i--; i >= 0)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
end--;
}
return;
}
分析一下就是通过大堆可以找到某组数据中的最大值,因为根节点一定是堆中最大的元素,,我们将堆的根节点从前往后放,再对剩余元素进行堆调整,循环这个过程即可。我们分析一下时间复杂度,这个建堆的算法时间复杂是n,一次向下调的时间复杂度是logN,循环n次,就是n*logn,由此总的来说堆排序的时间复杂度就是N*logN
关于这个一次向上或者向下调整的时间复杂度没有仔细分析,但是可以看出,一次调整是和二叉树的高度相关的,挪动交换数据的次数,就是logN。这个可以对照代码画画图很容易看出。
3.堆解决Top k问题
在文章前面就提到过Top k问题,就是在海量的数据中找到前几大或小的数据,我们也是采用堆来结解决。如果是想要找到前k大的数据需要建立小堆,反之就需要建大堆。用找前k大的数据为例,我们先用数据源中的前k个数据建立个小堆,在遍历剩余的源数据如果源数据中某个数据比堆顶数据大,就替换这个堆顶数据,在进行向下调整,最后要找的前k大的数据都在这个堆中。
代码示例
#include<stdio.h>
#include<stdlib.h>
void Swap(int* e1, int* e2)
{
int a = *e1;
*e1 = *e2;
*e2 = a;
}
void AdjustDown(int* arr, int n, int parents)
{
int child = parents * 2 + 1;
while (child < n)
{
if (arr[child] > arr[child + 1])
{
child++;
}
if (arr[child] < arr[parents])
{
Swap(&arr[child], &arr[parents]);
parents = child;
child = parents * 2 + 1;
}
else
{
break;
}
}
}
void TestHeap()
{
// 造数据
int k;
printf("请输入k:");
scanf("%d ", &k);
srand(time(0));
int a1[20];
for (int i = 0; i < 20; i++)
{
scanf("%d", &a1[i]);
}
int* minHeap = (int*)malloc(sizeof(int) * k);
if (minHeap == NULL)
{
perror("malloc fail");
return;
}
for (int i = 0; i < k; ++i)
{
minHeap[i]=a1[i];
}
// 建小堆
for (int i = (k - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(minHeap, k, i);
}
int val;
for (int j = k; j < 20; j++)
{
val = a1[j];
if (val > minHeap[0])
{
minHeap[0] = val;
AdjustDown(minHeap, k, 0);
}
}
for (int i = 0; i < k; ++i)
{
printf("%d ", minHeap[i]);
}
printf("\n");
free(minHeap);
}
int main()
{
TestHeap();
}
代码示例中,我用了20个数据当作数据源,数组a1就是数据源,假定这个20个数据代表海量数据,在这个20个数据中筛选出前k大的数据. 建立小堆根节就是最小的,每次建堆都能保证堆顶数据是这堆数据中最小的,一旦出现了比堆顶元素还大的数据,就对堆顶元素替换再进行调整,通过这样的处理,就会很快筛选出Top k数据。我们知道一次建堆的时间复杂度是O(N),每次进行调整时间复杂度是O(logk),最差情况的调整(N-K)次,总的时间复杂度就是(N-K)logk,所以总的时间复杂度就是N*(logK)空间复杂度是k,这样的时间复杂度处理海量数据简直太友好了,效率很高。
总的来说,建堆处理Top k问题很有效率。大致就是先建立一个堆,再比较替换堆顶元素重新调整堆。这些操作步骤也比较简单,但是确实很有用。
4.总结
- 1.本文中的堆举例主要是以大堆为主,阅读文章时要稍微注意一下。不管是大堆还是小堆,掌握知识后都还是比较简单的。
- 2.堆的本质是完二叉树但是物理存储方式往往采用数组来实现,堆的比较重要的地方就是向上向下调整和建堆算法。虽然这两者的代码实现差不多,但是时间复杂度可是不相同。向上向下调整一次时间复杂度是logN,建堆算法常常采用向下建堆,时间复杂度是N。
- 3.以上内容,如有问题,欢迎指正!