二叉树和堆的讲解和实现(图解+代码/C语言)

今天和大家分享的是二叉树的实现,关于遍历二叉树部分均采用递归的方式实现,最后附上部分OJ题供大家练习。
在这里插入图片描述

一、树的概念及结构

1.1 树的概念

树是一种非线性的数据结构,其理论模型像一棵倒立的树,因此得名。我们通常将最上方的结点成为根结点,它是没有先驱结点的。而其余结点都有且仅有一个先驱结点,也就是说,树结构中不存在通路,也就是不存在交集。
如下图,是几种树结构。
在这里插入图片描述
但是下面几种,则不是树结构
在这里插入图片描述

1.2 树的相关概念

我们用以下图片来讲解树的相关概念
在这里插入图片描述

节点的度:一个节点含有的子树的个数称为该节点的度。如上图:A的为2,B的度为3,D的度为0。
叶节点或终端节点:度为0的节点称为叶节点。 如上图:D、H、E、F、G为叶节点。
非终端节点或分支节点:度不为0的节点被称为分支结点。如上图:A、B、C为分支节点。
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。如上图:A是B的父节点,B是D、H、E的父节点。
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点。 如上图:B是A的孩子节点,D、H、E都是B的孩子节点。
兄弟节点:具有相同父节点的节点互称为兄弟节点。 如上图:B、C是兄弟节点,D、H、E是兄弟结点,但E、F不是兄弟结点。
树的度:一棵树中,最大的节点的度称为树的度。如上图:B的度最大,为3,因此树的度为3。
节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推。如上图:A为第一层,B、C为第二层,其余为第三层。
树的高度或深度:树中节点的最大层次。如上图:树的高度为3。
堂兄弟节点:双亲在同一层的节点互为堂兄弟。如上图:D、H、E和F、G互为堂兄弟节点。
祖先:从根到该节点所经分支上的所有节点。如上图:A是所有节点的祖先,B是D、H、E的祖先。
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙,D、H、E为B的子孙。
森林:由m(m>0)棵互不相交的树的集合称为森林。一棵树的情况也是森林,如上图就是一个森林。

1.3 树的表示

此处讲的是树,而树的每一个结点不一定确定,因此我们最常用的是孩子兄弟表示法,如下。

typedef int Datatype;
struct Node
{
	struct Node* first_Child; // 第一个孩子结点
	struct Node* next_Brother; // 指向下一个兄弟结点
	Datatype data; // 数据域
}

在这里插入图片描述

二、二叉树的概念及结构

2.1 概念

二叉树,故名思意就是分叉只有两个,即每一个树结点最多存在两个孩子结点。同时需要注意,这两个孩子结点有左右之分(如下图),因此二叉树也是有序树
在这里插入图片描述

2.2 二叉树的性质

  1. 若规定根节点层数为 1,则一棵非空二叉树的第 i 层上最多有 2(i-1) 个结点。
  2. 若规定根节点层数为 1,则深度为 h 的二叉树的最大结点数是 2h-1。
  3. 对任何一棵二叉树, 如果度为 0 其叶结点个数为 n0, 度为 2 的分支结点个数为 n2,则有 n0=n2+1。
  4. 若规定根节点层数为 1,具有 n 个结点的满二叉树的深度,h= log2(n+1)。
  5. 对于具有 n 个结点的完全二叉树,如果按照从上至下、从左至右的数组顺序,对所有节点从0开始编号,则对于序号为i的结点有:
    • 若 i > 0,i 位置节点的双亲序号:( i - 1 ) / 2;i = 0时,i 为根节点编号,无双亲节点;
    • 若 2i + 1 < n,左孩子序号:2i + 1;2i + 1 >= n 则无左孩子;
    • 若 2i + 2 < n,右孩子序号:2i + 2;2i + 2 >= n 则无右孩子。

2.3 二叉树的存储结构

二叉树一般有两种储存方式,一种是顺序结构,另一种则是链式结构。但是前者的使用条件有些限制。

2.3.1 二叉树的顺序结构

顺序结构的应用场景通常局限于完全二叉树,主要原因在于,非完全二叉树用顺序结构储存会存在空间的浪费,以及不便于查找是否存在结点。
在这里插入图片描述

2.3.2 二叉树的链式结构

相比之下二叉树更适合链式结构,一方面是空间利用率高,另一方是更符合其逻辑结构。二叉树其实就像有分叉的链表,分叉处便是决定左右孩子处。
在这里插入图片描述

三、二叉树的顺序结构及实现

3.1 二叉树的顺序结构

上文我们已经说到,普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费,而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆,使用顺序结构的数组来存储,需要注意的是这里的堆和地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段

3.2 堆的概念及结构

