目录
一.引言
在众多数据结构中,堆(Heap)以其独特的效率,完美解决了我们对动态优先级排序的需求。想象一下,你面对的是一组不断变化的数据,你需要随时高效地找出其中优先级最高(最大或最小)的那一个。如果使用普通数组,每一次添加或删除元素后,你都不得不耗费大量时间来重新维护这个“最重要”的元素。有没有一种数据结构,能完美充当这种动态的优先级队列,让你在高效获取队首元素的同时,还能快速地添加或删除元素呢?答案就是 堆(Heap)。
堆,作为一种特殊的完全二叉树,正是充当这种动态优先级队列的理想选择。它在操作系统、图算法、以及高效排序算法(如堆排序)中扮演着核心角色。本文将从最基础的树概念入手,引出 二叉树的精妙,并解释如何通过顺序存储来高效地表示二叉树,从而自然地过渡到我们今天的核心——堆。我们将深入剖析堆的内部工作机制,特别是其高效实现背后的关键接口,包括构建、插入、删除等。通过本文,你将彻底掌握堆的原理,并能运用简洁的 C 语言代码来实现一个功能完善的二叉堆!
二.树与二叉树
在理解堆之前,我们必须先从它的基础结构——树和二叉树——开始。堆本质上是一种特殊的二叉树,而二叉树的顺序存储方式,正是堆高效能的关键。
2.1 树(Tree):数据的层次世界
树是一种非常重要且应用广泛的非线性数据结构。它用于模拟具有层次关系的数据集合,例如文件系统、组织架构图、家谱等。

-
节点(Node): 树的基本组成单元,用于存储数据。
-
根节点(Root): 树中唯一没有父节点的节点,是整个结构的起始点。
-
父节点与子节点: 若节点 A 连接到节点 B,且 A 在 B 的上一层,则 A 是 B 的父节点,B 是 A 的子节点。
-
叶子节点(Leaf): 没有任何子节点的节点。
-
树的高度(或深度): 从根节点到最远叶子节点的最长路径上的边数(或节点数,取决于定义)。
注意:数的定义是递归的,每一个节点及其孩子都可以被看做子树。
2.2 二叉树(Binary Tree):结构受限的树
2.2.1 二叉树的概念
二叉树是树的一个重要特例,它对节点的子节点数量施加了严格的限制。
-
每个节点最多只有两个子节点。
-
这两个子节点被明确区分,分别称为左子节点和右子节点。

注意:对于任意的二叉树都是由以下几种情况复合而成的:

2.2.2 特殊的二叉树
-
满二叉树(Full Binary Tree): 树中所有层都填满了节点。在满二叉树中,除了叶子节点外,所有节点都有两个子节点。
-
完全二叉树(Complete Binary Tree): 这是我们研究堆时最重要的一类树。
-
它由满二叉树演变而来。
-
如果树的深度为 h,除了第 h 层外,其他各层(1 到 h−1)的节点数都达到了最大值。
-
第 h 层的节点都连续集中在最左边。
-
简单来说,如果将所有节点从上到下、从左到右编号,一个完全二叉树的节点数和这个编号序列是一致且连续的。
-

2.2.3 二叉树的性质


typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
}
// 三叉链
struct BinaryTreeNode
{
struct BinTreeNode* parent; // 指向当前结点的双亲
struct BinTreeNode* left; // 指向当前结点左孩子
struct BinTreeNode* right; // 指向当前结点右孩子
BTDataType data; // 当前结点值域
}
然而,对于完全二叉树,其结构的高度紧凑性,使得我们可以使用数组(顺序存储)来实现它,这避免了复杂的指针操作,极大地提高了存储和访问效率。

对于一个存储在数组中的完全二叉树,我们从索引 0 开始编号。任一节点的索引 i 与其父节点和子节点的位置关系是固定的:
| 节点关系 | 数组索引计算公式 |
|---|---|
| 左子节点 | 2i + 1 |
| 右子节点 | 2i + 2 |
| 父节点 | (i - 1) / 2 |
这种通过简单的下标运算就能找到父子节点的能力,使得我们可以在 O(1) 的时间内定位元素关系,为接下来的堆(Heap)操作提供了高性能的基石。
三.堆的核心接口实现
3.1 堆(Heap)的概念
堆是一种特殊的完全二叉树,它必须满足以下两个条件:
-
结构性:必须是完全二叉树。
-
堆序性:任一节点的值都必须大于或等于(或小于或等于)其子节点的值。
-
满足“父节点 ≥ 子节点”的堆称为大顶堆(Max-Heap),堆顶元素是最大值。
-
满足“父节点 ≤ 子节点”的堆称为小顶堆(Min-Heap),堆顶元素是最小值。
-

