【数据结构】从树到堆:揭开二叉堆的神秘面纱与高效实现!

目录

一.引言

二.树与二叉树

2.1 树(Tree):数据的层次世界

2.2 二叉树(Binary Tree):结构受限的树

2.2.1 二叉树的概念

2.2.2 特殊的二叉树

2.2.3 二叉树的性质

三.堆的核心接口实现

3.1 堆(Heap)的概念

3.2 基础结构与初始化

3.3 插入操作:HeapPush 与向上调整

3.4 删除操作:HeapPop 与向下调整

3.5 其他接口

3.6 性能分析


一.引言

       在众多数据结构中,堆(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 性能分析

       假设满二叉树的节点个数为N,高度为h。显然N=2^{0}+2^{1}+2^{3}+...+2^{h-1}=2^{h}-1,因此满二叉树的高度h与节点总数N的关系是h=\log (N+1),这个结果保证了树的结构是紧凑且平衡的,高度增长非常缓慢。

       从上述分析可知,堆的高性能体现在对数时间复杂度的动态操作上。堆的优势在于,不论数据量N有多大,插入和删除都只与树的高度\log N相关。 

接口名称核心操作时间复杂度
HeapPush (插入)向上调整O(logN)
HeapPop (删除)向下调整O(logN)
HeapTop (取顶)数组访问O(1)

       感谢各位读者点赞、收藏,+++++关注!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值