探索数据结构:分支的世界之二叉树与堆

 🔑🔑博客主页:阿客不是客

🍓🍓系列专栏:渐入佳境之数据结构与算法

欢迎来到泊舟小课堂

😘博客制作不易欢迎各位👍点赞+⭐收藏+➕关注

一、什么是树

1.1 什么是树

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

  • 根结点:根节点没有前驱结点。
  • 除根节点外,其余结点被分成是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继。
  • 因此,树是递归定义的

 1.2 树的相关概念

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

1.3 树的表示方法

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

代码如下所示:

typedef int DataType;

struct  TreeNode
{
	DataType val;

	TreeNode* FirstChild;
	TreeNode* FirstBrother;
};

二、特殊的树之二叉树

那么树的结构如此多样我们要怎么用呢,所以设计出了一种名为二叉树的数据结构来使用。

2.1 二叉树的概念

一棵二叉树是结点的一个有限集合,该集合或者为空,或者是由一个根节点加上两棵别称为左子树和右子树的二叉树组成。
二叉树的特点:

  1. 每个结点最多有两棵子树,即二叉树不存在度大于2的结点。
  2. 二叉树的子树有左右之分,其子树的次序不能颠倒。

2.2 数据结构中的二叉树

对于任意二叉树都是由以下几种结构复合形成的:

2.3 特殊的二叉树

有两种特殊的二叉树,能让我们更好地利用空间

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

2.4 二叉树的性质

  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 的结点有:
    1. 若i > 0,i 位置节点的双亲序号:(i - 1) / 2(计算机计算保留整数部分);若 i = 0,i 为根节点编号,无双亲节点
    2. 若2i + 1 < n,左孩子序号:2i + 1,2i + 1 >= n 否则无左孩子
    3. 若2i + 2 < n,右孩子序号:2i + 2,2i + 2 >= n 否则无右孩子

2.5 二叉树的结构

2.5.1 顺序结构

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

这种实现二叉树的数据结构我们称之为堆 

2.5.2 链式结构

二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面课程学到高阶数据结构如红黑树等会用到三叉链。

三、堆的概念及其结构

如果有一个数据的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足:每一个孩子都比它的父亲小(或大),则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

四、堆的实现

4.1 堆的向下和向上调整算法

现在我们给出一个数组,逻辑上看做一颗完全二叉树。

我们通过从根节点开始的向下调整算法可以把它调整成一个堆。

向下调整算法:

  • 大堆:父节点比孩子节点小,往下换,换孩子中大的那个
  • 小堆:父节点比孩子结点大,往下换,换孩子中小的那个
int array[] = {27,15,19,18,28,34,65,49,25,37};

下列代码实现的是建立一个大堆的示例:

void AdjustDown(HPDataType* a, HPDataType size, HPDataType parent)
{
	int child = parent * 2 + 1;
	while (child < size)
	{
		if (child + 1 < size && a[child] < a[child + 1])//建大堆
		{
			child++;
		}

		if (a[parent] < a[child])//建大堆
		{
			Swap(&a[child], &a[parent]);
			parent = child;
			child = parent * 2 + 1;
		}
		else
		{
			break;
		}
	}
}

向上调整算法则是从最后一个节点开始向上调整:

  • 大堆:父节点比孩子结点小,往上换
  • 小堆:父节点比孩子节点大,往上换

代码示例如下:

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;
		}
	}
}

4.2 堆的定义

typedef struct Heap//顺序表
{
	HPDataType* a;     //动态开辟的数组
	int size;	   //数据个数
	int capacity;   //容量大小
}HP;

4.3 堆的销毁

void HPDeseroy(HP* php)
{
	assert(php);
	if (php->a != NULL)
	{
		free(php->a);
		php->a = NULL;
		php->size = php->capacity = 0; 
	}
}

4.4 堆的初始化

void HPInit(HP* php)
{
	assert(php);
	php->a = NULL;
	php->size = 0;
	php->capacity = 0;
}

4.5 堆的插入

先插入一个10到数组的尾上,再进行向上调整算法,直到满足堆。

void HPPush(HP* php, HPDataType x)
{
	assert(php);

	if (php->size == php->capacity)
	{
		int newCapacity = php->capacity == 0 ? 4 : php->capacity * 2;
		HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newCapacity);
		if (tmp == NULL)//防止原先地址被覆盖
		{
			perror("realloc fail");
			return;
		}
		php->a = tmp;
		php->capacity = newCapacity;
	}

	php->a[php->size] = x;
	php->size++;

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

4.6 堆的删除

删除堆是删除堆顶的数据

堆的删除不能往前挪动覆盖,因为首先数组的挪动覆盖 比较慢,而后,堆的关系会乱。

删最后一个就不会改变堆的关系所以,将堆顶的数据根最后一个数据一换,然后删除数组最后一个数据,再进行向下调整算法。

void HPPop(HP* php)
{
	assert(php);
	assert(php->size > 0);

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

	AdjustDown(php->a, php->size, 0);
}

4.7 取堆顶元素

HPDataType HPTop(HP* php)
{
	assert(php);
	assert(php->size > 0);

	return php->a[0];
}

4.8 堆的判空

bool HPEmpty(HP* php)
{
	assert(php);

	return php->size == 0;
}

五、堆排序

5.1 堆的创建

下面我们给出一个数组,这个数组逻辑上可以看做一颗完全二叉树,但是还不是一个堆,现在我们通过算法,把它构建成一个堆。

5.1.1 向上调整建堆

从根节点开始一个个向上调整

for (int i = 1; i < size; i++)
{
	AdjustUp(a, i);
}

5.1.2 向下调整建堆

我们可以将二叉树的每一个非终端节点,也就是非叶节点看成一个小二叉树,将所有小二叉树排成堆,从每一个这样的节点向下调整

当根节点之下的两个子树都为堆的时候,只需要从根开始向下调整

代码示例如下: 

for (int i = ((size - 1) - 1) / 2; i >= 0; i--)
{
	AdjustDown(a, size, i);
}

5.2 建堆的时间复杂度

因为堆是完全二叉树,而满二叉树也是完全二叉树,此处为了简化使用满二叉树来证明
(时间复杂度本来看的 就是近似值,多几个节点不影响最终结果)

向上调整建堆的时间复杂度为O(N*logN)。

向下调整建堆的时间复杂度为O(N)。

所以堆的创建一般选取像下调整建堆

证明:

设数组元素有N个,则二叉树的高度为 \log _{2}(N+1)

1. 向上调整算法:

这种算法证明过程较为简单

向上调整算法相当于,从头开始将元素一个个插入在尾部,再进行向上调整,由于从底部到顶部高度为 log(N),则最坏情况要调整 log(N) 次,由于有N个元素,整体建堆的时间复杂度为O(N*logN)

2. 向下调整算法:

设时间复杂度为O( F(h) ),则我们可以得到向下调整算法最坏情况的调整次数:

F(h)=2^{0}\times (h-1)+2^{1}\times (h-2)+......+2^{h-3}\times 2+2^{h-2}\times 1       ①

由高中数列知识可化简得:

2*F(h)=2^{1} \times (h-1)+2^{2} \times (h-2)+......2^{h-2} \times 2+2^{h-1} \times 1       ②

② - ①可得:

F(h)=-(h-1)+2^{1}+2^{2}+......2^{h-2}+2^{h-1}

由等差数列求和公式可得

F(h)=(1-h)+(-1)+2^{h}-1

F(h)=2^{h}-h-1

已知N=2^{h}-1 且 h=\log _{2}(N+1)

\therefore F(h)=2^{\log _{2}(N+1)}-\log _{2}(N+1)-1=N-\log _{2}(N+1)\approx N

因此向下调整建堆的时间复杂度为O(N)

5.2 排序

for (int i = 1; i < size; i++)
{
	Swap(&a[0], &a[size - i]);
	AdjustDown(a, size - i, 0);
}

建堆之后最大的元素在堆顶,然后将堆顶与堆末尾的元素位置交换,再在前N-1从堆顶开始向下调整算法,此时最大的元素在数组的末尾,

以此类推排成了递减的数列

5.3 完整代码

