[数据结构] 树与二叉树的超详细解析 建议收藏 看它就够了

Posted 哦哦呵呵

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[数据结构] 树与二叉树的超详细解析 建议收藏 看它就够了相关的知识,希望对你有一定的参考价值。

这篇文章篇幅较长,可以点击下方目录查看对应内容。

一. 树

1. 基本概念

  树是一种非线性的数据结构,由n个节点组成具有层次关系的集合。其实就是一颗倒着长得大树,一个树根加了好多叶子。
  树中必定含有一颗根节点,和若干分支节点叶子节点。并且每个分支节点的孩子构成了一颗子树。每个子树的根节点只有一个前驱,但是可以由很多的后继。除了根节点外,每个节点有且只有一个父节点。
  
在这里插入图片描述

2. 树的基本性质

  1. 节点的度:一个节点含有子树的个数成为该节点的度,上图中A的度为2,B的度为3,D的度为0.
  2. 叶节点或终端节点:度为0的节点为叶节点。上图中 D、E、F、G、H。
  3. 分支节点:度不为0的节点。上图中B、C。
  4. 父节点:若一个含有子节点,则称这个节点为父节点。上图中A是B、C的父节点,B是D、E、F的父节点。
  5. 子节点:一个节点含有的子树的根节点称为该节点的子节点,上图中B、C是A的子节点
  6. 兄弟节点:由相同父节点的节点互称兄弟节点,上图中B、C互为兄弟,D、E、F互为兄弟。
  7. 树的度:一棵树中最大节点的度为数的度。上图中度最大的节点为B 3,所以3为树的度。
  8. 节点的层次:以根节点所在的层次定义为第1层,根的子节点为第2层。
  9. 树的高度或深度:树中节点的最大层次。上图高度为3
  10. 堂兄弟节点:父节点在同一层的节点。上图中 DEFGH互为堂兄弟。
  11. 节点的祖先:从根节点到该节点所经分支上的所有节点。上图中A是所有节点的祖先
  12. 森林:由N棵互不相交的树构成的集合称为森林。

3. 树的表示方法

在这里插入图片描述

1). 孩子表示法

  1. 数据结构
// 孩子节点结构
struct BiTreeNode
{
	int child; // 孩子节点的下标
	BiTreeNode* next;  // 指向下一节点的指针
}

// 
struct BiTreeHead
{
	DataType data;		// 存放树中节点的数据
	BiTreeNode* firstchild;  // 指向第一个孩子的指针
}

struct CTree{
    CTBox BiTreeHead[MAX_SIZE]; //存储结点的数组
    int n,r; //结点数量和树根的位置
};
  1. 图示
    在这里插入图片描述
  2. 优缺点
    优点:找孩子方便。缺点:找双亲不方便

2). 双亲表示法

  1. 数据结构
struct PTNode{
    DataType data; //树中结点的数据类型
    int parent;    //结点的父结点在数组中的位置下标
};
struct PTree{
    PTNode tnode[MAX_SIZE]; //存放树中所有结点
    int n;    //根的位置下标和结点数
};
  1. 图示
    在这里插入图片描述
  2. 优缺点
    优点:找双亲方便。缺点:找孩子不方便

3). 孩子双亲表示法

实则为孩子表示法与双亲表示法的整合。

  1. 数据结构
// 孩子节点结构
struct BiTreeNode
{
	int child; // 孩子节点的下标
	BiTreeNode* next;  // 指向下一节点的指针
}

// 
struct BiTreeHead
{
	DataType data;		// 存放树中节点的数据
	BiTreeNode* firstchild;  // 指向第一个孩子的指针
	int parent; // 双亲的下标
}

struct CTree{
    CTBox BiTreeHead[MAX_SIZE]; //存储结点的数组
    int n,r; //结点数量和树根的位置
};
  1. 图示
    在这里插入图片描述

4). 孩子兄弟表示法

  1. 数据结构
