二叉树及堆

本文详细介绍了二叉树的概念、特殊结构、性质,包括满二叉树与完全二叉树,并深入探讨了堆的数据结构、初始化、插入和删除操作,以及堆排序、TOP-K问题和二叉树的遍历与销毁。重点讲解了堆的使用及其在排序和搜索算法中的应用。
摘要由CSDN通过智能技术生成

一.树的概念及结构

1.1树的概念

树是一种非线性的数据结构, 由n(n>0)个有限节点组成一个具有层次关系的集合。

  • 有一个节点很特殊,就是根节点,根节点没有前驱节点。
  • 树是不可以相交的,就像两枝树枝不会长成一枝树枝一样。
  • 每颗子树的根节点有且只有一个前驱,但是可以有0个或多个后继。

因此,树是递归定义的。

1.2树的相关概念

  • 节点的度:一个节点含有的子树的个数称为该节点的度。
  • 叶节点或终端节点:度为0的节点称为叶节点。
  • 非终端节点或分支节点:度不为0的节点。
  • 双亲节点或父节点:一个节点含有子节点,那么这个节点就是子节点的父节点或双亲节点。
  • 兄弟节点:具有相同父亲节点的节点互称为兄弟节点。
  • 树的度:一棵树中,最大的节点的度称为树的度。
  • 节点的层次:从根开始定义起,根为第一层,根的子节点为第二层,以此类推。
  • 树的高度或深度:树的最大节点层次。
  • 堂兄弟节点:在同一层但是不互为兄弟节点的节点为堂兄弟节点。
  • 森林:由m(m>0)个互不相交的树的集合称为森林。

树的表示

二.二叉树

2.2二叉树的概念及结构

二叉树的概念:

二叉树是节点的一个有限集合。

这个集合有几个注意的地方:

  1. 集合可能为空
  2. 由一个根节点加上两颗分别称为左子树和右子树的二叉树组成。
  3. 二叉树不存在度大于2的节点。
  4. 二叉树有左子树和右子树之分,次序不能颠倒,因此二叉树是有序树。

2.3特殊的二叉树

满二叉树:一个二叉树如果每层都达到了它的最大子节点的个数,就称这个数为满二叉树,若树的高度为k,那么节点的总数为2^k-1,则它是满二叉树。

完全二叉树:对深度为k的,有n个节点的二叉树,当且仅当每一个节点都与深度为k的满二叉树中编号从1至n的节点一一对应时称为完全二叉树,要注意满二叉树是一种特殊的完全的二叉树。

在这里插入图片描述

2.4二叉树的性质

  • 若规定根节点的层数为1,则一颗非空二叉树的第 i 层上最多有2^(i-1)个节点。
  • 若规定根节点的层数为1,则深度为h的二叉树的最大节点数为2^h-1。
  • 对任何一棵二叉树,如果度为0的二叉树叶节点的个数为n,度为2的分支节点的个数为n2,则有n=n2+1。(这个结论很重要)
  • 规定根节点的层数为1,具有n个节点的满二叉树的深度为log(n+1)。

二叉树的存储结构

二叉树一般可以使用两种数据结构,分别是顺序结构和链式结构

顺序结构:顺序结构就是用顺序表来进行表示的一种结构,但是一般只用顺序表来表示完全二叉树,因为不完全的二叉树用顺序表表示会造成一定程度的空间浪费。这里可能就会有疑问了,数组是怎么用来表示二叉树的呢?二叉树顺序存储在物理上是一个顺序存储,但是在逻辑上是一个二叉树。

在这里插入图片描述

链式存储:链式存储很容易理解,就是使用链表的方式将节点链接起来,但是这个链表的设计需要看情况而定,区别在于结构体中指针的个数。

typedef int BTDataType;
struct BinaryTreeNode
{
	struct BinTreeNode* _pLeft; // 指向当前节点左孩子
	struct BinTreeNode* _pRight; // 指向当前节点右孩子
	BTDataType _data; // 当前节点值域
}
// 三叉链
struct BinaryTreeNode
{
	struct BinTreeNode* _pParent; // 指向当前节点的双亲
	struct BinTreeNode* _pLeft; // 指向当前节点左孩子
	struct BinTreeNode* _pRight; // 指向当前节点右孩子
	BTDataType _data; // 当前节点值域
};

在链式存储中还有一种存储方式为左孩子右兄弟,结构体中只有结构体指针,分别指向孩子和兄弟。如果节点没用兄弟或孩子,就将二者对应置空。