void HPSort(int* a, int size)//升序建大堆,降序建小堆
{
	//效率为Nlog(N)
	/*for (int i = 1; i < size; i++)
	{
		AdjustUp(a, i);
	}*/
	//效率为log(N)
	for (int i = ((size - 1) - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, size, i);
	}

	for (int i = 1; i < size; i++)
	{
		Swap(&a[0], &a[size - i]);
		AdjustDown(a, size - i, 0);
	}
}

六、TOP-K问题

TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。 

对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:

  1. 用数据集合中前K个元素建堆
    1. 前k个最小的元素,则建大堆
    2. 前k个最大的元素,则建小堆
  2. 用剩余 N - k 个元素依次与堆顶元素(最大的)进行比较,比最大项要小就替换再向下调整

该方法的时间复杂度为:O(N*\log _{2}K)

代码示例如下:

void TestTopk()
{
	int n = 10000;
	int* a = (int*)malloc(sizeof(int) * n);
	srand(time(0));
	for (size_t i = 0; i < n; ++i)
	{
		a[i] = rand() % 1000000;
	}
	a[5] = 1000000 + 1;
	a[1231] = 1000000 + 2;
	a[531] = 1000000 + 3;
	a[5121] = 1000000 + 4;
	a[115] = 1000000 + 5;
	a[2335] = 1000000 + 6;
	a[9999] = 1000000 + 7;
	a[76] = 1000000 + 8;
	a[423] = 1000000 + 9;
	a[3144] = 1000000 + 10;
	PrintTopK(a, n, 10);
}
void PrintTopK(HPDataType* a, int size, int k)
{
	for (int i = (k - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, k, i);
	}

	for (int i = k; i < size; i++)
	{

		if (a[0] < a[i])
		{
			a[0] = a[i];
			AdjustDown(a, k, 0);
		}
	}

	cout << "最大的十个为: ";
	for (int i = 1; i < 10; i++)
	{
		cout << a[0] << " ";
		Swap(&a[0], &a[k - i]);
		AdjustDown(a, k - i, 0);
	}
	cout << a[0] << endl;
}

用文件读取的方式完成示例如下:

void TestTopk()
{
	int n = 10000;
	srand(time(NULL));
	const char* file = "data.txt";
	FILE* fin = fopen(file, "w");
	if (fin == NULL)
	{
		perror("fopen error");
		return;
	}
	for (int i = 0; i < n; i++)
	{
		int x = rand() + i;
		fprintf(fin, "%d\n", x);
	}
	fclose(fin);

	int* a = (int*)malloc(sizeof(int) * 10);
	PrintTopK(a, n, 10);
}
void PrintTopK(HPDataType* a, int size, int k)
{
	const char* file = "data.txt";
	FILE* fout = fopen(file, "r");
	if (fout == NULL)
	{
		perror("fopen error");
		return;
	}
	for (int i = 0; i < k; i++)
	{
		fscanf(fout, "%d\n", &a[i]);
	}

	for (int i = (k - 1 - 1) / 2; i >= 0; i--)
	{
		AdjustDown(a, k, i);
	}

	int x = 0;
	for (int i = k; i < size; i++)
	{
		fscanf(fout, "%d\n", &x);
		if (a[0] < x)
		{
			a[0] = x;
			AdjustDown(a, k, 0);
		}
	}
	fclose(fout);

	cout << "最大的十个为: ";
	for (int i = 1; i < 10; i++)
	{
		cout << a[0] << " ";
		Swap(&a[0], &a[k - i]);
		AdjustDown(a, k - i, 0);
	}
	cout << a[0] << endl;
}

七、链式二叉树

7.1 二叉树的遍历

7.1.1 二叉树的前、中、后序遍历

所谓遍历(Traversal)是指沿着某条搜索路线,依次对树中每个结点均做一次且仅做一次访问。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,是二叉树上进行其它运算之基础。

具体分为:前序、中序、后序,它们的递归结构遍历:是根据访问结点操作发生位置命名。

  • 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前
  • 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
  • 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。

代码实现如下:

//前序
void PrevOrder(BTNode* root)
{
	if (root == NULL)
	{
		cout << "N ";
		return;
	}

	cout << root->data << " ";
	PrevOrder(root->left);
	PrevOrder(root->right);
}

//中序
void InOrder(BTNode* root)
{
	if (root == NULL)
	{
		cout << "N ";
		return;
	}

	InOrder(root->left);
	cout << root->data << " ";
	InOrder(root->right);
}

