目录
一、前绪
我们要去学习堆,首先要知道一些关于二叉树的知识,在这里我们简单的介绍(详细的介绍在<二叉树>这篇博客中)一下二叉树概念,完全二叉树和满二叉树,以及二叉树的存储结构,如果对这部分知识已经掌握了解可以直接跳过第一部分。
1.1 二叉树的概念
二叉树是一种树形结构,它的每个节点最多有两个子节点,分别称为左子节点和右子节点。二叉树的特点是每个节点最多有两个子节点,且左子节点和右子节点的顺序不能颠倒。
对于任意的二叉树都是由以下几种情况复合而成的:
二叉树图例:
1.2 完全二叉树和满二叉树
1.2.1 满二叉树
满二叉树是一种特殊的二叉树,它的所有非叶子节点都有两个子节点,且所有叶子节点都在同一层上。满二叉树的节点数可以表示为2^h-1,其中h为树的高度。满二叉树的特点是具有最大的节点数,且每个节点的子树高度相同。
满二叉树图例:
满二叉树结点的计算:
1.2.2 完全二叉树
完全二叉树是一种二叉树,除了最后一层外,每一层的节点数都达到最大值,最后一层的节点都集中在左侧。也就是说,如果最后一层的节点数为k,则前面所有层的节点数都是满的,且节点都依次排列在左侧。
完全二叉树图例:
完全二叉树的节点数的范围是:2^(h-1) 到 2^h-1 ,其中h为树的高度。完全二叉树常用于堆的实现。满二叉树是一种特殊的完全二叉树。
1.3 二叉树的存储结构
1.3.1 顺序存储
顺序结构存储就是使用数组来存储,一般来说,数组只适合表示完全二叉树,如果不是完全二叉树会造成空间的浪费,这里的难点是:二叉树的存储在物理上是一个数组,在逻辑上是一颗二叉树。
1.3.2 链式存储
用链表来表示一颗二叉树,即用链表来指示元素的逻辑关系,通常链表中每个节点由三个域组成,分别是数据域和左右指针域,左右指针分别存储他的左孩子和右孩子的地址。
1.4 相关总结
二叉树的顺序存储父子相关下标关系:
parent = (child - 1) / 2leftchild = parent * 2 + 1
rightchild = parent * 2 + 2
二、堆的概念及结构
2.1 堆的概念
堆是一种特殊的树形数据结构,它满足以下两个条件:
1. 堆是一棵完全二叉树。
2. 堆中每个节点的值都大于等于(或小于等于)其子节点的值,称为大根堆(或小根堆)。
2.2 大根堆
2.3 小根堆
三、堆的操作
我们需要对堆的操作主要是插入和删除操作,对于这两个操作我们需要注意的是,不管插入还是删除操作,我们都需要确保插入前和插入后的结构都是堆。在下面实现的是大根堆。
3.1 操作预览
void AdjustUp(int* a, int child);
void AdjustDown(int* a, int parent, int n);
//初始化
void HeapInit(HP* php);
//插入
void HeapPush(HP* php, HPDataType x);
//删除
void HeapPop(HP* php);
//返回堆顶元素
HPDataType HeapTop(HP* php);
//判空
bool HeapEmpty(HP* php);
//返回元素个数
int HeapSize(HP* php);
3.2 堆的结构
堆是完全二叉树,用数组来实现相对来说简单,我们要描述一个堆,需要创建一个数组,需要一个变量来记录数组中的元素个数,一个变量来记录数组的容量,所以在这里定义一个结构体来实现。
#define N 4
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
3.3 初始化
//初始化
void HeapInit(HP* php)
{
assert(php);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (php->a == NULL)
{
perror("malloc fail");
return;
}
php->size = 0;
php->capacity = 4;
}
3.4 交换两个数
由于在接下来的操作中,我们都需要用到交换两个数的值,所以我们单独写一个交换函数。
void swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
3.5 插入
对于插入操作最重要的是,插入前是堆,插入后依旧应该是堆。
对于插入有以下几种情况:
对于堆的插入,我们必须保证插入之后依然是堆,所以我们需要一个向上调整函数,如果插入的子节点的值大于他的父节点就需要交换。
//插入
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;
php->size++;
AdjustUp(php->a, php->size - 1); //向上调整
}
3.6 向上调整
我们使用向上调整函数来确保插入后的二叉树依然是堆,把插入的子节点和他的父节点开始比较,直到child<=0结束。
下面是一个向上调整的示例:
//向上调整函数
void AdjustUp(int* 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;
}
}
}
向上调整的前提是:除了最新插入的结点,前面的数据构成堆。
3.7 删除堆顶元素
对于删除操作,我们需要保证的是删除前后的二叉树都是堆,那怎么删除栈顶元素呢?
或许有人会想到挪动覆盖,但是这种方式效率低下,删除之后其余元素的父子关系全部混乱了,对此我们并不推荐。
正确的思路是:让堆顶元素和堆尾元素进行交换然后将堆尾删除,再将堆顶元素依次向下调整,最后调整到合适位置。
//删除
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
swap(&php->a[php->size - 1], &php->a[0]);
php->size--;
AdjustDowm(php->a, 0, php->size);
}
3.8 向下调整
向下调整的目的是要使删除堆顶元素之后的二叉树依然是堆,我们在这里实现的是大根堆,大根堆的父节点大于他的子节点,所以在向下调整的过程中是是父节点和两个子节点中最大的进行比较,在下面的代码中,我们是默认左孩子的值大于右孩子,如果右孩子大于左孩子,再将child结点加1。
向下调整的最坏情况是调整到叶子结点,即没有孩子节点,如果一个结点的孩子节点超过数组的范围,就说明这个结点是叶子结点。
向下调整的前提是:左右结点都是堆。
示例:
void AdjustDowm(int* a, int parent,int n)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1<n&&a[child] < a[child + 1])
{
child++;
}
if (a[parent] < a[child])
{
swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
3.9 判空
//判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
3.10 返回堆顶元素
//返回堆顶元素
HPDataType HeapTop(HP* php)
{
assert(php);
return php->a[0];
}
3.11 返回结点个数
//返回元素个数
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
四、堆的完整代码
4.1 Heap.h
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdbool.h>
#include<stdlib.h>
#define N 4
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
int size;
int capacity;
}HP;
//初始化
void HeapInit(HP* php);
//插入
void HeapPush(HP* php, HPDataType x);
//删除
void HeapPop(HP* php);
//返回堆顶元素
HPDataType HeapTop(HP* php);
//判空
bool HeapEmpty(HP* php);
//返回元素个数
int HeapSize(HP* php);
4.2 Heap.c
#include "Heap.h"
//初始化
void HeapInit(HP* php)
{
assert(php);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * 4);
if (php->a == NULL)
{
perror("malloc fail");
return;
}
php->size = 0;
php->capacity = 4;
}
void swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
//向上调整函数
void AdjustUp(int* 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;
}
}
}
//插入
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;
php->size++;
AdjustUp(php->a, php->size - 1);
}
void AdjustDowm(int* a, int parent,int n)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1<n&&a[child] < a[child + 1])
{
child++;
}
if (a[parent] < a[child])
{
swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//删除
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
swap(&php->a[php->size - 1], &php->a[0]);
php->size--;
AdjustDowm(php->a, 0, php->size);
}
//返回堆顶元素
HPDataType HeapTop(HP* php)
{
assert(php);
return php->a[0];
}
//判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
//返回元素个数
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
4.3 Test.c
#include "Heap.h"
int main()
{
HP hp;
HeapInit(&hp);
HeapPush(&hp, 4);
HeapPush(&hp, 5);
HeapPush(&hp, 10);
while (!HeapEmpty(&hp))
{
printf("%d ", HeapTop(&hp));
HeapPop(&hp);
}
return 0;
}
五、堆排序
我们要对一个数组进行排序,如果要使用堆排序,怎么做呢?
int main()
{
int a[] = { 2,1,5,7,6,8,0,9,4,3 };
HeapSort(a, sizeof(a) / sizeof(a[0]));
return 0;
}
难道要用刚才写好的堆来一个一个选出来最大的吗?答案显然不是。如果像上述一样,有许多缺点:
- 如果没有事先写好的堆,还要手搓。
- 用堆这个数据结构,是把数组中的元素依次放到堆中,最后还要拷贝过来,还要重新开空间,太繁琐。
我们这里把数组直接建成堆,在这里有两种建堆方式:向上调整建堆,向下调整建堆。
5.1 向上调整建堆
向上调整建堆主要是模拟插入的过程,把最开始的第一个数看做是堆中的元素,把数组中的其他数依次插入堆中,在这里有一个前提是有向上调整函数。
注意:我们之前写的向上调整是针对大根堆的,在这里也是建的大根堆。
5.1.1 代码实现
//向上调整函数
void AdjustUp(int* 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;
}
}
}
void HeapSort(int* a, int n)
{
//建堆-向上调整建堆
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
}
5.1.2 时间复杂度
5.2 向下调整建堆
我们之前已经写过了向下调整的函数,对于向下调整,我们是将父节点依次向下进行比较,向下调整的前提是左右子树都是堆,在这里我们要把一个数组建成堆,要从那个数开始建堆?
如果从根结点(数组的第一个数)开始建堆,不满足向下调整的前提,向下调整的前提是左右子树都是堆。
如果从叶子结点开始建堆,太过繁琐,没有必要。
我们在这里是从倒数第一个非叶子结点(最后一个结点的父亲)开始向下调整建堆。
5.2.1 代码实现
//向下调整
void AdjustDowm(int* a, int parent,int n)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1<n&&a[child] < a[child + 1])
{
child++;
}
if (a[parent] < a[child])
{
swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//建堆-向下调整建堆
for (int i = (n-1-1)/2; i >= 0; i--)
{
AdjustDowm(a, i, n);
}
5.2.2 时间复杂度
5.3 排序
排升序,建大堆;排降序,减小堆。
为什么排升序,建大堆?
代码实现:
void HeapSort(int* a, int n)
{
//建堆-向下调整建堆
for (int i = (n-1-1)/2; i >= 0; i--)
{
AdjustDowm(a, i, n);
}
int end = n - 1;
while (end > 0)
{
swap(&a[end], &a[0]);
AdjustDowm(a, 0, end);
end--;
}
}
六、Top-K问题
6.1 定义
Top-K问题:即求数组中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强,富豪榜。
Top-K问题,能够想到的最简单直接的方式就是排序,但是如果数据量非常大,排序就不可取了,最佳方式就是堆排序,思路如下:
A:用数据集合的前K个元素来建堆
- 前K个最大的数据,即建小堆
- 前K个最小的数据,即建大堆
B:用剩余的N-K个元素依次与堆顶元素来进行比较,不满足则替换堆顶元素。
将剩余的N-K个元素依次与堆顶元素比较完后。堆中剩余的k个元素就是所求的前k个最小或者最大的元素。
6.2 代码
这里需要用到向下调整函数,是建小堆,还需要将上面建大堆的向下调整函数改动一下。
void AdjustDown(int* a, int parent,int n)
{
assert(a);
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1<n&&a[child] > a[child + 1])
{
child++;
}
if (a[parent] > a[child])
{
swap(&a[parent], &a[child]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
//Top-K问题
void CreateNDate()
{
int n = 1000;
srand(time(0));
const char* file = "data.txt";
FILE* fin = fopen(file, "w");
if (fin == NULL)
{
perror("fopen error");
return;
}
for (size_t i = 0; i < n; i++)
{
int x = rand() % 10000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
PrintTopK(file, 10);
}
void PrintTopK(const char* file, int k)
{
//建堆-用a中前k个元素建小堆
int* topk = (int*)malloc(sizeof(int) * k);
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, i, k);
}
//2.将剩余n-k个元素依次与堆顶元素交换,如果比堆顶元素大就替换
int val = 0;
int ret = fscanf(fout, "%d", &val);
while (ret != EOF)
{
if (val > topk[0])
{
topk[0] = val;
AdjustDown(topk, 0, k);
}
ret = fscanf(fout, "%d", &val);
}
for (int i = 0; i < k; i++)
{
printf("%d ", topk[i]);
}
printf("\n");
free(topk);
fclose(fout);
}
int main()
{
CreateNDate();
//PrintTopK("data.txt", 10);
return 0;
}