3.2 基础结构与初始化
堆通常用结构体封装,包含存储元素的数组、当前元素个数和容量。
typedef int HPDataType; // 假设堆中存储整数类型
typedef struct Heap
{
HPDataType* arr;
int size;
int capacity;
} Heap;
// 堆的初始化
void HeapInit(Heap* hp)
{
assert(hp);
hp->arr = NULL;
hp->size = 0;
hp->capacity = 0;
}
// 堆的销毁
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->arr);
hp->arr = NULL;
hp->size = 0;
hp->capacity = 0;
}
3.3 插入操作:HeapPush 与向上调整
插入操作的核心是将新元素添加到数组末尾(即完全二叉树的下一个空位),然后通过向上调整 (AdjustUp) 确保新元素不破坏堆序性。
向上调整 (AdjustUp):将新插入的节点与其父节点比较,如果新节点更小(小根堆),则交换它们,并持续向上重复此过程,直到到达根节点或满足堆序性为止。

// 交换函数
void Swap(HPDataType* a, HPDataType* b)
{
HPDataType tmp = *a;
*a = *b;
*b = tmp;
}
// 向上调整算法:从下往上维护堆序性
void AdjustUp(HPDataType* arr, int child)
{
int parents = (child - 1) / 2;
while (child > 0)
{
// arr[child] < arr[parents] 表明是小顶堆,子节点比父节点小,需要交换
if (arr[child] < arr[parents])
{
Swap(&arr[child], &arr[parents]);
child = parents; // 新节点位置变成父节点
parents = (child - 1) / 2;
}
else
{
break; // 满足堆序性,停止调整
}
}
}
// 堆的插入
void HeapPush(Heap* hp, HPDataType x)
{
assert(hp);
// 1. 扩容
if (hp->size == hp->capacity)
{
int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;
HPDataType* tmp = (HPDataType*)realloc(hp->arr, newcapacity * sizeof(HPDataType));
if (tmp == NULL)
{
perror("realloc fail!\n");
exit(1);
}
hp->arr = tmp;
hp->capacity = newcapacity;
}
// 2. 将新元素插入数组末尾
hp->arr[hp->size] = x;
hp->size++;
// 3. 向上调整
AdjustUp(hp->arr, hp->size - 1);
}
3.4 删除操作:HeapPop 与向下调整
删除操作永远针对堆顶元素(即数组第一个元素),因为堆的应用通常需要获取“最重要”的元素。其步骤如下:
-
将堆顶元素(要删除的元素)与数组末尾元素交换。
-
缩小堆的有效范围(
size--),逻辑上删除原堆顶元素。 -
对新的堆顶元素(原数组末尾元素)进行向下调整 (AdjustDown)。

向下调整 (AdjustDown):从上往下维护堆序性。对于一个父节点,从它的左右子节点中选出较小(小顶堆)或较大(大顶堆)的那一个,若子节点满足交换条件,则交换,并持续向下重复,直到到达叶节点或满足堆序性为止。

// 交换函数
void Swap(HPDataType* a, HPDataType* b)
{
HPDataType tmp = *a;
*a = *b;
*b = tmp;
}
// 向下调整算法:从上往下维护堆序性
void AdjustDown(HPDataType* arr, int n, int parents)
{
int child = (parents * 2) + 1; // 假设左孩子是较小的
while (child < n) // 确保子节点在有效范围内
{
// 找出左右孩子中较小的那个 (小顶堆)
if ((child + 1) < n && arr[child + 1] < arr[child])
{
child++; // child 指向较小的那个孩子
}
// 如果较小的孩子比父节点还小,则交换
if (arr[child] < arr[parents])
{
Swap(&(arr[child]), &(arr[parents]));
parents = child; // 继续向下调整
child = (parents * 2) + 1;
}
else
{
break; // 满足堆序性,停止调整
}
}
}
// 堆的删除(删除堆顶元素)
void HeapPop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
// 1. 堆顶和堆底元素交换
Swap(&(hp->arr[0]), &(hp->arr[hp->size - 1]));
// 2. 堆大小减 1
hp->size--;
// 3. 对新的堆顶元素向下调整
AdjustDown(hp->arr, hp->size, 0);
}
3.5 其他接口
其他的辅助接口实现简单,主要用于查看堆顶元素、获取堆的大小、判断堆是否为空等。
// 获取堆顶元素
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
return hp->arr[0];
}
// 获取堆的大小
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
// 判断堆是否为空
bool HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
3.6 性能分析
假设满二叉树的节点个数为,高度为
。显然
,因此满二叉树的高度
与节点总数
的关系是
,这个结果保证了树的结构是紧凑且平衡的,高度增长非常缓慢。
从上述分析可知,堆的高性能体现在对数时间复杂度的动态操作上。堆的优势在于,不论数据量有多大,插入和删除都只与树的高度
相关。
| 接口名称 | 核心操作 | 时间复杂度 |
|---|---|---|
| HeapPush (插入) | 向上调整 | |
| HeapPop (删除) | 向下调整 | |
| HeapTop (取顶) | 数组访问 |
感谢各位读者点赞、收藏,+++++关注!
902

被折叠的 条评论
为什么被折叠?



