[数据结构] 树与二叉树的超详细解析 建议收藏 看它就够了
Posted 哦哦呵呵
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[数据结构] 树与二叉树的超详细解析 建议收藏 看它就够了相关的知识,希望对你有一定的参考价值。
这篇文章篇幅较长,可以点击下方目录查看对应内容。
一. 树
1. 基本概念
树是一种非线性的数据结构,由n个节点组成具有层次关系的集合。其实就是一颗倒着长得大树,一个树根加了好多叶子。
树中必定含有一颗根节点,和若干分支节点与叶子节点。并且每个分支节点的孩子构成了一颗子树。每个子树的根节点只有一个前驱,但是可以由很多的后继。除了根节点外,每个节点有且只有一个父节点。
2. 树的基本性质
- 节点的度:一个节点含有子树的个数成为该节点的度,上图中A的度为2,B的度为3,D的度为0.
- 叶节点或终端节点:度为0的节点为叶节点。上图中 D、E、F、G、H。
- 分支节点:度不为0的节点。上图中B、C。
- 父节点:若一个含有子节点,则称这个节点为父节点。上图中A是B、C的父节点,B是D、E、F的父节点。
- 子节点:一个节点含有的子树的根节点称为该节点的子节点,上图中B、C是A的子节点
- 兄弟节点:由相同父节点的节点互称兄弟节点,上图中B、C互为兄弟,D、E、F互为兄弟。
- 树的度:一棵树中最大节点的度为数的度。上图中度最大的节点为B 3,所以3为树的度。
- 节点的层次:以根节点所在的层次定义为第1层,根的子节点为第2层。
- 树的高度或深度:树中节点的最大层次。上图高度为3
- 堂兄弟节点:父节点在同一层的节点。上图中 DEFGH互为堂兄弟。
- 节点的祖先:从根节点到该节点所经分支上的所有节点。上图中A是所有节点的祖先
- 森林:由N棵互不相交的树构成的集合称为森林。
3. 树的表示方法
1). 孩子表示法
- 数据结构
// 孩子节点结构
struct BiTreeNode
{
int child; // 孩子节点的下标
BiTreeNode* next; // 指向下一节点的指针
}
//
struct BiTreeHead
{
DataType data; // 存放树中节点的数据
BiTreeNode* firstchild; // 指向第一个孩子的指针
}
struct CTree{
CTBox BiTreeHead[MAX_SIZE]; //存储结点的数组
int n,r; //结点数量和树根的位置
};
- 图示
- 优缺点
优点:找孩子方便。缺点:找双亲不方便
2). 双亲表示法
- 数据结构
struct PTNode{
DataType data; //树中结点的数据类型
int parent; //结点的父结点在数组中的位置下标
};
struct PTree{
PTNode tnode[MAX_SIZE]; //存放树中所有结点
int n; //根的位置下标和结点数
};
- 图示
- 优缺点
优点:找双亲方便。缺点:找孩子不方便
3). 孩子双亲表示法
实则为孩子表示法与双亲表示法的整合。
- 数据结构
// 孩子节点结构
struct BiTreeNode
{
int child; // 孩子节点的下标
BiTreeNode* next; // 指向下一节点的指针
}
//
struct BiTreeHead
{
DataType data; // 存放树中节点的数据
BiTreeNode* firstchild; // 指向第一个孩子的指针
int parent; // 双亲的下标
}
struct CTree{
CTBox BiTreeHead[MAX_SIZE]; //存储结点的数组
int n,r; //结点数量和树根的位置
};
- 图示
4). 孩子兄弟表示法
- 数据结构
struct BiNode{
ElemType data;
BiNode* firstchild; // 孩子指针
BiNode* nextsibling; // 兄弟指针
};
- 图示
4. 树的应用
文件系统的目录结构
二. 二叉树
1. 概念
一颗二叉树是节点的一个有限集合,该集合为空,或是由一个根节点加上两棵左右子树构成。每个节点至多由两颗子树。
二叉树是一颗有序树,子树有左右之分。
2. 特殊二叉树
- 满二叉树:一个二叉树有n层,每一层的节点都达到了最大值
2^(n-1)
,该树的节点总数为2^n - 1
- 完全二叉树:一颗二叉树有n个节点,每个节点都与它所对应的满二叉树节点对应,则该树就是一棵完全二叉树。
完全二叉树中节点总个数如果为偶数,则只有一个节点具有左孩子,倒数第一个非叶子节点一定是只有一个左孩子。
完全二叉树中节点总个数如果为奇数,则所有的非叶子节点都有两个孩子。
3. 二叉树的性质
-
性质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 否则无右孩子
接下来根据上述性质,看一道例题:
假设完全二叉树中有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;
<以上是关于[数据结构] 树与二叉树的超详细解析 建议收藏 看它就够了的主要内容,如果未能解决你的问题,请参考以下文章