目录
1.树的概念及结构
1.1树的概念
1.2树的相关概念
1.3树的表示
1.3.1二叉树结构的定义
1.3.2左孩子右兄弟表示法
1.3.3双亲表示法
2.二叉树的结构及概念
2.1二叉树
2.2特殊的二叉树
2.3二叉树的性质
3.二叉树的顺序结构及实现
3.1二叉树的顺序结构
3.2堆的概念
3.3堆的实现
3.3.1构造堆结构
3.3.2初始化
3.3.3销毁
3.3.4插入数据到堆
3.3.4.1堆的向下调整算法
3.3.4.2向下调整算法如何创建一个堆
3.3.3.3堆的向上调整算法
3.3.3.4向上调整算法创建一个堆
3.3.5删除堆顶元素
3.3.6访问堆顶元素
3.3.7判空
3.3.8求堆的大小
补充:向上调整建堆和向下调整建堆的时间复杂度
4.二叉树的链式结构及实现
4.1快速构建二叉树
4.2二叉树的遍历
4.2.1前序遍历
4.2.2中序遍历
4.2.3后序遍历
4.2.4层序遍历
4.2.5重建二叉树问题
树是一种非线性的数据结构,它是由n(n>0)个有限节点组成一个具有层次关系的组合
把这个结构称为树是因为它看起来像是一棵倒挂的树,也就是说,它的根朝上,而叶朝下
如下图:
数据结构中的树
数据结构中树的特点:
- 有一个特殊的节点,称为根节点。根节点没有前驱结点
- 除根节点外,其余节点被分成M(M>0)个互不相交的集合T1、T2、...、Tm,其中每个集合Ti(1<=i<=m)又是一棵结构与树类似的子树。每棵子树的根节点有且只有一个前驱节点,可以有0个或多个后继节点
- 由上所述,树是递归定义的
Note:
树形结构中,子树之间不能有交集,否则就不是树形结构
节点的度:一个节点含有的子树的个数称为该节点的度;如上图:节点A的度为6
叶节点或终端节点:度为0的节点称为叶节点;如上图:叶节点有B,C,H,I,P,Q......
非终端节点或分支节点:度不为0的节点;如上图:分支节点有D,E,F,G.......
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点;如上图:A是D的父节点、D是H的父节点.....
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点;如上图:C是A的子节点、P是J的子节点.....
兄弟节点:具有相同父节点的节点互称为兄弟节点;如上图:C和D互为兄弟节点
树的度:一棵树中,最大的节点的度称为树的度;如上图:树的度为6
节点的层次:从根开始定义起,根为第一层,根的子节点为第二层,以此类推
树的高度或深度:书中节点的最大层次;如上图:树的高度为4
堂兄弟节点:双亲在同一层的节点互为堂兄弟 ;如上图:H、I护卫堂兄弟节点
节点的祖先:从根到该节点所经分支上所有节点;如上图:A是是所有节点的祖先
子孙:以某节点为根的子树中任一节点都称为该节点的子孙;如上图:所有节点都是A的子孙
森林:由m(m>0)棵互不相交的树构成的额集合称为森林
对于树形结构,既要保存值域,又要保存节点和节点之间的关系
实际中树的表示方法有:双亲表示法,孩子表示法,孩子双亲表示法及孩子兄弟表示法等
以下介绍最常用的孩子兄弟表示法
在线性结构如链表,栈,队列等实现时,我们已经了解数据结构可以定义成静态和动态的两种形式;静态的结构大小固定,实际应用较少,一般定义为动态增长的结构
静态二叉树定义
//树的度为5
#define N 5
struct TreeNode
{
int data;//数据域
struct TreeNode* childArr[N];
int childSize;//实际节点的个数
};
对于如下一棵树:
以根节点为例:根节点的度为3,根节点的结构中,数据域存储A,指针数组中存储其所有子节点的地址,数组存储实际节点的个数为3
缺点:树中节点的度各不相同,我们在结构中创建的指针数组按照树的度(所有节点中最大的度)开辟空间,会造成空间的浪费
动态二叉树定义
struct TreeNode
{
int data;
struct TreeNode** childArr;
int childSize;
int Capacity;
};
可以发现,动态定义时,将指针数组改造成了一个二级指针,这个指针指向一个指针数组,数组中存储所有子节点的地址,但这个数组是动态开辟的;childSize是实际存储子节点的个数;Capacity是动态开辟数组的容量
左孩子右兄弟表示法指的是一个节点中除数据域外包含两个指针,一个指向其第一个孩子,另一个指向其右邻的兄弟节点,如下图
于是可以定义以下的节点结构
//节点的结构
typedef int DataType;
typedef struct TreeNode
{
struct TreeNode* child;
struct TreeNode* brother;
DataType data;
}TreeNode;
对于使用左孩子右兄弟表示法定义的二叉树,遍历所有节点分为两步:
- 从根节点开始,找其第一个孩子 (孩子指针)
- 再找这个孩子的兄弟节点(兄弟指针)
以上两步不断递归即可实现所有节点的遍历
//递归遍历所有节点
void PrintTree(TreeNode* parent)
{
if (parent == NULL)
{
return;
}
TreeNode* cur = parent->child;
while (cur)
{
PrintTree(cur);//递归
cur = cur->brother;
}
printf("%d\n", cur->data);
return;
}
递归遍历的顺序:从根节点A开始,先找到第一个孩子B,B不为空,进入循环递归,B的第一个孩子为D,D不为空,进入循环递归,D为叶子节点,其孩子为空,不进入循环,打印D,返回到上一层递归调用的地方,cur被更新为D的兄弟节点E......依次类推可以访问所有节点
双亲表示法使用顺序存储结构
对于如上图中的树,其存储方式如下:
顺序结构中存放的是其对应位置节点的父亲节点的下标,这种存储方式方便任何一个节点找祖先,在并查集结构中广泛使用
二叉树即度为2的树
一棵二叉树可以分为根节点,根节点的左子树,根节点的右子树三部分
满二叉树:一个二叉树,如果每一层的节点数都达到最大值,则这个二叉树就是满二叉树
满二叉树的特点:如果一个满二叉树的层数为K,则其节点总数为2^K-1
完全二叉树:对于深度为K的,有n个节点的二叉树,当且仅当其每一个节点都与深度为K的满二叉树中编号从1至n的节点一一对应时称之为完全二叉树;通俗来说,一个完全二叉树的前K -1层为一个满二叉树,第K层的节点从左到右依次连续排列
完全二叉树的特点:节点个数处于区间[2^(K-1),2^K - 1]
即最后一层至少有一个节点,至多满,此时为满二叉树
二叉树有如下性质:
- 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2^(i-1)个节点
- 若规定根节点的层数为1,则深度为h的二叉树的最大节点数是2^h - 1
- 对任何一棵二叉树,如果度为0(叶子节点)的节点个数为n0,度为2的节点个数为n2,则恒有n0 = n2 + 1
- 若规定根节点的层数为1,具有n个节点的满二叉树的深度h = log (n+1),其中log (n+1)是以2为底,以n+1为对数
- 对于具有n个节点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点开始编号,则对序号为i的节点有
- 若i>0,i位置节点的双亲序号:(i-1)/ 2;i=0时为根节点,无双亲节点
- 若2i+1
=n则无左孩子 - 若2i+2
=n则无右孩子 例:在具有2n个节点的完全二叉树中,叶子节点的个数为
对于一棵完全二叉树,其只有度为0,度为1,度为2的节点
设度为0的节点为n0,设度为1的节点为n1,设度为2的节点为n2
有:n0+n1+n2 = 2n
且:n0 = n2+1
所以2n0 + n1 -1 = 2n ==> 2n0 + n1 = 2n + 1
对于完全二叉树,度为1的节点最多有1个,最少为0个
由上式,n1的个数为为奇数个,所以n1 = 1
综上,叶子节点即度为0的节点个数为n
二叉树一般采用两种结构存储,一种顺序结构,一种链式结构
顺序存储:
顺序结构就是采用数组来存储,一般使用数组只适合表示完全二叉树,不是完全二叉树采用数组存储会有空间的浪费,实际应用中只有堆才会采用数组来存储。
二叉树顺序存储在物理上是一个数组,在逻辑上是一棵二叉树
现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,这里注意区分堆结构和操作系统虚拟进程地址空间中的堆,前者是数据结构,后者是操作系统中管理内存的一块区域分段
如果有一个关键码的集合K = {k0,k1,k2, ......,k(n-1)},把它所有的元素按完全二叉树的顺序存储方式存储在一个一维数组中,并满足
则称之为小堆(大堆);将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆
堆的性质:
- 堆中某个节点的值总是不大于(大根堆)或不小于(小根堆)其父节点的值
- 堆总是一棵完全二叉树
以小根堆为例,我们分析其存储结构
对于根节点,其下标为0,其左孩子15的下标为1,右孩子30的下标为2
对于节点15,其左孩子25的下标3,右孩子30的下标为4
... ....
综上,可以总结数组下标计算父子关系的规律:
leftchild = parent*2 + 1
rightchild = parent*2 + 2
parent = (child -1)/2,其中child 为左孩子或右孩子
使用数组结构实现堆,为了避免空间的浪费,我们设计一个动态增长的堆
typedef int DataType;
typedef struct Heap
{
DataType* a;
int size;//实际大小
int capacity;//容量
}HP;
//初始化
void HeapInit(HP* php)
{
assert(php);
php->a = NULL;
php->size = php->capacity = 0;
}
销毁需要释放动态开辟的内存,php->a指向一块我们动态开辟的空间,所以free(php->a)即可,并将其他成员变量置0
//销毁
void HeapDestroy(HP* php)
{
assert(php);
free(php->a);
php->a = NULL;
php->size = php->capacity = 0;
}
堆的向下调整算法
向下调整算法的前提是:左右子树必须是一个堆,才能调整
现在我们给出一个数组,逻辑上看作一棵完全二叉树。我们可以通过从根节点开始的向下调整算法把它调整成一个小堆
int arr[] = { 27,15,19,18,28,34,65,49,25,37 };
根节点向下调整的步骤如下图:
具体步骤:根节点先与左孩子比较,如果大于左孩子则交换,如果小于左孩子则与右孩子比较,大于右孩子则交换两个节点,后续重复以上操作,直至满足根节点小于左右孩子,或走到左右孩子不存在
堆的向下调整算法实现如下(向下调整建小堆)
void AdjustDown(HPDataType* a, int n, int parent)
{
assert(a);
int minchild = parent * 2 + 1;
while (minchild < n)
{
//找到左右孩子中的较小者
if (minchild + 1 < n && a[minchild] > a[minchild + 1])
{
minchild = minchild + 1;//更新最小孩子
}
if (a[parent] > a[minchild])
{
swap(a[parent], a[minchild]);
//更新父子节点
parent = minchild;
minchild = parent * 2 + 1;
}
//当父子满足小堆关系时,不进行交换
else
{
break;
}
}
}
ps:向下调整建大堆与向下调整建小堆的步骤相同,只需要改变大小关系,取左右孩子中的较大者maxchild与根比较,根小于maxchild则交换即可
如何创建一个堆
以下我们给出一个数组,这个数组在逻辑上可以看作一棵完全二叉树,但其不是一个堆结构,我们可以通过向下调整算法,把它构建成一个大堆。我们从倒数的第一个非叶子节点的子树开始调整,一直调整到根节点的子树,就可以完成堆的构建
int a[] = { 1,5,3,8,7,6 };
Note:
倒数第一个非叶子节点的子树的根下标计算:
倒数第一个非叶子节点的子树的根即为最后一个节点的父母,由以上给出的父子关系计算公式,可以得到倒数第一个非叶子节点的根下标为(child - 1) / 2,其中child为最后一个节点的下标,即数组的长度减一
如何找到后续需要调整子树的根
由下图:我们调整的起始位置是倒数第一个非叶子节点的子树的根,第二个位置为其兄弟节点,依次向前,它们的下标依次减一,直至到达整棵树的根位置,下标为0
1️⃣从倒数第一个非叶子节点的子树开始调整,采用向下调整算法:
比较根(3)与左孩子(6),根小于左孩子,交换两个节点
2️⃣调整上一个节点的兄弟节点,下标为上一个节点减一
比较根(5)与左右孩子中的较大者(8),根小于最大的孩子,交换两个节点
3️⃣调整上一个节点的父节点,下标为上一个节点减一
比较根(1)与左右孩子中的较大者(8),根小于最大的孩子,交换两个节点
4️⃣此时更新父子节点
比较根(1)与左右孩子中的较大者(7),根小于最大的孩子,交换两个节点
5️⃣最终构建的大堆如下图:
Note:
每一次向下调整都会使这个数据到达其最终符合堆关系的位置,因为在向下调整算法中,如果有交换操作,其中一棵子树的父子关系发生变化,其后续子树的关系也会发生变化,因此需要不断更新父子关系,向下调整,直到到达叶子节点
堆的向上调整算法用于插入一个数据到堆时,将其调整到符合堆关系的位置
如在以下数组中插入10 ,使其仍然为小堆
int arr[] = { 15,18,19,25,28,34,65,49,27,37 };
步骤:先插入10到数组额尾上,再通过向上调整算法调整到合适位置
具体步骤:新节点与其父节点比较,小于父节点则向上调整,,直至到达整棵树的根
堆的向下调整算法实现如下(小堆):
//向上调整算法
void AdjustUp(HPDataType* a, int child)
{
assert(a);
int parent = (child - 1) / 2;
while (child > 0)
{
if (a[child] < a[parent])
{
Swap(&a[parent], &a[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
Note:
向上调整算法中,调整结束时parent为0,可以使用parent>=0作为循环的条件
但使用child>0作为循环条件效率更高
在数组中原有元素个数为0时,我们在数组尾部插入一个数据,此时不需要调整,当使用parent>=0作为循环的条件时,会进入循环一次,而使用child>0作为循环条件时则不会进入循环,效率更高
我们已经介绍了堆的向上调整算法,插入数据到堆使用向上调整算法
//插入数据到堆
void HeapPush(HP* php, HPDataType x)
{
assert(php);
//为堆开辟空间
if (php->size == php->capacity)
{
int newcapacity = php->size == php->capacity == 0 ? 4 : (php->capacity) * 2;
//HPDataType* tmp = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);
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++;
//调整位置
AdjustUp(php->a, php->size - 1);
}
HeapPush函数一般用于创建一个堆结构,给出一组数据,通过依次将这些数据插入堆,每次插入的数据都会向上调整,因此可以创建一个堆,以下代码为用a数组中的数据创建一个小堆的测试函数
void test2()
{
HP heap;
int a [] = { 65,100,70,32,50,60 };
HeapInit(&heap);//初始化
for (int i = 0; i < sizeof(a) / sizeof(a[0]); i++)
{
HeapPush(&heap, a[i]);
}
HeapPrint(&heap);
}
为了便于观察,我们可以打印堆(以顺序表的形式)
//打印
void HeapPrint(HP* php)
{
assert(php);
for (int i = 0; i < php->size; i++)
{
printf("%d ", php->a[i]);
}
printf("\n");
}
删除堆顶元素
法1:挪动删除,即从数组下标为1的元素开始依次向前覆盖前一个元素,但这种删除方式时间复杂度为O(N),且这种操作会导致堆中的父子关系混乱,所以不采用
法2:交换堆顶元素和最后一个叶子节点,删除最后一个叶子节点,堆顶元素即根节点向下调整至适当位置,步骤如下图:
//删除堆顶元素
void HeapPop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
//交换
Swap(&php->a[0], &php->a[php->size - 1]);
//删除
php->size--;
//调整
AdjustDown(php->a, php->size, 0);
}
堆顶元素即为为数组中下标为1的元素
//访问堆顶元素
HPDataType HeapTop(HP* php)
{
assert(php);
assert(!HeapEmpty(php));
return php->a[0];
}
当堆中实际元素个数为0时,堆即为空
//判空
bool HeapEmpty(HP* php)
{
assert(php);
return php->size == 0;
}
堆的大小即为堆中实际元素的个数
//求堆的大小
int HeapSize(HP* php)
{
assert(php);
return php->size;
}
向下调整建堆的时间复杂度
对于高度为h,节点数量为N的完全二叉树(最坏情况为满二叉树)
节点数N = 2^h - 1 ==> h = log(N+1)
向下调整建堆,最后一层的节点不需要移动,所以需要移动的节点总个数为:
2^0 + 2^1 + 2^2 + ... ... + 2^(h-2) 即前h-1层所有的节点
对于第一层2^0个节点,每个节点需要移动的最坏情况为h-1层,即调整到最后一层
对于第二层2^1个节点,每个节点需要移动的最坏情况为h-2层,即调整到最后一层
... ... ... ...
对于第h-1层2^(h-2)个节点,每个节点需要移动的最坏情况为1层,即调整到最后一层
综上,需要移动的节点总步数为:
T(n) = 2^0 * (h-1) + 2^1 * (h-2) + 2^2 * (h-3) + ... ... + 2^(h-2) * 1
2T(n) = 2^1 * (h-1) + 2^2 * (h-2) + 2^3 * (h-3) + ... ... + 2^(h-1) * 1
以上两式错位相减得:
T(n) = 2^h - 1 - h
h = log(N+1)代入:T(n) = n + log(n+1)
因此,向下调整建堆的时间的复杂度为O(N)
向上调整建堆的时间复杂度
对于高度为h,节点数量为N的完全二叉树(最坏情况为满二叉树)
节点数N = 2^h - 1 ==> h = log(N+1)
向下调整建堆,从最后一个叶子节点开始向上调整,所以需要移动的节点总个数为:
2^0 + 2^1 + 2^2 + ... ... + 2^(h-2) + 2^(h-1) 即所有的节点
对于第一层2^0个节点,不需要调整
对于第二层2^1个节点,每个节点需要移动的最坏情况为1层,即调整到第一层
... ... ... ...
对于第h-1层2^(h-2)个节点,每个节点需要移动的最坏情况为h-2层,即调整到第一层
对于第h层2^(h-1)个节点,每个节点需要移动的最坏情况为h-1层,即调整到第一层
综上,需要移动的节点总步数为:
T(n) = 2^(h-1) * (h-1) + 2^(h-2) * (h-2) + ... ... + 2^2 * 2 + 2^1 * 1
2T(n) = 2^h * (h-1) + 2^(h-1) * (h-2) + ... ... + 2^3 * 2 + 2^2 * 1
以上两式错位相减得:
T(n) = 2^h * (h-2) + 2
h = log(N+1)代入:T(n) = N[ log(N+1) - 2] + 2
因此,向下调整建堆的时间的复杂度为O(NlogN)
构建一棵具有如下结构的二叉树
快速构建只需要创建节点,再将每个节点按照其父子关系链接起来即可
typedef int DataType;
typedef struct TreeNode
{
struct TreeNode* left;
struct TreeNode* right;
DataType data;
}TreeNode;
//创建节点
TreeNode* BuyNode(DataType x)
{
TreeNode* newnode = (TreeNode*)malloc(sizeof(TreeNode));
if (newnode == NULL)
{
perror("malloc fail");
exit(-1);
}
newnode->data = x;
newnode->left = NULL;
newnode->right = NULL;
return newnode;
}
//快速构建一棵树
TreeNode* CreateBinaryTree()
{
TreeNode* n1 = BuyNode(1);
TreeNode* n2 = BuyNode(2);
TreeNode* n3 = BuyNode(3);
TreeNode* n4 = BuyNode(4);
TreeNode* n5 = BuyNode(5);
TreeNode* n6 = BuyNode(6);
n1->left = n2;
n1->right = n3;
n2->left = n4;
n2->right = NULL;
n3->left = n5;
n3->right = n6;
n4->left = NULL;
n4->right = NULL;
n5->left = NULL;
n5->right = NULL;
n6->left = NULL;
n6->right = NULL;
return n1;
}
二叉树分为:
- 空树
- 非空树:由根节点,根节点的左子树,根节点的右子树构成
ps:每棵子树又可以分成根和其左子树和右子树,可以看出,二叉树的定义是递归式的,因此对于二叉树的操作基本都使用递归的分治思想实现
二叉树遍历:按照某种特定的规则,依次对二叉树的节点进行相应额操作,并且每个节点只操作一次。
二叉树遍历分类:前序遍历,后序遍历,中序遍历,除此之外还有层序遍历
前序遍历:也称先序遍历,访问根节点的操作发生在遍历其左右子树之前
上图的二叉树先序遍历的结果是:
1 2 3 NULL NULL NULL 4 5 NULL NULL 6 NULL NULL
一般NULL不写出来,表示为: 1 2 3 4 5 6
但实际上访问的顺序包含空节点
先序遍历的过程:
当树的根不为空时,先访问根节点,再访问左子树,最后访问右子树,而对于每棵子树,也是先访问根节点,再访问其左子树,最后访问右子树,这是一个递归的过程
当树的根为空时,返回空,为了便于观察,在返回空之前打印NULL
递归实现先序遍历代码如下:
//前序遍历
void PreOrder(TreeNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
printf("%d ", root->data);
PreOrder(root->left);
PreOrder(root->right);
}
中序遍历:访问根节点的操作发生在遍历其左右子树中间
上图二叉树的中序遍历结果为:
NULL 3 NULL 2 NULL 1 NULL 5 NULL 4 NULL 5 NULL
中序遍历的过程:
当树的根不为空时,先访问左子树,再访问根节点,最后访问右子树,而对于每棵子树,也是先访问左子树,再访问其根节点,最后访问右子树,这是一个递归的过程
当树的根为空时,返回空,为了便于观察,在返回空之前打印NULL
递归实现中序遍历代码如下:
//中序遍历
void InOrder(TreeNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
InOrder(root->left);
printf("%d ", root->data);
InOrder(root->right);
}
后序遍历:访问根节点的操作发生在遍历其左右子树之后
上图二叉树的后序遍历结果为:
NULL NULL 3 NULL 2 NULL NULL 5 NULL NULL 6 4 1
后序遍历的过程:
当树的根不为空时,先访问左子树,再访问右子树,最后访问根节点,而对于每棵子树,也是先访问左子树,再访问右子树,最后访问根节点,这是一个递归的过程
当树的根为空时,返回空,为了便于观察,在返回空之前打印NULL
递归实现中序遍历代码如下:
//后序遍历
void PostOrder(TreeNode* root)
{
if (root == NULL)
{
printf("NULL ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->data);
}
层序遍历:设二叉树的根节点所在的层数为1,层序遍历就是从所在二叉树的根结点出发,首先访问第一层的树根节点,然后从左到右访问第二层上的节点,接着是第三层的节点,以此类推自上而下,自左向右逐层访问树的节点的过程
上图二叉树层序遍历的结果为:
A B C D E F G H I
已知前序序列和中序序列重建二叉树
前序序列:1 2 3 7 4 5
中序序列:3 2 7 1 5 4
1️⃣由前序序列确定根,再由中序序列找到左右子树
2️⃣由前序序列找到左右子树的根,再由中序序列找到根所对应的左右子树
3️⃣重建树
已知中序序列和后序序列重建二叉树
中序序列: b a d c e
后序序列:b d e c a
1️⃣由后序序列确定根,再由中序序列找到左右子树
2️⃣由后序序列找到左右子树的根,再由中序序列找到根所对应的左右子树
3️⃣重建树
综上所述:前序序列+中序序列 或 后序序列+中序序列可以唯一确定一棵树