今天我们来讲一种新的数据结构:堆。
完整代码 在 最后面,有C和C++两个版本。
堆的概念
如果有一个关键码的集合K = { , , ,…, },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: <= 且 <= ( >= 且 >= ) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值;
- 堆总是一棵完全二叉树
这里我们得注意一下:
我们在数据结构里讲的堆,和操作系统虚拟进程中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
堆的实现
堆的向下调整算法
在我们能够创建出一个堆之前,我们要先了解一下堆的向下调整算法。
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个堆。
但这个算法有一个前提条件:左右子树必须是一个堆,才能调整:
当我们想要一个小堆时:
同理当我们需要一个大堆的时候,我们需要根节点的左右子树为大堆。
那么向下调整算法是如何运作的呢:
如图,我们可以总结(创建小堆):
1.从根开始,不断往下调整
2. 选出左右孩子中小的,与父亲比较a.如果比父亲小,与父亲交换。以小孩的位置继续往下调整,最坏调到叶子终止。
b. 如果比父亲大,则停止。
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustDown(int* a, int n, int parent)
{
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[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
同理,创建大堆,我们将部分符号改变即可:
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustDown(int* a, int n, int parent)
{
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[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
堆的创建
了解了堆的向下调整算法,我们现在可以思考如何创建一个堆了。
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。
int a[] = {1,5,3,8,7,6};
根节点左右子树不是堆,我们怎么调整呢?这里我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的树,就可以调整成堆
for (int i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(php->a, n, i);
}
这就是我们的建堆的代码。
建堆的时间复杂度
堆的相关操作
当我们将堆建好了之后,我们来研究一下堆的一些操作。
堆的插入
假设我们已经有了一个数组,并且在逻辑上被置成了大堆:
65 49 34 37 19 27 28 25 18 15
这时候我们插入一个数据98,我们该如何操作?
我们先将该数据放在数组末端,在逻辑上就是二叉树多了一个叶子节点。
这个时候,插入的数据破坏了我们的大堆结构,很显然,这个时候,我们会想到之前的建堆算法:
for (int i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(php->a, n, i);
}
但是,每每插入一个数据就重新建堆的化,当然可以达到目的,但十分拙劣。所以在这里我们我们应该参考堆的向下调整算法,而想到堆的向上调整算法:
void AdjustUp(int* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[parent] < a[child])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
所以,堆的插入算法如下:
void HeapPush(HP* php, HPDataTpye x)
{
assert(php);
if (php->size == php->capacity)
{
HPDataTpye* tmp = (HPDataTpye*)realloc(php->a, php->capacity * 2 * sizeof(HPDataTpye));
if (php->a == NULL)
{
printf("realloc fail\n");
exit(-1);
}
php->capacity *= 2;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
}
这个时候,有些同学可能会产生疑惑:如果当我们插入之前,该二叉树已经是满二叉树了,我们还能插入进去吗?
当然可以。二叉树只是一个抽象的逻辑结构,当二叉树最后一层满了,再插入的化,就会在新的一行了。
堆的删除
删除堆 一般指 删除堆顶数据。
对于这个操作,我们很容易这样想:
先将首元素删除,再将剩下的数据重新建堆
这种方法没错,但依旧比较笨。
在这里我们仔细观察会发现,当我们删除了根节点之后,左右的子树的堆结构并没有被打破。所以我们可以采用下面这种方法:
- 将堆顶元素与堆中最后一个元素进行交换。
- 删除堆中最后一个元素
- 将堆顶元素向下调整到满足堆的特征为止
这个时候,我们发现,我们只需要调用一次堆的向下调整算法即可。
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);
--php->size;
AdjustDown(php->a, php->size, 0);
}
堆的代码实现
这里我贴出堆的代码:
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<string.h>
#include<stdbool.h>
typedef int HPDataTpye;
//大堆
typedef struct Heap
{
HPDataTpye* a;
int size;
int capacity;
}HP;
void Swap(int* a, int* b);
void AdjustDown(int* a, int n, int parent);
void AdjustUp(int* a, int n, int child);
void HeapPrint(HP* php);
void HeapInit(HP* php, HPDataTpye* a, int n);
void HeapDestroy(HP* php);
void HeapPush(HP* php, HPDataTpye x);
void HeapPop(HP* php);
int HeapTop(HP* php);
bool HeapEmpty(HP* php);
int HeapSize(HP* php);
#include"标头.h"
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustDown(int* a, int n, int parent)
{
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[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
void AdjustUp(int* a, int child)
{
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[parent] < a[child])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
void HeapPrint(HP* php)
{
for (int i = 0; i < php->size; i++)
{
printf("%d ", php->a[i]);
}
printf("\n");
}
void HeapInit(HP* php, HPDataTpye* a, int n)
{
assert(php);
php->a = (HPDataTpye*)malloc(sizeof(HPDataTpye) * n);
if (php->a==NULL)
{
printf("malloc fail\n");
exit(-1);
}
memcpy(php->a, a, sizeof(HPDataTpye) * n);
//建堆
for (int i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(php->a, n, i);
}
php->size = n;
php->capacity = n;
}
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
void HeapPush(HP* php, HPDataTpye x)
{
assert(php);
if (php->size == php->capacity)
{
HPDataTpye* tmp = (HPDataTpye*)realloc(php->a, php->capacity * 2 * sizeof(HPDataTpye));
if (php->a == NULL)
{
printf("realloc fail\n");
exit(-1);
}
php->capacity *= 2;
}
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);
/*for (int i = (php->size - 2) / 2; i >= 0; --i)
{
AdjustDown(php->a, php->size, i);
}*/
}
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);
--php->size;
AdjustDown(php->a, php->size, 0);
}
int HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];
}
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
void PrintTopK(int* a, int n, int k)
{
HP hp;
HeapInit(&hp, a, k); //建小堆
for (int i = k; i < n; ++i)
{
if (a[i] > HeapTop(&hp))
{
HeapPop(&hp);
HeapPush(&hp, a[i]);
}
}
HeapPrint(&hp);
HeapDestroy(&hp);
}
#include"标头.h"
void TestHeap()
{
int a[] = { 27,37,28,18,19,34,65,25,49,15 };
HP hp;
int len = sizeof(a) / sizeof(a[0]);
HeapInit(&hp, a, len);
HeapPrint(&hp);
HeapPush(&hp, 88);
HeapPrint(&hp);
HeapPush(&hp, 38);
HeapPrint(&hp);
/*while (!HeapEmpty(&hp))
{
printf("%d ", HeapTop(&hp));
HeapPop(&hp);
}*/
}
int main()
{
TestHeap();
return 0;
}
堆的应用
堆排序
堆排序 即 利用堆的思想来进行排序,总共分为两个步骤:
1.建堆
- 升序:建大堆
- 降序:建小堆
2.利用堆删除思想来进行排序
我们排升序的时候,为什么要建大堆?
如果我们建小堆的画:
- 我们先选出最小的数,最小的数就放在第一个位置。
- 紧接着我们要选择次小的数字…不断选择下去,如何选?
只能对剩下的n-1个数继续建堆
所以我们发现,这种方式是可以的,但是效率太低,**时间复杂度为O(n),**堆的价值没有体现,还不如直接遍历。
但是,我们建大堆的化:
- 建大堆。选出最大的数字,交换放到最后。
- 此时除了根节点,堆的结构没被破坏(最后一个数不看做堆里的),向下调整一次即可。这样我们就可以选出第二大。
- 重复上述操作。时间复杂度为o(N*logN)
void HeapSort(int* a, int n)
{
for (int i = (n - 2) / 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;
}
}
TOP_K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
这里我们可以想到几种解法:
-
推排序法 时间复杂度O(N*logN)
-
建一个N个数的堆(优先级队列),不断选数,选出前k个
while (!HeapEmpty(&hp)&&k-->0)
{
printf("%d ", HeapTop(&hp));
HeapPop(&hp);
}
这种方法的时间复杂度为O(N+k*log(N))
一般情况下k<<N,所以这个方法会优于堆排序法。
但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。
最佳的方式就是用堆来解决,基本思路如下:
- 用数据集合中前K个元素来建堆
前k个最大的元素,则建小堆
前k个最小的元素,则建大堆 - 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。
void PrintTopK(int* a, int n, int k)
{
// 1. 建堆--用a中前k个元素建堆
// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换
}
void TestTopk()
{
int n = 10000;
int* a = (int*)malloc(sizeof(int)*n);
srand(time(0));
for (size_t i = 0; i < n; ++i)
{
a[i] = rand() % 1000000;
}
a[5] = 1000000 + 1;
a[1231] = 1000000 + 2;
a[531] = 1000000 + 3;
a[5121] = 1000000 + 4;
a[115] = 1000000 + 5;
a[2335] = 1000000 + 6;
a[9999] = 1000000 + 7;
a[76] = 1000000 + 8;
a[423] = 1000000 + 9;
a[3144] = 1000000 + 10;
PrintTopK(int* a, n, 10);
}
c++版本(只包含关键函数):
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
void AdjustDown(vector<int>&v,int parent) {
//建一个大堆
int n = v.size();
int child = 2 * parent + 1;//找到左孩子
while (child < n) {
if (child + 1 < n && v[child] < v[child + 1]) {
child = child + 1;
}
if (v[child] > v[parent]) {
swap(v[child], v[parent]);
parent = child;
child = child * 2 + 1;
}
else {
break;
}
}
}
void AdjustUp(vector<int>&v,int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (v[child] > v[parent]) {
swap(v[child], v[parent]);
child = parent;
parent = (parent - 1) / 2;
}
else {
break;
}
}
}
//建堆算法
void HeapMake(vector<int>&v) {
for (int i = (v.size() - 2)/2; i >=0; i--) {
AdjustDown(v, i);
}
}
//插入元素
void HeapPush(vector<int>& v, int x) {
v.push_back(x);
AdjustUp(v, v.size() - 1);
}
//删除(堆顶)元素
void HeapPop(vector<int>& v) {
swap(v[0], v[v.size() - 1]);
v.pop_back();
AdjustDown(v, 0);
}
int main()
{
vector<int>v = { 2,4,5,6,8,7,1 };
HeapMake(v);
HeapPush(v, -1);
HeapPop(v);
}