堆是一种数据结构,是一种特殊的二叉树。堆首先是一种完全二叉树,其次,每个结点的孩子结点均不大于或不小于其父结点
设存在集合 K = {k0,k1,k2,…,kn},将 K 按照完全二叉树的顺序依次存入一维数组,且满足( ki <= k2i+1 , ki <= k2i+2,i = 0,1,2,…,n),则称为小堆(小根堆,最小堆),若上述不等式为 >= ,则称为大堆(大根堆,最大堆)。
在这里插入图片描述

3.3 堆的实现

为了方便大小堆的创建,使用了预处理和必要的重定义,内容如下:

typedef int HPDataType;
typedef struct Heap
{
	HPDataType* _a;
	int _size;
	int _capacity;
}Heap;
#define judge <
// < 为小堆,> 为大堆

3.3.1 堆的向下调整算法

对于一个结点,当其左右子树均为同一类型堆(大堆或小堆)时,可以对该节使用向下调整算法,首先我们需要找出较小的孩子结点,然后使该结点和较小的孩子结点交换,这样就使得该节点和其左右子树共同成为堆。
在这里插入图片描述

3.3.2 堆的向上调整算法

相比于向下调整算法,向上调整算法对子树和父结点没有要求,仅需不断比较然后前移交换即可。
在这里插入图片描述

3.3.3 堆的创建

我们通过读取一个数组的内容来创建一个堆。这里主要用两种方法进行创建,一种是向上调整,一种是向下调整。
向上调整的思路:我们每插入一个数字就向上调整,这样就保证了已经插入的所有数据组成堆,插入的时间复杂度为 O(log2n),建堆的时间复杂度为 O(n*log2n);
向下调整的思路:我们先将数组全部插入堆中,然后按照其逻辑结构,从第一个非叶子结点的结点开始向下调整,插入的时间复杂度为O(log2n),建堆时间复杂度为 O(n)。

void HeapCreate(Heap* hp, HPDataType* a, int size)
{
	hp->_a = (HPDataType*)malloc(sizeof(HPDataType) * size * 2);
	hp->_size = 2 * size;
	hp->_capacity = 0;
	int adWay(0); // 0为向下调整, 1为向上调整
	switch (adWay)
	{
	case 0:
		hp->_capacity = size;
		for (int i = 0; i < size; i++)
			hp->_a[i] = a[i];
		for (int i = (size - 2) / 2; i >= 0; i--)
			AdjustDown(hp, i);
		break;
	case 1:
		for (int i = 0; i < size; i++, hp->_capacity++)
		{
			hp->_a[i] = a[i];
			AdjustUp(hp, i);
		}
		break;
	}
}

3.3.4 堆的插入

我们已经知道了向下调整算法和向上调整算法,但是对于一个已知的堆,插入数据如果使用向下 调整的话,那么不仅需要将数组整体后移,同时数组也不一定保持堆结构,还需要重新建堆,这样一来时间复杂度就上升了,因此我们通常采用向上调整算法。
向上调整思路:由于原本堆结构完好,我们只需再最后插入新的数据,将其向上调整即可,不会破坏原本的堆结构。

void HeapPush(Heap* hp, HPDataType x)
{
	assert(hp);
	if (hp->_capacity == hp->_size)
	{
		hp->_size = hp->_size == 0 ? 10 : 2 * hp->_size;
		hp->_a = (HPDataType*)realloc(hp->_a, sizeof(HPDataType) * (hp->_size));
		if (hp->_a == nullptr)
		{
			perror("realloc fail!\n");
			exit(0);
		}
	}
	hp->_a[hp->_capacity] = x;
	AdjustUp(hp, hp->_capacity);
	hp->_capacity++;
}

3.3.5 堆的删除

通常意义上,堆的删除是指删除堆顶元素,常用的解决思路是覆盖删除法,其思路为,将堆的最后一个元素覆盖到堆顶元素,然后对新的堆顶元素采用向下调整算法即可。

void HeapPop(Heap* hp)
{
	assert(hp);
	assert(!HeapEmpty(hp));
	exchange(hp->_a[hp->_capacity - 1], hp->_a[0]);
	hp->_capacity--;
	AdjustDown(hp, 0);
}

3.3.6 堆的应用

堆作为一种有序二叉树,其主要作用就是解决排序类问题。

堆排序

堆排序实际上就是一个建堆和出堆的过程,在这个过程我们需要注意的是升序建大堆,降序建小堆

// 堆的判空
int HeapEmpty(Heap* hp)
{
	assert(hp);
	return hp->_capacity == 0;
}
// 取堆顶的数据
HPDataType HeapTop(Heap* hp)
{
	assert(hp);
	assert(!HeapEmpty(hp));
	return hp->_a[0];
}

