目录
一、树的结构和概念
1.1 树的概念
树是一种非线性的数据结构,它是由n(n>=0)个有限结点组成一个具有层次关系的集合。把它叫做树是因 为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
树有一个特殊的结点,称为根结点,根节点没有前驱结点。
树除根节点外,其余结点被分成M(M>0)个互不相交的集合T1、T2、……、Tm,其中每一个集合Ti(1<= i <= m)又是一棵结构与树类似的子树。每棵子树的根结点有且只有一个前驱,可以有0个或多个后继。
因此,树是递归定义的。
生活中的二叉树:
数据结构中的二叉树:
注意:树形结构中,子树之间不能有交集,否则就不是树形结构。
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)棵互不相交的树的集合称为森林。
1.3 树的表示
树结构相对线性表就比较复杂了,要存储表示起来就比较麻烦了,既然保存值域,也要保存结点和结点之间的关系,实际中树有很多种表示方式如:双亲表示法、孩子表示法、孩子双亲表示法以及孩子兄弟表示法等。我们这里就简单的了解其中最常用的孩子兄弟表示法。
typedef int DataType;
struct Node
{
struct Node* firstChild1;
struct Node* pNextBrother;
DataType data;
};
二、二叉树的结构及概念
2.1 二叉树的概念
一棵二叉树是结点的一个有限集合,该集合:
1. 为空;
2. 由一个根节点加上两棵被称为左子树和右子树的二叉树组成。
从上图可以看出:
1. 每个结点最多有两棵子树,即二叉树不存在度大于2的结点。
2. 二叉树的子树有左右之分,次序不能颠倒,因此二叉树是有序树。
注意:对于任意的二叉树都是由以下几种情况复合而成的:
2.2 特殊的二叉树
1. 满二叉树:一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。也就是说,如果一个二叉树的层数为K,且结点总数是,则它就是满二叉树。
2. 完全二叉树:完全二叉树是效率很高的数据结构,完全二叉树是由满二叉树而引出来的。对于深度为K的,有n个结点的二叉树,当且仅当其每一个结点都与深度为K的满二叉树中编号从1至n的结点一一对应时称之为完全二叉树。要注意的是满二叉树是一种特殊的完全二叉树。
2.3 二叉树的性质
1. 若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有
个结点。
2. 若规定根节点的层数为1,则深度为h的二叉树的最大结点数是
。
3. 对任何一棵二叉树,如果度为0的节点即叶结点的个数为
,度为2的分支结点个数为
,则有
=
+1。
4. 若规定根节点的层数为1,具有n个结点的满二叉树的深度,h =
。
5. 对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对 于序号为i的结点有:
若i>0,i位置节点的双亲节点序号:(i-1)/2;
i=0,i为根节点编号,无双亲节点。
2.4 二叉树的存储结构
二叉树一般可以使用两种结构存储,一种顺序结构,一种链式结构。
1. 顺序存储:
顺序结构存储就是使用数组来存储,一般使用数组只适合表示完全二叉树,因为不是完全二叉树会有空间的浪费。而现实中使用中只有堆才会使用数组来存储。二叉树顺序存储在物理上是一个数组,在逻辑上是一颗二叉树。
2. 链式存储:
二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示元素的逻辑关系。 通常的方法是链表中每个结点由三个域组成,数据域和左右指针域,左右指针分别用来给出该结点左孩子和右孩子所在的链结点的存储地址 。链式结构又分为二叉链和三叉链,当前我们学习中一般都是二叉链,红黑树等会用到三叉链。
typedef int BTDataType;
// 二叉链
struct BinaryTreeNode
{
struct BinaryTreeNode* pLeft; // 指向当前节点左孩子
struct BinaryTreeNode* pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
// 三叉链
struct BinaryTreeNode
{
struct BinaryTreeNode* pParent; // 指向当前节点的双亲
struct BinaryTreeNode* pLeft; // 指向当前节点左孩子
struct BinaryTreeNode* pRight; // 指向当前节点右孩子
BTDataType _data; // 当前节点值域
};
三、二叉树的顺序结构及实现
3.1 二叉树的顺序结构
普通的二叉树是不适合用数组来存储的,因为可能会存在大量的空间浪费。而完全二叉树更适合使用顺序结构存储。现实中我们通常把堆(一种二叉树)使用顺序结构的数组来存储,需要注意的是这里的堆和操作系统虚拟进程地址空间中的堆是两回事,一个是数据结构,一个是操作系统中管理内存的一块区域分段。
3.2 堆的概念结构及实现
这部分可以看我的另一篇博客:【数据结构】堆(解决堆排序与TopK问题)。
四、二叉树链式结构的实现
4.1 前置说明
在学习二叉树的基本操作前,需先要创建一棵二叉树,然后才能学习其相关的基本操作。由于二叉树的创建较二叉树的基本操作难,为了降低学习成本,此处手动快速创建一棵简单的二叉树,快速进入二叉树操作学习,等二叉树结构了解的差不多时,我们反过头再来研究二叉树真正的创建方式。
//节点的定义
typedef int BTDataType;
typedef struct BinaryTreeNode
{
BTDataType val;
struct BinaryTreeNode* left;
struct BinaryTreeNode* right;
}BTNode;
//节点创建函数
static BTNode* BinaryTreeNodeCreat(const BTDataType value)
{
BTNode* newnode = (BTNode*)malloc(sizeof(BTNode));
if (newnode == NULL)
{
perror("malloc");
exit(-1);
}
newnode->val = value;
newnode->left = newnode->right = NULL;
return newnode;
}
//节点的创建
BTNode* n1 = BinaryTreeNodeCreat(1);
BTNode* n2 = BinaryTreeNodeCreat(2);
BTNode* n3 = BinaryTreeNodeCreat(3);
BTNode* n4 = BinaryTreeNodeCreat(4);
BTNode* n5 = BinaryTreeNodeCreat(5);
BTNode* n6 = BinaryTreeNodeCreat(6);
n1->left = n2;
n2->left = n3;
n1->right = n4;
n4->left = n5;
n4->right = n6;
注意:上述代码并不是创建二叉树的方式!
4.2 二叉树的遍历
4.2.1 前序、中序以及后序遍历的概念及过程
学习二叉树结构,最简单的方式就是遍历。所谓二叉树遍历(Traversal)是按照某种特定的规则,依次对二叉树中的节点进行相应的操作,并且每个节点只操作一次。访问结点所做的操作依赖于具体的应用问题。 遍历是二叉树上最重要的运算之一,也是二叉树上进行其它运算的基础。
按照规则,二叉树的遍历有:前序/中序/后序的递归结构遍历:
1. 前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点的操作发生在遍历其左右子树之前。
2. 中序遍历(Inorder Traversal)——访问根结点的操作发生在遍历其左右子树之中(间)。
3. 后序遍历(Postorder Traversal)——访问根结点的操作发生在遍历其左右子树之后。
由于被访问的结点必是某子树的根,所以N(Node)、L(Left subtree)和R(Right subtree)又可解释为根、根的左子树和根的右子树。NLR、LNR和LRN分别又称为先根遍历、中根遍历和后根遍历。
前序遍历递归图解:
前序遍历(先遍历根,再遍历左子树,最后遍历右子树)结果:1 2 3 NULL NULL NULL 4 5 NULL NULL 6 NULL NULL
中序遍历(先遍历左子树,再遍历根,最后遍历右子树)结果:NULL 3 NULL 2 NULL 1 NULL 5 NULL 4 NULL 6 NULL
后序遍历结果(先遍历左子树,再遍历右子树,最后遍历根)结果:NULL NULL 3 NULL 2 NULL NULL 5 NULL NULL 6 4 1
为了方便了解过程,我在这把NULL也表示了出来,但在实际运用时就不需要表示了。
我们可以发现,前、中、后序说的是根遍历的顺序且左子树一定在右子树前遍历。
4.2.2 前序、中序以及后序遍历的实现
二叉树大多数的操作都是通过递归来实现的,遍历当然不不例外。
前序遍历:
void PreOrder(const BTNode* root) { if (root) { printf("%d ", root->val); PreOrder(root->left); PreOrder(root->right); } else { printf("NULL "); } }
中序遍历:
void InOrder(const BTNode* root) { if (root == NULL) { printf("NULL "); } else { InOrder(root->left); printf("%d ", root->val); InOrder(root->right); } }
后序遍历:
void PostOrder(const BTNode* root) { if (root == NULL) { printf("NULL "); } else { PostOrder(root->left); PostOrder(root->right); printf("%d ", root->val); } }
前、中、后序的思想是一致的,无非是输出的顺序(或执行相关操作)不同罢了。
我们简单就前序遍历来理解一下,首先我们需要判断root是否为空,如果为空就不执行操作(或输出NULL)。不为空我们就先输出根节点的值,达到前序的目的,然后我们把root的左子树作为新的根再次执行相同逻辑,会一直输出自身的值然后再把自己的左子树作为新的根执行相同逻辑,直到左子树为NULL(叶节点)为止。这样递归的递就结束了,我们要进行递归的归,程序会从最深的递归层次开始(叶节点)不断的回到上一个函数(本节点的根)对本节点的根的右子树进行遍历,重复与左子树遍历逻辑相同的操作,最后回到整棵树真正的根。
4.2.3 层序遍历的过程
层序遍历:除了先序遍历、中序遍历、后序遍历外,还可以对二叉树进行层序遍历。设二叉树的根节点所在 层数为1,层序遍历就是从所在二叉树的根节点出发,首先访问第一层的树根节点,然后从左到右访问第2层 上的节点,接着是第三层的节点,以此类推,自上而下,自左至右逐层访问树的结点的过程就是层序遍历。
层序遍历比前、中、后序遍历要难上许多,层序不用递归的方法,而是采用我们之前学习过的队列,利用了队列先进先出的特性。
我们先把根节点放入队列。然后我们以队列不为空为条件开始迭代,不断地把队首的数据取出来,然后把取出来的节点的两个子节点放入队列中(如果为空就不放),当队列为空,即二叉树的所有节点都进入并出过队列后层序遍历就结束了。
思考:是不是一定把二叉树的整个节点存进队列呢?是不是只放二叉树节点中的数据就行了呢?
回答:不能只放二叉树节点中的数据,如果只存储数据,那么我们就无法根据一个节点去找到它的两个子节点,比如我们只存放节点1的值,那么我们就无法找到节点2和节点3。
4.2.4 层序遍历的实现
因为我们需要用到队列,所以我们不妨把我们之前写的队列加入到我们的工程中。
我们可以先将队列的文件拷贝一份放到二叉树的文件夹下(防止对队列的原本进行更改),如果你用的IDE是VS,我们可以分别右键源文件和头文件,通过添加现有项把队列的文件载入进去。
添加成功之后,我们需要对队列的头文件做一点小小的改动。
//层序遍历不采用递归的方式 而是借助队列完成
void LevelOrder(BTNode* root)
{
//如果根都为NULL了,那就不需要遍历了
if (root == NULL)
{
return;
}
Queue Q;
QueueInit(&Q);
QueuePush(&Q, root);
while (!QueueEmpty(&Q))
{
BTNode* Front = QueueFront(&Q);
printf("%d ", Front->val);
QueuePop(&Q);
if (Front->left)
{
QueuePush(&Q, Front->left);
}
if (Front->right)
{
QueuePush(&Q, Front->right);
}
}
printf("\n");
Queuedestroy(&Q);
}
在层序遍历结束后记得把队列销毁哦,不然就会造成内存泄漏。
4.2.5 层序的运用——判断一棵树是否为完全二叉树
根据完全二叉树的定义我们可以直到,一棵高度为K的完全二叉树,那么它第一层到第K-1层的节点个数一定是到达了这一层的上限的,根据这个特性再结合上层序我们就能发现一个现象。
是完全二叉树在层序遍历时,它的有效节点连成一片的,中间没有夹杂任何其他东西。而非完全二叉树就没有这种现象,它在层序遍历时,有效节点之间会夹杂着无效节点(NULL)。要判断一棵树是否为完全二叉树,我们就要在这里下文章。
判断一棵树是否为完全二叉树的整体思路与层序是相同的,只不过层序并没有把NULL放进队列中,而我们现在要把NULL也放入队列中。
这样整体思路就确定了,那我们怎么确定节点是连续的呢?其实我们可以反过来想,既然有效节点是连续的,那么NULL肯定也是连续的,我们只要判断从读取到队列中第一个NULL开始,到队列为空结束,期间是否会出现有效节点就行了,出现了有效节点,这棵树就不是完全二叉树,反之则是。
bool BinaryTreeComplete(BTNode* root)
{
//采用层序遍历的思想 来判断一棵树是否为完全二叉树
//如过为完全二叉树 那么如果在遍历时把NULL打印出来 NULL会连成一片 其间没有其他数据
//反之 NULL中会混入有效数据
if (root == NULL)
{
return true;
}
Queue Q;
QueueInit(&Q);
QueuePush(&Q, root);
while (!QueueEmpty(&Q))
{
BTNode* Front = QueueFront(&Q);
if (Front == NULL)
{
break;
}
QueuePop(&Q);
QueuePush(&Q, Front->left);
QueuePush(&Q, Front->right);
}
while (!QueueEmpty(&Q))
{
BTNode* Front = QueueFront(&Q);
if (Front != NULL)
{
Queuedestroy(&Q);
return false;
}
QueuePop(&Q);
}
Queuedestroy(&Q);
return true;
}
在两种返回结果的情况前,都要记得销毁队列哦。
4.3 计算二叉树节点的个数和二叉树叶子节点的个数
4.3.1 计算二叉树节点的个数
在学习正确解法前,我们不妨来看一下错误写法:
int TreeSize(BTNode* root)
{
if(root == NULL)
return 0;
int size = 0;
++size;
TreeSize(root->left);
TreeSize(root->right);
return size;
}
怎么样?看出错误了吗?
其实这个代码表面看是可以的,这就类似于二叉树的遍历(前、中、后序都可),只不过将打印数据变为了,每访问一次不为NULL的节点(有效的二叉树节点),size就++。但下面的代码存在一处致命错误,那就是因为size是局部变量,每次递归调用,函数都会产生一个自己的size,则每次的size++的都不是一个size。
最后的结果就为0或1了。
那么我们要采用什么方法来解决这个问题呢?
创建全局变量可以吗?
创建静态变量可以吗?
诶,好像可以。但是这样呢?
我们发现,每一次调用求节点大小的函数,它求出来的节点个数并不是在0的基础上增加,而是在上一次的结果上增加,那么这个函数就只能使用一次,是不合格的函数,那我们就必须另想它法。
我们主要是想让该函数在递归时使用同一个size,那为什么我们不在主函数中创建一个size然后把地址传进去呢?
void TreeSize(BTNode* root,int* psize)
{
if(root == NULL)
return 0;
(*psize)++;
TreeSize(root->left,psize);
TreeSize(root->right,psize);
}
这样的话,整个递归过程就用的是同一个size了,不过它需要在第二次使用前将size置空,欸,那这就有问题了,全局变量也可以在第二次使用前置空,为什么不用全局变量而用指针呢?那是因为全局变量不安全,整个工程内都可以使用全局变量,所以在设计程序时,要尽量避免使用全局变量。而且我们还有比这些更好的方法。
size_t TreeNodeSize(const BTNode* root)
{
//加一是加上自己本身的个数
return root == NULL ? 0 : TreeNodeSize(root->left) + TreeNodeSize(root->right) + 1;
}
我们可以将整棵树分成多棵树。我们将整棵树分成根、左子树、右子树,而左右子树又可以分为根,左子树,右子树。这样的话所有的节点都会作为根,所以我们只要对根进行判断就行了,如果根为NULL,就返回0,反之就把它的左子树和右子树再作为根进行求节点大小,最后两者相加,再加上自己本身的个数,就是自己这棵树的大小了。
Ⅰ.如果是空树,直接返回0,作为递归的出口。
Ⅱ.如果不是空树,则不断访问它的左右子树,不断逼近递归出口,并加上自己本身的个数。
4.3.2 计算和二叉树叶子节点的个数
计算二叉树叶子节点的个数与计算二叉树全部节点的思路相同,只是要多判定一次该节点是否为叶子节点。
void TestLeafNodesize(BTNode* root, int* psize) { if (root == NULL) { return; } else if (root->left == NULL && root->right == NULL) { ++(*psize); } TestLeafNodesize(root->left, psize); TestLeafNodesize(root->right, psize); }
size_t LeafNodesize(const BTNode* root) { //空树的叶子结点是0个 if (root == NULL) { return 0; } //如果是叶子结点 //叶子节点的左右子树都为NULL if (root->left == NULL && root->right == NULL) { return 1; } //判断完根后再判断根的左右子树 //如果自己本身不是叶子节点,则它的左右子树(左右孩子)可能为叶子节点 //对左右子树进行判断,并返回结果 return LeafNodesize(root->left) + LeafNodesize(root->right); }
当前树的叶子结点个数 = 左子树叶子节点个数 + 右子树叶子结点个数,而判断是否为叶子结点,看他的左子树和右子树是否同时为NULL即可。
4.4 计算二叉树的高度和二叉树第k层节点个数
4.4.1 计算二叉树的高度
计算二叉树的高度之前,我们先回顾一下二叉树高度的概念:
树的高度或深度:树中节点的最大层次。
说人话就是要计算二叉树算上根节点有几层。树的高度可以分解为在左右子树中最大的高度基础上再加一。所以我们又可以用递归的方法,不断的将二叉树分为子树,计算其左右子树的高度,再在其中最大的高度上加一。
int TreeHeight(const BTNode* root)
{
if (root == NULL)
{
return 0;
}
//树的高度为左右子树最高的高度再加一
int LeftHeight = TreeHeight(root->left);
int RightHeight = TreeHeight(root->right);
return LeftHeight > RightHeight ? LeftHeight + 1 : RightHeight + 1;
}
return TreeHeight(root->left) > TreeHeight(root->right) ?
TreeHeight(root->left) + 1 : TreeHeight(root->right) + 1;
可能有人会为了图方便,或者是少创建两个变量而改为上述形式。但其实这样并不好,因为我们知道递归的消耗是非常大的,而上述逻辑会在判断和返回时都执行递归,这样的话就会进行三次递归计算,比我们最开始要多一次,效率也就更低了,所以我在此推荐第一种。
4.4.2 计算二叉树第k层节点个数
我们知道如果以二叉树根节点所在层次为第一层,那么这棵二叉树第一层的节点个数就只有两种情况:
Ⅰ.空树,则第一层节点个数为0。
Ⅱ.非空树,则第一层节点个数为1。
我们也可以根据之前讲过的结论:“若规定根节点的层数为1,则一棵非空二叉树的第i层上最多有个结点。”来得到这个推论。
那么这个推论有什么用呢?递归的思想就是把一件大事分割成一件件重复、类似的小事,做到大事化小。计算二叉树第k层节点个数也是这样,二叉树的第k层节点个数并不好求,但我们可以把问题分解为求二叉树第二层的第K-1层的节点个数,二叉树第三层的第K-2层的节点个数……直到把问题分解为二叉树第K层的第1层的节点个数,那么第一层的节点个数好不好求呢?那可太好求了,因为第一层就两种情况,我们只需要对这两种情况进行判断就行了。
int TreeKLevel(const BTNode* root,const int K)
{
//如果K为负数 就没有意义了 最后的答案都是0
assert(K >= 0);
//当K等于1时 即为该层的第一层时 每一个子树的节点个数 要不为1 要不为0
//为0时 root == NULL 则返回0 反之则返回1
if (root == NULL)
{
return 0;
}
if (K == 1)
{
return 1;
}
//计算树的第K层可以转变为计算子树的第K-1层
return TreeKLevel(root->left, K - 1) + TreeKLevel(root->right, K - 1);
}
4.5 在二叉树内查找特定的值
查找依旧用的是与遍历相同的思维,四种遍历方式都可,但是我在这里推荐先序遍历,因为它好实现,而且如果根节点就是我们想要的值,就不需要再继续在左右子树中找了。
BTNode* TreeFind(BTNode* root, const BTDataType val)
{
//用来记录是否找到目标值
if (root == NULL)
{
return NULL;
}
//先从根开始找
if (root->val == val)
{
return root;
}
//根没找到就去左子树找 找到了就返回会对应的地址 没找到进行下一步
BTNode* LeftRet = TreeFind(root->left, val);
if (LeftRet != NULL)
{
return LeftRet;
}
//左子树没找到就在右子树找
BTNode* RightRet = TreeFind(root->right, val);
if (RightRet != NULL)
{
return RightRet;
}
//在根 左子树 右子树都没找到就说明没有 返回NULL
return NULL;
}
4.6 二叉树的创建与销毁
4.6.1 二叉树的销毁
为了能够找到左右子树,我们选择采取后序遍历的方式。
void BinaryTreeDestroy(BTNode* root)
{
//为了找到下一个节点 我们需要采用后序遍历 即先销毁左右子树再销毁根
if (root)
{
BinaryTreeDestroy(root->left);
BinaryTreeDestroy(root->right);
free(root);
root = NULL;
}
}
如果想要防止野指针,我们也可以传入二级指针。
void DestroyTree(BTNode** root) { if(*root) { DestroyTree((*root)->left); DestroyTree((*root)->right); free(*root); *root = NULL; } }
但是为了接口的一致性,可能就不会这么做。
4.6.2 二叉树的创建
4.6.2.1 根据先序创建二叉树
这里,我们给定一个字符串,ABC##DE#G##F### 其中“#”表示的是空格,空格字符代表空树。我们就根据这个字符串来创造先序字符串。
我们首先需要知道,遍历字符串是需要下标的,而二叉树的创建是要由递归来完成的,所以我们必须自己在主函数中创建一个下标,再把下标的地址作为参数传入函数。
BTNode* BinaryTreeNodeCreat(char* string, int* i)
{
if (string[*i] == '#')
{
++(*i);
return NULL;
}
BTNode* root = (BTNode*)malloc(sizeof(BTNode));
root->data = string[*i];
++(*i);
root->left = BinaryTreeNodeCreat(string, i);
root->right = BinaryTreeNodeCreat(string, i);
return root;
}
我们从数组的第一个元素开始遍历,如果不为‘#’就创建一个新节点,并把数组中对应的值存放进去,然后通过递归再依此进入左子树和右子树。如果为‘#’,就让下标向后走一位后返回NULL,最后返回在本次递归时创建的节点地址给上一次递归,达到连接子树与根的效果,这样根据先序创建二叉树就完成了。
4.6.2.2 根据先序和中序创建二叉树
根据先序和中序创建二叉树要比单一根据先序创建二叉树难上许多。
这里博主还没学到,等之后学会了再来完成这部分,还请谅解!
五、结语
到这里我们就已经初步学习了二叉树,但是仍然还有很大一部分没有学习。这需要我们到高级数据结构再去深入理解。
二叉树作为我们学习的第一个非线性数据结构,在理解上要比其他的线性数据结构要难上许多,尤其是在学习过程中并没有熟练使用递归的人,所以我们需要在学习之后进行一些练习,这里是一些oj题,能够巩固我们学到的知识。
做完并理解了这些oj,你一定会更加熟练地掌握二叉树的基本操作,如果有需要,我也可以写博客来解明这些oj。
希望我的博客能对你学习二叉树有所帮助,如有错误还请指正!