栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > C/C++/C#

【数据结构】堆的实现

C/C++/C# 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

【数据结构】堆的实现

【数据结构】堆——完全二叉树顺序存储实现
“我想要现实的真相和爱的幻想,做成精神的房梁,敷脊背的伤。” ——《我想要》

✨✨

Hello!上期我们初步认识了树,今天我们来一起学习一下完全二叉树的顺序实现——堆。

所谓顺序实现即我们借助数组来实现。

一上来我们有一些问题:

1.为什么是完全二叉树?普通的二叉树可以吗?

理论上是没有问题的,但是会存在严重的空间浪费。

根据上期完全二叉树的定义,所有结点可以连续放入数组仍能维持树的结点的关系特性,而普通的树放入数组结点之间是不连续的

2.堆与完全二叉树有什么关系

堆在物理结构上实际是一个数组,但是在逻辑上,我们将其视为完全二叉树,且借助我们一些操作可以很好的维持其结点间的关系特性

我们可以这样说:堆是一棵完全二叉树

但是堆相较于完全二叉树又有一些其他的性质上的规定

3.为什么要学堆,堆有什么用处

堆作为一种数据结构,其应用有:TopK问题(选择最大/最小的前K个数)、堆排序、实现优先队列等,后续我会学习更新堆的其他应用。

注意:在堆这部分,我们逻辑上是借助完全二叉树来思考,但实际上操作的是数组。这十分重要


文章目录
    • 【数据结构】堆——完全二叉树顺序存储实现
      • “我想要现实的真相和爱的幻想,做成精神的房梁,敷脊背的伤。” ——《我想要》
      • 1.堆的概念与结构
      • 2.堆的创建
        • 2.1 插入建堆
          • 2.1.1 向上调整算法
          • 2.1.2 插入建堆相关API
          • 2.1.3 HeapPush的实现
          • 2.1.4 HeapCreate实现
          • 2.1.5 复杂度分析
        • 2.向下调整建堆
          • 2.2.1 向下调整算法
          • 2.2.2 向下调整建堆相关API
          • 2.2.3 HeapCreate实现
          • 2.2.4 复杂度分析
      • 3.堆其他操作的API
        • 3.1 HeapPop实现
          • 3.1.1 规定
          • 3.1.2 误区
          • 3.1.3 思路
          • 3.1.4 代码实现
        • 3.2 HeapInit实现
        • 3.3 HeapTop实现
        • 3.4 HeapSize实现
        • 3.5 HeapEmpty实现
        • 3.6HeapDestroy 实现
      • 4.一句话总结:

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

由定义出发我们可以得到如下性质:

  1. 堆某一结点的值总是不大于或不小于其父结点的值。
  2. 大堆的根结点值最大,小堆根结点值最小

堆的结构:

typedef int DataType;
typedef struct Heap
{
	DataType* a;	//数据域
	int size;		//有效数据数目
	int capacity;	//容量
}Heap;

2.堆的创建 2.1 插入建堆

正如我们学习链表时,我们通过一个数据一个数据插入来创建一个链表,我们也可以通过插入数据来建立一个堆。在示例中,我们会创建一个小堆。

在此之前我们需要介绍一下:向上调整算法

2.1.1 向上调整算法

如图:我们初始时有一个小堆,即橘色曲线圈出的部分。我们要在这个堆后面插入一个数据为11的结点。通过向上调整算法,要把这个结点向上调整,维持堆的特性。

调整过程:

从图中我们可以发现,向上调整的路径:孩子结点—>父节点—>祖父结点—>曾祖父······

形象一点来说整个向上调整的过程就是一个后辈孩子寻祖宗的过程,我且称为”寻根“(虽然不是特别贴切hhh)

不扯了,我们总结一下向上调整算法的思路(维持小堆)

  1. 待调整的结点值同父结点比较大小

  2. 调整的结点值 < < <父结点小,则交换两个结点

    待调整的结点值 ≥ ge ≥父结点,向上调整结束,退出过程

  3. 重复1-2的过程,直至调整结点成为根结点时,退出过程

