二叉树前传 - 树与堆的概念结构及堆的实现

目录

  • 前言
  • 1. 树概念及结构
    • 1.1 树的概念
    • 1.2 树的相关概念
    • 1.3 树的表示
  • 2. 简要了解二叉树概念
    • 2.1 二叉树的概念
    • 2.2 特殊的二叉树
    • 2.3 现实中的二叉树
  • 3. 堆
    • 3.1 堆的概念及结构
    • 3.2 堆的实现


前言

前面学习过的顺序表链表等都是线性表(结构),逻辑结构上数据是诶个存储的,呈现出一条线性。而今天要了解的树就不再是一种线性结构了。

1. 树概念及结构

1.1 树的概念

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


二叉树前传 - 树与堆的概念结构及堆的实现_第1张图片
数据结构中的树
二叉树前传 - 树与堆的概念结构及堆的实现_第2张图片

  • 有一个特殊的结点(最开始的结点),称为根结点,根节点没有前驱结点

  • 除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱(父节点),可以有0个或多个后继(子结点)

  • 树是递归定义的

在上图结构中对应的父子关系为:结点A为父结点,BCD则为子结点,而子结点也可能同时为父节点,相应的,B为EF的父节点,EF为B的子结点…

任何一个结点都有0~N个子结点。

注意:树形结构中,子树之间不能有交集,否则就不是树形结构

二叉树前传 - 树与堆的概念结构及堆的实现_第3张图片

1.2 树的相关概念

数据结构中树的概念本质是:树的植物本身 + 人类亲缘关系的概念的结合。
二叉树前传 - 树与堆的概念结构及堆的实现_第4张图片

注:加粗为重要概念

节点的度:一个节点含有的子树(孩子)的个数称为该节点的度; 如上图:A的为6

叶节点或终端节点:度(孩子)为0的节点称为叶节点; 如上图:B、C、H、I…等节点为叶节点

非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G…等节点为分支节点

双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图:A是B的父节点

孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点

亲兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点

树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6

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

树的高度或深度:树中节点的最大层次; 如上图:树的高度为4

堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点

节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先

子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙

森林:由m(m>0)棵互不相交的树的集合称为森林;

1.3 树的表示

树结构相对线性表就比较复杂了,要存储表示起来也比较麻烦,既然保存值域,也要保存结点和结点之间的关系。
这里就简单的了解其中最常用的孩子兄弟表示法

typedef int DataType;
struct Node
{
	struct Node* _firstChild1;   // 左边第一个孩子结点
	struct Node* _pNextBrother; // 指向其下一个亲兄弟结点
	DataType _data; // 结点中的数据域
};

二叉树前传 - 树与堆的概念结构及堆的实现_第5张图片
父亲结点永远指向左边第一个孩子结点,兄弟之间互相用兄弟指针链接起来。

2. 简要了解二叉树概念

2.1 二叉树的概念

一棵二叉树是结点的一个有限集合,该集合:

  1. 或者为空
  2. 由一个根节点加上两棵别称为左子树和右子树的二叉树组成

二叉树前传 - 树与堆的概念结构及堆的实现_第6张图片

从上图可以看出:

  1. 二叉树不存在度大于2的结点
  2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树

注意:对于任意的二叉树都是由以下几种情况复合而成的:
二叉树前传 - 树与堆的概念结构及堆的实现_第7张图片

2.2 特殊的二叉树

  1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是 2k - 1,则它就是满二叉树
  2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
    换句话说,完全二叉树的前k-1层都是满的,最后一层满或不满都可以,但是如果不满要求从左到右的结点是必须连续。
    二叉树前传 - 树与堆的概念结构及堆的实现_第8张图片

2.3 现实中的二叉树

二叉树前传 - 树与堆的概念结构及堆的实现_第9张图片
嗯,很完美的满二叉树,弹弓迷的梦中情树。

本文只是简单引出概念,后面的文章会着重介绍二叉树。

3. 堆

现实中通常把堆(一种二叉树)使用顺序结构的数组来存储,此堆为数据结构中的堆,而非操作系统中对内存区域划分中的堆。

