【数据结构】二叉树基本知识 & 堆的简单应用(二叉树部分上篇)

本文详细介绍了树和二叉树的基本概念,包括节点的度、叶节点、非叶节点、双亲节点、孩子节点、兄弟节点等,并阐述了满二叉树和完全二叉树的特性。接着,讲解了堆的定义、性质以及如何用数组实现堆,包括向上调整、向下调整、插入、删除等操作。最后,提到了堆排序和TopK问题的解决方案。
摘要由CSDN通过智能技术生成

树的概念、结构及其表示方法

树的概念

①树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
有一个特殊的结点,称为根结点,根节点没有前驱结点。
②除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继。因此,树是递归定义的。
注意:树形结构中,子树之间不能有交集,否则就不是树形结构(成为图)。

树的相关概念定义

在这里插入图片描述
节点的度:一个节点含有的子树的个数称为该节点的度; 如上图: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)棵互不相交的树的集合称为森林;

树的表示方法

树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法。

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

在这里插入图片描述
经过转化,得到下面的树,这个树每个结点最多有两个孩子结点,便于表示。
在这里插入图片描述

二叉树

二叉树的概念

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

  1. 或者为空
  2. 由一个根节点加上两棵别称为左子树和右子树的二叉树组成
    在这里插入图片描述从上图可以看出:

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

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

注意:对于任意的二叉树都是由以下几种情况复合而成的:
在这里插入图片描述

一些特殊的二叉树

  1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是2^k - 1,则它就是满二叉树。
  2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。

二叉树的性质

  1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2^(i-1)个结点.

  2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2^h-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否则无右孩子

二叉树的顺序结构

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

堆的概念及结构

如果有一个关键码的集合K = { K0, K1,K2,…,K(N-1) },把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:Ki<=K(2i+1)且Ki<=K(2i+2)(Ki>=K(2i+1)且Ki>=K(2i+2)) i = 0,1,2…,则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
①堆中某个节点的值总是不大于或不小于其父节点的值;
②堆总是一棵完全二叉树。
在这里插入图片描述

堆的实现

堆的声明和一些接口

typedef int HPDataType;
typedef struct Heap {
	HPDataType* a;
	int size;
	int capacity;
}HP;//堆的声明

//堆的初始化
void HeapInit(HP* php);
// 堆的销毁
void HeapDestory(HP* php);
// 堆的插入
void HeapPush(HP* php, HPDataType x);
// 堆的删除
void HeapPop(HP* php);
// 取堆顶的数据
HPDataType HeapTop(HP* php);
// 堆的数据个数
int HeapSize(HP* php);
// 堆的判空
bool HeapEmpty(HP* php);
//堆的向上调整算法
void AdjustToTop(HPDataType* a, int child);
//堆的向下调整算法
void AdJustToBottom(HPDataType* a, int size, int parent);
//交换函数,主要在上面两个算法中使用
void Swap(HPDataType* x, HPDataType* y);
//堆排序
void HeapSort(HPDataType* a, int a_size);
//堆元素打印
void HeapPrint(HP* php);

堆接口的实现

初始化函数
void HeapInit(HP* php)
{
	assert(php);

	php->a = NULL;
	php->capacity = php->size = 0;
}

将int*指针置空,堆的容积和元素个数置为0,完成初始化。

交换函数
void Swap(HPDataType* x, HPDataType* y)
{
	int tmp = *x;
	*x = *y;
	*y = tmp;
}

不赘述,简单的元素交换。

堆元素的打印
void HeapPrint(HP* php)
{
	assert(php);

	for (int i = 0; i < php->size; i++)
	{
		printf("%d ",php->a[i]);
	}
	printf("\n");
}

虽然堆结构的实现与二叉树有千丝万缕的联系,但其底层仍是由数组实现的,因此我们可以通过遍历数组打印堆中的所有元素。

