目录
- 前言
- 1.二叉树的顺序结构及实现
- 1.1二叉树的顺序结构
- 1.2堆的概念及结构
- 2.堆的功能函数的实现
- 2.1堆结构体的定义
- 2.2堆的初始化
- 2.3堆的插入
- 2.4 获取堆是否为空、堆大小、堆顶元素的函数
- 2.5堆的销毁
- 2.6对利用堆结构数组的数据建堆
- 2.7堆的删除
- 堆结构的源码
- 3.堆排序
- 建堆的时间复杂度:
- 4.Topk问题
- 5.堆的思维导图
- 总结
前言
在没有学习堆排序之前,我们可能会存在一些问题
1.什么是堆的向上调整?
2.什么是堆的向下调整?
3.堆的向下调整,为啥从最后一个非叶子节点开始调整?而不是从堆顶开始把数据往下调整?
4.向上调整和向下调整都可以建堆吗?它们的时间复杂度分别是多少呢?
5.为啥使用堆排序–排升序建大堆、排降序建小堆呢?
6.topk问题,为啥找前k个最大的数据建小堆,找前k个最小的数据建大堆呢?
7.向上调整、向下调整的应用,能不能相互替代呢?
1.二叉树的顺序结构及实现
1.1二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
逻辑结构是我们想象出来的;而物理结构是实实在在存在的结构。
由上图我们可知二叉树的值在数组位置中的父子下标关系
parent = (child - 1) / 2;
leftchild = parent * 2 + 1;
rightchild = parent * 2 + 2;
1.2堆的概念及结构
如果有一个关键码的集合K = { k0,k1 ,k2 ,…,kn-1 }
,把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: Ki
<= K(2i+2)
且Ki
<=K(2i+2)
( Ki>= K(2i+1)
且Ki >=K(2i+2)
) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一颗完全二叉树。
由上图可知
- 小根堆中所有的父亲节点的值都小于或等于孩子节点的值。
- 大根堆中所有的父亲节点的值都大于或等于孩子节点的值。
2.堆的功能函数的实现
2.1堆结构体的定义
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;//记录存储数据个数
int capacity;//堆的容量
}HP;
2.2堆的初始化
void HeapInit(HP* php)
{
assert(php);
HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * 3);
if (tmp == NULL)
{
perror("malloc::fail");
return;
}
php->a = tmp;
php->size = 0;
php->capacity = 3;
}
首先我们需要为结构体成员变量
a
在堆上开辟sizeof(HPDataType) * 3
的空间,保证其他函数也能使用,并将成员变量size
初始化为0,成员变量capacity
初始化为3。
2.3堆的插入
接下来我们以大堆为例,向大堆中插入数字,然后经过调整后,再使其成为大堆。
我们在堆底插入了一个数据成为二叉树的孩子节点,通过与其父亲节点的值比较,若孩子节点的值大于父亲节点,我们该节点向上调整(即交换该孩子节点和父亲节点的值)直到该节点的值小于等于其父亲节点的值为止。这时候我们需要写一个向上调整的函数,来进行堆的插入。
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//堆的向上调整
void AdjustUp(HPDataType* a, int child)
{
assert(a);
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;
}
}
}
在堆的向上调整函数中,将孩子的节点的值与其父亲节点的值进行比较,若孩子节点值大于父亲节点的值,则通过
child = parent;parent = (child - 1) / 2;
的操作语句不断向上调整,直到将数组末尾的孩子节点调整到堆顶或者该孩子节点的值小于父亲节点的值为止。
堆的插入函数
//堆的插入
void HeapPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
HPDataType* tmp = (HPDataType*)realloc(php->a,
sizeof(HPDataType) * php->capacity * 2);
if (tmp == NULL)
{
perror("realloc::fail");
return;
}
php->a = tmp;
php->capacity *= 2;
}
php->a[php->size++] = x;
AdjustUp(php->a, php->size - 1);
}
在堆的向上调整函数中,①用
assert
对php->a
地址进行断言,若该地址为空则报错;②php->size == php->capacity
判断开辟的堆空间是否已经满了,若堆空间满了,则需要使用realloc
函数将堆的空间扩大为原来的两倍,从而达到动态开辟空间的效果③php->a[php->size++] = x;
插入新的节点,然后使用堆的向上调整函数,使该结构重新成为堆。
进行调试的代码
void Test1()
{
HP hp;
HeapInit(&hp);
HeapPush(&hp, 18);
HeapPush(&hp, 99);
HeapPush(&hp, 29);
HeapPush(&hp, 45);
HeapPush(&hp, 108);
HeapPush(&hp, 66);
HeapPush(&hp, 95);
}
代码调试的结果如下:
2.4 获取堆是否为空、堆大小、堆顶元素的函数
//堆的判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
//堆的大小
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
//堆顶元素的获取
HPDataType HeapTop(HP* php)
{
assert(php);
return php->a[0];
}
2.5堆的销毁
//堆的销毁
void HeapDestroy(HP* php)
{
assert(php);
php->capacity = php->size = 0;
free(php->a);
php->a = NULL;
}
2.6对利用堆结构数组的数据建堆
// 堆的构建
void HeapCreate(HP* php, HPDataType* a, int n)
{
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->a == NULL)
{
perror("malloc::fail");
return;
}
php->capacity = n;
php->size = n;
for (int i = (n - 1 - 1) / 2; i > 0; i--)
{
AdjustDown(php->a, php->size, i);
}
}
2.7堆的删除
删除堆是删除堆顶的数据,将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,再进行向下调整算法。 ⭐⭐⭐
堆的向下调整函数
//堆的向下调整
//堆的左右子树都是大堆或者都是小堆
void AdjustDown(HPDataType* a,int n, int parent)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
//选出左右孩子较大的那一个
if (child + 1 < n && a[child] < a[child + 1])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
在堆的向下调整函数中: ①选出要向下调整节点的左右孩子节点,较大的孩子节点;②将向下调整的双亲节点的值与其较大孩子节点的值进行比较,如果双亲结点的值较小,则进行交换,并进行
parent = child;child = parent * 2 + 1;
的操作进行向下调整,直到该节点的值大于或者等于其孩子结点的值或者该结点调整到堆底为止(即成为叶子节点)。(注意: 我们在实现堆的向下调整函数的时候,形参n为堆的数据个数,parent为向下调整的位置)
堆的删除函数:
//堆的删除
void HeapPop(HP* php)
{
assert(php);
//删除数据
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
//堆顶数据向下调整
AdjustDown(php->a, php->size, 0);
}
- 将堆顶元素与最后一个元素进行交换
- 删除最后一个元素
- 将堆顶元素向下调整至满足堆的特性为止
调试运行的代码如下:
HP hp;
HeapInit(&hp);
HeapPush(&hp, 18);
HeapPush(&hp, 99);
HeapPush(&hp, 29);
HeapPush(&hp, 45);
HeapPush(&hp, 108);
HeapPush(&hp, 66);
HeapPush(&hp, 95);
while (!HeapEmpty(&hp))
{
printf("%d ", HeapTop(&hp));
HeapPop(&hp);
}
代码运行的结果为:
通过观察代码运行的结果,我们可以发现打印出来的数据已经有序,这为我们使数据有序提供了一种思路,但是这种使数据有序的方法是有前提的:①必须手搓一个堆的数据结构才能实现②通过堆的删除然后向下调整,每删除一次就要向下调整一次,时间效率会大幅度降低,基于这两个前提实现堆数据的排序不现实。但是我们可以利用堆删除的思想实现堆排序,接下来让我们一起实现一下吧!
堆结构的源码
Heap.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<stdbool.h>
#include<assert.h>
#include<time.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;//记录存储数据个数
int capacity;//堆的容量
}HP;
//堆的初始化
void HeapInit(HP* php);
//堆的向上调整
void AdjustUp(HPDataType* php, int child);
//堆的插入
void HeapPush(HP* php, HPDataType x);
//堆的向下调整
void AdjustDown(HPDataType* a, int n, int parent);
//堆的删除
void HeapPop(HP* php);
//堆的判空
bool HeapEmpty(HP* php);
//堆的大小
int HeapSize(HP* php);
//堆顶元素的获取
HPDataType HeapTop(HP* php);
//堆排序
void HeapSort(int* a, int n);
// 堆的构建
void HeapCreate(HP* php, HPDataType* a, int n);
//堆的销毁
void HeapDestroy(HP* php);
Heap.c
#define _CRT_SECURE_NO_WARNINGS 1
#include"Heap.h"
//堆的初始化
void HeapInit(HP* php)
{
assert(php);
HPDataType* tmp = (HPDataType*)malloc(sizeof(HPDataType) * 3);
if (tmp == NULL)
{
perror("malloc::fail");
return;
}
php->a = tmp;
php->size = 0;
php->capacity = 3;
}
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//堆的向上调整
//除了child位置,前面的数据构成堆
void AdjustUp(HPDataType* a, int child)
{
assert(a);
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;
}
}
}
//堆的判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
//堆的大小
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
//堆顶元素的获取
HPDataType HeapTop(HP* php)
{
assert(php);
return php->a[0];
}
//堆的插入
void HeapPush(HP* php, HPDataType x)
{
assert(php);
if (php->size == php->capacity)
{
HPDataType* tmp = (HPDataType*)realloc(php->a,
sizeof(HPDataType) * php->capacity * 2);
if (tmp == NULL)
{
perror("realloc::fail");
return;
}
php->a = tmp;
php->capacity *= 2;
}
php->a[php->size++] = x;
AdjustUp(php->a, php->size - 1);
}
//堆的向下调整
//堆的左右子树都是大堆或者都是小堆
void AdjustDown(HPDataType* a,int n, int parent)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
//选出左右孩子较大的那一个
if (child + 1 < n && a[child] < a[child + 1])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//堆的删除
void HeapPop(HP* php)
{
assert(php);
//删除数据
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
//堆顶数据向下调整
AdjustDown(php->a, php->size, 0);
}
// 堆的构建
void HeapCreate(HP* php, HPDataType* a, int n)
{
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (php->a == NULL)
{
perror("malloc::fail");
return;
}
php->capacity = n;
php->size = n;
for (int i = (n - 1 - 1) / 2; i > 0; i--)
{
AdjustDown(php->a, php->size, i);
}
}
//堆的销毁
void HeapDestroy(HP* php)
{
assert(php);
php->capacity = php->size = 0;
free(php->a);
php->a = NULL;
}
3.堆排序
堆排序即用堆的思想来进行排序,总共分为两个步骤:
1.建堆:升序:建大堆 ; 降序:建小堆
2.利用堆删除的思想进行排序
需要调用的函数:
//堆的向上调整
//除了child位置,前面的数据构成堆
void AdjustUp(HPDataType* a, int child)
{
assert(a);
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;
}
}
}
//堆的向下调整
//堆的左右子树都是大堆或者都是小堆
void AdjustDown(HPDataType* a,int n, int parent)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
//选出左右孩子较大的那一个
if (child + 1 < n && a[child] < a[child + 1])
{
child++;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
堆排序代码如下:
//堆排序
//排升序--建大堆
void HeapSort(int* a, int n)
{
向上调整建堆
//for (int i = 1; i < n; i++)
//{
// AdjustUp(a, i);
//}
//向下调整建堆
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--;
}
}
对一堆无序的数据进行排序步骤:①进行建堆,可以向上调整建堆,默认堆里面已经有一个数据,用
for
循环向上插入进行调整建堆,直到插入并调整完所有数据为止;还可以向下调整建堆,从最后一个非叶子节点开始向下调整建堆,直到调整到堆顶②定义一个变量int end = n - 1;
指向堆最后一个数据(即堆底的数据),交换堆顶和堆底的数据,AdjustDown(a, end, 0);
,把交换后的堆顶的数据向下调整end--;
删除交换后的堆底数据。(end
为堆中的数据个数,同时也是堆底数据的下标)
调试的代码如下:
int main()
{
int a[10] = { 2, 1, 5, 7, 6, 8, 0, 9, 4, 3 };
HeapSort(a, 10);
return 0;
}
代码调试的结果为:
建堆的时间复杂度:
向上调整的时间复杂度:
图形理解:
可以观察到向上调整建堆从堆顶往下的节点数越多,向上调整次数越多,由向上调整建堆的计算过程可以得到时间复杂度为:
O(N*logN)
向下调整的时间复杂度:
图形理解:
可以观察到向下调整建从最后一个非叶子节点开始向上的节点数越少,向下调整次数越多,由向下调整建堆的计算过程可以得到时间复杂度为:
O(N)
。
向下调整排序的时间复杂度:
距离堆顶的节点数越多,向下调整的次数也越多,其过程与向上建堆的计算过程一样,所以时间复杂度为:
O(N*logN)
总结:堆排序的时间复杂度=建堆时间复杂度+向下调整排序的时间复杂度,无论是向上调整建堆O(N*logN)
,还是向下调整建堆O(N)
,但因为向下调整排序的时间复杂度O(N*logN)
,根据大O渐进法的规则,所以堆排序的时间复杂度为:O(N*logN)
4.Topk问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
1.用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆
2.用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
找前k个最大的元素的topk代码如下:
//堆的向下调整
//堆的左右子树都是大堆或者都是小堆
void AdjustDown(HPDataType* a, int n, int parent)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
//选出左右孩子较小的那一个
if (child + 1 < n && a[child] > a[child + 1])
{
child++;
}
//建小堆
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void CreateNData()
{
int n = 100000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (file == NULL)
{
perror("fopen::fail\n");
return;
}
for (int i = 0; i < n; i++)
{
int x = rand() % 10000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
void PrintTopK(const char* file, int k)
{
// 1. 建堆--用a中前k个元素建堆
int* topk = (int*)malloc(sizeof(int) * k);
if (topk == NULL)
{
perror("PrintTopK::malloc");
return;
}
FILE* fin = fopen(file, "r");
for (int i = 0; i < k; i++)
{
fscanf(fin, "%d", &topk[i]);
}
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(topk, k, i);
}
// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换
int val = 0;
int ret = fscanf(fin, "%d", &val);
while (ret!=EOF)
{
if (val > topk[0])
{
topk[0] = val;
AdjustDown(topk, k, 0);
}
ret = fscanf(fin, "%d", &val);
}
for (int i = 0; i < k; i++)
{
printf("%d ", topk[i]);
}
printf("\n");
fclose(fin);
free(topk);
}
①数据比较多的时候,我们可以把数据输出到
“data.txt”
文件中;②使用malloc开辟k个空间,然后使用向下调整函数建小堆(注意符号的改变)③把数据一个个读取到val变量中,然后与堆顶的数据比较,如果在读取的数据比堆顶的数据大,则val替换topk[0],向下调整成为小堆,重复该过程一直读取完所有数据为止。
为了方便验证是否为最大的前k个数据:①可以先使用造数据的函数
void Test()
{
CreateNData();
//PrintTopK("data.txt",5);
}
②修改数据:
③注释掉造数据的函数,进行打印出前k个最大的数据,与“data.txt”
文件的数据比较进行验证。
void Test()
{
//CreateNData();
PrintTopK("data.txt",5);
}
代码运行的结果为:
5.堆的思维导图
总结
在没有学习堆排序之前,我们可能会存在一些问题
1.什么是堆的向上调整?
答:假设已有一堆无序的数据,以建大堆为例,从第二个节点开始,模拟从堆底插入一个数据,如果它的值比父亲节点的大则向上调整,直到它交换的合适的位置(即重新调整为大堆),一直到最后一个节点的数据向上调整为止。
2.什么是堆的向下调整?
答:假设已有一堆无序的数,以建大堆为例,从最后一个非叶子节点开始(如果该节点的值小于左右孩子的较大的那一个,把该数据向下调整),一直到第一个节点的值向下调整为止。
3.堆的向下调整,为啥从最后一个非叶子节点开始调整,而不是从叶子节点开始呢?而不是从堆顶开始把数据往下调整?
答:堆的向下调整,通俗一点讲,就是把某节点的数据和它的子节点的数据进行交换,而叶子节点没有可以和它比较的(和它交换的)子节点,所以从最后一个非叶子节点开始向下调整才是有意义的;以建大堆为例,如果从堆顶开始将数据向下调整,不能保证与它的子节点的数据是最大的数据,所以需要从最后一个非叶子节点开始往上向下调整。
4.向上调整和向下调整都可以建堆吗?它们的时间复杂度分别是多少呢?
答:向上调整可以建堆,方向:从上往下,从第二个节点开始,堆越往下数据越多,要调整的次数越多,根据等比数列的求和公式、错位相减法,求出时间复杂度为O(N*log2N)
;向下调整可以建堆,方向:从下往上,从最后一个非叶子节点开始,堆越往上数据越少调整次数越多,要调整的次数越多,根据等比数列的求和公式、错位相减法,求出的时间复杂度为O(N)
。
5.为啥使用堆排序–排升序要建大堆、排降序要建小堆呢?
答:堆排序和堆删除的思想一样,把大堆(小堆)堆顶的数据与堆底的数据交换,然后把交换到堆底的最大数(最小的数)从堆中删去(因为已经有序,还在数组中,只不过不算做堆中的数据而已),交换到堆顶的数据向下调整,又可以选出新的堆中最大的数,继续交换、向下调整,直到堆中只有一个数据为止(注意: 前提是刚开始的时候,左右子树都为大堆(小堆));排升序建小堆的缺点:①需要额外开辟空间存放数据②取走堆顶的数据,剩下的数据关系全乱,需要重新建堆③需要挪动覆盖数据。
6.topk问题,为啥找前k个最大的数据建小堆,找前k个最小的数据建大堆呢?
答:前k个最大的数据,通过比较,大的数据替换掉小堆堆顶的数据才可以进堆,若是建大堆一开始在堆顶上的数据就是最大的,则无法通过比较大小让前k个最大的数据进入堆中。
7.向上调整、向下调整的应用,能不能相互替代呢?
答:向上调整可以建堆、实现堆的插入,向下调整可以建堆、实现堆的删除、堆的排序,两者不能相互替代。只有向上调整很难实现堆的删除、堆排序,只有向下调整很难保证在插入数据之后,堆还是大堆(小堆),两者有相同作用之处,也有自己的特点。
感谢大家的阅读,希望对大家认识堆的数据结构有些许帮助!若有不对,欢迎纠正!🎠🎠🎠