typedef int DataType;
struct TreeNode
{
	struct TreeNode*fistChild1;
	struct TreeNode*pNextBrother;
	DataType x;
};

堆的概念及结构

将一些数据按照二叉树的顺序存储存储到顺序表中,如果父节点永远比子节点大,那么就是大堆,反之是小堆。
在这里插入图片描述

堆的实现

堆的初始化与销毁

这很简单,直接上代码

void HeapInit(HP* hp);
void HeapDestroy(HP* hp);


void HeapInit(HP* hp)
{
	assert(hp);
	hp->a = NULL;
	hp->capacity = 0;
	hp->top = 0;
}



void HeapDestroy(HP* hp)
{
	assert(hp);
	free(hp->a);
	hp->a = NULL;
	hp->capacity = hp->top = 0;
}

堆的数值的插入

堆的插入不像顺序表一样是一个简单的尾插或者头插,首先否定头插,因为堆顶只能有一个数据,所以不能直接插入,所以就考虑尾插,尾插之后要对插入进行调整,使得保持调整之前的顺序。这就涉及到堆向上调整算法。

堆向上调整算法:堆向上调整的算法就是子节点和父节点进比较,然后对其进行相应的调整,是其到达相应的位置的算法。

void AdjustUp(HPDataType* a, int child)
{
int parent = (child - 1) / 2;
	while (child>0)
	{
		if (a[child] < a[parent])
		{
			Swap(&a[child], &a[parent]);
			child = parent;
			parent = (child - 1) / 2;
		}
		else
			break;
	}
}
void HeapPush(HP* hp, HPDataType x)
{
	assert(hp);
	if (hp->capacity == hp->top)
	{
		int newcapacity = hp->capacity==0?4:hp->capacity * 2;
		HPDataType*new=(HPDataType*)realloc(hp->a, sizeof(HPDataType) * newcapacity);
		if (new == NULL)
		{
			perror("realloc::new");
			exit(-1);
		}
		hp->a = new;
		hp->capacity = newcapacity;
	}
	hp->a[hp->top] = x;
	hp->top++;
	AdjustUp(hp->a, hp->top-1);
}

这里有个注意的点就是向上调整的while循环的判断条件,很多人第一次写就会写成parent>=0,其实是不合适的,在循环中当child==0的时候,(child-1)/2的值还是0,这个时候再进入循环就不合适了。

堆中数值的删除

删除堆中任意的一个数据不能直接对其进行删除,因为直接删除就会导致堆中的关系都乱了,所以删除要先将堆中要删除的元素和堆尾的元素进行交换,交换之后就会用一个新的算法调整(向下调整算法)
这里代码演示删除堆顶元素

void AdjustDown(int* a, int n, int parent)
{
	assert(a);
	int child1 = parent * 2 + 1, child2 = parent * 2 + 2;
	int max = 0;
	while (child1 < n)
	{
		if (child2 == n)
			max = child1;
		else if (a[child1] >= a[child2])
			max = child1;
		else
			max = child2;
		if (a[parent] < a[max])
		{
			Swap(&(a[parent]), &(a[max]));
			parent = max;
			child1 = parent * 2 + 1; child2 = parent * 2 + 2;
		}
		else
			break;
	}
}
void HeapPop(HP* hp)
{
	assert(hp);
	Swap(&(hp->a[0]), &(hp->a[hp->top - 1]));
	hp->top--;
	int parent = 0;
	AdjustDown(hp->a, hp->top, parent);
}

代码中将堆的删除和向下调整算法集成到了一起。要注意的是在交换的时候可以先选出左孩子和右孩子中比较大的一个,然后将其与父节点进行比较。

有关堆的其他函数汇总

1.取堆顶元素

HPDataType HeapTop(HP* hp)
{
	assert(hp);
	assert(!HeapEmpty(hp));
	return hp->a[0];
}

2.判断堆是否为空

bool HeapEmpty(HP* hp)
{
	assert(hp);
	return hp->top == 0;
}

3.获取堆中的元素个数

int HeapSize(HP* hp)
{
	assert(hp);
	return hp->top;
}

堆排序

给一组数据,如果只是升序打印,那么只需要建立小堆,取堆顶元素,然后对堆顶元素进行删除,依次操作就可以将数据进行升序打印。同理,降序打印建立大堆,但是这个时候需要修改向下调整的代码。代码的改动只需要改变大于小于以及左右孩子的大小的比较