向上调整算法
void AdjustToTop(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 AdJustToBottom(HPDataType* a, int size, int parent)
{

	int minChild = parent * 2 + 1;
	
	while (minChild < size)
	{
		if (minChild + 1 < size && a[minChild] > a[minChild + 1])
		{
			minChild++;
		}
		if (a[parent] > a[minChild])
		{
			Swap(&a[parent], &a[minChild]);
			parent = minChild;
			minChild = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

当我们删除堆顶元素之后,为了保证堆能够保持他原来的性质,我们会将堆此时的最后一个叶子结点变为当前堆的根结点,此时根结点的左子树和右子树都还符合他原来的性质(大根堆或者小根堆),这时候我们可以直接通过向下调整并在时间复杂度为O(n)的条件下将其恢复为大根堆(小根堆)。
在算法中我们需要传入堆的数组,堆当前的大小以及需要向下调整的最上处(因为这里我们将叶子结点直接放到了堆定,所以parent应该为0)。然后第一步先求出parent结点的左孩子,因为我们并不知道parent的左右孩子谁更大(小),因此我们先默认左孩子小,将其赋值给minchild,然后和下标为minchild+1的元素进行比较,留下较大(小)的元素和parent比较,若有必要则进行交换。如果父亲结点已经比两个孩子都大(小)的话,直接跳出循环,完成堆的向下调整。

向堆中插入元素
// 堆的插入
void HeapPush(HP* php, HPDataType x)
{
	assert(php);
	if (php->capacity == php->size)
	{
		int newcapacity = 0 == php->capacity ? 4 : 2 * php->capacity;
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);
		if (tmp == NULL)
		{
			perror("realloc fail");
			exit(-1);
		}
		php->a = tmp;
		php->capacity = newcapacity;
	}
	php->a[php->size] = x;
	php->size++;

	AdjustToTop(php->a, php->size - 1);
}

刚才我们已经讲解了向上调整算法,所以这里的主要工作就是判断当前堆中是否有空间能够存放插入的元素,若空间足够,则直接插入,堆size++,否则要进行空间申请/重分配,然后插入元素。

堆元素删除
// 堆的删除
void HeapPop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));

	Swap(&php->a[0], &php->a[php->size - 1]);
	php->size--;
	 
	AdJustToBottom(php->a, php->size, 0);
}

删除元素我们前面在向下调整算法中已经提及。这里需要注意在删除前要断言堆不为空,而且堆的删除只能从堆顶进行删除,因为这个数据对于整个数据集是有意义的,它是整个数据集中最大或者最小的;但如果我们要删除最后一个叶子结点虽然也能删除,但是它对整个数据集并没有什么意义,他并不能代表整个数据集的某些特征,因此我们规定堆的pop只能删除堆定元素。

取堆顶数据
// 取堆顶的数据
HPDataType HeapTop(HP* php)
{
	assert(php);
	assert(!HeapEmpty(php));

	return php->a[0];
}
求堆中数据个数
// 堆的数据个数
int HeapSize(HP* php)
{
	return php->size;
}
堆判空
// 堆的判空
bool HeapEmpty(HP* php)
{
	return php->size == 0;
}
堆销毁
// 堆的销毁
void HeapDestory(HP* php)
{
	assert(php);

	free(php->a);
	php->capacity = php->size = 0;
}
堆排序※※※
void HeapSort(HPDataType* a, int a_size)
{
	//向上调整建堆 时间复杂度O(NlogN)
	//for (int i = 1; i < a_size; i++)
	//{
	//	AdjustToTop(a, i);
	//}

	//向下调整建堆 时间复杂度O(N)
	for (int i = (a_size - 1 - 1) / 2; i >= 0; i--)
	{
		AdJustToBottom(a, a_size, i);
	}

	//排升序用大顶堆
	int i = 1;
	while (i < a_size)
	{
		Swap(&a[0], &a[a_size - i]);
		AdJustToBottom(a, a_size - i, 0);
		++i;
	}
}