perception

  1. 有效的向上调整要求我们除了待调整结点,其余部分必须满足堆的特性。
  2. 在向上调整的过程中,有两个出口,一个是调整到根,还有就是发现父结点比待调整结点小时即可终止过程,这是由第一点保证的。
  3. 下 标 为 i 的 结 点 的 父 结 点 下 标 为 ⌊ ( i − 1 ) / 2 ⌋ 下标为i的结点的父结点下标为lfloor (i-1) /2rfloor 下标为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的实现

思路:

  1. 检查容量

    容量满则扩容

  2. 数组尾部添加数据

  3. 向上调整插入的结点

//插入数据
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 failedn");
			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+1times2^{1}+2times2^{2}+···+(h-2)times2^{h-2}+(h-1)times2^{h-1} qquad(1)\ 2S_h=0+1times2^{2}+2times2^{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−1​2i=(h−1)×2h−2−1−24−2h​
整 理 得 : 整理得: 整理得:
S h = ( h − 2 ) × 2 h + 2 ( 3 ) S_h=(h-2)times2^{h}+2qquad (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)+2qquad (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 向下调整算法

在这个状态下虽然不满足堆的条件但是,根结点左子树是一个小堆,根结点的右子树是一个小堆,这个时候我们便采取向下调整算法来将根结点向下调整,使其原结构成为堆

调整过程:

向下调整的思路:

  1. 在待调整的结点的左孩子与右孩子(存在的前提下)中选出值更小的结点

  2. 将选出的结点同待调整的结点进行比较

    待调整的结点 > > >选出的结点,交换两结点

    待调整的结点 ≤ le ≤选出的结点,调整结束,退出过程

  3. 重复过程1、2,直至待调整结点既无左孩子又无又孩子时,调整结束,退出过程

perception

  1. 有效的向下调整需要左右子树均为堆
  2. 调整过程有两个出口,一个是调整到叶子结点,还有一个是左右孩子均比待调整结点大,后者是由第一点保障的。
  3. 经过向下调整之后,根、左子树、右子树整体满足堆的性质

代码实现:

在选出左右孩子中小的那个的逻辑的实现是下面代码实现的十分优雅简洁,值得学习

回顾:下标为 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实现

分析:

此时面对任意一个随意的数组,要按照一定的顺序进行向下调整来将其改造成堆

而向下调整需要左右子树均为堆,我们选择从倒数第一个非叶子结点开始调整,调整完根结点终止。

因为倒数第一个非叶子结点之后的所有结点都是叶子结点,叶子结点左右孩子都为空,可以看成是一个堆(不违反堆的定义),那么倒数第一个叶子的左子树和右子树(存在的情况下)都为堆,此时便可以对倒数第一个非叶子结点运用向下调整。

因为是从后往前调整,后面的局部不断变为堆,那么对于前面的结点,左右子树都为堆,便也可以运用向下调整。(像是一个滚动的过程)

我们可以证明:经过这样的调整,原数组被改造成了一个堆

因为:经历了上述过程后,对于树的每一个局部,都是一个堆,那么整体便也是堆。

实现思路:

  1. 找到倒数第一个非叶子结点
  2. 从最后一个叶子结点开始到根结点结束,每个结点均向下调整一次

代码实现:

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^0times (h-1)+2^1times (h-2)+···+2^{h-3}times 2+2^{h-2}times1 qquad (1)\ 2S_h=2^1times (h-1)+2^2times (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−2​2iSh​=2h−1−(h−1)+1−22−2h−1​
整 理 得 : 整理得: 整理得:
S h = 2 h − h − 1 ( 3 ) S_h=2^h-h-1qquad(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 思路
  1. 把堆顶元素与最后一个元素交换
  2. 删除最后元素(即为堆顶元素)
  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.一句话总结:

堆这最核心的部分就是要去理解并灵活运用向上调整算法向下调整算法

珞到此我们这篇博客也接近尾声了。

希望大家能在阅读完后有所收获!!!这将是对我最大的激励!

敲代码,码字,作图不易,期待一个小小的点赞❤❤

如果你对博客内容有什么疑惑,或者对思考题有什么不解,很欢迎大家来和我交流讨论哦✔

本期所有的代码我将传到我的gitee仓库中,如有需要可自行下载

仓库传送门:数据结构

我们下篇博客见!

转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/835601.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号