struct BiNode{
    ElemType data;
    BiNode* firstchild;	// 孩子指针
    BiNode* nextsibling; // 兄弟指针
};
  1. 图示
    在这里插入图片描述

4. 树的应用

文件系统的目录结构

二. 二叉树

1. 概念

  一颗二叉树是节点的一个有限集合,该集合为空,或是由一个根节点加上两棵左右子树构成每个节点至多由两颗子树
  二叉树是一颗有序树,子树有左右之分。

2. 特殊二叉树

  1. 满二叉树:一个二叉树有n层,每一层的节点都达到了最大值 2^(n-1),该树的节点总数为2^n - 1
    在这里插入图片描述
  2. 完全二叉树:一颗二叉树有n个节点,每个节点都与它所对应的满二叉树节点对应,则该树就是一棵完全二叉树。
    在这里插入图片描述

  完全二叉树中节点总个数如果为偶数,则只有一个节点具有左孩子,倒数第一个非叶子节点一定是只有一个左孩子。
  完全二叉树中节点总个数如果为奇数,则所有的非叶子节点都有两个孩子。

3. 二叉树的性质

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

  2. 性质2:若规定根节点的层数为1,则深度为h的二叉树的最大节点数是2^h - 1

  3. 性质3:对任何一颗二叉树,如果度为0的节点个数为n0,度为2的节点个数为n2,则有 n0 = n2 + 1

  4. 性质4:若规定根节点的层数为1,具有n个节点的满二叉树深度,h=log2(n+1)

  5. 性质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 否则无右孩子
    

接下来根据上述性质,看一道例题:
  假设完全二叉树中有1000个节点,则该树中共有多个叶子几点,多少个分支节点,多少个节点只有左孩子。

	已知二叉树的节点总数 n = n0 + n1 + n2;
	因为树的节点总数为偶数,所以 n1=0
	根据性质3,我们可知 n0 = n2 + 1;
		所以可以得出, 
		n = n0 + n2 --> 1000 = n2+1+n2
		所以n2 = 499, n0 = 500 没有直接点只有左节点

4. 二叉树的存储方式

1). 顺序存储方式

  顺序存储方式利用数组来进行对二叉树的节点进行存储,通过下标表示节点间关系。
  顺序存储方式只适合存储完全二叉树,利用性质5对二叉树的每个节点进行编号,数组小标与元素编号相同。
  如果一定要使用顺序方式存储非完全二叉树,则需要将该树在逻辑上补充为完全二叉树,并且在存储时将该树作为完全二叉树的形式存储。但是使用这种形式进行存储,会造成空间的大量浪费,对于非完全二叉树,推荐使用链式的形式进行存储。
具体的实现会在下文中堆的实现进行详细解释

2). 链式存储方式

  通过指针的形式表示节点间关系,形如上文中树的表示形式,具体内容,会在下文详细解释。

三. 二叉树的顺序结构与实现(堆)

1. 堆的概念

  将所有元素按照完全二叉树的顺序存储方式存储在一个一维数组中。并且该树要满足以下条件:对于任意节点,如果节点值都比其孩子节点大,则称为大根堆。如果节点值都比其任意一个孩子小则称为小根堆。
在这里插入图片描述

2. 堆的性质

1 堆顶元素一定是堆中最小或最大元素
2 堆中存储的树结构一定是一颗完全二叉树
3 堆序为升序或降序(从根节点到叶子节点的路径)
4 双亲节点比其任意子孙节点小(大)

3. 堆的实现

  在实现堆时,给定一个数组,这个数组在逻辑上可以看作为一棵完全二叉树,但这个数组的树形式,可能不满足堆的定义,所以需要通过一些算法,将该数组调整为一个符合堆性质的数组。
  在这里我们介绍对的向下调整算法。

1). 堆的向下调整算法

1.思路:
  判断每个父节点是否满足大于或小于其子节点,如果需要交换则交换两节点,交换后可能还是不满足堆特性,所以移动父节点与子节点位置继续判断是否满足特性,直到满足堆特性,此次调整结束。

  下图示例,为左右子树满足堆特性,但根节点加入后不满足堆特性,所以只需要对根节点调整即可。