堆排序是用二叉树结构实现的一种非常重要的排序,它的时间复杂度非常优秀,只有O(nlogn),这里我们先谈谈建堆算法的选取。刚才我们说到,建堆的方法包括向上调整算法和向下调整算法,这里我们先贴结论,向上调整算法建堆的时间复杂度为O(nlogn),向下调整算法建堆的时间复杂度为O(n),因此我们毅然决然地选择了向下调整建堆。二者证明过程如下,还请读者仔细端详:
向上调整算法:在这里插入图片描述
向下调整算法:在这里插入图片描述
在建好堆之后,因为每次对堆进行调整都会将数据集中的最大值(最小值)放到堆顶(数组的0号位置),因此我们就索性直接将0号位置空出来放每次pop出来的数据。(这里我们假设目标为排升序序列,并采用大根堆)大根堆建立完成后,堆定元素为最大值,此时我们要将他与数组元素的最后一个进行交换,并将参与堆排序的数组规模减少1,然后再进行向下调整并重复上述过程,数组中共有n个数,每次向下调整的执行次数为O(logn),所以整体排序算法的时间复杂度为O(nlogn)。
那么有人就会问,为什么不用小根堆来排升序呢?我直接将小根堆的堆顶放在数组0号位置处,然后将数组的左端右移来减少进入堆排序的规模并重复此过程,最后不也能完成排序么?
回答:第一个元素放在数组0号位置固然没有问题,但是此时我们将数组左端右移,此时堆顶变为第二个元素,整个堆的关系就全乱掉了,原先的堆顶左右的两个小根堆直接被破坏掉,需要重新建堆才能继续选出次小值,而重新建堆的代价最低也是O(n),此时在重复n次,时间复杂度来到了O(n^2),和O(nlogn)不再是一个量级了,失去了堆排序的优势,因此想排升序要用大根堆,同理,排降序要用小根堆。

堆的应用

TopK问题

在大规模数据处理中,经常会遇到的一类问题:在海量数据中找出出现频率最好的前k个数,或者从海量数据中找出最大的前k个数,这类问题通常被称为top K问题。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:

  1. 用数据集合中前K个元素来建堆
    前k个最大的元素,则建小堆
    前k个最小的元素,则建大堆
  2. 用剩余的N-K个元素依次与堆顶元素来比较,不满足则替换堆顶元素将剩余N-K个元素依次与堆顶元素比完之后,堆中剩余的K个元素就是所求的前K个最小或者最大的元素。

为什么k个最大(小)元素要建小(大)堆呢?
我们想,先将数据集前k个元素拿来建堆,然后对后面的数据进行遍历,如果后面的数据比我的堆顶元素(堆中最小的元素)要大,这就说明当前我这个含有k个元素的堆里包含的并不是数据集中k个最大的数,因为在外面有比这里面最小的数还要大的,因此要进行替换,并重新调整为小顶堆。当遍历完所有的元素之后,没有进堆的所有元素都要比我堆顶的元素要小,因此就找到了数据集中k个最大的数。
实现代码如下:

void PrintTopK(const char* filename, int k)
{
	assert(filename);
	FILE* fout = fopen(filename, "r");
	if (fout == NULL)
	{
		perror("fopen fail");
		return;
	}
	int* MinHeap = (int*)malloc(sizeof(int) * k);
	if (MinHeap == NULL)
	{
		perror("malloc fail");
		return;
	}
	//读入前k个数据
	for (int i = 0; i < k; ++i)
	{
		fscanf(fout, "%d", &MinHeap[i]);//除了第一个指针,其他和scanf用法一样,记得最后一个参数要传地址
	}
	//用k个数据建堆
	for (int j = (k - 2) / 2; j >= 0; --j)
	{
		AdJustToBottom(MinHeap, k, j);
	}
	//读入剩下的n-k个数据
	int val = 0;
	while (fscanf(fout, "%d", &val) != EOF)
	{
		if (val > MinHeap[0])
		{
			MinHeap[0] = val;
			AdJustToBottom(MinHeap, k, 0);
		}
		else
		{
			continue;
		}
	}
	for (int j = 0; j < k; j++)
	{
		printf("%d ", MinHeap[j]);
	}
	fclose(fout);
}

因为这里我们默认要求出文件中的数据的TopK,所以执行了一些文件操作。在将文件中的数据读取至内存后,我们先取出其中的前k个建堆,然后遍历后续没有进堆的元素,若比我们堆定元素要大,则将其替换,并且重新调整堆使其变为小根堆,直至遍历完所有元素,打印堆中元素,显示出来的元素则为整个数据集中的TopK元素。

结束语

以上就是关于二叉树的一些基础知识及其相关概念——堆的简单应用,此部分为内容上篇,下篇我们将实现二叉树及其相关接口并讲解一部分简单的二叉树OJ题,如文章有不足或遗漏之处还请大家指正,笔者感激不尽;同时也欢迎大家在评论区进行讨论,一起学习,共同进步!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值