//后序
void PostOrder(BTNode* root)
{
	if (root == NULL)
	{
		cout << "N ";
		return;
	}

	PostOrder(root->left);
	PostOrder(root->right);
	cout << root->data << " ";
}

7.1.2 层序遍历

层序遍历:除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。

思路:

  1. 创建一个队列,并将根节点入队。
  2. 当队列不为空时,执行以下步骤:
    1. 从队列标记队首节点并取出

    2. 将该节点的所有子节点(如果存在)依次入队。

  3. 如果队列为空,则表示遍历结束。

由于队列的特性,首先入队的节点会先被访问,保证了按照层级顺序遍历节点。

代码实现:

void LevelOrder(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root)
		QueuePush(&q, root);

	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		cout << front->data << " ";
		QueuePop(&q);
		if(front->left)
			QueuePush(&q, front->left);
		if(front->right)
			QueuePush(&q, front->right);
	}
	cout << endl;
}

7.2 二叉树的相关操作

7.2.1 基本操作

// 二叉树结点个数
int BTSize(BTNode* root)
{
	/*if (root == NULL)
	{
		return 0;
	}
	int size1 = BTSize(root->left);
	int size2 = BTSize(root->right);
	return size1 + size2 + 1;*/
	return root == NULL ? 0 : BTSize(root->left) + BTSize(root->right) + 1;
}  

// 二叉树叶子结点个数
int BTLSize(BTNode* root)
{
	/*if (root->left == NULL && root->right == NULL)
		return 1;
	else if (root->left == NULL)
		return BTLSize(root->right);
	else if (root->right == NULL)
		return BTLSize(root->left);
	else
		return BTLSize(root->right) + BTLSize(root->left);*/
	if (root == NULL)
		return 0;
	if (root->left == NULL && root->right == NULL)
		return 1;

	return BTLSize(root->right) + BTLSize(root->left);
}

//二叉树的高度
int BTHight(BTNode* root)
{
	if (root == NULL)
		return 0;

	return max(BTHight(root->left), BTHight(root->right)) + 1;
}

// 二叉树第k层结点个数
int BTKSize(BTNode* root, int k)
{
	if (root == NULL)
		return 0;
	if (k == 1)
		return 1;
	return BTKSize(root->left, k - 1) + BTKSize(root->right, k - 1);
}

// 二叉树查找值为x的结点
BTNode* BTFind(BTNode* root, BTDataType x)
{
	if (root == NULL)
		return NULL;
	if (root->data == x)
		return root;
	
	BTNode* node = BTFind(root->left, x);
	if (node != NULL)
		return node;
	node = BTFind(root->right, x);
	if (node != NULL)
		return node;
	return NULL;
	//return BTFind(root->right, x);
}

// 二叉树销毁
void BTDestory(BTNode** root)
{
	if (*root == NULL)
		return;

	BTDestory(&((*root)->left));
	BTDestory(&((*root)->left));
	free(*root);
	*root = NULL;
}

7.2.2 判断一个二叉树是否是完全二叉树

一颗完全二叉树:

一颗非完全二叉树:

思路分析:

  • 创建一个队列,并将根节点入队
  • 通过层序遍历来遍历树中的每一个结点,有以下两种情况
  1. 当遍历节点不为空时,执行以下步骤:
    1. 从队列标记队首节点并取出
    2. 将该节点的所有子节点(包括空节点)依次入队
  2. 当取的结点为空结点时,若要为完全二叉树,后续所有结点必须都为空(用 i 标记出来)
  • 当列表为空节点,退出循环,二叉树为完全二叉树

代码实现如下:

// 判断二叉树是否是完全二叉树
bool BTComplete(BTNode* root)
{
	Queue q;
	QueueInit(&q);
	if (root)
		QueuePush(&q, root);

	int i = 0;
	while (!QueueEmpty(&q))
	{
		BTNode* front = QueueFront(&q);
		if (front == NULL)
			i++;
		if (i == 1 && front != NULL)
			return false;

		QueuePop(&q);
		if (front != NULL)
		{
			QueuePush(&q, front->left);
			QueuePush(&q, front->right);		
		}
	}
	QueueDestroy(&q);
	return true;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值