在这里插入图片描述
2. 代码实现

// 堆的向下调整算法:
// 传入的节点为双亲节点,判断当前双亲节点是否满足堆的性质,不满足则将当前父节点与子节点进行比较
// 如果有两个孩子,则与值较小的孩子进行交换位置,一致按照这种方式比较下去,直到到达叶子节点或不满足条件退出
void AdjustDown(Heap* hp, int nodeId)
{
	HPDataType* data = hp->_a;
	int child = 2 * nodeId + 1;

	while (child < hp->_size)
	{
		// 找到两个孩子中的小值
		if (child + 1 < hp->_size && hp->_cmp(data[child + 1], data[child]))
			child++;

		// 判断该子树中的节点是否满足堆的性质,不满足则交换
		if (hp->_cmp(data[child], data[nodeId]))
		{
			Swap(&data[nodeId], &data[child]);

			// 交换完该节点后,下方子树可能不满足堆的性质,所以移动父节点与孩子节点的位置,继续判断
			nodeId = child;
			child = 2 * nodeId + 1;
		}
		else
		{
			return;
		}
	}
}

2). 堆的创建

  创建堆的过程实则就是将一个数组调整为符合堆性质的过程,与堆的向下调整过程完全相同。

找到倒数第一个非叶子节点 `lastNotLeaf = ((size-1)-1)/2`
从该节点开始一直到根节点逐个向前对每个节点应用向下调整算法

代码部分:

void HeapCreate(Heap* hp, HPDataType* a, int n, CMP cmp)
{
	// 初始化
	hp->_a = (HPDataType*)malloc(sizeof(HPDataType) * n);
	if (NULL == hp->_a)
	{
		assert(0);
		return;
	}
	hp->_size = 0;
	hp->_capacity = n;
	hp->_cmp = cmp;

	// 导入数据
	memcpy(hp->_a, a, sizeof(HPDataType) * n);
	hp->_size = n;

	// 使用向下调整算法,将堆调整为符合性质的堆 从倒数第一个节点的父节点开始遍历,直到遍历至根节点
	for (int root = (n - 2) / 2; root >= 0; root--)
	{
		AdjustDown(hp, root);
	}
}

3). 堆的插入

堆的插入过程为

1 检测堆空间是否足够,不够则需要扩容
2 将元素插入至堆尾,插入堆尾后可能会破坏堆的特性
3 对插入元素使用向上调整算法,通过孩子求双亲,进行比较

堆的向上调整算法
  该算法与向下调整算法一致,不再赘述。

堆插入过程的图示:
在这里插入图片描述
代码部分

void CheckCapacity(Heap* hp)
{
	if (hp->_size == hp->_capacity)
	{
		hp->_a = (HPDataType*)realloc(hp->_a, sizeof(HPDataType) * hp->_capacity * 2);
		if (NULL == hp->_a)
		{
			assert(0);
			return;
		}

		hp->_capacity *= 2;
	}
}

// 向上调整算法
// 思路相同,传入的节点为孩子节点,判断孩子与双亲的关系,是否满足条件,满足则逐个交换,直到根节点或不满足条件
void AdjustUp(Heap* hp, int nodeId)
{
	HPDataType* data = hp->_a;
	int parent = (nodeId - 1) / 2;

	while (nodeId > 0)
	{
		if (parent >= 0 && hp->_cmp(data[nodeId], data[parent]))
		{
			Swap(&data[parent], &data[nodeId]);

			nodeId = parent;
			parent = (nodeId - 1) / 2;
		}
		else
		{
			return;
		}
	}
}

// 堆的插入
// 判断空间是否足够,空间足够则将该元素插入到队尾的位置,判断该节点是否满足堆的性质,不满足则向上调整,使其满足性质
void HeapPush(Heap* hp, HPDataType x)
{
	assert(hp);

	// 扩容
	CheckCapacity(hp);

	// 将元素插入队尾
	hp->_a[hp->_size++] = x;

	AdjustUp(hp, hp->_size - 1);
}

