前言
各位读者朋友们大家好!这篇博客我们来讲一下数据结构的堆和堆排序以及代码的实现。
目录
一. 堆的概念
堆是一种满足特定条件的完全二叉树,主要可分为两种类型:大顶堆和小顶堆。
- 大顶堆: 父亲节点的值 >= 子节点的值。
- 小顶堆: 父亲节点的值 <= 子节点的值。
堆还有以下性质:
- 堆中某个结点的值总是不大于或不小于其父结点的值
- 堆总是一棵完全二叉树
二. 堆的实现
这里我们建大堆,如果要建小堆只需要调整修改大小逻辑判断(例如将<改为>)。
2.1 堆的存储与表示
因为堆总是一棵完全二叉树,所以我们用数组来存储堆会更加方便。当我们使用数组来实现堆的时候,很容易通过下标来找到某个节点的父亲节点和子节点。
- 父亲节点:(i - 1) / 2
- 子节点:2i + 1(左孩子节点)2i + 2(右孩子节点)
2.2 元素入堆
给定元素 val ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏,因此需要修复从插入节点到根节点的路径上的各个节点。
在对堆进行修复的时候,我们需要用到向上调整算法,即将比父亲节点大的节点的元素换到父亲节点的位置,父亲节点的元素跟该子节点交换。
向上调整算法
void AdjustUp(HPDataType* a, int child)
{
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;
}
}
}
2.3 删除堆顶元素
堆顶元素是二叉树的根节点,即数组首元素。如果我们直接从数组中删除首元素,那么堆的所有节点的索引都会发生变化,这将使得后续进行修复变得困难。为了尽量减少元素索引的变动,我们将堆顶元素与最后一个元素进行交换,然后删除最后一个元素,再进行堆的调整。
这里需要使用向下调整算法,我们在删除堆顶数据的时候,并没有破坏左子树和右子树的堆的属性,因此我们在向下调整时,只需比较根节点的值与左右子节点的值,如果小于子节点的值只需与子节点中较大的一个进行交换,直到调整到叶子节点即可。
向下调整算法
//parent是父亲节点的下标,n是数组的元素个数
void AdjustDown(HPDataType* a, int parent, int n)
{
int child = 2 * parent + 1;
while (child < n)//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.1 向上调整建堆
将数组的第一个元素作为堆顶元素,随后将数组的后续元素依次进行向上调整建堆。
void HPCreat(HPDataType* a, int n)
{
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);
}
}
要将下面数组中的元素建一个大堆按照上面的算法,步骤如下:
3.1.1 向上调整建堆的时间复杂度
根据上面图示分析,当插入一个节点时,要向上调整层数次,一共有N个节点,因此向上调整建堆的时间复杂度是:O(Nlog N)
3.2 向下调整建堆
在向下调整算法中,左子树和右子树都是大(小)堆,我们调整的时候,只需直接向下调整即可,但是在建堆的过程中我们不能保证左右子树都是大(小)堆,在这种情况下就引出了向下调整建堆算法。我们要使每一个父亲节点的左右子树都是大(小)堆,所以我们在最后一个非叶子结点的位置向下调整,这样就可以保证在进行向下调整时,左右子树都是大(小)堆了。
void HPCreat(HPDataType* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--) //n - 1是最后一个子节点
{
AdjustDown(a, i, n);
}
}
3.2.1 向下调整建堆的时间复杂度
向下调整建堆的时间复杂度:O(n)
所以在实际应用中,我们使用向下调整建堆。
四. 堆排序
- 排升序,建大堆
- 排降序,建小堆
堆排序的思想是将最大(小)的元素调整到堆顶,然后将其换到数组末尾对数组的元素个数-1(忽略换过去的所有元素),再继续进行向下调整,将次大(小)的元素调整到堆顶。
void HeapSort(HPDataType* a, int n)
{
//对数组进行建堆
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, i, n);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, 0, end);//这里的end是新数组的元素个数(原数组元素个数-1),
//即忽略了换到后面的元素
end--;
}
}
4.1 堆排序的时间复杂度
时间复杂度:O(NlogN)
五. TOP-K问题
我们可以基于堆更加高效地解决 Top-k 问题:
- 1.初始化一个小堆,其堆顶元素最小。
- 2.先将数组的前 k 个元素依次入堆。
- 3.从第 k 个元素开始,若当前元素大于堆顶元素,则将堆顶元素出堆,并将当前元素入堆。
- 4.遍历完成后,堆中保存的就是最大的 k 个元素。
我们基于文件操作来实现TOP-K问题:
#include<stdio.h>
#include<time.h>
#include<stdlib.h>
void CreateNDate()
{
// 造数据
int n = 100000;
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() % 10000000;
fprintf(fin, "%d\n", x);
}
fclose(fin);
}
void AdjustDown(int* arr, int n, int src)
{
int child = 2 * src + 1;
while (child < n)
{
if (child + 1 < n && arr[child] > arr[child + 1])
{
child++;
}
if (arr[child] < arr[src])
{
int tmp = arr[child];
arr[child] = arr[src];
arr[src] = tmp;
src = child;
child = src * 2 + 1;
}
else
{
break;
}
}
}
void PrintTopK()
{
int k = 0;
scanf("%d", &k);
int* kminheap = (int*)malloc(sizeof(int) * k);
if (kminheap == NULL)
{
perror("malloc fail");
return;
}
const char* file = "data.txt";
FILE* fout = fopen(file, "r");
if (fout == NULL)
{
perror("fopen error");
return;
}
// 读取文件中前k个数
for (int i = 0; i < k; i++)
{
fscanf(fout, "%d", &kminheap[i]);
}
//建小堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(kminheap, k, i);
}
int x = 0;
while (fscanf(fout, "%d", &x) > 0)
{
if (x > kminheap[0])
{
kminheap[0] = x;
AdjustDown(kminheap, k, 0);
}
}
for (int i = 0; i < k; i++)
{
printf("%d ", kminheap[i]);
}
}
结语
以上我们就讲完了堆的基本内容,希望对大家有所帮助,欢迎大家批评指正!