但是堆排序没这么简单。
这里有两种堆排序的算法:

1.依然是升序建小堆,降序建大堆

void HeapSort(int* a, int n)
{
	assert(a);
	HP hp;
	HeapInit(&hp);
	for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++)
	{
		HeapPush(&hp, a[i]);
		//将数据建堆
	}
	int i = 0;
	while (!HeapEmpty(&hp))
	{
		a[i++] = HeapTop(&hp);
		HeapPop(&hp);
	}
	HeapDestroy(&hp);
}

但是这个样写明显更复,首先是得建堆,其次这种算法还有O(N)的空间复杂度。

  1. 不需要借助数据结构的堆排序
    首先思路上想:要先将数据建堆,建堆的话有两种方法,可以向下调整或向上调整。
  • 向上调整:
    向上调整很简单,就是把第一个元素定为堆顶,然后将之后的元素进行调整。

  • 向下调整
    向上调整有原则,就是必须调整的时候被调整的元素的左树和右树都是有序堆。那么就要求在进行向下调整的数据上,要先从尾端开始调整,使得下层有序,之后再对上层进行调整。

这里就要思考一个问题:升序建大堆还是建小堆,降序呢?

常规思想我们可能会认为升序应该选小堆,但是用小堆选出最小的数据之后,剩余的数据就没办法处理,只能通过再建堆来进行处理,但是这样的话就会导致时间复杂度增大。就体现不出来堆的意义了。

所以这里排升序就要建大堆,选出最大的数之后与末尾的数据进行交换,交换之后再进行一次向下调整,就能选出来次大的数

void HeapSort2(int* a, int n)
{
	//建堆1
	//for (int i = 1; i < n; i++)
	//{
	//	AdjustUp(a, i);
	//}
	//建堆2
	for (int i = (n - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(a, n, i);
	}
	int end = n - 1;
	while (end>0)
	{
		Swap(&a[0], &a[end]);
		AdjustDown(a, end, 0);
		--end; 
	}
}

TOP-K问题

TOP-K问题就是求数据结合中前K个最大的元素或者最小的元素,一般情况下N远大于K

这里有三种解决方式:

  1. 直接进行堆排序,对N个数进行排序,时间复杂度是O(N*logN)
  2. 建立N个数大堆,对堆顶的数据Pop K次,时间复杂度是O(N+logN*k),相比第一种方法进步很大
  3. 假设N非常大,K还是比较小,这样的话就会出现内存不够用的问题 -------这里的解决方案就是建立K个数的小堆,然后将剩下的N-K个数据一一与堆顶的数据进行比较,如果比堆顶的数据大就与堆顶的数据进行交换,交换之后重新进行向下调整重新选出最小的数,这样就会使得最后的K个堆中的数据是最大的。时间复杂度为 O(K+(N-k)*logK)。随然在时间上并没有很大的优势,但是缺解决的数据量过大的问题。
//top—K问题
void PrintTopK(int* a, int n, int k)
{
	assert(a);
	//用前K个元素建堆
	int* KMinHeap = (int*)malloc(sizeof(int) * k);
	if (KMinHeap == NULL)
	{
		perror("malloc::KMinHeap");
		exit(-1);
	}
	for (int i = 0; i < k; ++i)
	{
		KMinHeap[i] = a[i];
	}
	for (int i = (k - 1 - 1) / 2; i >= 0; --i)
	{
		AdjustDown(KMinHeap, k, i);
	}
	//将剩余的N-K个数据依次与堆顶的数据进行交换
	for (int j = k; j < n; ++j)
	{
		if (a[j] > KMinHeap[0])
		{
			Swap(&a[j], &KMinHeap[0]);
			AdjustDown(KMinHeap, k, 0);
		}
	}
	for (int i = 0; i < k; ++i)
	{
		printf("%d ", KMinHeap[i]);
	}
	printf("\n");
}

二叉树链式结构的实现

按照二叉树的特性,二叉树有两个子节点,分别是左子树和右子树,以及当前节点的数据。

typedef int BTDataType;
typedef struct BinaryTreeNode
{
	BTDataType x;
	struct BinaryTreeNode* left;
	struct BinaryTreeNode* right;
}BTNode;

这里要说明几点:二叉树的增删查改没有什么意义,首先是增加和删除的节点不能随意确定。
那么用二叉树存储数据呢?也是不合理的,因为我们学过更好用的顺序表和链表。

二叉树在当前看起来是没有什么意义的

二叉树的遍历

二叉树的遍历有:前序/中序/后序的递归结构遍历

  1. 前序遍历:访问根节点的操作发生在遍历其左右子树之前
  2. 中序遍历:访问根节点的操作发生在遍历其左右子树之间
  3. 后序遍历:访问根节点是操作发生在遍历其左右子树之后

二叉树的遍历方式其实本质上就是一种分治算法,将问题不断分割,最终分到不可继续分割的子问题就返回。

前序遍历

前序遍历就是先访问根节点,之后分别访问左子树和右子树。实现起来也比较简单:

//前序遍历
void PreOrder(BTNode*root)
{
	if (root == NULL)
	{
		printf("# ");
		return;
	}
	printf("%d ", root->data);
	PreOrder(root->left);
	PreOrder(root->right);
}

中序遍历

中序遍历就是先访问左子数然后访问根节点,之后再访问右子树:

//中序遍历
void InOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("# ");
		return;
	}
	InOrder(root->left);
	printf("%d ", root->data);
	InOrder(root->right);
}