4). 堆的删除操作

  堆删除的过程删除堆顶元素,在删除堆顶元素时,为了简化流程,使用如下思路删除堆顶元素:

1 将堆顶元素与堆尾元素进行交换
2 删除堆尾元素
3 再对堆顶元素利用向下调整算法,使其符合堆的特性。

删除过程图示:
在这里插入图片描述


代码部分

// 堆的删除
// 从堆头删除,交换堆头和堆尾元素,忽略队尾元素,因为调整了堆的结构,可能导致堆不满足性质,
// 所以,对根节点进行向下调整
void HeapPop(Heap* hp)
{
	HPDataType* data = hp->_a;
	
	Swap(&data[0], &data[hp->_size - 1]);
	hp->_size--;

	AdjustDown(hp, 0);
}

4. 堆的应用

应用一:堆排序

  利用删除堆的思想对堆进行排序。删除堆的过程是,将堆头元素与堆尾元素交换,堆头元素为该堆中的最大值或者最小值,所以每次都是将最大值最小值向堆尾移动,移动完成后利用向下调整算法。
  当每个元素都经历上述这个过程后,该堆就变得有序了。
  需要注意的是,排升序建大根堆,排降序建小根堆。

图示
在这里插入图片描述
代码实现

// 思路:
//		1. 先将n个元素建立一个大堆或这小堆
//		2. 建成之后,堆头元素要么最大或者最小
//		3. 将堆头元素与堆尾元素交换,并将队尾元素剔除,
//	    4. 一直重复上述过程,直到堆中元素全部剔除完成

// 对数组进行堆排序
void HeapSort(int* a, int n, CMP cmp)
{
	Heap hp;
	// 建堆
	HeapCreate(&hp, a, n, cmp);

	// 将堆头元素放置堆尾,并且剔除该元素,该操作与删除堆元素相同
	while (hp._size > 0)
	{
		HeapPop(&hp);
	}

	for (int i = 0; i < n; i++)
	{
		printf("%d ", hp._a[i]);
	}
	printf("\\n");
}

应用二:Top-K

  求最小或最大的前k个数据,eg:数据集合中的最大的10个元素。通常这类问题,所提供的数据量会十分的巨大,使用普通排序时间复杂度最低也是O(logn),所以再在求解Top-k问题上使用堆来进行求解,时间复杂度为O(n).

思路
  1. 使用前k个元素建堆,取最小值建大堆,取最大值建小堆。
  2. 使用剩余的n-k个元素依次与堆顶元素进行比较,如果满足条件则交换两值,交换后使用向下调整算法使其满足堆特性。一直往复上述操作,直至比较完成。因为堆顶元素要么是最大值要么是最小值。

图示
在这里插入图片描述

代码实现

// 找最大的前K个,建立K个数的小堆
// 找最小的前K个,建立K个数的大堆
// 思路: 1 建堆 用前k个元素建堆
//		 2 使用剩余的 n-k 个元素依次与堆顶元素比较,如果堆顶元素大于/小于该元素,
//  		则将该元素与堆顶元素进行替换,替换后使用向下调整算法调整该堆符合性质
// 根据用户选择计算top-k最大的还是最小的
void PrintTopK(int* a, int n, int k, CMP cmp)
{
	Heap hp;
	// 建堆
	HeapCreate(&hp, a, k, cmp);

	// 用n-k个元素与堆顶进行比较
	for (int i = k; i < n; i++)
	{
		if (hp._cmp(hp._a[0], a[i]))
		{
			// 交换堆头元素
			Swap(&(hp._a[0]), &a[i]);

			// 向下调整
			AdjustDown(&hp, 0);
		}
	}

	for (int i = 0; i < k; i++)
	{
		printf("%d ", hp._a[i]);
	}
}

