文章目录
一、前言
二、二叉树的顺序存储结构
1、二叉树采取顺序存储的原因
- 普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
2、堆的概念及结构
- 堆可以这么理解:它是一个完全二叉树,并且完全二叉树都可以转变为堆,为什么说堆是特殊的二叉树呢?因为堆其实就是把这个完全二叉树变得有规律了。
三、堆的实现
1、介绍
- 需要注意的是,无论是向上调整还是向下调整,我们都需要保证的前提是除了要调整的这个元素外,其余节点本身就是一个堆(大根堆或小根堆),也就是说是在原本堆的基础上进行的向下和向上调整。
2、向上调整建堆
说明
实现
- 建大堆
//向上调整建大堆
#include <stdio.h>
void AdjustUp(int* a,int n)
{
int child = n;
int parent = (child - 1) / 2;
while (child > 0)
//判断条件不能为parent,因为parent为整型,但是最后那个结果是-0.5,对于整型来说,浮点数小数点后的忽略,那么此时parent还是0,child也变成0了,它们的值会相等然后return,虽然这样程序照样过了,但是这种想法其实是错的,所以我们通过判断child是否>0来作为是否结束的标志。
{
if (a[child] > a[parent])
{
int temp = a[child];
a[child] = a[parent];
a[parent] = temp;
}
else
return;//如果子节点小于父亲节点那么本次循环就不用比了,因为再向上比的那些之前已经调整过了。
child = parent;
parent = (parent - 1) / 2;
}
}
int main()
{
int a[7] = { 2,5,6,7,4,9,3 };
int size = sizeof(a) / sizeof(int);
int n = 1;
while (n < size)
{
AdjustUp(a, n);
n++;
}
for (int n = 0; n < size; n++)
{
printf("%d ", a[n]);
}
return 0;
}
3、向下调整建堆
说明
实现
- 大根堆
//向下调整建大堆
#include <stdio.h>
void AdjustDown(int* a, int n,int size)
{
int parent = n;
int child = n * 2 + 1;//先假设左子树是孩子中较大的,然后再进行判断,看是否替换为右子树(右子树需要存在)。
while (child < size)
{
if (child + 1 < size && a[child] < a[child + 1])//第一个判断的原因是首先右子树要存在才能比较。
{
child = child + 1;
}
if (a[parent] < a[child])
{
int temp = a[parent];
a[parent] = a[child];
a[child] = temp;
}
parent = child;
child = child * 2 + 1;
}
}
int main()
{
int a[7] = { 2,5,6,7,4,9,3};
int size = sizeof(a) / sizeof(int);
int n = (size - 2) / 2;
while (n >= 0)
{
AdjustDown(a, n,size);
n--;
}
n = 0;
while (n < size)
{
printf("%d ", a[n]);
n++;
}
return 0;
}
- 就像上面两幅图,虽然相同的数采用向上和向下调整建堆的方法建出来的堆不太一样,但是都是大根堆。
4、向上向下调整时间复杂度
- 向上调整建堆时间复杂度:O(N*logN) --向上调整建堆时层的结点个数少时向上的层数少(也就相当于每个遍历的次数少),层的结点个数多时向上的层数多(每个节点遍历的次数多),因此时间复杂度大。
- 向下调整建堆时间复杂度:O(N) --向下调整建堆时层的结点个数多时向下的层数少,层的结点个数少时向下的层数多,所以会比向上调整效率高。
- 向下调整时间复杂度证明
5、堆的插入
6、堆的删除
7、总代码
Heap.h
#pragma once
typedef int HeapDateType;
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef struct Heap
{
HeapDateType* a;
int size;
int capacity;
}Heap;
//堆的初始化
void HeapCreat(Heap* php);
//堆的销毁
void HeapDestory(Heap* php);
//堆的插入
void HeapPush(Heap* php, HeapDateType x);
//堆的删除
void HeapPop(Heap* php);
//取堆顶的数据
HeapDateType HeapTop(Heap* php);
//堆的数据个数
int HeapSize(Heap* php);
//堆的判空
int HeapEmpty(Heap* php);
Heap.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"
//堆的初始化
void HeapInit(Heap* php)
{
assert(php);
php->a = (HeapDateType*)malloc(sizeof(HeapDateType) * 4);
if (php->a == NULL)
{
perror("malloc error");
assert(php);
}
php->size = 0;
php->capacity = 4;
}
//堆的销毁
void HeapDestory(Heap* php)
{
assert(php);
free(php->a);
php->capacity = 0;
php->size = 0;
}
//数据交换
void Swap(HeapDateType* a, HeapDateType* b)
{
int temp = 0;
temp = *b;
*b = *a;
*a = temp;
}
void AdjustUp(HeapDateType* a, int n)
{
assert(a);
int child = n - 1;
int parent = (child - 1) / 2;
while (child > 0)
//判断条件不能为parent,因为parent为整型,但是最后那个结果是-0.5,对于整型来说,浮点数小数点后的忽略,那么此时parent还是0,child也变成0了,它们的值会相等然后return,虽然这样程序照样过了,但是这种想法其实是错的,所以我们通过判断child是否>0来作为是否结束的标志。
{
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);//后面还会用到交换,所以封装成一个函数。
}
else
return;
child = parent;
parent = (parent - 1) / 2;
}
}
//堆的插入
void HeapPush(Heap* php,HeapDateType x)
{
assert(php);
if (php->capacity == php->size)
{
HeapDateType* inspect = realloc(php->a, sizeof(HeapDateType) * php->capacity * 2);
if (inspect == NULL)
{
perror("realloc error");
assert(inspect);
}
php->a = inspect;
inspect = NULL;
php->capacity *= 2;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size);
}
//向上向下调整的前提是必须调整前是一个堆(大堆或者小堆)
void AdjustDown(HeapDateType* a, int n,int parent)
{
assert(a);
int child = parent*2+1;//默认向下调整是跟左孩子交换,如果下面比较左右孩子大小时右孩子大,那么让child+1即可表示与右孩子交换。
while (child <= n)
{
if (child < n&& (a[child] < a[child + 1]))//前提是要有右兄弟,不然就会越界
{
child = child + 1;
}
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
}
else
return;
parent = child;
child = child * 2 + 1;
}
}
//堆的删除(删除的是堆顶,也就是根)
void HeapPop(Heap* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[php->size - 1], & php->a[0]);//直接让最后一个覆盖根也行,因为根是要删掉的,就算这样交换了,下面size--后,根还是没有了。
php->size--;
AdjustDown(php->a, php->size,0);
}
//取堆顶的数据
HeapDateType HeapTop(Heap* php)
{
assert(php);
return php->a[0];
}
//堆的数据个数
int HeapSize(Heap* php)
{
assert(php);
return php->size;
}
//堆的判空
int HeapEmpty(Heap* php)
{
assert(php);
return php->size == 0;
}
Test.c
#define _CRT_SECURE_NO_WARNINGS 1
#include "Heap.h"
int main()
{
Heap hp;
HeapInit(&hp);
HeapPush(&hp, 0);
HeapPush(&hp, 47);
HeapPush(&hp, 26);
HeapPush(&hp, 48);
HeapPush(&hp, 52);
HeapPush(&hp, 79);
HeapPush(&hp, 6);
HeapPush(&hp, 83);
HeapPush(&hp, 38);
HeapPush(&hp, 24);
HeapPop(&hp);
HeapPop(&hp);
HeapPop(&hp);
HeapPop(&hp);
return 0;
}
四、堆的应用
堆排序
介绍
- 虽然我们知道堆分为大根堆和小根堆,子节点和父节点有明确的大小关系,但因为兄弟节点的大小关系不确定,随意实际在数组中并不是顺序存放的,我们利用堆里删除元素的思想对堆进行排序。
- 规律:升序建大堆,降序建小堆!
- 原因如下:
- 注意:堆排序首先是要建堆。
实现
//堆排序实现
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
//数据交换
void Swap(int* a, int* b)
{
int temp = 0;
temp = *b;
*b = *a;
*a = temp;
}
void AdjustUp(int* a, int n)
{
assert(a);
int child = n - 1;
int parent = (child - 1) / 2;
while (parent >= 0)
//判断条件不能为parent,因为parent为整型,但是最后那个结果是-0.5,对于整型来说,浮点数小数点后的忽略,那么此时parent还是0,child也变成0了,它们的值会相等然后return,虽然这样程序照样过了,但是这种想法其实是错的,所以我们通过判断child是否>0来作为是否结束的标志。
{
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);//后面还会用到交换,所以封装成一个函数。
}
else
return;
child = parent;
parent = (parent - 1) / 2;
}
}
//向上向下调整的前提是必须调整前是一个堆(大堆或者小堆)
void AdjustDown(int* a, int n, int parent)
{
assert(a);
int child = parent * 2 + 1;//默认向下调整是跟左孩子交换,如果下面比较左右孩子大小时右孩子大,那么让child+1即可表示与右孩子交换。
while (child < n)
{
if (child < n - 1 && (a[child] < a[child + 1]))//前提是要有右兄弟,不然就会越界
{
child = child + 1;
}
if (a[parent] < a[child])
{
Swap(&a[parent], &a[child]);
}
else
return;
parent = child;
child = child * 2 + 1;
}
}
void HeapSort(int* arr, int size)//假设要求升序排列
{
向上调整建大堆--时间复杂度为N*logN
//for (int n = 2; n < size; n++)
//{
// AdjustUp(arr, n);
//}
//向下调整建堆--时间复杂度为N
for (int n = (size - 2) / 2; n >= 0; n--)//size-1是指最后一个元素的下标,另一个-1是公式套的
{
AdjustDown(arr, size, n);
}
//排序--第一个和最后一个替换,然后让新根向下调整即可,这样就让该堆中最大的数放到了该放的位置。
while (size--)
{
Swap(&arr[0], &arr[size]);
AdjustDown(arr, size, 0);
}
}
int main()
{
int arr[] = { 45,26,91,34,82,41,67,81,47,35 };
HeapSort(arr, sizeof(arr) / sizeof(int));
for (int i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
TOP-K问题
- TOP-K问题:即当求数据结合中前K个最大或者最小元素,一般情况下数据量会很大。比如:世界500强,富豪榜等等。你可能会想,直接排序不就行了,但是如果当数据量特别大时,整个内存都放不下,这时候最佳方法就是使用堆排序。
- 解释:因为TOP-K一般是求前k个最值数,所以我们需要将其找出来,但是当数据特别多时,内存放不下,所以就不能使用堆排序(内存放不下),因此我们用另外的方法:假设求最大的前K个,我们先建一个k个数的小堆,然后我们将剩下的数放进文件(磁盘)里,然后分别与小堆的根(最小的数比较),如果大于这个根,那么直接覆盖上去,并让新根进行向下调整,使得覆盖后仍然是一个小堆。
- 注意:不能与我们建的堆的最后一个元素比,因为它不一定是最大或最小,只有根我们才能确定它一定是一个最值。
五、总结
- 升序建大堆,降序建小堆原因:假如升序建小堆,那么第一个当然就是我们要的最小的数,但是之后就不行了,因为虽然这个位置是最小的不用动了,但是当我们找第二小的时,我们就要忽略掉第一个已经定了的这个,那么这次谁当根呢,直接父子兄弟什么的全乱了,我们只能再次通过(N-1)个数进行一次堆排序建立一个新堆然后新堆的根就是第二小的,但是这样就太麻烦了,因为建一次堆的时间复杂度就是O(N*logN)(向上调整建堆)或者O(N)(向下调整建堆)而如果建成相反的(大堆),然后根和最后一个元素换一下再向下调整,这样就能确定最大的数,一次时间复杂度是logN。
- 关于堆(顺序存储)的所有操作注意:就是尽量不破坏原本堆的结构,不让父子乱辈,不然就可能乱了很多,处理起来就很麻烦,只能重新建堆。
- TOP-K问题总结:求最大的建小堆,求最小的建大堆。
- 博主长期更新,博主的目标是不断提升阅读体验和内容质量,如果你喜欢博主的文章,请点个赞或者关注博主支持一波,我会更加努力的为你呈现精彩的内容。