【初探数据结构】二叉树的顺序结构——堆的实现详解(上下调整算法的时间复杂度分析)
💬 欢迎讨论:在阅读过程中有任何疑问,欢迎在评论区留言,我们一起交流学习!
👍 点赞、收藏与分享:如果你觉得这篇文章对你有帮助,记得点赞、收藏,并分享给更多对数据结构感兴趣的朋友
文章目录
- 前言
- 1. 堆的概念与结构
- 1.2 堆的重要性质
- 2. 堆的实现
- 2.1 堆的插入+向上调整算法
- 2.1.1 向上调整算法
- 2.1.2 堆的插入
- 2.2 堆的删除+向下调整算法
- 2.2.1 向下调整算法
- 2.2.2 堆的删除
- 3. 复杂度分析(向上调整与向下调整)
前言
堆是一种基于完全二叉树的数据结构,通常分为最大堆(父节点值≥子节点)和最小堆(父节点值≤子节点)。由于完全二叉树的特性,堆可以用数组高效存储,通过索引关系快速定位父子节点。
1. 堆的概念与结构
如果有⼀个关键码的集合,把它的所有元素按完全⼆叉树的顺序存储⽅式存储,在⼀个⼀维数组中,并满⾜:
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},i = 0、1、2...
,则称为⼩堆(或⼤堆)。将根结点最⼤的堆叫做最⼤堆或⼤根堆,根结点最⼩的堆叫做最⼩堆或⼩根堆。
1.2 堆的重要性质
对于具有n
个结点的完全二叉树,如果按照从上至下、从左至右的数组顺序对所有结点从0
开始编号,对于序号为i
的结点有:
- 若
i > 0
,i
位置结点的双亲序号为:(i - 1)/2
; 当i
为0
时,i
为根结点。 - 若
2i + 1 < n
,左孩子序号:2i + 1
;如果2i + 1 >=n
,则无左孩子 - 若
2i + 2 < n
,左孩子序号:2i + 2
;如果2i + 2 >=n
,则无右孩子
2. 堆的实现
原码自取gitee
现在我们知道,完全二叉树的顺序结构就是堆,顾名思义,堆就是用顺序表(数组)实现的。
Heap.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
#include<time.h>
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a;//堆
int size;//堆的大小
int capacity;//堆的容量
}HP;
//堆的初始化
void HeapInit(HP* php);
// 堆的销毁
void HeapDestory(HP* php);
// 堆的插入
void HeapPush(HP* php, HPDataType x);
// 堆的删除
void HeapPop(HP* php);
// 取堆顶的数据
HPDataType HeapTop(HP* php);
// 堆的数据个数
int HeapSize(HP* php);
// 堆的判空
int HeapEmpty(HP* php);
//向下调整
void AdjustDown(HPDataType* a, int n, int parent);
//向上调整
void AdjustUp(HPDataType* a, int child);
//交换
void swap(HPDataType* p1, HPDataType* p2);
声明:本文以实现小堆为例,如需实现大堆,修改小堆调整算法的条件即可,这里不过多赘述。
2.1 堆的插入+向上调整算法
2.1.1 向上调整算法
该算法辅助实现堆的插入
将新数据插入到数组的尾上,再进行向上调整算法,直到满足堆。
仔细观察这个图解,不难发现:从某个结点出发,与其父亲比较,如果小于他的父亲,就交换位置;如果大于或等于就不动,调整结束。
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;
}
}
}
2.1.2 堆的插入
- 先将元素插⼊到堆的末尾,即最后⼀个孩⼦之后
- 插⼊之后如果堆的性质遭到破坏,将新插⼊结点顺着其双双亲往上调整到合适位置即可
void HeapPush(HP* php, HPDataType x)
{
assert(php);
//若空间不足,则扩容
if (php->size == php->capacity) {
HPDataType* ptr = realloc(php->a, sizeof(HPDataType) * php->capacity * 2);
if (ptr == NULL) {
perror("realloc fail");
exit(-1);
}
php->a = ptr;
php->capacity *= 2;
}
php->a[php->size] = x;
AdjustUp(php->a, php->size);
php->size++;
}
2.2 堆的删除+向下调整算法
2.2.1 向下调整算法
该算法辅助实现堆的删除
现在我们给出一个数组,逻辑上看做一颗完全二叉树。我们通过从根节点开始的向下调整算法可以把它调整成一个小堆。向下调整算法有一个前提:左右子树必须是一个堆,才能调整。
int array[] = {27,15,19,18,28,34,65,49,25,37};
仔细观察这个图解,不难发现:从根结点出发,与其较小的孩子比较,如果小于这个孩子,就交换位置;如果大于或等于就不动,调整结束。
代码核心:
- 父亲与孩子的下标关系:
child=2*parent + 1
- 孩子的下标小于n,避免数组越界访问
void AdjustDown(HPDataType* a,int n,int parent)
{
int child = 2 * parent + 1;
while (child < n) {
// 假设法,选出左右孩⼦中⼩的那个孩⼦
if (a[child] < a[child + 1] && child + 1 < n){
child++;
}
if (a[parent] < a[child]) {
swap(&a[parent], &a[child]);
parent = child;
child = 2 * parent + 1;
}
else break;
}
}
2.2.2 堆的删除
删除时需注意判断堆是否为非空,若对空堆进行删除,必然出错。
- 将堆顶元素与堆中最后⼀个元素进⾏交换
- 删除堆中最后⼀个元素
- 将堆顶元素向下调整到满⾜堆特性为⽌
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
swap(php->a,&php->a[php->size-1]);
php->size--;
AdjustDown(php->a,php->size, 0);
}
其余的初始化、销毁、判空等等,非常简单,都是老套路了,这里留给读者自己发挥。
在我过去的一些文章都有类似的详解(顺序表、链表、栈、队列)。大家可以照猫画虎。
传送门呈上~
【初探数据结构】线性表———顺序表的详解和实现
【初探数据结构】线性表————链表(一)(单链表的实现)
【初探数据结构】线性表——链表(二)带头双向循环链表(详解与实现)
【初探数据结构】线性表——栈与队列(代码实现与详解)
3. 复杂度分析(向上调整与向下调整)
因为堆是完全二叉树,而满二叉树也是完全二叉树,为了简化证明,此出以满二叉树为研究对象。(时间复杂度本来看的就是近似值,多几个节点不影响最终结果)
3.1 向下调整时间复杂度( O ( N ) O(N) O(N))
最坏的情况是什么呢?
就是树的每个结点全部都要进行一次调整堆底。
设树的高度为h
我们需要计算的是向下移动的总次数T(n)
分析:
第1层,
2
0
2^0
20个结点,需要向下移动h-1
层
第2层,
2
1
2^1
21个结点,需要向下移动h-2
层
第3层,
2
2
2^2
22个结点,需要向下移动h-3
层
第4层,
2
3
2^3
23个结点,需要向下移动h-4
层
…
第h-1层,
2
h
−
2
2^{h-2}
2h−2 个结点,需要向下移动1
层
则需要移动结点总的移动步数为:每层结点个数乘向下调整次数
T ( h ) = 2 0 ∗ ( h − 1 ) + 2 1 ∗ ( h − 2 ) + 2 2 ∗ ( h − 3 ) + 2 3 ∗ ( h − 4 ) + … … + 2 h − 3 ∗ 2 + 2 h − 2 ∗ 1 T(h) = 2^0*(h-1)+ 2^1*(h-2)+2^2*(h-3)+2^3*(h-4)+……+2^{h-3}*2+2^{h-2}*1 T(h)=20∗(h−1)+21∗(h−2)+22∗(h−3)+23∗(h−4)+……+2h−3∗2+2h−2∗1
利用数列的错位相减(计算步骤如下图)
得出:
T
(
h
)
=
2
h
−
1
−
h
T(h) = 2^h −1 − h
T(h)=2h−1−h
根据⼆叉树的性质:
n
=
2
h
−
1
和
h
=
l
o
g
2
(
n
+
1
)
n = 2^h-1和h=log_2(n+1)
n=2h−1和h=log2(n+1)
得出:
T
(
n
)
=
n
−
l
o
g
2
(
n
+
1
)
≈
n
T(n)=n-log_2(n+1)≈n
T(n)=n−log2(n+1)≈n
向下调整算法建堆时间复杂度为:O(n)
3.2 向上调整时间复杂度( n ∗ l o g n n*logn n∗logn)
与向下调整类似,计算所有结点向上移动至堆顶的移动总次数即可
设树的高度为h
我们需要计算的是向上移动的总次数T(n)
分析:
第1层,
2
0
2^0
20个结点,需要向下移动0
层
第2层,
2
1
2^1
21个结点,需要向下移动1
层
第3层,
2
2
2^2
22个结点,需要向下移动2
层
第4层,
2
3
2^3
23个结点,需要向下移动3
层
…
第h层,
2
h
−
2
2^{h-2}
2h−2 个结点,需要向下移动h-1
层
则需要移动结点总的移动步数为:每层结点个数乘向上调整次数(第⼀层调整次数为0)
T ( h ) = 2 1 ∗ 1 + 2 2 ∗ 2 + 2 3 ∗ 3 + … … + 2 h − 2 ∗ ( h − 2 ) + 2 h − 1 ∗ ( h − 1 ) T(h) = 2^1*1+2^2*2+2^3*3+……+2^{h-2}*(h-2)+2^{h-1}*(h-1) T(h)=21∗1+22∗2+23∗3+……+2h−2∗(h−2)+2h−1∗(h−1)
也是利用数列的错位相减得:
T
(
h
)
=
−
(
2
h
−
1
)
+
2
h
∗
(
h
−
1
)
+
2
0
T(h) = -(2^h-1)+2^h * (h-1)+2^0
T(h)=−(2h−1)+2h∗(h−1)+20
根据⼆叉树的性质:
n
=
2
h
−
1
和
h
=
l
o
g
2
(
n
+
1
)
n = 2^h-1和h=log_2(n+1)
n=2h−1和h=log2(n+1)
得出:
T
(
n
)
=
(
n
+
1
)
(
l
o
g
2
(
n
+
1
)
−
2
)
+
2
≈
n
∗
l
o
g
2
n
T(n)=(n + 1)(log_2(n +1) − 2) + 2≈n*log_2n
T(n)=(n+1)(log2(n+1)−2)+2≈n∗log2n
向上调整算法建堆时间复杂度为: n ∗ l o g n n*logn n∗logn
3.3 结论
由此可见,向下调整的效率是由于向上调整的,所以我们以后需要建堆时,应该优先考虑向下调整建堆。再后面的堆排序中我们可以深刻体会到。