3.1 堆的概念及结构

如果有一个关键码的集合K = { K 0 K_0 K0 K 1 K_1 K1 K 2 K_2 K2 ,…, K n − 1 K_{n-1} Kn1 },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足: K i K_i Ki <= K 2 ∗ i + 1 K_{2*i+1} K2i+1 K i K_i Ki <= K 2 ∗ i + 2 K_{2*i+2} K2i+2 ( K i K_i Ki >= K 2 ∗ i + 1 K_{2*i+1} K2i+1。且 K i K_i Ki = K 2 ∗ i + 2 K_{2*i+2} K2i+2) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

堆的性质:

  1. 堆中某个节点的值总是不大于或不小于其父节点的值;
  2. 堆总是一棵完全二叉树。

二叉树前传 - 树与堆的概念结构及堆的实现_第10张图片
小根堆特点:任何一个(父)结点的值 <= 孩子的值
二叉树前传 - 树与堆的概念结构及堆的实现_第11张图片
相反地,大根堆特点:任何一个(父)结点的值 >= 孩子的值

3.2 堆的实现

因为堆是完全二叉树,因此用数组把它每一层的结点的值依次存储到数组中,类似于数组实现顺序表。

这里出现了一个问题,由于数组是用下标存储和访问的,那么每个结点的父子关系是怎样的?

不妨探究一下父子结点在数组中对应下标的位置关系:
二叉树前传 - 树与堆的概念结构及堆的实现_第12张图片
根据上图的位置关系,有大佬就总结出了这么一条计算左右孩子结点下标位置的公式:

  • 左孩子结点下标 = 父结点下标 * 2 + 1
  • 右孩子结点下标 = 父结点下标 * 2 + 2

相应地,根据孩子结点下标来算出父节点位置下标:

  • 父结点下标 = (左孩子结点下标 - 1) / 2
  • 父结点下标 = (右孩子结点下标 - 2) / 2

左孩子下标都是奇数,右孩子都是偶数,根据除法性质,可以不用考虑左右孩子,一律使用 (孩子结点下标 - 1) / 2,即可算出目标位置。

实现堆之前,先要想清楚,想象的逻辑结构是二叉树,而实际操纵的是数组,因此要利用下标,跳跃着寻找它的左右孩子结点,跳出数组大小就意味着到空了。

堆的最直观的特点是可以快速找出最大或者最小的元素,堆允许增删查改,修改后依然要让其保持为大堆/小堆。


下面来实现小堆结构(大堆结构只不过是与小堆相反的)。

  • 创建堆结构
typedef int DataType;
//后面更改数据类型只需要该int
typedef struct Heap
{
	DataType* heap;
	int size;
	int capacity;
}Heap;
  • 初始化堆
//赋值为初始状态
void InitHeap(Heap* php)
{
	assert(php);
	php->heap = NULL;
	php->size = php->capacity = 0;
}
  • 插入堆

插入数据为整个堆的实现中最难的步骤,因为插入数据后还要保持其小堆结构,如果不符合堆的顺序就要使用向上调整算法,这里是向上调整的是它的祖先结点。
二叉树前传 - 树与堆的概念结构及堆的实现_第13张图片
如上图,如果插入的数据是10,不符合小堆的顺序(父结点的值 <= 孩子结点的值),因此需要向上与它的祖先结点进行调整交换,直到符合小堆的顺序。

根据上面通过孩子下标计算父亲结点下标的公式,即可算出对应的祖先结点。

void Swap(DataType* ele1, DataType* ele2)
{
	DataType tmp = *ele1;
	*ele1 = *ele2;
	*ele2 = tmp;
}

//向上调整
void AdjustUp(DataType* heap, int childIndx)
{
	int fatherIndex = (childIndx - 1) / 2;
	//结束条件有二:
	//1.孩子结点下标到0了,说明它到了堆顶无需交换了
	//2.当孩子比父亲大也停止
	while (childIndx > 0 && heap[childIndx] < heap[fatherIndex])
	{
		//迭代交换
		Swap(&heap[childIndx], &heap[fatherIndex]);

		childIndx = fatherIndex;
		fatherIndex = (childIndx - 1) / 2;
	}
}

//插入data到堆中
void PushHeap(Heap* php, DataType data)
{
	assert(php);
	//扩容
	if (php->heap == php->capacity)
	{
		php->capacity = php->capacity == 0 ? 4 : php->capacity * 2;
		DataType* tmp = (DataType*)realloc(php->heap, sizeof(DataType) * php->capacity);
		if (!tmp)
		{
			perror("realloc fail");
			exit(-1);
		}
		php->heap = tmp;
	}
	php->heap[php->size] = data;
	php->size++;

	//插入数据后向上调整
	AdjustUp(php->heap, php->size - 1);
}
  • 删除堆顶元素

该操作的实际意义是:找次大或者次小的数据

问题是如何删除?如果采用顺序表的从前向后挪动覆盖删除,会有两个弊端:

  1. 挪动删除的时间复杂度为O(N),就体现不出堆的作用了。
  2. 堆中的父子结点关系全乱了。

这时就有人想出了一种方法:把堆顶的元素和最后一个元素交换后删除,因为堆的底层是数组实现的,数组的尾删效率非常高。交换后一定不满足小堆的顺序了,因此这里需要使用向下调整算法
具体地,向下寻找孩子结点,因为左右子树都是小堆,因此找出两个孩子中值较小的进行交换,直到满足小堆顺序。
二叉树前传 - 树与堆的概念结构及堆的实现_第14张图片

向下调整与向上调整地时间复杂度均为O(logN),因为堆是完全二叉树,如果树的高度是k层,它的结点范围在[2k-1,2k - 1],那么[k = l o g 2 N + 1 log_2N + 1 log2N+1,k = l o g 2 ( N + 1 ) log_2(N + 1) log2(N+1)],把对结果影响不大的项数都去掉后,复杂度即为O(logN)。

//向下调整
void AdjustDown(DataType* heap, int fatherIndex, int heapSize)
{
	int minchild = fatherIndex * 2 + 1;
	//孩子下标得小于堆的最大下标才有的玩
	while (minchild < heapSize)
	{
		//假设两个孩子中小的一个为左孩子
		//再判断右孩子是否大于左孩子
		if (minchild + 1 < heapSize && heap[minchild] > heap[minchild + 1])
		{
			++minchild;
		}
		//父亲大于孩子就调整
		if (heap[minchild] < heap[fatherIndex])
		{
			Swap(&heap[minchild], &heap[fatherIndex]);
			fatherIndex = minchild;
			minchild = fatherIndex * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

//删除堆顶元素
void PopHeap(Heap* php)
{
	assert(php);
	assert(!isHeapEmpty(php));

	//交换后删除
	Swap(&php->heap[0], &php->heap[php->size - 1]);
	php->size--;

	AdjustDown(php->heap, 0, php->size);
}
  • 返回堆顶元素
//堆不能为空才有数据可以返回
DataType HeapTop(Heap* php)
{
	assert(php);
	assert(!isHeapEmpty(php));

	return php->heap[0];
}
  • 堆判空
//size = 0说明堆为空
bool isHeapEmpty(Heap* php)
{
	assert(php);
	return php->size == 0;
}
  • 返回堆的元素个数
//简单的不提
int elementsNumofHeap(Heap* php)
{
	assert(php);
	return php->size;
}
  • 打印堆数据
//没啥好说的
void PirintHeap(Heap* php)
{
	assert(php);

	for (int i = 0; i < php->size; ++i)
	{
		printf("%d ", php->heap[i]);
	}
	printf("\n");
}
  • 销毁堆
//注意释放动态开辟的数组
//并把对应的成员置成初始状态
void DestroyHeap(Heap* php)
{
	assert(php);

	free(php->heap);
	php->heap = NULL;
	php->size = php->capacity = 0;
}

以上就是堆的实现代码,在了解了堆的向下调整算法后,下一篇文章会介绍堆的实际应用:堆排序和TopK问题。


本篇完

你可能感兴趣的:(数据结构初阶,数据结构)