许久没发博客,在这里跟各位看客道声久等了~
冬至已至,各位有没有吃上热乎的饺子呢
下面给各位奉上承载着满满干货的饺子吧:
目录
本期博客将会对树和二叉树进行全面的详解:
一、树
在说二叉树之前,首先要弄清树,因为二叉树本来就是树的特殊类型。
1. 树的结构定义
树是一种非线性的数据结构,它是由n (n>=O)个有限结点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
如图:A~L都是树的节点
有一个特殊的结点,称为根结点,根结点没有前驱结点
在上图中A就是根节点。
注:树形结构中,子树之间不能有交集,否则就不是树形结构
如下图这些都不是树:
另外一棵有n个节点的树有n-1条边
2. 树的相关概念
下面我拿这个树举例来介绍树的相关概念:
节点的度:一个节点含有的子树的个数称为该节点的度; 如上图:A的度为6。
叶节点或终端节点:度为0的节点称为叶节点; 如上图:B、C、H、I...等节点为叶节点。
非终端节点或分支节点:度不为0的节点; 如上图:D、E、F、G...等节点为分支节点。
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如上图: A是B的父节点。
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点。
兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点。
树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为6 节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推。
树的高度或深度:树中节点的最大层次; 如上图:树的高度为4。
堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:H、I互为兄弟节点。
节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先。
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙 。
森林:由m(m>0)棵互不相交的树的集合称为森林。
3. 树的表示
我们已经知道树的结构了,接下来我们如何用代码来表示它们呢?
我们想到树无非就是父节点和孩子节点之间的关系,那我们就可以使用结构体来储存一个节点的所以孩子节点就行:
typedef int DataType;
struct TreeNode
{
//数据
DataType data;
//所有的孩子节点
struct TreeNode* child1;
struct TreeNode* child2;
struct TreeNode* child3;
//...
};
但是我们如果不知道这棵树的度,那我们到底要定义多少个TreeNode*类型的孩子节点呢?
显然这种方法是不合理的。
那使用指针数组或者定义一个指针顺序表来储存节点的所以孩子节点呢?
例如:
#define N 10
typedef int DataType;
struct TreeNode
{
//数据
DataType data;
//指针数组
struct TreeNode* children[N];
};
typedef int DataType;
typedef struct TreeNode* SLDataType
struct TreeNode
{
//数据
DataType data;
//指针顺序表
Seqlist children;
};
但是这样子做太过于复杂
下面有一种非常简便的方式来表示树:
孩子兄弟表示法
即定义一个结构体节点,该结构体只有两个指针,一个指向该节点的第一个孩子节点,另一个指向其兄弟节点。这样就可以将整个树表示出来:
typedef int DataType;
struct TreeNode
{
//数据
DataType data;
struct TreeNode* child;//指向第一个孩子节点
struct TreeNode* brother;//指向其兄弟节点
};
我们使用该方法表示一下这棵树:
该树使用孩子兄弟表示法表示的结构图如下:
二、二叉树
在所以树类型中二叉树是最常见,使用最广泛的树。
1. 二叉树的结构定义
二叉树即度为二的树,即树的每个节点的度不大于二
上图就是一个二叉树。
注:二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树
2. 特殊的二叉树
满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是 说,如果一个二叉树的层数为K,且结点总数是2^K - 1 ,则它就是满二叉树。
完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树(2^(k-1) ≤ n ≤ 2^k-1),当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树(就是该二叉树的K-1层及以上的结构是个满二叉树,第K层的节点可以不满但是一定要连续)。 要注意的是满二叉树是一种特殊的完全二叉树。
3. 二叉树的性质
1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有2^(i-1)个结点
2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是2^h-1
3. 对任何一棵二叉树, 如果度为0的叶结点个数为n , 度为2的分支结点个数为x ,则有 x=n+1
4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h= (是log以2 为底,n+1为对数)
5. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对于序号为i的结点有:
> 若i>0,i位置节点的双亲序号:(i-1)/2;i=0,i为根节点编号,无双亲节点
> 若2i+1=n否则无左孩子
> 若2i+2=n否则无右孩子
4. 二叉树的存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构:
顺序存储
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆(一种完全二叉树)才会使用数组来存储,关于堆在下面会专门讲解。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
链式存储
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,后面的高阶数据结构如红黑树等会用到三叉链。
三、 二叉树(堆)的顺序结构及实现
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
1. 堆的概念及结构
如果有一个关键码的集合K = { K(0),K(1) ,K(2) ,…,K(n-1) },把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足: K(i)<=K(2*i+1) 且 K(i)<=K(2*i+2)(K(i)>=K(2*i+1) 且 K(i)>=K(2*i+2)) (i = 0,1, 2…),则称为小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。
堆的性质:
堆中某个节点的值总是不大于或不小于其父节点的值
堆总是一棵完全二叉树
由上图我们可以发现:
孩子和父亲下标的关系:
leftchild = parent*2+1 (都为奇数)
rightchild = parent*2+2 (都为偶数)
parent = (child-1)/2
注:任何一个数组表示为一个完全二叉树不一定是堆
下面我们进入到代码实战:
2. 定义堆结构
想要实现堆我们首先需要确定好怎么去定义一个堆的结构
由于使用数组来实现我们可以直接使用一个顺序表来定义堆结构:
typedef int HPDataType;//定义数据类型
//定义堆结构
typedef struct Heap
{
HPDataType* data;//数据
int size;//记录所存储的有效数据个数
int capacity;//记录可以存储数据个数的容量
}HP;
3. 初始化堆
下面我们来对堆进行初始化:
void HeapInit(HP* php)
{
assert(php);//传入的指针不能为空
php->capacity = php->size = 0;
php->data = NULL;
}
4. 销毁堆
void DestoryHeap(HP* php)
{
assert(php);//传入的指针不能为空
free(php->data);//释放数据空间
php->data = NULL;
php->capacity = php->size = 0;
}
5. 向堆中插入数据
我们本次实现的是大堆,所以向堆中插入数据时要将数据于其祖先节点的数据进行比较大小,让顺序表一直是以堆形式存储的(想要实现小堆只需修改一下AdjustUp函数中的判断条件即可):
void Swap(HPDataType* parent, HPDataType* child)
{
HPDataType temp = *parent;
*parent = *child;
*child = temp;
}
void AdjustUp(HPDataType* data,int child)
{
int parent = (child - 1) / 2;
while (data[child] > data[parent] && child > 0)//如果孩子节点大于父亲节点就进行交换调整,注意如果想实现小堆的话只需要将前一个>改成<即可
{
Swap(&data[parent], &data[child]);//交换孩子和父亲节点
child = parent;
parent = (child - 1) / 2;
}
}
void HeapPush(HP* php, HPDataType x)
{
assert(php);
if (php->capacity == php->size)//判断是否需要扩容
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* new = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);
if (new == NULL)//判断扩容是否成功
{
perror("realloc");
exit(-1);
}
php->a = new;
php->capacity = newcapacity;
}
int temp = php->size;
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);//对插入的数据进行调整
}
6. 删除堆顶元素
我们要想删除堆顶元素是不是可以向顺序表一样头删完草草了事呢?
我们不妨试一试:
由上图可以看到头删数据会改变整个数据的结构,可能使其不是堆
为了使数组在删除头元素后还能具有大堆的性质,我们可以先将头元素与尾元素进行互换,再将头元素数据与其孩子节点进行比大小,选取大的孩子进行位置交换,直到孩子节点的数据都小于其节点数据或者自己成为叶子节点时结束。(按小堆调整只需改变AdjustDown函数中的判断条件)
下面是该方法的逻辑模拟图:
代码实现:
void AdjustDown(HPDataType* data, int n,int parent)
{
int chlid = parent * 2 + 1;//找到其左孩子
while (chlid < n)//防止数组越界
{
if (chlid + 1 < n && data[chlid + 1] > data[chlid] )//判断左右孩子的大小,chlid + 1 < n要防止存在左孩子而无右孩子时的数组越界(想要按小堆调整需将前一个>改为<号)
{
chlid += 1;
}
if (data[chlid] > data[parent])//(想要按小堆调整需将 > 改为 < 号)
{
Swap(&data[chlid], &data[parent]);//将大的孩子元素与其替换
//继续向下比较替换
parent = chlid;
chlid = parent * 2 + 1;
}
else//如果孩子元素都没有其父元素大则直接跳出
{
break;
}
}
}
void HeapPop(HP*php)
{
assert(php);//传入的指针不能为空
assert(php->size > 0);//堆中有效数据个数不能为0
Swap(&php->a[php->size - 1], &php->a[0]);//交换首元素与尾元素
php->size--;//删除尾元素
AdjustDown(php->a, php->size, 0);//进行调整
}
7. 取堆顶元素
HPDataType HeapTop(HP* php)
{
assert(php);//传入的指针不能为空
assert(php->size > 0);//堆中有效数据个数不能为0
return php->a[0];//返回堆顶元素
}
8. 堆构建的方式
在这里我们提一下堆构建的方式为下面构建堆做一个准备:
现在我们有一个随机数组怎么将其调整成堆呢?
下面有两种方法供大家参考:
8.1 向上调整构建堆
我们可以将数组看成一个完全二叉树,再从第二层开始向下一个一个元素进行向上调整,如果该元素对于它的父节点的元素不满足大(小)堆的条件,就将其与其父节点节点元素进行交换,一直到满足堆的条件为止,再去调整下一个元素,直到最后一个元素被调整完毕:
下面用大堆来进行举例:
先看第二层第一个元素,比其父节点小满足大堆条件,再看第二层第二个元素,发现比其父节点大不满足大堆条件,因此进行交换:
接着看到第三层第一个元素,发现比其父节点大不满足大堆条件,因此进行交换:
交换之后发现其满足大堆条件,再看到第三层第二个元素比其父节点小满足大堆条件
接着看到第三层第三个元素,发现比其父节点大不满足大堆条件,因此进行交换:
交换之后满足大堆条件,再看到第三层第四个元素比其父节点小满足大堆条件
接着看到第四层第一个元素,发现比其父节点大不满足大堆条件,因此进行交换:
交换之后满足大堆条件
接着看到第四层第二个元素,发现比其父节点大不满足大堆条件,因此进行交换:
交换之后发现比其父节点大不满足大堆条件,因此继续进行交换:
交换之后发现比其父节点大不满足大堆条件,因此继续进行交换:
交换之后满足大堆条件
最后一个元素已调整完毕,大堆构建已完成
上代码:
void Heap_createUp(int* a, int n)
{
for (int i = 1; i < n; i++)
{
AdjustUp(a, i);//向上调整一直到最后一个元素
}
}
很好理解,向上调整进行堆排序的时间复杂度为:O(N*㏒⑵N)
8.2 向下调整构建堆
我们可以将数组看成一个完全二叉树,再从最后一个节点的父节点开始调整,如果该节点的元素对于它的孩子节点的元素不满足大(小)堆的条件,就将其与其大的一个孩子点节点元素进行交换,接着对其孩子节点进行调整直到满足大堆条件为止,再找到上一个节点进行如上调整,直到第一个元素被调整完为止:
下面用大堆来进行举例:
找到最后一个节点的父节点(第三层第一个),发现其与右孩子不满足大堆条件,因此进行交换:
调整完毕发现满足大堆条件
再看到其节点的上一个节点(第二层第二个)发现其孩子节点元素都比其节点元素小,满足大堆条件
接着找到再上一个节点(第二层第一个)发现其与左孩子不满足大堆条件,因此进行交换:
调整完毕发现不满足大堆条件,再将其孩子节点与其较大的孩子节点进行交换:
调整完毕发现满足大堆条件
再看到其节点的上一个节点(第一层第一个)发现不满足大堆条件,再将其孩子节点与其较大的孩子节点进行交换:
调整完毕发现不满足大堆条件,再将其孩子节点与其大的孩子节点进行交换:
调整完毕发现不满足大堆条件,再将其孩子的孩子节点与其大的孩子节点进行交换:
调整完毕发现满足大堆条件
第一个元素调整完毕,大堆排序已完成
上代码:
void Heap_createDown(int* a, int n)
{
//向下调整一直到第一个
for (int i = (n - 1 - 1) / 2; i >= 0; i--)//n-1是最后一个元素的物理位置((n-1)-1)/2是其父节点的物理位置
{
AdjustDown(a, n, i);//向下调整
}
}
下面我们对向下调整进行堆排序进行时间复杂度的推导:
我们假设有一个h层的完全二叉树:
每一层的节点数为:2^(这一层数-1)
我们从倒数第二层的最后一排的父节点开始调整时,每个节点最多调整一次
从倒数第三层的倒数最后第二排的父节点开始调整时,每个节点最多调整两次
从倒数第四层的倒数最后第三排的父节点开始调整时,每个节点最多调整三次
……
以此类推,从第x层的节点开始调整时,每个节点最多调整x-1次
如此一来,第x层总调整数为(x-1)*2^(x-1)
所以h层的二叉树最多总调整次数为F(h)=2^(h-2)*1+2^(h-3)*2+···+2^1*(h-2)+2^0*(h-1)
一看这公式是等差等比数列的组合,我们拿出高中的看家本领错位相减法:
2*F(h)=2^(h-1)*1+2^(h-2)*2+···+2^2*(h-2)+2^1*(h-1)
2*F(h)-F(h)=2^(h-1)+2^(h-2)+2^(h-3)+···+2^2+2^1-(h-1)
=2^(h-1)+2^(h-2)+2^(h-3)+···+2^2+2^1+1+h
=2^(h-1)+2^(h-2)+2^(h-3)+···+2^2+2^1+2^0-h
=2^h-1-h
刚好数的总节点数N=2^h-1
那就有F(N)=N-㏒⑵(N+1)
所以向下调整构建堆的时间复杂度为:O(N)=N
比向上调整进行堆排序有优势哦~
9. 构建堆
我们已经有了HeapPush函数来向堆一个个插入数据,那直接插入一个数组来构建一个堆不更加方便吗?
构建一个堆我们可以用两种方式:
(1)复用之前的插入函数HeapPush(这种方法效率不高,就是向上调整建堆)
void HeapCreate(HP* php, HPDataType* data, int n)
{
assert(php);//传入的指针不能为空
HeapInit(php);//初始化堆
for (int i = 0; i < n; i++)
{
HeapPush(php, data[i]);//复用HeapPush向堆中一个个插入数据
}
}
(2)直接将整个数组拷贝插入空间,再向下调整建堆
void HeapCreate(HP* php, HPDataType* data, int n)
{
assert(php);//传入的指针不能为空
//开辟空间
HPDataType* newSpace = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (newSpace == NULL)
{
perror("malloc");
exit(-1);
}
//拷贝数据
php->a = newSpace;
php->capacity = php->size = n;
memcpy(php->a, data, n * sizeof(HPDataType));
//向下调整建堆
for (int i = (n - 2) / 2; i >= 0; i--)
{
AdjustDown(php->a, n, i);
}
}
10. 堆排序
这里给一个随机数组,要求对其升序排序,我们可以先建堆。
那建大堆还是小堆呢?
当然是大堆了,如果建小堆每次进行堆调整时都会将大的元素调至前面,如果不另外开辟空间,将不能很好的就行下一次的调整。
我们采用向下调整建堆的方式进行堆排序:
先给一随机数组,我们可以将其先用向下调整的方式构建一个大堆,再将第一个最大的元素与堆尾元素交换,接着将最大元素移除堆再进行调整,以此来得到一个升序的数组。
代码如下:
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)//n-1是最后一个元素的物理位置((n-1)-1)/2是其父节点的物理位置
{
AdjustDown(a, n, i);//向下调整
}
int end = n - 1;
while (end > 0)
{
Swap(&a[end], &a[0]);//每一次调整之后将最大的元素挪到堆的最后面
AdjustDown(a,end,0);
end--;
}
}
如果想要降序排列就构建一个小堆来进行排序。
堆排序的时间复杂度为:O(N*㏒⑵N)
非常快哦~
11. TOP-K问题
TOP-K问题:即求数据结合中前K个最大的元素或者最小的元素,一般情况下数据量都比较大。
比如:专业前10名、世界500强、富豪榜、游戏中前100的活跃玩家等。
对于Top-K问题,能想到的最简单直接的方式就是排序,但是:如果数据量非常大,排序就不太可取了(可能数据都不能一下子全部加载到内存中)。最佳的方式就是用堆来解决,基本思路如下:
假如我们现在想要找到100亿个整数中,取出前K个最大的数,如果我们直接对100亿个整数进行排序至少需要40G的内存,这会造成空间上巨大的浪费。
我们可以先取K个数建立一个小堆,再将后100亿-K个数依次与堆顶元素相比较,如果比堆顶元素大就将其替换后重新向下调整为一个小堆,再接着与下一个数相比,这样最终就可以找到前K个最大的整数了。(节约了大量的空间)
该方法的时间复杂度为:O(N*㏒⑵K)
空间复杂度为O(K)
四、堆实现总代码
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<string.h>
typedef int HPDataType;//定义数据类型
//定义堆结构
typedef struct Heap
{
HPDataType* a;//数据
int size;//记录所存储的有效数据个数
int capacity;//记录可以存储数据个数的容量
}HP;
void HeapInit(HP* php)
{
assert(php);//传入的指针不能为空
php->capacity = php->size = 0;
php->a = NULL;
}
void DestoryHeap(HP* php)
{
assert(php);//传入的指针不能为空
free(php->a);//释放数据空间
php->a = NULL;
php->capacity = php->size = 0;
}
void Swap(HPDataType* parent, HPDataType* child)
{
HPDataType temp = *parent;
*parent = *child;
*child = temp;
}
void AdjustUp(HPDataType* data,int child)
{
int parent = (child - 1) / 2;
while (data[child] > data[parent] && child > 0)//如果孩子节点大于父亲节点就进行交换调整,注意如果想实现小堆的话只需要将前一个>改成<即可
{
Swap(&data[parent], &data[child]);//交换孩子和父亲节点
child = parent;
parent = (child - 1) / 2;
}
}
void HeapPush(HP* php, HPDataType x)
{
assert(php);
if (php->capacity == php->size)//判断是否需要扩容
{
int newcapacity = php->capacity == 0 ? 4 : php->capacity * 2;
HPDataType* new = (HPDataType*)realloc(php->a, sizeof(HPDataType) * newcapacity);
if (new == NULL)//判断扩容是否成功
{
perror("realloc");
exit(-1);
}
php->a = new;
php->capacity = newcapacity;
}
int temp = php->size;
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1);//对插入的数据进行调整
}
void AdjustDown(HPDataType* data, int n,int parent)
{
int chlid = parent * 2 + 1;//找到其左孩子
while (chlid < n)//防止数组越界
{
if (chlid + 1 < n && data[chlid + 1] > data[chlid])//判断左右孩子的大小,chlid + 1 < n要防止存在左孩子而无右孩子时的数组越界(想要按小堆调整需将前一个>改为<号)
{
chlid += 1;
}
if (data[chlid] > data[parent])//(想要按小堆调整需将 > 改为 < 号)
{
Swap(&data[chlid], &data[parent]);//将大的孩子元素与其替换
//继续向下比较替换
parent = chlid;
chlid = parent * 2 + 1;
}
else//如果孩子元素都没有其父元素大则直接跳出
{
break;
}
}
}
void HeapPop(HP*php)
{
assert(php);//传入的指针不能为空
assert(php->size > 0);//堆中有效数据个数不能为0
Swap(&php->a[php->size - 1], &php->a[0]);//交换首元素与尾元素
php->size--;//删除尾元素
AdjustDown(php->a, php->size, 0);//进行调整
}
HPDataType HeapTop(HP* php)
{
assert(php);//传入的指针不能为空
assert(php->size > 0);//堆中有效数据个数不能为0
return php->a[0];//返回堆顶元素
}
//void HeapCreate(HP* php, HPDataType* data, int n)
//{
// assert(php);//传入的指针不能为空
// HeapInit(php);//初始化堆
// for (int i = 0; i < n; i++)
// {
// HeapPush(php, data[i]);//复用HeapPush向堆中一个个插入数据
// }
//}
void HeapCreate(HP* php, HPDataType* data, int n)
{
assert(php);//传入的指针不能为空
//开辟空间
HPDataType* newSpace = (HPDataType*)malloc(sizeof(HPDataType) * n);
if (newSpace == NULL)
{
perror("malloc");
exit(-1);
}
//拷贝数据
php->a = newSpace;
php->capacity = php->size = n;
memcpy(php->a, data, n * sizeof(HPDataType));
//向下调整建堆
for (int i = (n - 2) / 2; i >= 0; i--)
{
AdjustDown(php->a, n, i);
}
}
//void Heap_createUp(int* a, int n)
//{
// for (int i = 1; i < n; i++)
// {
// AdjustUp(a, i);//向上调整一直到最后一个元素
// }
//}
void Heap_createDown(int* a, int n)
{
//向下调整一直到第一个
for (int i = (n - 1 - 1) / 2; i >= 0; i--)//n-1是最后一个元素的物理位置((n-1)-1)/2是其父节点的物理位置
{
AdjustDown(a, n, i);//向下调整
}
}
void HeapSort(int* a, int n)
{
for (int i = (n - 1 - 1) / 2; i >= 0; i--)//n-1是最后一个元素的物理位置((n-1)-1)/2是其父节点的物理位置
{
AdjustDown(a, n, i);//向下调整
}
int end = n - 1;
while (end > 0)
{
Swap(&a[end], &a[0]);//每一次调整之后将最大的元素挪到堆的最后面
AdjustDown(a,end,0);
end--;
}
}
本期的博客到这里就结束了,感谢各位看官的支持,后面会加快更新的速度,请大家不要走开哦~
本期代码量较多,如有纰漏,还请各位大佬不吝赐教。
最后祝大家圣诞快乐,一路平安~