void HeapSort(int* arr, int size)
{
	Heap x;
	HeapCreate(&x, arr, size);
	for (int i = 0; i < size; i++)
	{
		arr[i] = HeapTop(&x);
		HeapPop(&x);
	}
}
TOP_K 问题

TOP_K 问题:在一个集合中,找出前 K 大(小)的元素

这类问题我们需要注意的是:找大建小,找小建大
以找出前 K 大的元素为例:先用集合的前 K 个元素建立小堆,此时堆顶元素是堆内最小的元素,然后遍历集合剩下的其他元素,如果遍历元素小于堆顶元素,则覆盖堆顶元素并向下调整,遍历完之后,堆内元素就是 TOP_K 元素了。

  • 集合元素个数较小时,直接全部元素建堆即可
void TopK_small(int* a, int n, int k)
{
	Heap x;
	HeapCreate(&x, a, n);
	HeapPrint(&x);
	for (int i = 0; i < k; i++)
	{
		printf("%d ", HeapTop(&x));
		HeapPop(&x);
	}
	printf("\n");
}
  • 集合元素较多,需要存放在文件中时,只能建 K 个元素的小堆
void PrintTopK_big(const char* FILE_Name, int n, int k)
{
	HPDataType p(0);
	Heap x;
	FILE* data = fopen(FILE_Name, "r");
	if (data == nullptr)
	{
		perror("FILE open fail!\n");
		exit(0);
	}
	HeapCreate(&x, NULL, 0);
	for (int i = 0; i < k; i++)
	{
		fscanf(data, "%d", &p);
		HeapPush(&x, p);
	}
	while(fscanf(data, "%d", &p) != EOF)
	{
		if (p > HeapTop(&x))
		{
			x._a[0] = p;
			AdjustDown(&x, 0);
		}
	}
	HeapPrint(&x);
	HeapDestory(&x);
}

四、二叉树的链式结构及实现

二叉树的很多问题都可以用递归实现,因此遇到问题首要的想法是能不能转换为左右子树问题求解

4.1 二叉树的遍历

二叉树的核心内容就是遍历,学好遍历我们才能掌握如何按照某种顺序创建二叉树。

4.1.1 DFS 深度优先遍历(递归)

深度优先的意思就是,遍历的顺序主要先探索到某一个子树的最深结点,再依次探索其它子树。

前序遍历(先根遍历)

前序遍历:遇到每个结点,先打印出该节点的值,再去遍历左右子树
在这里插入图片描述

void BinaryTreePrevOrder(BTNode* root)
{
	if (root)
	{
		cout << root->_data << " ";
		BinaryTreePrevOrder(root->_left);
		BinaryTreePrevOrder(root->_right);
	}
	else
	{
		cout << "NULL ";
	}
	
}
中序遍历(中根遍历)

中序遍历:遇到每个结点,先进入左子树,直到遍历完左子树时,打印出该节点的值,再去遍历右子树在这里插入图片描述

void BinaryTreeInOrder(BTNode* root)
{
	if (root)
	{
		BinaryTreeInOrder(root->_left);
		cout << root->_data << " ";
		BinaryTreeInOrder(root->_right);
	}
	else
	{
		cout << "NULL ";
	}
}
后序遍历(后根遍历)

后序遍历:遇到每个结点,先遍历左子树,直到没有左子树时,遍历右子树,当左右子树都遍历完时,打印该节点的值
在这里插入图片描述

void BinaryTreePostOrder(BTNode* root)
{
	if (root)
	{
		BinaryTreePostOrder(root->_left);
		BinaryTreePostOrder(root->_right);
		cout << root->_data << " ";
	}
	else
	{
		cout << "NULL ";
	}
}

4.1.2 BFS 广度优先遍历(队列)

广度优先的意思就是,优先遍历完同一深度的所有结点,再遍历其他深度。

层序遍历

我们用队列的方式遍历每一层,我们先将二叉树的根节点入队,随后我们遍历队内元素,对于每个元素,我们将其左右孩子结点入队,同时将该结点出队,直到队内无其他元素。
在这里插入图片描述在这里插入图片描述

// QueueInit 队列初始化
// QueuePush 元素入队尾
// QueueFront 获取队首元素
// QueuePop 队首元素出队

