
本章我们主要分析一下堆
1. 二叉树结构
1.1 顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,缓存命中高。
对于有些存在空的位置的二叉树,由于为了表示某些位置是空的,所以必须要留空,那么可能会有空间浪费,这种情况就不是很适合使用数组,形式,所以数组形式一一般还是适用于完全二叉树或者满二叉树
1.2 链式结构
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,一般都是二叉链,如红黑树,AVL树等会用到三叉链。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
}
// 三叉链
struct BinaryTreeNode
{
struct BinTreeNode* _pParent; // 指向当前节点的双亲
struct BinTreeNode* _pLeft; // 指向当前节点左孩子
struct BinTreeNode* _pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
2. 堆
首先注意这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
如果有一个关键码的集合
K
=
k
0
,
k
1
,
k
2
,
.
.
.
,
k
n
−
1
K = {k_0,k_1,k_2,...,k_n-1}
K=k0,k1,k2,...,kn−1
,把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:
K
i
<
=
K
2
∗
i
+
1
且
K
i
<
=
K
2
∗
i
+
2
(
K
i
>
=
K
2
∗
i
+
1
且
K
i
>
=
K
2
∗
i
+
2
)
i
=
0
,
1
,
2
…
K_i<= K_{2*i+1}且K_i <=K_{2*i+2} ( K_i>=K_{2*i+1} 且K_i >=K_{2*i+2} ) i = 0,1,2…
Ki<=K2∗i+1且Ki<=K2∗i+2(Ki>=K2∗i+1且Ki>=K2∗i+2)i=0,1,2…
,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的逻辑结构
From What is Heap Data Structure | Types, Applications, Implementation & Standard Heap Operations
堆的物理结构
这个倒是自己画的
这两个堆有什么用?
解决堆排序,topK问题,和实现优先级队列,结合c++模板
2.1 堆的性质
🍁 堆中某个孩子节点的值总是不大于或不小于其父节点的值(我们不比较左右孩子,只比较父亲孩子)
🍁 堆总是一棵完全二叉树。
2.2 堆的实现
2.2.1 堆的结构
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;
size_t size;
size_t capacity;
}HP;
2.2.2 HeapInit
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
2.2.3 HeapDestroy
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
2.2.4 HeapPrint
void HeapPrint(HP* php)
{
assert(php);
for (size_t i = 0; i < php->size; ++i)
{
printf("%d ", php->a[i]);
}
printf("\n");
}
2.2.5 在HeapPush之前
接下来我们来考虑一下堆的插入要怎么实现?
首先考虑扩容,其次考虑插入的值在插入之后,实际上会影响到它的祖先,那么也就是这个堆的结构被破坏了,而且破坏的结构值出现在最后一个叶结点的祖先节点,于是我们要实现一个向上调整,又由于向上调整需要交换函数,Swap函数,C语言是不提供的,所以我们要自己写
⚠️ 一定要知道,我们想的是二叉树,实际操作的是数组
2.2.6 Swap
AdjustUp和AdjustDown所必须的Swap函数
void Swap(HPDataType* pa, HPDataType* pb)
{
HPDataType tmp = *pa;
*pa = *pb;
*pb = tmp;
}
2.2.7 AdjustUp
向上调整算法要通过孩子找到父亲,这里的向上调整算法其实本质上是可以有两种写法的,看根据要完成的是什么堆来决定,改一个大于小于号就可以了以下我们完成的是小堆
void AdjustUp(HPDataType* a, size_t child)
{
size_t 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;
}
}
}
10是我要插入的节点,那么就去找祖先一步步换,如果小就换,反之就直接结束
2.2.8 HeapPush
先插入一个数据到数组的尾上,再进行向上调整算法,直到满足堆。
void HeapPush(HP* php, HPDataType x)
{
assert(php);
//判断扩容
if (php->size == php->capacity)
{
size_t newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
//为什么不直接realloc在原来的a上,因为防止realloc fail 销毁之前的数据
HPDataType* tmp = realloc(php->a, sizeof(HPDataType) * newCapacity);
if (tmp == NULL)
{
printf("realloc failed\n");
exit(-1);
}
php->a = tmp;
php->capacity = newCapacity;
}
php->a[php->size] = x;
++php->size;
// 向上调整,控制保持是一个小堆
AdjustUp(php->a, php->size - 1);
}
2.2.9 在HeapPop之前
堆的删除是简简单单的删除吗,并不是,我们要从堆中删除一个数据,HeapPop一般都是对数组进行头删操作,即对于小堆(大堆)就是删除最小(最大)的数据。那么直接来会导致
-
堆结构的破坏
-
挪动数据时间复杂度是O(N)
为了保证堆结构,我们想到的是交换头尾数据值,然后删除尾+向上调整,使成堆,这样复杂度最高也是高度即
l
o
g
2
N
log_2N
log2N
2.2.10 AdjustDown
略微比向上调整麻烦,因为父亲只有一个,然而要找孩子却需要找左孩子和右孩子中更小(大)的哪一个,来构建小堆(大堆)
void AdjustDown(HPDataType* a, size_t size, size_t root)
{
size_t parent = root;
size_t child = parent * 2 + 1;
while (child < size)
{
// 1、选出左右孩子中小的那个
if (child + 1 < size && a[child + 1] > a[child])
{
++child;
}
// 2、如果孩子小于父亲,则交换,并继续往下调整
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
2.2.11 HeapPop
删除堆是删除堆顶的数据,将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,再进行向下调
整算法。
// 删除堆顶的数据。(最小/最大)
void HeapPop(HP* php)
{
assert(php);
assert(php->size > 0);
//交换头尾数据,尾删之后再调整
Swap(&php->a[0], &php->a[php->size - 1]);
//抹掉数据
--php->size;
AdjustDown(php->a, php->size, 0);
}
2.2.12 HeapEmpty
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
2.2.13 HeapSize
size_t HeapSize(HP* php)
{
assert(php);
return php->size;
}
2.2.14 HeapTop
HPDataType HeapTop(HP* php)
{
assert(php);
assert(php->size > 0);
return php->a[0];
}
2.2.15 HeapInitArray
通过已知数组建堆
void HeapInitArray(HP* php, HPDataType* a, size_t n)
{
int i;
assert(php && a);
php->a = (HPDataType*)malloc(sizeof(HPDataType) * n);
php->size = n;
php->capacity = n;
for (i = 0; i < n; ++i)
{
php->a[i] = a[i];
}
for (i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(php->a, php->size, i);
}
}
3. 建堆算法
下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算
法,把它构建成一个堆。根节点左右子树不是堆,我们怎么调整呢?
int a[] = { 4, 2, 7, 8, 5, 1, 0, 6 };
利用向上调整的堆排序,时间复杂度也是O(n*logn),不过空间复杂度优化了,即把每次数据的插入改成向上调整
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);
}
3.1 分析建堆方法及其时间复杂度
向上调整建堆和向下调整建堆,建出来的堆结构可能是不一样的,这两者时间复杂度也不一样
很多人认为都是
O
(
N
∗
l
o
g
2
N
)
O(N*log_2N)
O(N∗log2N)
实际上并不是,他们本质上是一个等比数列,错位相减法可以计算出时间复杂度
3.1.1 向上调整算法
( N + 1 ) ( l o g 2 ( N + 1 ) − 2 ) + 2 (N+1)(log_2(N+1)-2)+2 (N+1)(log2(N+1)−2)+2
约等于
O
(
N
∗
l
o
g
2
N
)
O(N*log_2N)
O(N∗log2N)
3.1.2 向下调整算法
因此:建堆的时间复杂度可以为O(N),所以建堆一般采用向下调整算法
4. 堆排序
堆排序借助之前的调整算法就可以了,借助之前的堆的代码
void HeapSort(int* a, int size)
{
HP hp;
HeapInit(&hp);
for (int i = 0; i < size; ++i)
{
HeapPush(&hp, a[i]);
}
size_t j = 0;
while (!HeapEmpty(&hp))
{
a[j] = HeapTop(&hp);
j++;
HeapPop(&hp);
}
HeapDestroy(&hp);
}
当前堆排序复杂度就可以到O(n*logn),然而这样的堆其实会有O(N)的空间复杂度,我们可以再优化
然而这样的堆排序明显不合理,因为我们不仅要之前的一堆接口,而且还要额外的空间复杂度,所里这里我们选择先对给定数组改变顺序,建成一个堆,然后堆排序
4.1 建大堆还是小堆
当我们在选择建堆来排序的时候,我们会遇到下面的问题
如果我要升序,再去利用它去建小堆,最小的数已经在第一个位置了,剩下的数关系全部乱了,需要重新建堆,建堆还要O(N),再选出此校的,不断建堆选数,如果这样,那么还不如直接遍历来选数,说明效率不行,还是复杂,建完堆又排序走算法复杂度达到O(N2)
堆排序即利用堆的思想来进行排序,总共分为两个步骤:
- 建堆
升序:建大堆
降序:建小堆 - 利用堆删除思想来进行排序
下面来叙述一下建堆的逻辑,当我们需要获得一个升序,选择建一个大堆,我们将end指向下标最后一个的位置,然后交换第一个数和最后一个数,因为最后一个数据已经是在正确的位置上,所以我们忽略最后一个数据,然后走一遍向下调整算法,这时end指向次大的数会变为最大的数,就继续迭代,end不断往前走,直到走到下标为0,就调完了
图片来源于http://www.btechsmartclass.com/data_structures/heap-sort.html
void HeapSort(int* a, int n)
{
1.向下调整--建堆O(N*logN)不合适
//for (int i = 1; i < n; ++i)
//{
// AdjustUp(a, i);
//}
//2.向下调整--建堆O(N)合适
//找到倒数第一个非叶子节点
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
size_t end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
AdjustDown(a, end, 0);
--end;
}
}
时间复杂度
O ( N ∗ l o g 2 N ) O(N*log_2N) O(N∗log2N)
##5. Top-K问题
什么是Top-K?
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
5.1 排序
时间复杂度:O(N*logN),空间复杂度O(1) --要求进一步优化
5.2 建立n个数的堆
建立N个数的大堆,pop操作K次,就可以选出最大的前K个,时间复杂度O(N+K*logN),空间复杂度O(1)
但是有时候N非常大,以及远大于K,那么100亿个数里面找出最大的前10个
上面的方法不适合使用,也就是海量数据处理100亿个数据,内存不够
相当于10G放在了磁盘中,也就是文件中
5.3 建立k个数的堆
🍁 用数据集合中前K个元素来建堆
🌿前k个最大的元素,则建小堆
🌿前k个最小的元素,则建大堆
🍁 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素
void PrintTopK(int* a, int n, int k)
{
// 1. 建堆--用a中前k个元素建堆
int* kminHeap = (int*)malloc(sizeof(int) * k);
assert(kminHeap);
for (int i = 0; i < k; ++i)
{
kminHeap[i] = a[i];
}
// 建小堆
for (int j = (k - 1 - 1) / 2; j >= 0; --j)
{
AdjustDown(kminHeap, k, j);
}
// 2. 将剩余n-k个元素依次与堆顶元素交换,不满则则替换
for (int i = k; i < n; ++i)
{
if (a[i] > kminHeap[0])
{
kminHeap[0] = a[i];
AdjustDown(kminHeap, k, 0);
}
}
for (int j = 0; j < k; ++j)
{
printf("%d ", kminHeap[j]);
}
printf("\n");
free(kminHeap);
}
测试
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[2305] = 1000000 + 6;
a[99] = 1000000 + 7;
a[76] = 1000000 + 8;
a[423] = 1000000 + 9;
a[0] = 1000000 + 1000;
PrintTopK(a, n, 10);
}
如果对代码有需要的话,欢迎访问我的Giteehttps://gitee.com/allen9012/c-language/tree/master/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/Heap2