用数组实现小根堆
- [6.4.3 堆的实现](#6.4.3 堆的实现)
-
- [6.4.3.1 堆的向上调整算法建堆](#6.4.3.1 堆的向上调整算法建堆)
- [6.4.3.2 堆的向下调整算法建堆](#6.4.3.2 堆的向下调整算法建堆)
- [6.4.3.3 建堆时间复杂度分析](#6.4.3.3 建堆时间复杂度分析)
- [6.4.4 堆的其他操作](#6.4.4 堆的其他操作)
-
- [1. 初始化(Init)](#1. 初始化(Init))
- [2. 销毁(Destroy)](#2. 销毁(Destroy))
- [3. 插入(Push)](#3. 插入(Push))
- [4. 弹出堆顶元素(Pop)](#4. 弹出堆顶元素(Pop))
- [5. 获取堆顶元素(Top)](#5. 获取堆顶元素(Top))
- [6. 判断堆是否为空(Empty)](#6. 判断堆是否为空(Empty))
- [7. 获取堆的结点数(Size)](#7. 获取堆的结点数(Size))
6.4.3 堆的实现
数据转换成堆的思路:
- 新开辟一个新数组。
- 将数组作为完全二叉树,通过调整算法改造成堆,再交换对顶和最后一个元素,并逐步缩小可操作的堆结点数(或范围)。改造过程其实是堆的插入过程。
调整算法有两种:向上调整和向下调整。
堆的实现过程都可以在数组中完成。
堆的基本信息:
c
typedef int HPDataType;
typedef struct Heap
{
HPDataType* _a;
int _size;
int _capacity;
}Heap;
6.4.3.1 堆的向上调整算法建堆
这是一个小堆,分别插入数据80,40和5时,如何将这个数组重新调整使数组依旧是小堆?
当插入数据80时,整体还是小堆,无需调整。
当插入数据40或5时均需做调整。
可以看到,新增的结点在参与调整时都是和父结点做比较 ,当新结点比自己的父结点的数据小时,和父结点做置换 ,自己来充当父结点。
这种调整在自己成了新的根结点时即可停止 。或者说它一直充当父结点,而父结点最终也只会为0,因为(child-1)/2== − 1 2 -\frac{1}{2} −21,因为c语言的整型会舍去小数部分,所以还是0,当child为根结点时生效。所以调整的结束条件 为child>0
。
所以我们便有了这样一个向上调整的算法:
c
//向上调整算法
void adjustUp(HPDataType* 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;
}
需要注意的是,在进行向上调整时需要保证该结点插入之前所有结点都已经成了堆 。所以在使用这个算法进行调整时可以从二叉树的第二层开始调整 ,此时第二层上方只有1个根结点 ,此时可以看成堆。
c
void f1() {//测试向上调整算法
srand((size_t)time(0));//随机数的种子
int a[10] = { 0 };
int i = 0;
for (i = 0; i < 10; i++)
a[i] = rand() % 100 + 1;//生成完全二叉树
for (i = 0; i < 10; i++)
printf("%d ", a[i]);
printf("\n");
for (i = 1; i <10; i++)//从第二层开始调整
adjustUp(a, i);
for (i = 0; i < 10; i++)
printf("%d ", a[i]);
}
当我们将除了根结点外的所有结点都进行了向上调整时,会发现这个树变成了我们想要的小堆。
6.4.3.2 堆的向下调整算法建堆
们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整 。
当被调整的结点成为新的叶结点或结点来到它应该在的位置时,调整完成。
每个结点都至少有2个子结点,选择哪个子结点作为配合调整的对象,取决于哪个结点更符合条件。例如上图是个小堆,所以从15和19两个结点中选择最小的那个结点15。在开始时我们先默认左结点更符合条件,之后再看右结点。
根据上文的大概描述,我们有了向下调整算法:
c
//向下调整算法
void adjustDown(HPDataType* a, int n, int parent) {
int child = 2 * parent + 1;//默认左结点更符合条件
while (child < n) {
if (child + 1 < n)//考虑数组容量,或者说右孩子不存在的情况
if (a[child + 1] < a[child])//选择更合适的结点
++child;
if (a[child] < a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = 2 * parent + 1;
}
else break;
}
}
我们看到完全二叉树的倒数第二层,可以看到他们的左、右结点要么为空,要么有一个或两个子结点。这些子结都是叶结点,我们可以看成是堆。
但既然是向下调整,则向下调整的结点必须有子结点。所以我们从倒数第一个父结点开始向下调整。
c
void f2() {//测试向下调整算法
srand((size_t)time(0));//随机数的种子
int a[10] = { 0 };
int i = 0;
for (i = 0; i < 10; i++)
a[i] = rand() % 100 + 1;//生成完全二叉树
for (i = 0; i < 10; i++)
printf("%d ", a[i]);
printf("\n");
for (i = (10-1-1)/2; i >=0; i--)//从倒数第一个父结点开始调整
adjustDown(a, 10,i);
for (i = 0; i < 10; i++)
printf("%d ", a[i]);
}
6.4.3.3 建堆时间复杂度分析
向上调整算法:
完全二叉树的最后一层拥有树快一半的结点,最坏的情况是最后一层要调整的结点刚好是全部,每个结点调整h-1
次,于是调整次数为 2 h − 1 ( h − 1 ) 2^{h-1}(h-1) 2h−1(h−1)次(也就是满二叉树并且每个叶结点都要调整),
于是总的调整次数和高度的关系为
F ( h ) = 2 1 × 1 + 2 2 × 2 + ⋯ + 2 h − 1 × ( h − 1 ) F(h)=2^1\times1+2^2\times 2+\cdots+2^{h-1}\times(h-1) F(h)=21×1+22×2+⋯+2h−1×(h−1),
这是一个等差数列和等比数列交叉相乘再相加组成的数列,通过错位相减或公式 ( k n + m ) q n − m ( k = a q − 1 , m = b − k q − 1 ,这个公式是数列 ( a n + b ) q n − 1 的前 n 项和公式 ) (kn+m)q^n-m(k=\frac{a}{q-1},m=\frac{b-k}{q-1},这个公式是数列(an+b)q^{n-1}的前n项和公式) (kn+m)qn−m(k=q−1a,m=q−1b−k,这个公式是数列(an+b)qn−1的前n项和公式)
可得到
F ( h ) = 2 h × h − 2 × 2 h + 2 F(h)=2^h\times h-2\times2^h+2 F(h)=2h×h−2×2h+2,
因为树高 h = l o g 2 ( n + 1 ) h=log_{2}(n+1) h=log2(n+1),
所以 F ( h ) = ( n + 1 ) l o g 2 ( n + 1 ) − 2 n F(h)=(n+1)log_{2}(n+1)-2n F(h)=(n+1)log2(n+1)−2n。
它可以看成树高为h的完全二叉树进行向上调整建堆的调整次数。
所以向上调整算法的时间复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)。
向下调整算法:
因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明(时间复杂度本来看的就是近似值,多几个节点不影响最终结果):
或从这个代码来推:
c
void f(){
for(int i=(n-1-1)/2;i>=0;i--)
adjustDown(a,n,i);
}
可得调整次数:
F ( h ) = 2 h − 2 × 1 + 2 h − 3 × 2 + ⋯ + 2 1 × ( h − 2 ) + 2 0 × ( h − 1 ) F(h)=2^{h-2}\times 1+2^{h-3}\times2+\cdots+2^1\times(h-2)+2^0\times(h-1) F(h)=2h−2×1+2h−3×2+⋯+21×(h−2)+20×(h−1),
2 h − 2 × 1 2^{h-2}\times 1 2h−2×1表示倒数第二层的结点,每个结点最多调整1次,总的调整次数。
我们同样用错位相减得到
F ( h ) = 2 h − 1 − h F(h)=2^h-1-h F(h)=2h−1−h,
因为树高 h = l o g 2 ( n + 1 ) h=log_2(n+1) h=log2(n+1),所以表达式变成了
F ( h ) = n − l o g 2 ( n + 1 ) ≈ n F(h)=n-log_2{(n+1)}\approx n F(h)=n−log2(n+1)≈n,
于是向下调整建堆的时间复杂度为 O ( n ) O(n) O(n)。
对比向下调整建堆(时间复杂度 O ( n ) O(n) O(n))和向上调整建堆(时间复杂度 O ( n l o g n ) O(nlogn) O(nlogn)),我们知道当n大于对数的底数时,对数是大于1的。所以同样且有很多结点的完全二叉树,向上调整建堆在最坏情况下是比向下调整建堆耗时更长。
6.4.4 堆的其他操作
不管建堆怎么样,堆还是完全二叉树,还是数据结构。管理数据是数据结构的任务。
1. 初始化(Init)
本次建堆用的是数组建立,很多功能参考顺序表笔记------目标是随时手搓顺序表-CSDN博客
很多功能以前都有记过笔记,就不过多赘述,直接上菜。
c
//堆的初始化
void HeapInit(Heap* hp) {
assert(hp);
hp->_a = NULL;
hp->_size = 0;
hp->_capacity = 0;
}
2. 销毁(Destroy)
参考顺序表。
c
// 堆的销毁
void HeapDestory(Heap* hp) {
assert(hp);
free(hp->_a);
hp->_a = NULL;
hp->_size = hp->_capacity = 0;
}
3. 插入(Push)
每次插入数据都要进行调整使被新数据打乱结构的树重新成为堆。
c
// 堆的插入
void HeapPush(Heap* hp, HPDataType x) {
assert(hp);
if (hp->_size == hp->_capacity)//容量检测
{
int newCapacity = hp->_capacity == 0 ? 4 : hp->_capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(hp->_a, newCapacity * sizeof(HPDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
hp->_a = tmp;
hp->_capacity = newCapacity;
}
hp->_a[hp->_size] = x;
hp->_size++;
adjustUp(hp->_a,hp->_size-1);
}
这里不能使用向下调整算法进行堆的调整,因为向下调整算法是调整从当前结点到叶结点这一路径,而我们希望的是新加入的叶结点能快速融入到堆中,自然需要将新增的叶结点和各个父结点进行对比。
4. 弹出堆顶元素(Pop)
根结点和最后一个结点交换位置,然后删除最后一个结点,并进行调整使被打乱结构的树重新成为堆。
c
// 弹出堆顶元素
void HeapPop(Heap* hp) {
assert(hp);
assert(!HeapEmpty(hp));
Swap(&hp->_a[0], &hp->_a[hp->_size - 1]);
hp->_size--;
adjustDown(hp->_a, hp->_size, 0);
}
5. 获取堆顶元素(Top)
返回根结点即可。
c
//获取堆顶元素
HPDataType HeapTop(Heap* hp) {
assert(hp);
assert(!HeapEmpty(hp));
return hp->_a[0];
}
6. 判断堆是否为空(Empty)
我们设计的堆有专门的计数变量,可以通过计数变量来判断。
c
// 堆的判空
bool HeapEmpty(Heap* hp) {
assert(hp);
return hp->_size == 0;
}
7. 获取堆的结点数(Size)
我们设计的堆有专门的计数变量,可以通过计数变量来判断。
c
// 堆的数据个数
int HeapSize(Heap* hp) {
assert(hp);
return hp->_size;
}