数据结构:二叉树的数组结构以及堆的实现详解

发布于:2025-02-27 ⋅ 阅读:(20) ⋅ 点赞:(0)

目录

一.树与二叉树

1.树的概念与相关术语:

2.二叉树:

(1)定义:

(2)特殊的二叉树:

(3)完全二叉树

(4)二叉树的存储结构:

二.堆的概念与性质

1.堆的概念:

2.堆的重要性质:

三.堆的具体代码实现

1.堆的结构:

2.堆的初始化与销毁:

3.向上调整算法和向下调整算法:

(1)向上调整算法:

(2)向下调整算法:

(3)总结两种算法的区别:

(4)堆元素的插入:

(5)堆元素的删除:

(6)取堆顶元素:

(7)堆的判空:

四.堆的具体应用

1.基于已有堆结构的堆排序(通过不断取堆顶):

2.数组建堆,先向下调整建堆,再向上调整:


一.树与二叉树

1.树的概念与相关术语:

     概念

       树是⼀种非线性的数据结构,它是由 n(n>=0) 个有限结点组成⼀个具有层次关系的集合。把它叫做 树是因为它看起来像⼀棵倒挂的树,也就是说它是根朝上,而叶朝下的。

        有一个特殊的结点,称为根结点,根结点没有前驱结点。

         除根结点外,其余结点被分成 M(M>0) 个互不相交的集合 T1、T2、……、Tm ,其中每⼀个集合 Ti( 又是⼀棵结构与树类似的子树。每棵⼦树的根结点有且只有⼀个前驱,可以 有 0 个或多个后继。因此,树是递归定义的

相关术语

父结点/双亲结点:若⼀个结点含有子结点,则这个结点称为其子结点的父结点

子结点/孩子结点:⼀个结点含有的⼦树的根结点称为该结点的⼦结点

结点的度:⼀个结点有⼏个孩⼦,他的度就是多少

树的度:⼀棵树中,最⼤的结点的度称为树的度

叶子结点/终端结点:度为 0 的结点称为叶结点

分支结点/非终端结点:度不为 0 的结点

兄弟结点:具有相同父结点的结点互称为兄弟结点(亲兄弟)

结点的层次:从根开始定义起,根为第 1 层,根的⼦结点为第 2 层,以此类推

树的高度或深度:树中结点的最大层次

路径:⼀条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列

2.二叉树:

(1)定义:

二叉树满足以下两个特点:

       1. 二叉树不存在度大于 2 的结点

       2. 二叉树的子树有左右之分,次序不能颠倒,因此⼆叉树是有序树

2)特殊的二叉树:

 满二叉树
        ⼀个二叉树,如果每⼀个层的结点数都达到最⼤值,则这个二叉树就是满二叉树。也就是说,如果⼀ 个二叉树的层数为 K ,且结点总数是2的k次方-1 ,则它就是满二叉树。

(3)完全二叉树

        完全二叉树是效率很高的数据结构,完全二叉树是由满⼆叉树而引出来的。

        对于深度为 K 的,有 n 个 结点的⼆叉树,当且仅当其每⼀个结点都与深度为K的满⼆叉树中编号从 1 至 n 的结点⼀⼀对应时称 之为完全⼆叉树。要注意的是满⼆叉树是⼀种特殊的完全⼆叉树

                                   

简单来说:完全二叉树和满二叉树的区别·就在于那它们的区别主要在于结构上的差异,满二叉树要求每一层都填满,而完全二叉树只要求前几层填满最后一层可以填到最左边但不一定全部填满

(4)二叉树的存储结构:

顺序存储(即下面我要说的堆链式结构

(我在这篇文章主要说明的是二叉树的顺序存储,链式结构的介绍我会详细再另外写一篇文章)


二.堆的概念与性质

1.堆的概念:

     堆(Heap)是计算机科学中的一种特殊数据结构,它可以被视为一棵完全二叉树的数组表示形式。堆的特点是节点的值总是不大于或不小于其父节点的值,这种性质使得堆的根节点总是该数据结构中的最大值或最小值

     其中,堆的根节点是该数据结构中的最大值则称该堆为大根堆

     堆的根节点是该数据结构中的最小值则称该堆为小根堆

     堆满足以下特性:

*堆中某个结点的值总是不大于或不小于其父结点的值

*堆总是⼀棵完全二叉树

2.堆的重要性质:

对于具有 n 个结点的完全⼆叉树,如果按照从上至下从左至右的数组顺序对所有结点从 0 开始编号,则对于序号为 i 的结点有:

        1. 若 i>0 , i 位置结点的双亲序号: (i-1)/2 ; i=0 , i 为根结点编号,无双亲结点

        2. 若 2i+1,左孩子序号: 2i+1 , 2i+1>=n 否则无左孩子

        3. 若 2i+2,右孩子序号: 2i+2 , 2i+2>=n 否则无右孩子


三.堆的具体代码实现

1.堆的结构:

//堆的结构
typedef int HPDataType;
typedef struct Heap
{
	HPDataType* arr;
	int size;//有效数据个数
	int capacity;//空间大小
}HP;

2.堆的初始化与销毁:

//堆的初始化
void HPInit(HP* php)
{
	assert(php);
	php->arr = NULL;
	php->size = php->capacity = 0;
}

//堆的销毁
void HPDestroy(HP* php)
{
	assert(php);
	if (php->arr)
	{
		free(php->arr);
	}
	php->arr = NULL;
	php->capacity = php->size = 0;
}

3.向上调整算法和向下调整算法:

(1)向上调整算法:

目标

将新插入的节点或被修改的子节点调整到合适位置,使其满足堆性质
步骤
从当前节点开始,比较其与父节点的值
如果违反堆性质(如最小堆中子节点值更小),则交换两者
重复上述过程,直到到达根节点或满足堆性质

void AdjustUp(HP* hp, int child)//传入堆和调整的起始孩子
{
	int parent = (child - 1) / 2;//公式
	while (child > 0)//注意退出条件
	{
		//建小根堆
		if (hp->arr[parent] > hp->arr[child])
		{
			swap(&hp->arr[parent], &hp->arr[child]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
		{
			break;//如果不用换了。说明这个堆正常了,和adjustdown的退出原因一样
		}
	}
}

(2)向下调整算法:

目标

将删除根节点后的最后一个元素调整到堆顶,或修复被修改的根节点
步骤
找到当前节点的子节点(左、右),选出符合堆性质的子节点(如最小堆中较小的子节点)
如果当前节点违反堆性质(如比子节点更大),则与子节点交换
重复上述过程,直到叶子节点或满足堆性质

void AdjustDown(HP* hp, int parent, int n)//n是向下调整的下界范围
{
	int child = parent * 2 + 1;//公式
	while (child < n)
	{
		//建小根堆
		if (child + 1 < n && hp->arr[child + 1] < hp->arr[child])//child+1的目的是确保两个孩子结点都是有意义的结点
		{
			child++;
		}
		if (hp->arr[parent] > hp->arr[child])
		{
			swap(&hp->arr[parent], &hp->arr[child]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			//我最小的孩子都比父节点大,那么这个堆就是正常堆了,直接退出
			break;
		}
	}
}
(3)总结两种算法的区别:

1. 方向不同
向上调整:

从子节点向根节点方向调整
例如,插入新节点时,将其放在堆末尾,然后逐层与父节点比较并交换
向下调整:

从父节点向叶子节点方向调整
例如,删除根节点后,将末尾节点移到根位置,逐层与子节点比较并交换

2. 应用场景不同
向上调整:
插入新元素:新元素被添加到堆的末尾,可能破坏堆性质,需向上调整
节点值增大:在最大堆中,若某节点的值变大,可能需要向上调整
向下调整
删除根节点:删除堆顶后,将末尾节点移到堆顶,需向下调整
节点值减小:在最大堆中,若某节点的值变小,可能需要向下调整
构建堆:从最后一个非叶子节点开始,逐个向下调整,时间复杂度为 O(n)


3. 操作步骤不同
向上调整:
比较当前节点与父节点
若违反堆性质(如最大堆中当前节点值更大),则交换两者
重复直到到达根节点或满足堆性质
向下调整:
比较当前节点与左右子节点
选择最大(或最小)的子节点(根据堆类型)
若子节点违反堆性质,则交换两者
重复直到到达叶子节点或满足堆性质


4. 时间复杂度
常数因子差异:向下调整需比较两个子节点,操作略复杂;向上调整仅需比较父节点

以下是时间复杂度的具体算法

(4)堆元素的插入:
void HeapPush(HP* hp, int x)
{
	assert(hp);
	//判断空间是否已满
	if (hp->capacity == hp->size)
	{
		//注意capacity的单位是个,不是字节
		int newcapacity = hp->capacity == 0 ? 4 : 2 * hp->capacity;//第一次开就开四个
		//空间开辟
		//注意这里是realoc不是malloc
		//所有链式结构的用malloc,咱们开辟新节点
		//所有线性结构,底层是数组的,咱们就realoc
		//因为咱们要保留数组之前的数据,所以必须要扩容而不是重新开辟
		HP* newspace = (HP*)realloc(hp->arr, newcapacity * sizeof(int));
		if (newspace == NULL)
		{
			perror("malloc wrong!");
			exit(1);
		}
		hp->arr = newspace;
		hp->capacity = newcapacity;
	}
	//直接插入
	//size是当前有效元素个数,对应的也是下一个该存放元素位置的下标
	hp->arr[hp->size] = x;
	hp->size++;
	//插入元素之后配套进行向上调整
	AdjustUp(hp, hp->size - 1);//注意传入的是下标
}
(5)堆元素的删除:
void HeapPop(HP* hp)
{
	//几乎所有的数据结构,在插入元素是要断言这个数据结构是否存在
	//在删除元素的时候断言这个数据结构中要含有元素
	assert(!empty(hp));
	//删除根元素
	swap(&hp->arr[0], &hp->arr[hp->size - 1]);
	hp->size--;
	//配套使用向下调整算法
	AdjustDown(hp, 0, hp->size);
}
(6)取堆顶元素:
int HeapTop(HP* hp)
{
	assert(!empty(hp));
	//empty里面判断了这个堆必须存在,所以说不用再在这判断了、
	return hp->arr[0];
}
(7)堆的判空:
bool empty(HP* hp)
{
	assert(hp);
	return hp->size == 0;
}

四.堆的具体应用

1.基于已有堆结构的堆排序(通过不断取堆顶):

// 1、需要堆的数据结构
// 2、空间复杂度 O(N)
void HeapSort(int* a, int n)
{
 HP hp;
 for(int i = 0; i < n; i++)
 {
 HPPush(&hp,a[i]);
 }
 int i = 0;
 while (!HPEmpty(&hp))
 {
 a[i++] = HPTop(&hp);
 HPPop(&hp);
 }
 HPDestroy(&hp);
}

2.数组建堆,先向下调整建堆,再向上调整:

// 升序,建⼤堆
// 降序,建⼩堆
// O(N*logN)
void HeapSort(int* a, int n)
{

     // a数组直接建堆 O(N)
 for (int i = (n-1-1)/2; i >= 0; --i)
 {
     AdjustDown(a, n, i);
 }
     // O(N*logN)
     int end = n - 1;
 while (end > 0)
 {
     Swap(&a[0], &a[end]);
     AdjustDown(a, end, 0);
     --end;
 }
}

欧克,到这里就差不多把数组结构的堆介绍完了,更多的就是堆的Top-K问题了,感兴趣的可以先去了解,我后续会写关于它的解析

那就这样吧

全文终