后序遍历

后序遍历先访问左右子树,最后访问根节点:

//后序遍历
void PostOrder(BTNode* root)
{
	if (root == NULL)
	{
		printf("# ");
		return;
	}
	PostOrder(root->left);
	PostOrder(root->right);
	printf("%d ", root->data);
}

上面的代码用打印来观察遍历的顺序,通过观察发现,在代码实现的层面,遍历的顺序的区别在于打印数据的顺序有所不同。

这里有个问题,如何计算二叉树具有几个节点呢?

int size=0;
void BtreeSize(BTNode*root)
{
	if(root==NULL)
	{
		return 0;
	}
	++size;
	BtreeSize(root->left);
	BtreeSize(root->right);
}

这就是通过前序遍历来统计一共有几个节点,这里有一个小细节:将size定义在函数外面,使得size在静态区开辟空间存放,函数栈帧的创建和销毁就不会影响size。

还有一个问题:如何求第K层节点个数的问题?

可以转换成求左子树的第K-1层+求右子树的第K-1层

int TreeKLevel(BTNode*root,int k)
{
	assert(k>=1);
	if(root==NULL)
	return 0;
	
	if(k==1)
	return 1;

	return TreeLevel(root->left,k-1)+TreeLevel(root->right,k-1);
}

二叉树的层序遍历

二叉树的层序遍历是要借助数据结构队列实现的
具体实现是如果一个节点出队列之后,会将其左子树和右子树所对应的节点也带入队列,这样知道队列为空就将全部的二叉树的节点进行的层序遍历。

之前写过的队列里的数据类型是int,这里一定要注意一下,在二叉树的层序遍历中是不能单一地将root->data入队列的,这里需要将整个节点入进去,之后的出队列就可以将左子树和右子树对应的节点入队列了。

void LevelOrder(BTNode*root)
{
	Queue q;
	QueueInit(&q);
	if(root)
	{
		QueuePush(&q,root);
	}
	while(!QueueEmppty(&q))
	{
		BTNode*front=QueueFront(&q);
		printf("%d->",front->data);
		QueuePop(&q);
		if(front->left)
		{
			QueuePush(&q,front->left);
		}
		if(front->right)
		{
			QueuePush(&q,front->right);
		}
	}
	QueueDestory(&q);	
}

在函数实现的过程中发现,引入新的头文件会导致Queue不认识BTNode*(队列中存放的是节点的指针), Heap.h头文件还会在Heap.c文件中展开,所以就要在头文件中申明一下,加一个二叉树节点的前置声明
想看队列的实现可以戳这里

二叉树的销毁

二叉树在销毁的时候,是不推荐通过前序遍历的方式销毁的,前序遍历销毁,首先销毁root节点,会导致找不到左子树和右子树,当然也可以设置变量将指向左子树和右子树的节点保存,但是明显有些麻烦了。

所以二叉树在销毁的时候使用后序遍历会更好一些

void TreeDestory(BTNode*root)
{
	if(root==NULL)
	return;
	
	TreeDestory(root->left);
	TreeDestory(root->right);
	free(root);
}

到这里所有的内容就都结束了,初阶的二叉树就结束了。祝大家学习快乐,早日拿到自己心仪的offer!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Feng,

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值