【数据结构】堆——完全二叉树顺序存储实现

✨✨🎶🎶
Hello!上期我们初步认识了树,今天我们来一起学习一下完全二叉树的顺序实现——堆。
所谓顺序实现即我们借助数组来实现。
一上来我们有一些问题:
1.为什么是完全二叉树?普通的二叉树可以吗?
2.堆与完全二叉树有什么关系
3.为什么要学堆,堆有什么用处
注意:在堆这部分,我们逻辑上是借助完全二叉树来思考,但实际上操作的是数组。这十分重要
文章目录
1.堆的概念与结构
定义:将集合 S = { k 1 , k 2 , k 3 , k 4 ⋅ ⋅ ⋅ ⋅ k n } S=\{ k_1,k_2,k_3,k_4····k_n\} S={k1,k2,k3,k4⋅⋅⋅⋅kn} 的所有元素按照完全二叉树的顺序存储形式存储在数组中且满足如下性质 k i ≥ k 2 i + 1 且 k i ≥ k 2 i + 2 ( 大 堆 ) 或 者 k i ≤ k 2 i + 1 且 k i ≤ k 2 i + 2 ( 小 堆 ) , 其 中 2 i + 2 ≤ n k_i \ge k_{2i+1} 且 k_i \ge k_{2i+2}(大堆) 或者k_i \le k_{2i+1} 且 k_i \le k_{2i+2}(小堆),其中{2i+2 } \le n ki≥k2i+1且ki≥k2i+2(大堆)或者ki≤k2i+1且ki≤k2i+2(小堆),其中2i+2≤n
由定义出发我们可以得到如下性质:
- 堆某一结点的值总是不大于或不小于其父结点的值。
- 大堆的根结点值最大,小堆根结点值最小
堆的结构:
typedef int DataType;
typedef struct Heap
{
DataType* a; //数据域
int size; //有效数据数目
int capacity; //容量
}Heap;
2.堆的创建
2.1 插入建堆
正如我们学习链表时,我们通过一个数据一个数据插入来创建一个链表,我们也可以通过插入数据来建立一个堆。在示例中,我们会创建一个小堆。
在此之前我们需要介绍一下:向上调整算法
2.1.1 向上调整算法
如图:我们初始时有一个小堆,即橘色曲线圈出的部分。我们要在这个堆后面插入一个数据为11的结点。通过向上调整算法,要把这个结点向上调整,维持堆的特性。
调整过程:
从图中我们可以发现,向上调整的路径:孩子结点—>父节点—>祖父结点—>曾祖父······
形象一点来说整个向上调整的过程就是一个后辈孩子寻祖宗的过程,我且称为”寻根“(虽然不是特别贴切hhh)
不扯了,我们总结一下向上调整算法的思路(维持小堆):
-
待调整的结点值同父结点比较大小
-
调整的结点值 < < <父结点小,则交换两个结点
待调整的结点值 ≥ \ge ≥父结点,向上调整结束,退出过程
-
重复1-2的过程,直至调整结点成为根结点时,退出过程
perception:
- 有效的向上调整要求我们除了待调整结点,其余部分必须满足堆的特性。
- 在向上调整的过程中,有两个出口,一个是调整到根,还有就是发现父结点比待调整结点小时即可终止过程,这是由第一点保证的。
- 下 标 为 i 的 结 点 的 父 结 点 下 标 为 ⌊ ( i − 1 ) / 2 ⌋ 下标为i的结点的父结点下标为\lfloor (i-1) /2\rfloor 下标为i的结点的父结点下标为⌊(i−1)/2⌋,而C/C++中是向下取整的,故直接在代码中写为 ( i − 1 ) / 2 (i-1) /2 (i−1)/2
代码实现:
//辅助函数,交换两数字
void Swap(DataType* a, DataType* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
//向上调整算法
void AdjustUp(DataType* a,int child)
{
int parent = (child - 1) / 2;//父结点下标是(child-1)除以2后向下取整
while (child)
{
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
child = parent;
parent = (child - 1) / 2;
}
else
break;
}
}
2.1.2 插入建堆相关API
//辅助函数,进行两数交换
void Swap(DataType* a, DataType* b);
//向上调整算法
void AdjustUp(DataType* a, int child);
//插入数据
void HeapPush(Heap* pheap, DataType x);
//传入一个数组,通过插入建立一个堆,返回指向这个堆的指针
Heap* HeapCreate(DataType* a, int n);
2.1.3 HeapPush的实现
思路:
-
检查容量
容量满则扩容
-
数组尾部添加数据
-
向上调整插入的结点
//插入数据
void HeapPush(Heap* pheap, DataType x)
{
assert(pheap);
if (pheap->capacity == pheap->size)
{
int newcapacity = pheap->capacity == 0 ? 4 : 2 * pheap->capacity;
DataType* tmp = (int*)realloc(pheap->a, sizeof(int) * newcapacity);
if (tmp == NULL)
{
printf("realloc failed\n");
exit(-1);
}
else
{
pheap->a = tmp;
pheap->capacity = newcapacity;
}
}
pheap->a[pheap->size++] = x;
AdjustUp(pheap->a, pheap->size - 1);
}
2.1.4 HeapCreate实现
完成了前面的函数,我们手上就有了所有的强有力的工具。插入建堆,手到擒来!
注: 此处的HeapInit()
函数作用是将申请的堆初始化,包括将数据指针置空,容量与元素数置为0。
Heap* HeapCreate(DataType* a, int n)
{
Heap* ret = (Heap*)malloc(sizeof(Heap));
HeapInit(ret);
//插入建堆
for (int i = 0; i < n; i++)
HeapPush(ret,a[i]);
return ret;
}
2.1.5 复杂度分析
为了简化我们分析问题的复杂程度,我们考虑最多最坏的情况(满二叉树)
第 h 层 需 要 调 整 的 总 次 数 : ( h − 1 ) ∗ 2 h − 1 第h层需要调整的总次数:(h-1)*2^{h-1} 第h层需要调整的总次数:(h−1)∗2h−1
h
层
满
二
叉
树
需
要
调
整
的
次
数
:
h层满二叉树需要调整的次数:
h层满二叉树需要调整的次数:
∑
i
=
1
h
(
i
−
1
)
∗
2
i
−
1
\sum_{i=1}^{h}(i-1)*2^{i-1}
i=1∑h(i−1)∗2i−1
这
种
差
比
数
列
运
用
高
中
学
到
的
错
位
相
减
法
有
:
这种差比数列运用高中学到的错位相减法有:
这种差比数列运用高中学到的错位相减法有:
S
h
=
0
+
1
×
2
1
+
2
×
2
2
+
⋅
⋅
⋅
+
(
h
−
2
)
×
2
h
−
2
+
(
h
−
1
)
×
2
h
−
1
(
1
)
2
S
h
=
0
+
1
×
2
2
+
2
×
2
3
+
⋅
⋅
⋅
+
(
h
−
2
)
×
2
h
−
1
+
(
h
−
1
)
×
2
h
(
2
)
S_h=0+1\times2^{1}+2\times2^{2}+···+(h-2)\times2^{h-2}+(h-1)\times2^{h-1} \qquad(1)\\ 2S_h=0+1\times2^{2}+2\times2^{3}+···+(h-2)\times2^{h-1}+(h-1)\times2^{h} \qquad(2)\\
Sh=0+1×21+2×22+⋅⋅⋅+(h−2)×2h−2+(h−1)×2h−1(1)2Sh=0+1×22+2×23+⋅⋅⋅+(h−2)×2h−1+(h−1)×2h(2)
(
2
)
式
−
(
1
)
式
得
到
:
(2)式-(1)式得到:
(2)式−(1)式得到:
S
h
=
(
h
−
1
)
×
2
h
−
2
−
∑
i
=
2
h
−
1
2
i
=
(
h
−
1
)
×
2
h
−
2
−
4
−
2
h
1
−
2
S_h=(h-1)\times2^{h}-2-\sum_{i=2}^{h-1}2^i\\ \qquad=(h-1)\times2^{h}-2-\frac{4-2^h}{1-2}\\
Sh=(h−1)×2h−2−i=2∑h−12i=(h−1)×2h−2−1−24−2h
整
理
得
:
整理得:
整理得:
S
h
=
(
h
−
2
)
×
2
h
+
2
(
3
)
S_h=(h-2)\times2^{h}+2\qquad (3)
Sh=(h−2)×2h+2(3)
对
于
满
二
叉
树
,
结
点
总
数
n
与
层
数
h
满
足
:
n
=
2
h
−
1
,
对
(
3
)
式
进
行
替
换
得
:
对于满二叉树,结点总数n与层数h满足:n=2^h-1,对(3)式进行替换得:
对于满二叉树,结点总数n与层数h满足:n=2h−1,对(3)式进行替换得:
总
次
数
N
=
(
log
2
(
n
+
1
)
−
2
)
×
(
n
+
1
)
+
2
(
4
)
总次数N=(\log_{2}(n+1)-2)\times(n+1)+2\qquad (4)
总次数N=(log2(n+1)−2)×(n+1)+2(4)
由(4)式我们可以分析出的结论为:
插入建堆时间复杂度为: O ( N l o g N ) O(NlogN) O(NlogN)
2.向下调整建堆
提前剧透一下,这个建堆方法就厉害了嗷!
同样我们先来介绍一下向下调整算法
2.2.1 向下调整算法
在这个状态下虽然不满足堆的条件但是,根结点左子树是一个小堆,根结点的右子树是一个小堆,这个时候我们便采取向下调整算法来将根结点向下调整,使其原结构成为堆
调整过程:
向下调整的思路:
-
在待调整的结点的左孩子与右孩子(存在的前提下)中选出值更小的结点
-
将选出的结点同待调整的结点进行比较
待调整的结点 > > >选出的结点,交换两结点
待调整的结点 ≤ \le ≤选出的结点,调整结束,退出过程
-
重复过程1、2,直至待调整结点既无左孩子又无又孩子时,调整结束,退出过程
perception:
- 有效的向下调整需要左右子树均为堆
- 调整过程有两个出口,一个是调整到叶子结点,还有一个是左右孩子均比待调整结点大,后者是由第一点保障的。
- 经过向下调整之后,根、左子树、右子树整体满足堆的性质
代码实现:
在选出左右孩子中小的那个的逻辑的实现是下面代码实现的十分优雅简洁,值得学习
回顾:下标为 i i i 的结点的左孩子下标 2 i + 1 2i+1 2i+1,右孩子下标 2 i + 2 2i+2 2i+2
void AdjustDown(DataType* a, int parent, int n)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && a[child + 1] < a[child])
child++;
if (a[child] < a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
2.2.2 向下调整建堆相关API
//辅助函数,交换结点
void Swap(DataType* a, DataType* b);
//向下调整算法
void AdjustDown(DataType* a, int parent, int n);
//向下调整建堆
Heap* HeapCreate(DataType* a, int n);
2.2.3 HeapCreate实现
分析:
此时面对任意一个随意的数组,要按照一定的顺序进行向下调整来将其改造成堆
而向下调整需要左右子树均为堆,我们选择从倒数第一个非叶子结点开始调整,调整完根结点终止。
因为倒数第一个非叶子结点之后的所有结点都是叶子结点,叶子结点左右孩子都为空,可以看成是一个堆(不违反堆的定义),那么倒数第一个叶子的左子树和右子树(存在的情况下)都为堆,此时便可以对倒数第一个非叶子结点运用向下调整。
因为是从后往前调整,后面的局部不断变为堆,那么对于前面的结点,左右子树都为堆,便也可以运用向下调整。(像是一个滚动的过程)
我们可以证明:经过这样的调整,原数组被改造成了一个堆
因为:经历了上述过程后,对于树的每一个局部,都是一个堆,那么整体便也是堆。
实现思路:
- 找到倒数第一个非叶子结点
- 从最后一个叶子结点开始到根结点结束,每个结点均向下调整一次
代码实现:
Heap* HeapCreate(DataType* a, int n)
{
Heap* ret = (Heap*)malloc(sizeof(Heap));
HeapInit(ret);
int* data = (int*)malloc(n * sizeof(int));
memcpy(data, a, n * sizeof(int));
//line 8->13 为核心
int parent = (n - 1 - 1) / 2;
while (parent >= 0)
{
AdjustDown(data, parent, n);
parent--;
}
ret->a = data;
ret->capacity = ret->size = n;
return ret;
}
2.2.4 复杂度分析
同样为了简化问题,我们考虑最坏的情况:
对
于
高
度
为
h
的
树
,
总
调
整
次
数
S
h
为
:
对于高度为h的树,总调整次数S_h为:
对于高度为h的树,总调整次数Sh为:
S
h
=
2
0
×
(
h
−
1
)
+
2
1
×
(
h
−
2
)
+
⋅
⋅
⋅
+
2
h
−
3
×
2
+
2
h
−
2
×
1
(
1
)
2
S
h
=
2
1
×
(
h
−
1
)
+
2
2
×
(
h
−
2
)
+
⋅
⋅
⋅
+
2
h
−
2
×
2
+
2
h
−
1
×
1
(
2
)
S_h=2^0\times (h-1)+2^1\times (h-2)+···+2^{h-3}\times 2+2^{h-2}\times1 \qquad (1)\\ 2S_h=2^1\times (h-1)+2^2\times (h-2)+···+2^{h-2}\times 2+2^{h-1}\times1 \qquad (2)\\
Sh=20×(h−1)+21×(h−2)+⋅⋅⋅+2h−3×2+2h−2×1(1)2Sh=21×(h−1)+22×(h−2)+⋅⋅⋅+2h−2×2+2h−1×1(2)
(
2
)
与
(
1
)
错
位
相
减
得
:
(2)与(1)错位相减得:
(2)与(1)错位相减得:
S
h
=
2
h
−
1
−
(
h
−
1
)
+
∑
i
=
1
h
−
2
2
i
S
h
=
2
h
−
1
−
(
h
−
1
)
+
2
−
2
h
−
1
1
−
2
S_h=2^{h-1}-(h-1)+\sum_{i=1}^{h-2}2^i\\ S_h=2^{h-1}-(h-1)+\frac{2-2^{h-1}}{1-2}
Sh=2h−1−(h−1)+i=1∑h−22iSh=2h−1−(h−1)+1−22−2h−1
整
理
得
:
整理得:
整理得:
S
h
=
2
h
−
h
−
1
(
3
)
S_h=2^h-h-1\qquad(3)
Sh=2h−h−1(3)
$对于高度为h的满二叉树,总结点树n与h满足:n=2^h-1 $
用
n
替
换
(
3
)
式
中
的
h
得
到
需
要
调
整
的
总
次
数
N
:
用n替换(3)式中的h得到需要调整的总次数N:
用n替换(3)式中的h得到需要调整的总次数N:
N
=
n
−
l
o
g
2
(
n
+
1
)
(
4
)
N=n-log_2(n+1)\qquad (4)
N=n−log2(n+1)(4)
根据(4)式我们得到的结论是:
向下调整建堆的时间复杂度为: O ( N ) O(N) O(N)
也就是说我们建堆的时间复杂度是线性的,较于插入建堆,向下调整建堆的效率更加高。
3.堆其他操作的API
//堆的初始化
void HeapInit(Heap* pheap);
//删除数据(弹出堆顶数据)
void HeapPop(Heap* pheap);
//获取堆顶数据
DataType HeapTop(Heap* pheap);
//求堆中元素个数
int HeapSize(Heap* pheap);
//堆判空
int HeapEmpty(Heap* pheap);
//堆的销毁
void HeapDestroy(Heap* pheap);
3.1 HeapPop实现
3.1.1 规定
我们规定,在堆中删除一个元素时,删除的是堆顶元素。
这很好理解,因为堆顶元素有一个特性,即为集合中元素的最大/最小值,删除这个值相较于其他值,会更加“有用”
3.1.2 误区
既然我们操作的数组,我们知道,在数组中要去删除一个元素只需要移动覆盖即可。是不是删除堆顶的元素只需要移动覆盖掉第一个元素就可以了呢?
回答是否定的!
在删除堆顶元素时,我们希望删除后仍能维持堆的特性。
仅仅粗暴地把首元素覆盖掉,会把原来堆的结构完全破坏掉,此后我们如果需要再维持堆的特性得重新经历向下调整建堆的过程,由上面的分析可知,复杂度为 O ( N ) O(N) O(N) 这样的损耗还是太大了,我们有更加优雅的实现。
3.1.3 思路
- 把堆顶元素与最后一个元素交换
- 删除最后元素(即为堆顶元素)
- 将堆顶元素向下调整
分析:
因为我们操作的是数组,删除最后一个元素只需size--
即可,且删除最后一个元素对整个堆的结构没有影响。此时原结构中,最后一个元素来到堆顶,成为根结点,左右子树均为堆,我们只需要向下调整根结点即可。
向下调整根结点的复杂度: O ( log N ) O(\log{N}) O(logN)
notes:当数据量足够大时,比如: N = 2 20 N=2^{20} N=220,大约是1千万时, O ( N ) O(N) O(N)要调约千万次,而 O ( log N ) O(\log{N}) O(logN)只需调20多次即可
3.1.4 代码实现
void HeapPop(Heap* pheap)
{
assert(pheap);
if (pheap->size < 1)
return;
Swap(&pheap->a[0], &pheap->a[pheap->size - 1]);
pheap->size--;
AdjustDown(pheap->a, 0, pheap->size);
}
3.2 HeapInit实现
void HeapInit(Heap* pheap)
{
assert(pheap);
pheap->a = NULL;
pheap->capacity = pheap->size = 0;
}
3.3 HeapTop实现
DataType HeapTop(Heap* pheap)
{
assert(pheap);
assert(pheap->size > 0);
return pheap->a[0];
}
3.4 HeapSize实现
int HeapSize(Heap* pheap)
{
assert(pheap);
return pheap->size;
}
3.5 HeapEmpty实现
int HeapEmpty(Heap* pheap)
{
assert(pheap);
return pheap->size == 0;
}
3.6HeapDestroy 实现
void HeapDestroy(Heap* pheap)
{
assert(pheap);
free(pheap->a);
pheap->a = NULL;
pheap->capacity = pheap->size = 0;
}
4.一句话总结:
堆这最核心的部分就是要去理解并灵活运用向上调整算法与向下调整算法