void TestTopk()
{
	int arr[] = { 42, 65, 12, 65, 12, 56, 654, 12, 654, 12312, 656, 12,1, 233, 3, 5, 6, 7, 8,100 ,9 ,10 };
	int k = 0;
	int flag = 0;

	printf("输入格式: num1 num2 ---> num1 显示前多少位,  num2 显示最大还是最小,最大1 最小0\\n");
	printf("请输入参数:> ");
	scanf("%d %d", &k, &flag);

	if(1 == flag)
		PrintTopK(arr, sizeof(arr) / sizeof(arr[0]), 5, Less);
	else
		PrintTopK(arr, sizeof(arr) / sizeof(arr[0]), 5, Greater);
}

四. 二叉树的链式存储

1. 二叉树的链式存储结构

struct BiTreeNode
{
	DataType data;
	BiTreeNode* lchild;	// 指向左孩子
	BiTreeNode* rchild; // 指向右孩子
};

在这里插入图片描述

2. 二叉树的遍历操作

  遍历是指按照某种特定的规则,对二叉树中的节点进行某种相应的操作,并且每个节点只操作一次。

  二叉树中的遍历方式共分为三种,命名方式按照根节点的访问次序进行命名。在下文中的遍历,通过递归对树进行遍历。

1). 前序遍历
  先访问根节点,再访问左子树,最后访问右子树。(根-左-右)
2). 中序遍历
  先访问左子树,再访问根节点,最后访问右子树。(左-根-右)
3). 前序遍历
  先访问左子树,再访问右子树,最后访问根节点。(左-右-根)

图示
使用中序遍历举例:
在这里插入图片描述
以上图为例:

前序遍历: A B D F C G H
中序遍历: D B F A C G H
后序遍历: D F B C G H A

代码实现

// 前序遍历二叉树
void PreOrderTraversal(BTNode* bt, int level)
{
	if (bt != NULL)
	{
		Vist(bt->data, level);
		PreOrderTraversal(bt->lchild, level + 1);
		PreOrderTraversal(bt->rchild, level + 1);
	}
}

// 中序遍历
void InOrderTraversal(BTNode* bt, int level)
{
	if (bt != NULL)
	{
		InOrderTraversal(bt->lchild, level + 1);
		Vist(bt->data, level);
		InOrderTraversal(bt->rchild, level + 1);
	}
}

// 后序遍历
void PostOrderTraversal(BTNode* bt, int level)
{
	if (bt != NULL)
	{
		PostOrderTraversal(bt->lchild, level + 1);
		PostOrderTraversal(bt->rchild, level + 1);
		Vist(bt->data, level);
	}
}

4). 二叉树的层序遍历
  这种遍历方式较好理解,就是将每一层的元素从左至右依次输出。但是它的实现方式与上述的三种遍历方式完全不同。
  因为要实现层序遍历,就要存储每一层的所有子节点,如果树的深度较大,则数据量是巨大的。所以在层序遍历中,使用队列来进行二叉树的层序遍历。
关于队列操作,点击此处队列操作

层序遍历过程

1. 定义一个队列结构并初始化,将根节点入队。
2. 从队列头取出队头节点,遍历该节点的左右子树,并将左右子树入队。
3. 循环上述操作,直至队列为空。

图示
在这里插入图片描述
代码实现

// 层序遍历 按照树的层次关系,从第一层开始从左至右遍历出现的节点。
// 遍历时,利用队列的特性,将根节点先入队,之后遍历左右子树的根节点,将左右子树加入队列节点。
void BinaryTreeLevelOrder(BTNode* root)
{
	if (NULL == root)
		return;

	Queue queue;

	<

以上是关于[数据结构] 树与二叉树的超详细解析 建议收藏 看它就够了的主要内容,如果未能解决你的问题,请参考以下文章

普通树与二叉树

< 数据结构 > 树与二叉树

王道数据结构5(树与二叉树)

王道数据结构5(树与二叉树)

树森林与二叉树的相互转换

数据结构_树与二叉树