1.树
1.1 树的概念与结构
树是一种非线性的数据结构,它是由n(n>=0)个有限节点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一颗倒挂的树,也就是说它是根朝上,而树叶朝下的。
左侧是现实生活中的树,右侧是我们通过对它进行数学化,结构化生成的一棵树。
树形结构中,子树之间不能有交集,否则就不是树形结构
非树形结构:
子树是不相交的(如果存在相交就是图了)。
除了根节点外,每个节点有且仅有一个父节点
一棵N个节点的树有N-1条边。
1.2 树相关术语
父节点/双亲节点:若一个节点含有子节点,则这个节点称为其子节点的父节点。如上图:A是B的父节点。
子节点/孩子节点:一个节点含有的子树的根节点称为该节点的子节点。入上图:B是A的孩子节点。
结点的度:一个结点有几个孩子,他的度就是多少。比如A的度为6,F的度为2,K的度为0
树的度:一棵树中,最大的结点的度称为树的度。如上图:树的度为 6
叶子结点/终端结点:度为 0 的结点称为叶结点。如上图: B、C、H、I... 等结点为叶结点(叶子节点)
分支结点/非终端结点:度不为 0 的结点。如上图: D、E、F、G... 等结点为分支结点(非叶子节点)
兄弟结点:具有相同父结点的结点互称为兄弟结点(亲兄弟)。如上图:B、C 是兄弟结点
结点的层次:从根开始定义起,根为第 1 层,根的子结点为第 2 层,以此类推。
树的高度或深度:树中结点的最大层次。如上图:树的高度为 4
结点的祖先:从根到该结点所经分支上的所有结点。如上图: A 是所有结点的祖先
路径:一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。比如A到Q的路径为:A-E-J-Q;H到Q的路径H-D-A-E-J-Q
子孙:以某结点为根的子树中任一结点都称为该结点的子孙。如上图:所有结点都是A的子孙
森林:由 m(m>0) 棵互不相交的树的集合称为森林。
1.3 树的表示
孩子兄弟表示法:
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法,孩子表示法,孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法
1.4 树形结构实际运用场景
文件系统是计算机存储和管理文件的一种方式,它利用树形结构来组织和管理文件和文件夹。在文件系统中,树结构被广泛应用,它通过父结点和子结点之间的关系来表示不同层级的文件和文件夹之间的关联。
文件系统使用的就是树形结构。
2. 二叉树
2.1 概念与结构
二叉树是树形结构的一种,把树加以限制形成了新的树,二叉树,在树形结构中,我们最常用的就是二叉树,一颗二叉树是节点的一个有限集合,该集合由有一个跟节点加上两颗别称为左子树和右子树的二叉树组成或者为空。
从上图可以看出二叉树具有以下特点:
1. 二叉树不存在度大于2的节点(不存在第三个孩子的节点)
2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
注意:对于任意的二叉树都是由以下几种情况复合而成的
现实中的二叉树
2.2 特殊的二叉树
2.2.1 满二叉树
一个二叉树,如果每一个层的节点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为k,且节点总数是2^k-1,则它就是满二叉树。
2.2.2 完全二叉树
完全二叉树是效率很高的树形结构,完全二叉树是由满二叉树而引出来的。对于深度为k的,有n个节点的二叉树,当且仅当其每一个节点都与深度为k的满二叉树中编号从1至n的节点一一对应时称之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。
二叉树的性质
根据满二叉树的特点可知:
1) 若规定根节点的层数为1,则一颗非空二叉树的第i层上最多有2^(i-1)个节点
2) 若规定根节点的层数为1,则深度为h的二叉树的最大节点数是2^h-1
3) 若规定根节点的层数为1,具有n个节点的满二叉树的深度h = log2(n+1) (log以2为底,n+1为对数)
2.3 二叉树存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
2.3.1 顺序结构
顺序结构存储就是使用数组来存储,一般使用数组只适合表四完全二叉树,因为不是完全二叉树会有空间的浪费,完全二叉树更适合使用顺序结构存储。
现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
2.3.2 链式结构
二叉树的链式存储结构是指,用链表来表示一颗二叉树,即用链来指定元素的逻辑关系。通常的方法是链表中每个节点由三个域组成,数据域和左右指针域,左右指针域分别用来给出该节点左孩子和右孩子所在的链节点的存储地址。链式结构又分为二叉链和三叉链。
对于二叉树来说,它的度最多是2,不可能超过2,最多有两个孩子,因此我们定义两个指针,一个指针指向左孩子,另一个指针指向右孩子,要么左孩子不为空,要么右孩子不为空,要么左右孩子都为空,通过这两个指针就能够表示这样的链式结构的节点,链式结构的二叉树。
3. 实现顺序结构的二叉树
一般堆使用顺序结构的数组来存储数据,堆是一种特殊的二叉树,具有二叉树的特征的同时,还具备其它的特性。
3.1 堆的概念与结构
堆具有以下性质
堆中某个节点的值总是不大于或不小于其父节点的值。
堆总是一颗完全二叉树。
3.2 堆的实现
3.2.1 堆的初始化
代码:
//堆的初始化
void HPInit(HP* php)
{
//不能传空指针
assert(php);
//指针初始化为空指针
php->arr = NULL;
//有效元素个数和空间大小初始化为0
php->capacity = php->size = 0;
}
3.2.2 堆的销毁
代码:
//堆的销毁
void HPDestroy(HP* php)
{
//不能传空指针
assert(php);
if (php->arr != NULL)
//释放动态申请的空间
free(php->arr);
//指向堆的指针置为空
php->arr = NULL;
//将空间大小和有效元素个数置为0
php->capacity = php->size = 0;
}
3.2.3 入堆(向上调整算法)
堆的向上调整:
代码:
//交换函数
void Swap(int* x, int* y)
{
int z = *x;
*x = *y;
*y = z;
}
//向上调整算法
void AdjustUp(HPDataType* a, int child)
{
//根据子节点计算父节点
int parent = (child - 1) / 2;
//当child为0的时候就应该停止调整。
while (child > 0)
{
//判断子节点是否小于父节点,如果小于父节点,就交换,否则直接退出
if (*(a + child) < *(a + parent))
{
//交换
Swap(a + child, a + parent);
//计算交换后的节点对应的父节点
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
//堆的插入
void HPPush(HP* php, HPDataType x)
{
//不能传空指针
assert(php);
//判断空间是否足够
if (php->capacity == php->size)
{
//计算增容的大小
int newCapacity = php->capacity == 0 ? 4 : 2 * php->capacity;
//使用realloc增容
HPDataType* newArr = (HPDataType*)realloc(php->arr,sizeof(HPDataType)*newCapacity);
//判断realloc的返回值
if (newArr == NULL)
{
//realloc的返回值如果为空就打印错误信息,并且退出程序
perror("realloc error!\n");
exit(1);
}
//将扩容后的地址赋给结构体成员
php->arr = newArr;
//将新的空间大小赋给结构体成员
php->capacity = newCapacity;
}
//插入值
*(php->arr + php->size) = x;
//为了保证是有效的小堆,调用向上调整算法,使其变成小堆
AdjustUp(php->arr, php->size);
//有效元素个数自增
++php->size;
}
测试:
向上调整算法建堆的时间复杂度:
3.2.4 出堆(向下调整算法)
堆的向下调整:
代码:
//向下调整算法
// 要调整的数组,从哪个位置开始,堆的有效数据个数
void AdjustDown(HPDataType* a, int parent, int n)
{
//计算左子树
int child = 2 * parent + 1;//2i+1
//child小于n就说明
while (child < n)
{
//计算左右子树哪个小
if (child+1 < n && *(a + child) > *(a + child + 1))
{
//如果右子树小就让child++,指向右子树
child++;
}
//判断当前节点和子节点哪个小,若子节点小则需要交换,否则退出
if (*(a + child) < *(a + parent))
{
//交换
Swap(a + child, a + parent);
//parent跟着调整的节点的位置走
parent = child;
//child继续计算出parent节点的左子树节点
child = 2 * parent + 1;
}
else
{
break;
}
}
}
// 删除堆顶的数据
void HPPop(HP* php)
{
//不能传空指针以及堆不能为空
assert(php && php->size);
//首先交换堆顶的数据和最后一个数据
Swap(php->arr, php->arr + php->size - 1);
//有效元素个数-1
--php->size;
//堆的向下调整算法
AdjustDown(php->arr, 0, php->size);
}
测试:
向下叫做算法建堆的时间复杂度:
3.2.5 取堆顶元素
代码:
//取堆顶数据
HPDataType HPTop(HP* php)
{
//不能传空指针以及堆不能为空
assert(php && php->size);
//返回下标为0的数据
return *php->arr;
}
3.2.6 判断堆是否为空
代码:
// 判空
bool HPEmpty(HP* php)
{
//不能传空指针
assert(php);
//有数据就返回true,否则返回false
return php->size;
}
3.2.7 循环打印堆顶数据
代码:
//一直取堆顶,直到堆为空跳出循环
while (HPEmpty(&hp))
{
//打印堆顶的数据
printf("%d ", HPTop(&hp));
//将堆顶的数据出堆
HPPop(&hp);
}
测试:
3.3 堆的应用
3.3.1 堆排序
上面这种只是将数组利用堆树蕨结构排序,但是并没有对数组本身做任何的修改,所有这里还不算是堆排序。
那我们可以堆上面的代码做修改,既然需要修改数组,那么我们将堆顶取到的元素直接插入到数组中,然后打印数组就可以了。
3.3.1.1 排序
版本一:基于已有数组建堆,取堆顶元素完成排序版本
代码:
void HPTest()
{
//创建堆
HP hp;
//初始化堆
HPInit(&hp);
//创建数组
int arr[] = { 17,20,10,13,19,15 };
//循环6次往堆里面push
for (int i = 0; i < 6; i++)
{
//入堆
HPPush(&hp, arr[i]);
}
//一直取堆顶,直到堆为空跳出循环
int index = 0;
while (HPEmpty(&hp))
{
//取出堆顶的值插入到数组中
*(arr + index++) = HPTop(&hp);
//将堆顶的数据出堆
HPPop(&hp);
}
//打印数组
for (int i = 0; i < 6; i++)
{
printf("%d ", *(arr + i));
}
printf("\n");
//销毁堆
HPDestroy(&hp);
}
测试:
这样也是可以的,但是这样写有一个前提,必须提供现成的数据结构堆,而且一直往堆中push数据,空间复杂度为O(N),而冒泡排序空间复杂度为O(1)。
3.3.1.2 向上调整算法建堆排序
版本二:数组建堆,首尾交换,交换后的堆尾数据从堆中删掉,将堆顶数据向下调整选出次大的数据。
思路:
向上调整算法建堆:
//堆排序
void HeapSort(int* arr, int n)
{
//arr数组直接建堆
for (int i = 0; i < n; i++)
{
//调用向上调整算法
AdjustUp(arr, i);
}
}
测试:
堆排序:
代码:
//堆排序
void HeapSort(int* arr, int n)
{
//arr数组直接建堆
//降序 -- 小堆
for (int i = 0; i < n; i++)
{
//调用向上调整算法
AdjustUp(arr, i);
}
//排序
//计算堆尾的位置
int end = n - 1;
//当end大于0的时候就说明没有排序完成
while(end > 0)
{
//堆顶的数据和堆尾的数据交换
Swap(arr, arr + end);
//此时还不是一个有效的小堆,因此需要向下调整
AdjustDown(arr, 0, end);
//元素个数自减
end--;
}
}
测试:
3.3.1.3 升序降序的区别
排降序就用小堆,同理,要是排升序用大堆。
因此如果要建大堆的话在代码中将<改成>即可。
测试:
堆排序 - 大堆
代码:
//向上调整算法
void AdjustUp(HPDataType* a, int child)
{
//根据子节点计算父节点
int parent = (child - 1) / 2;
//当child为0的时候就应该停止调整。
while (child > 0)
{
if (*(a + child) > *(a + parent))
{
//交换
Swap(a + child, a + parent);
//计算交换后的节点对应的父节点
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
//向下调整算法
// 要调整的数组,从哪个位置开始,堆的有效数据个数
void AdjustDown(HPDataType* a, int parent, int n)
{
//计算左子树
int child = 2 * parent + 1;//2i+1
//child小于n就说明
while (child < n)
{
//小堆: 找左右孩子中最小的
//大堆: 找左右孩子中左大的
if (child+1 < n && *(a + child) < *(a + child + 1))
{
//如果右子树小就让child++,指向右子树
child++;
}
//判断当前节点和子节点哪个小,若子节点小则需要交换,否则退出
if (*(a + child) > *(a + parent))
{
//交换
Swap(a + child, a + parent);
//parent跟着调整的节点的位置走
parent = child;
//child继续计算出parent节点的左子树节点
child = 2 * parent + 1;
}
else
{
break;
}
}
}
输出结果:
排升序的话就要排大堆,这个时候就要知道堆顶数据, 如果堆顶数据是最大的,我要让它跟最后一个位置去交换,这里肯定是最大的放到最后面,排的是升序,拿如果要排降序的话也就意味着最后一个数据是最小的,所以我们要排小堆,因为小堆的堆顶一定是堆里面最小的数据,这是我们的排序算法。
冒泡排序算法的时间复杂度是O(N^2),那么堆排序的时间复杂度是多少呢?
堆排序的时间复杂度为O(n*logn),而冒泡排序的时间复杂度为O(N^2)。
3.3.1.4 向下调整算法建堆排序
// 向下调整算法建堆
for (int i = (n - 1) / 2; i >= 0; i--)
{
AdjustDown(arr, i, n);
}
输出结果:
堆排序结果:
3.3.2 TOP-K问题
TOP-K问题:即求数据结合中前k个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名,世界500强,富豪榜,游戏中前100对的活跃玩家等。
对于TOP-K问题,要想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中),最佳的方式就是用堆来解决。
我们就不在代码中去创建一个数组n让它存储10亿个数据,肯定是不行的,那么我们就需要创建一个文件,这个文件里面我们给它插入100000个数据。
代码:
//造数据
void CreateNDate()
{
int n = 100000;
srand(time(0));
//文件名
const char* file = "data.txt";
//以写的形式打开文件
FILE* fin = fopen(file, "w");
//判断fopen的返回值
if (fin == NULL)
{
perror("fopen error!\n");
return;
}
for (int i = 0; i < n; i++)
{
//生成随机数
int x = (rand() + i) % 1000000;
//写值
fprintf(fin, "%d\n", x);
}
//关闭文件
fclose(fin);
}
//TOP-K
void TOPk()
{
int k = 0;
printf("请输入k:");
scanf("%d", &k);
//打开文件
const char* file = "data.txt";
FILE* File = fopen(file, "r");
if (File == NULL)
{
perror("fopen fail!\n");
exit(2);
}
//申请k个大小的整型数组
int* HpMinArr = (int*)malloc(k * sizeof(int));
if (HpMinArr == NULL)
{
perror("malloc fail!\n");
exit(1);
}
//取文件中的前k个数据
for (int i = 0; i < k; i++)
{
fscanf(File, "%d", HpMinArr + i);
}
//建小堆
for (int i = (k-1-1)/2; i >= 0; i--)
{
//向下调整算法建堆
AdjustDown(HpMinArr, i, k);
}
int index = 0;
//循环读取
while(fscanf(File, "%d",&index ) != EOF)
{
//如果读到的数据比堆顶的数据大就入堆
if (index > *HpMinArr)
{
//入堆
*HpMinArr = index;
//向下调整算法使其变成小堆
AdjustDown(HpMinArr, 0, k);
}
}
//打印
for (int i = 0; i < k; i++)
{
printf("%d ", *(HpMinArr + i));
}
//关闭文件
fclose(File);
}
测试:
代码中生成随机数的时候%1000000,所以文件中的数字不可能超过1000000,那我就可以手动在文件中修改6个最大的数字,所以我们终端在输出的是啥输出的就是修改的6个最大的数字。
4. 实现链式结构二叉树
用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。通常的方法是链表中每个节点由三个域组成,数据域和左右指针域,左右指针分别用来给出该节点左孩子和右孩子所在的链节点的存储位置,其结构如下:
//类型重定义
typedef int BTdataType;
//定义二叉树的链式结构
//二叉树节点的结构
typedef struct BinaryTreeNode //Binary -> 二进制的
{
//当前节点域值
BTdataType val;
//指向当前节点左孩子
struct BinaryTreeNode* left;
//指向当前节点右孩子
struct BinaryTreeNode* right;
}BTNode;
手动创建链式二叉树
代码:
BTNode* BuyNode(BTdataType x)
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
if (newnode == NULL)
{
perror("malloc error!\n");
exit(1);
}
newnode->val = x;
newnode->left = newnode->right = NULL;
return newnode;
}
void CreateTreeTest()
{
BTNode* n1 = BuyNode(1);
BTNode* n2 = BuyNode(2);
BTNode* n3 = BuyNode(3);
BTNode* n4 = BuyNode(4);
n1->left = n2;
n1->right = n3;
n2->left = n4;
}
调试:
二叉树分为空树(n1 = NULL)和非空二叉树,非空二叉树由根节点,根节点的左子树,根节点的右子树组成的。
根结点的左子树和右子树分别又是由子树结点,子树结点的左子树,子树结点的右子树组成的,因此
二叉树定义是递归式的,后序链式二叉树的操作中基本都是按照该概念实现的。
4.1 前中后序遍历
二叉树的操作离不开树的遍历,我们先来看看二叉树的遍历有哪些方式
4.1.1 遍历规则
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
1. 前序遍历(Preorfder Traversal亦称先序遍历):访问根节点的操作发生在遍历其左右子树之前
访问顺序为:根节点,左子树,右子树
2.中序遍历(Inordar Traversal): 访问根节点的操作发生在遍历其左右子树的中间
访问顺序为:左子树,根节点,右子树
3.后序遍历(Postorder Traversal): 访问根节点的操作发生在遍历其左右子树之后
访问顺序为:左子树,右子树,根节点
4.1.2 代码实现
//前序遍历 - 根左右
void preOrder(BTNode* root)
{
if (root == NULL)
return;
printf("%d ", root->val);
preOrder(root->left);
preOrder(root->right);
}
//中序遍历 - 左根右
void InOrder(BTNode* root)
{
if (root == NULL)
return;
InOrder(root->left);
printf("%d ", root->val);
InOrder(root->right);
}
//后序遍历 - 左右根
void PostOrder(BTNode* root)
{
if (root == NULL)
return;
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->val);
}
测试:
4.2 节点个数以及高度等
4.2.1 二叉树结点个数
代码:
// 二叉树结点个数
int BinaryTreeSize(BTNode* root)
{
if (root == NULL)
return 0;
return 1 + BinaryTreeSize(root->left) + BinaryTreeSize(root->right);
}
测试:
4.2.2 二叉树叶子结点个数
叶子节点:左孩子和右孩子都为空,也就是没有孩子的节点,度为0的节点。
代码:
// 二叉树叶子结点个数
int BinaryTreeLeafSize(BTNode* root)
{
if (root == NULL)
return 0;
if (root->left == NULL && root->right == NULL)
return 1;
return BinaryTreeLeafSize(root->left) + BinaryTreeLeafSize(root->right);
}
测试:
4.2.3
4.2.3 二叉树第k层结点个数
代码:
// 二叉树第k层结点个数
int BinaryTreeLevelKSize(BTNode* root, int k)
{
if (root == NULL)
{
return 0;
}
if (k == 1)
{
return 1;
}
return BinaryTreeLevelKSize(root->left, k-1) + BinaryTreeLevelKSize(root->right, k-1);
}
测试:
4.2.4 二叉树的深度/高度
代码:
//二叉树的深度/高度
int BinaryTreeDepth(BTNode* root)
{
if (root == NULL)
{
return 0;
}
int leftDep = BinaryTreeDepth(root->left);
int rightDep = BinaryTreeDepth(root->right);
return leftDep > rightDep ? leftDep+1 : rightDep+1;
}
测试:
4.2.5 二叉树查找值为x的结点
代码:
// 二叉树查找值为x的结点
BTNode* BinaryTreeFind(BTNode* root, BTDataType x)
{
if (root == NULL)
{
return NULL;
}
if (root->val == x)
{
return root;
}
BTNode* leftFind = BinaryTreeFind(root->left,x);
if (leftFind)
return leftFind;
BTNode* rightFind = BinaryTreeFind(root->right,x);
if (rightFind)
return rightFind;
return NULL;
}
测试:
4.2.6 二叉树的销毁
代码:
// 二叉树销毁
//销毁左子树 + 销毁右子树 + 销毁根节点
void BinaryTreeDestory(BTNode** root)
{
if (*root == NULL)
{
return NULL;
}
//销毁左子树
BinaryTreeDestory(&((*root)->left));
//销毁右子树
BinaryTreeDestory(&((*root)->right));
free(*root);
*root = NULL;
}
调试:
4.3 层序遍历
除了先序遍历,中序遍历,后续遍历外,还可以队二叉树进行层序遍历。设二叉树的根节点所在层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第二层上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层风闻树的节点的过程就是层序遍历。
实现层序遍历需要额外借助数据结构:队列
思路:
代码:
//层序遍历
//借助数据结构 -- 队列
void Levelorder(BTNode* root)
{
//创建队列
Queue q;
//队列初始化
QueueInit(&q);
//将堆顶入队
QueuePush(&q, root);
//循环判断队是否为空
while (!QueueEmpty(&q))
{
//取队头的数据
BTNode* top = QueueFront(&q);
//打印队头中的值
printf("%d ", top->val);
//出队
QueuePop(&q);
//判断左孩子和右孩子是否为空,不为空就入队
if(top->left)
QueuePush(&q, top->left);
if(top->right)
QueuePush(&q, top->right);
}
//这里的销毁代码中不能用assert判断队列是否为空,因为队列中的数据取完才销毁
//队列的销毁
QueueDestroy(&q);
}
测试:
4.4 判断是否为完全二叉树
代码:
//判断二叉树是否为完全二叉树
//借助数据结构队列
bool BinaryTreeComplete(BTNode* root)
{
//创建队列
Queue q;
//队列初始化
QueueInit(&q);
//将堆顶入队
QueuePush(&q, root);
//循环判断队是否为空
while (!QueueEmpty(&q))
{
//取队头的数据
BTNode* top = QueueFront(&q);
//出队
QueuePop(&q);
//判断取到的节点是否为空,为空就退出循环,否则将左孩子和右孩子放入到队列中
if (top == NULL)
{
break;
}
QueuePush(&q, top->left);
QueuePush(&q, top->right);
}
while (!QueueEmpty(&q))
{
//取队头的数据
BTNode* top = QueueFront(&q);
//循环取队列里面剩下的数据,如果取到有效节点就说明不是完全二叉树
//出队
QueuePop(&q);
if (top != NULL)
{
//队列的销毁
QueueDestroy(&q);
//返回false
return false;
}
}
//这里的销毁代码中不能用assert判断队列是否为空,因为队列中的数据取完才销毁
//队列的销毁
QueueDestroy(&q);
return true;
}
测试: