文章目录
前言
在学习“树”之前,相信伙计们已经接触过了线性表一类的数据结构知识。经过观察,我们很容易发现这些线性结构的一个最大特点就是“一对一”,但是在我们现实生活中,事物之间的关系可没有这么简单,我们还需要其他的数据结构表达更为复杂的关系,因此,一种新的数据结构——树,应运而生。
1. 树的概念
树(Tree)是n(n≥0)个结点的有限集。n=0时称为空树。在任意一棵非空树中:
(1)有且仅有一个特定的称为根(Root)的结点;
(2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2 、……、Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)
树的概念图:
在这里,我们把这一个个的圆圈叫做“结点”,并且把位于最顶部的那一个结点(图中为A)叫做“根结点”(根结点唯一),而与根节点相邻的各个结点(图中为B、C)及其各自所产生的一系列结点的集合,即{B、D、G、H、I }与{ C、E、F、J},叫做根节点A的“子树”。如图:
此外!!!有一点需要特别注意!那就是“在树的定义下,子树必然互不相交”!像下图,子树相交的不能称之为树:
1.1 结点的分类
树的结点包含一个数据元素及若干指向其子树的分支。结点拥有的子树数称为结点的度(Degree)。度为0的结点称为叶结点(Leaf )或终端结点;度不为0的结点称为非终端结点或分支结点。除根结点之外,分支结点也称为内部结点。树的度是树内各结点的度的最大值。如上图所示,因为这棵树结点的度的最大值是结点D的度,为3,所以树的度也为3。
1.2 结点间的关系
结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(Parent)。同一个双亲的孩子之间互称兄弟(Sibling)。由一个结点一直追溯到根,所经过分支上的任何结点都叫做这个结点的祖先(Ancestor),所以对于H来说,D、B、A都是它的祖先。反之,以某结点为根的子树中的任一结点都称为该结点的子孙,比如B的子孙有D、G、H、I。
1.3 一些树的相关概念
结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。 若某结点在第l层,则其子树就在第l+1层。其双亲在同一层的结点互为堂兄弟。显然图6-2-6中的D、E、F是堂兄弟,而G、H、I与J也是堂兄弟。树中结点的最大层次称为树的深度(Depth)或高度,当前树的深度为4。
如果将树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。
森林(Forest)是m(m≥0)棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。
1.4 树与线性表的差别
2. 树的表示
实际中树有很多种表示方式如:双亲表示法,孩子表示法、孩子双亲表示法以及孩子兄弟表示法
等。我们这里就简单的了解其中最常用的孩子兄弟表示法
2.1 孩子兄弟表示法
任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。
typedef int DataType;
struct Node
{
struct Node* _firstChild1; // 第一个孩子结点
struct Node* _pNextBrother; // 指向其下一个兄弟结点
DataType _data; // 结点中的数据域
}
3. 二叉树
3.1 二叉树的概念
一棵二叉树是结点的一个有限集合,该集合:
1. 或者为空
2. 由一个根节点加上两棵别称为左子树和右子树的二叉树组成
从上图可以看出:
1. 二叉树不存在度大于2的结点
2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
3. 根结点从1开始编号:
若当前节点的下标为 i, 则其父节点的下标为 i/2,其左子节点的下标为 i×2,其右子节点的下标为i×2+1
4. 根结点从0开始编号:
若当前节点的下标为 i, 则其父节点的下标为 (i-1)/2,其左子节点的下标为 i×2+1,其右子节点的下标为i×2+2
5. 对于任意的二叉树都是由以下几种情况复合而成的:
3.2 特殊的二叉树
1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是
说,如果一个二叉树的层数为K,且结点总数是2^k-1,则它就是满二叉树。
2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K
的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对
应时称之为完全二叉树。 要注意的是满二叉树是一种特殊的完全二叉树。
满二叉树:
完全二叉树:
以下这些不是完全二叉树:
3.3 二叉树的储存结构
二叉树的储存一般有两种形式:
1.顺序储存
2.链式储存
3.3.1 顺序储存
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空
间的浪费。二叉树在顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
3.3.2 链式储存
1)二叉树的链式存储结构是指用链表来表示一棵二叉树 ,链表中的每个结点通常有三个域:数据域、左指针域和右指针域,左右指针域分别存储该结点的左、右孩子的地址 。
2)链式结构又分为二叉链和三叉链
这里放一下结点代码感受一下结构:
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; // 当前节点值域
};
3.4 堆——二叉树顺序储存的典例
1)堆(Heap)以二叉树的顺序存储方式来存储元素
2)堆只有两种,即大堆(大顶堆)和小堆(小顶堆)
3)堆还是一种完全二叉树
4)
a.根结点从1开始编号:
若当前节点的下标为 i, 则其父节点的下标为 i/2,其左子节点的下标为 i×2,其右子节点的下标为i×2+1
b.根结点从0开始编号:
若当前节点的下标为 i, 则其父节点的下标为 (i-1)/2,其左子节点的下标为 i×2+1,其右子节点的下标为i×2+2
小堆:每个双亲结点的值都小于或等于其左右孩子结点的值
大堆:每个双亲结点的值都大于或等于其左右孩子结点的值
图为两种堆的逻辑结构(二叉树)与储存结构(数组):
3.4.1 堆的实现及堆排序
我们学习堆,其实最重要的是要学习“堆排列”,这个排列算法是非常非常非常牛的,在一些情况下它的效率可以拉冒泡排序几百条街。但在学习这个重头戏之前,我们要先讲一些前置知识:堆的两种调整算法以及堆的创建。
3.4.1.1 堆的向下调整算法
向下调整算法可以把一个数组调整为大堆或者小堆,现在我们给出一个数组,逻辑上看做一颗完全二叉树,要把它调整为小堆(小堆与大堆的调整思想类似,这里我们展示调整为小堆的步骤)。
前提:
1)若想将其调整为小堆,那么根结点的左右子树必须都为小堆。
2)若想将其调整为大堆,那么根结点的左右子树必须都为大堆。
步骤:
1.从根节点开始与其孩子中较小的替换
2.如果替换后又大于其下面两个孩子(也就是原来的孙子),继续与较小的孩子替换
3.以此类推直至<=两个孩子,或者变为叶结点(没有孩子)
时间复杂度:
使用堆的向下调整算法,最坏的情况下(即一直需要交换结点),需要循环的次数为:h - 1次(h为树的高度)。而h = log2(N+1)(N为树的总结点数)。所以堆的向下调整算法的时间复杂度为:O(logN) 。
代码:
//交换函数
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;
*y = tmp;
}
//根结点从1开始编号:
//若当前节点的下标为 i, 则其父节点的下标为 i/2,其左子节点的下标为 i×2,其右子节点的下标为i×2+1
//根结点从0开始编号:
//若当前节点的下标为 i, 则其父节点的下标为 (i-1)/2,其左子节点的下标为 i×2+1,其右子节点的下标为i×2+2
//堆的向下调整(小堆)
void AdjustDown(int* a, int n, int parent)
{
//这里根结点从0开始编号
//child记录左右孩子中值较小的孩子的下标
int child = 2 * parent + 1;//先默认其左孩子的值较小
while (child < n)
{
if (child + 1 < n&&a[child + 1] < a[child])//右孩子存在并且右孩子比左孩子还小
{
child++;//较小的孩子改为右孩子
}
if (a[child] < a[parent])//左右孩子中较小孩子的值比父结点还小
{
//将父结点与较小的子结点交换
Swap(&a[child], &a[parent]);
//继续向下进行调整
parent = child;
child = 2 * parent + 1;
}
else//已成堆
{
break;
}
}
}
3.4.1.2 堆的向上调整算法
如果我们在一个堆后面插入一个结点,那么这个结点的值的大小是有可能毁掉堆本来的性质的。如下图:
当我们从数组后面插入 50 ,在逻辑结构上,原本的堆就不是堆了。因此,为了插入结点时不破坏堆的性质,我们需要用到堆的向上调整算法来给新插入的结点安排个适合的位置。
这里我们用小堆举例,小堆的向上调整代码的实现如下:
void AdjustUp_little(HPDataType* a, int child)//小堆的向上调整
{
//这里根结点从0开始编号
int parent = (child - 1) / 2;
while (child>0)
{
if (a[child] < a[parent])//孩子小于父亲则交换
{
HPDataType tmp = a[child];
a[child] = a[parent];
a[parent] = tmp;
}
else//调整完毕
{
break;
}
//让child往上走直至根结点
child = parent;
parent = (child - 1) / 2;
}
}
3.4.1.3 堆的代码实现
介绍了上面两种重要算法,我们现在可以来实现堆了。
a.堆的结构
typedef int HPDataType;
typedef struct Heap
{
int* a;//数组
int size;//大小
int capacity;//容量
}HP;
b.接口
void HeapInit(HP* php);//堆的初始化
void HeapDestroy(HP* php);//堆的销毁
void HeapPush(HP* php,HPDataType x);//堆的插入
void HeapPop(HP* php);//堆的删除
HPDataType HeapTop(HP* php);//获取堆顶元素
int HeapSize(HP* php);//获取堆内元素个数
bool HeapEmpty(HP* php);//堆的判空
c.功能函数
void AdjustUp_little(HPDataType* a, int child);//小堆的向上调整
void AdjustDown_little(HPDataType* a, int n, int parent);//小堆的向下调整
void Swap(HPDataType* p1, HPDataType* p2)
{
HPDataType tmp = *p1;
*p1 = *p2;
*p2 = tmp;
}
d.初始化
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = 0;
php->capacity = 0;
}
e.判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
f.堆的插入
新结点在数组尾部插入,符合堆向上调整的条件。步骤:数组判满扩容->插入->堆向上调整
void HeapPush(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, newCapacity * sizeof(HPDataType));
if (tmp == NULL)
{
perror("realloc fail\n");
return;
}
php->a = tmp;
php->capacity = newCapacity;
}
php->a[php->size++] = x;
AdjustUp_little(php->a, php->size-1);//size-1为了对应数组下标
}
g.堆的删除
堆只能删除(pop)堆顶元素,但是堆顶元素的删除会破坏堆作为树的结构,并且不易恢复堆。所以我们可以把堆顶元素(数组首元素)与尾部 (数组尾部) 的元素调换,从尾部把原堆顶元素删除,此时的新根结点的左右树都符合小堆(我们这里只举例小堆),可以使用向下调整算法,最后恢复堆形态。
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
Swap(&php->a[0], &php->a[php->size - 1]);//将根节点与“尾部”的结点调换
php->size--;//删除原来的“根节点”
AdjustDown_little(php->a, php->size, 0);//恢复堆形态
}
h.获取堆顶元素
HPDataType HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];
}
i.获取堆内元素个数
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
j.堆的销毁
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->capacity = php->size = 0;
}
3.4.1.4 堆排序(降序)
这里只分享降序版本的堆排序,改为升序只需在调整算法的代码中将几个比大小符号改一改就可以了,这里伙计们可以自己去试试。
依旧是调整算法的应用,我们只用向下调整算法(这里运用向上调整算法也可实现,但是要多写一份代码,而且效率不佳)
如图:
代码:
void HeapSortDown(HPDataType*a , int n)
{
assert(a);
int i;
for (i = (n-1-1)/2; i >= 0; i--);
{
AdjustDown_little(a,n,i);
}
for (int i = n - 1; i > 0; i--)
{
Swap(&a[0], &a[i]);
AdjustDown_little(a, i, 0);
}
}
测试结果:
通过测试结果我们可以发现堆排序其实并不是把数组的元素给赋予单调性,这是堆排序区别于冒泡排序的一点。
在物理逻辑上,堆排序可以把一棵完全二叉树给“堆化”,但是在储存的意义上,堆化并不等于把数组内的元素从小到大(从大到小)排列。
堆排序的特性总结:
- 堆排序使用堆来选数,效率就高了很多。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
结语:
本文给大家介绍了树的相关概念及其关系表示,重点介绍了二叉树的顺序结构——堆的知识。篇幅原因,二叉树的链式结构及其实现在本文未提及,笔者打算放到下篇文章介绍。希望本文能对大家有帮助。
新人上路,有误务必指出!!
感谢阅读Thanks♪(・ω・)ノ