void BinaryTreeLevelOrder(BTNode* root)
{
	int size = root ? 1 : 0;
	Queue* arr = (Queue*)malloc(sizeof(Queue));
	QueueInit(arr);
	QueuePush(arr, root);
	BTNode* rec = NULL;
	while (size)
	{
		rec = QueueFront(arr);
		if (rec)
		{
			cout << rec->_data << " ";
			if (rec->_left)
			{
				size++;
				QueuePush(arr, rec->_left);
			}
			else
			{
				QueuePush(arr, NULL);
			}
			if (rec->_right)
			{
				size++;
				QueuePush(arr, rec->_right);
			}
			else
			{
				QueuePush(arr, NULL);
			}
		}
		if (!rec)
			cout << "NULL ";
		if (rec)
			size--;
		QueuePop(arr);
	}
	cout << endl;
}

4.2 判断二叉树是否为完全二叉树

判断完全二叉树的方法需要用到我们上面学到的层序遍历,我们依次遍历每一层,如果二叉树为完全二叉树,那么在第一次遇到空结点之后,就不会再有非空结点了。

int BinaryTreeComplete(BTNode* root)
{
	Queue* arr = (Queue*)malloc(sizeof(Queue));
	QueueInit(arr);
	QueuePush(arr, root);
	int flag = 0;
	BTNode* rec = NULL;
	while (!QueueEmpty(arr))
	{
		rec = QueueFront(arr);
		if (rec->_left)
		{
			if (flag)
				return 0;
			QueuePush(arr, rec->_left);
		}
		else
			flag = 1;
		if (rec->_right)
		{
			if (flag)
				return 0;
			QueuePush(arr, rec->_right);
		}
		else
			flag = 1;
		QueuePop(arr);
	}
	return 1;
}

4.3 二叉树的节点个数

这里我们就需要用到之前提到的转换为左右子树求解问题,一棵二叉树的结点数可以分为左右子树的结点数加上自身。

int BinaryTreeSize(BTNode* root)
{
	if (!root)
		return 0;
	return root == NULL ? 0 : BinaryTreeSize(root->_right) + BinaryTreeSize(root->_left) + 1;
}

4.4 二叉树叶子节点的个数

叶子结点的个数为左右子树中叶子结点的个数

int BinaryTreeLeafSize(BTNode* root)
{
	if (!root)
		return 0;
	return (root->_left == NULL && root->_right == NULL) ? 1 : 
		((root->_left == NULL ? 0 : BinaryTreeLeafSize(root->_left)) + (root->_right == NULL ? 0 : BinaryTreeLeafSize(root->_right)));
}

4.5 二叉树第 k 层的节点个数

我们同样是转换为左右子树问题,不过需要注意的是,这里每次递归的子树求解问题发生了一定改变,我们求第 k 层,就是求相对于第二层的第 k-1 层的节点数。 具体的代码如下。

int BinaryTreeLevelKSize(BTNode* root, int k)
{
	if (!root || k <= 0)
		return 0;
	if (k == 1)
		return 1;
	return BinaryTreeLevelKSize(root->_left, k - 1) + BinaryTreeLevelKSize(root->_right, k - 1);
}

4.6 二叉树的高度

同样的,二叉树的高度也可以转换为左右子树求解问题,一颗二叉树的高度为自身加上左右子树中最高的高度
需要注意的是,这里要先计算高度,再用三目运算符,不然会重复计算一次左右子树高度,会浪费一定时间。

int BinaryTreeHigh(BTNode* root)
{
	if (!root)
		return 0;
	int leftHigh = BinaryTreeHigh(root->_left);
	int rightHigh = BinaryTreeHigh(root->_right);
	return 1 + (leftHigh > rightHigh ? leftHigh : rightHigh);
}

4.7 二叉树创建和销毁

这里讲解按照前序遍历的方式创建二叉树,给出一个字符串,用 # 代表空结点,按照前序遍历的方式创建二叉树。

BTNode* BinaryTreeCreate(BTDataType* a, int size, int* returnsize)
{
	if (a[*returnsize] == '#')
	{
		(*returnsize)++;
		return NULL;
	}
	else if (*returnsize < size)
	{
		BTNode* new_node = (BTNode*)malloc(sizeof(BTNode));
		if (!new_node)
			perror("malloc fail\n");
		new_node->_data = a[*returnsize];
		(*returnsize)++;
		new_node->_left = BinaryTreeCreate(a, size, returnsize);
		new_node->_right = BinaryTreeCreate(a, size, returnsize);
		return new_node;
	}
	return NULL;
}

二叉树的销毁则更加简单,只需要遍历整棵树,然后 free 掉每一个结点即可。需要注意的是需要传入二级指针,不然无法 free 掉树的根节点。

void BinaryTreeDestory(BTNode** root)
{
	if (!(*root))
		return;
	if ((*root)->_left)
		BinaryTreeDestory(&((*root)->_left));
	if ((*root)->_right)
		BinaryTreeDestory(&((*root)->_right));
	free(*root);
	*root = NULL;
}
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值