接下来要学习的一种学习结构就是一对多的情况,这种数据结构被称为“树”。
树(Tree)的定义:树是由n(n>=0)个结点的有限集,n=0时称为空树。在任意一颗非空的树中,1.有且只有一个特定的称为根的结点;2.当n>=1时,其余的结点可分为m(m>0)个互不相交的有限集,其中每个集合本身又是一颗树;并且称为根的子树。
根据定义我们可以知道,黄色和蓝色的部分就是A的子树。
关于树还需要强调几点:
(1)n>0的时候,根结点时唯一的,不可能存在多个根结点的情况。
(2)子树之间是不相交的。比如下图就符合树的定义。
因为D和E结点相交了。
关于树有很多的概念需要先了解一下,要不然没法开展下面的学习,虽然多,但其实也不难记。
节点的度:一个节点含有的子树的个数称为该节点的度; 如下图:A结点的度为2。
叶节点或终端节点:度为0的节点称为叶节点; 如下图:G,E,H,F,M节点为叶节点
非终端节点或分支节点:度不为0的节点; 如上图:B,C等节点为分支节点
双亲节点或父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点; 如下图:A是B的父节点
孩子节点或子节点:一个节点含有的子树的根节点称为该节点的子节点; 如上图:B是A的孩子节点
兄弟节点:具有相同父节点的节点互称为兄弟节点; 如上图:B、C是兄弟节点
树的度:一棵树中,最大的节点的度称为树的度; 如上图:树的度为2
节点的层次:从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
树的高度或深度:树中节点的最大层次; 如下图:树的高度为4
堂兄弟节点:双亲在同一层的节点互为堂兄弟;如上图:E,F互为堂兄弟节点
节点的祖先:从根到该节点所经分支上的所有节点;如上图:A是所有节点的祖先
子孙:以某节点为根的子树中任一节点都称为该节点的子孙。如上图:所有节点都是A的子孙
森林:由m(m>0)棵互不相交的树的集合称为森林;
树的结构在图中很容易就能看的懂,但是要想使用代码将树的结构链接起来,并不是那么容易的事。
主流的有三种链接方式
1,双亲表示法
2,孩子表示法
3,孩子兄弟表示法
前两种表示法这里不做解释,因为我们的重点并不是树的代码实现方法,而是接下来的二叉树,所以这里只解释第三种–孩子兄弟表示法。
孩子兄弟表示法的思路是这样的,我们设置两个指针(child和brothers),这俩个指针的关系指向如下图:
我们通过child指针来找到孩子结点,然后再通过brothers指针去找兄弟结点,这样就能实现将整个树都链接起来的目的了。
这也是我们即将要实现的二叉树的一大特性。
在实际的代码应用中,其实相比于树,应用的更多的是树的另一种结构----二叉树。
那什么又是二叉树呢?
二叉树(Binary
Tree):是n(n>=0)个有限结点的集合,该集合或者为空,或者有一个根节点和两个互不相交的,分别称为根结点的左子树和右子树的二叉树组成。
二叉树的特点:
1.每个结点最多有两个子树
2.左子树和右子树是有序的。
3.即使某个结点只有一个子树,也要区分左右。
谈到二叉树,就必须要说一下两种特殊的二叉树,这也是后面实现堆和堆排序的重要概念基础。
满二叉树
一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是满二叉树。
完全二叉树
完全二叉树由满二叉树引出来,如果树的深度为k,那么k-1层的结点数必须是最大,并且第k层的结点必须是连续的。
我们来看一下推导的过程,第一条性质我们已经知道了第h层的结点最多有2^(h - 1)个结点,那么深度为h的二叉树的最大结点数,就是满二叉树的结点数。
我们将从第一层到最后一层的结点数加起来。
推导出了,最大的结点情况,我们再来思考一下,最少的结点的表达式。
最少的结点的情况就是完全二叉树的第h层只有一个结点的情况,那么我们可以求出前h-1层的结点的和再+1即可得到,深度为h的二叉树最少有多少结点的情况。
有了n和h的关系式,大家肯定也能反推出h的表达式,我就不再说了。
这个性质是通过长期的观察得出的,并没什么推到的过程,大家可以多画几张图来验证一下这个。记住就好。
讲到这里,大部分的概念部分都讲完了,接下来就是二叉树的代码实现了。
链式二叉树和我们之前所认识到的数据结构在代码的实现上有较大的区别,对于链式二叉树,我们不再把关注的重点放在任何对数据进行操作上(如:增删查改…),而是更加的关注它的结构特点,比如如何遍历二叉树,已经查找二叉树中的某个结点,个结点的个数个问题。
谈到链式的二叉树,就不得不谈一下二叉树的四种遍历方式前序遍历,中序遍历,后序遍历,层序遍历。
二叉树作为以后更难得复杂树的基础,掌握这些都是必要的。
想要完成对二叉树的乙一系列操作的话,就需要先存在一个二叉树,方便我们之后的研究。所以我们以下图中的二叉树为例,用代码链接一个二叉树出来。
//构建二叉树
typedef int BinaryTreeData;
typedef struct BinaryTree
{
BinaryTreeData val;
struct BinaryTree* left;
struct BinaryTree* right;
}BinaryTree;
//构造结点
BinaryTree* BuyBinaryTree(int x)
{
BinaryTree* ret = (BinaryTree*)malloc(sizeof(BinaryTree));
if (!ret)
{
perror("BuyBinaryTree fail::");
exit(-1);
}
ret->val = x;
ret->left = ret->right = NULL;
return ret;
}
//链接结点
int main()
{
BinaryTree* n1 = BuyBinaryTree(1);
BinaryTree* n2 = BuyBinaryTree(2);
BinaryTree* n3 = BuyBinaryTree(3);
BinaryTree* n4 = BuyBinaryTree(4);
BinaryTree* n5 = BuyBinaryTree(5);
BinaryTree* n6 = BuyBinaryTree(6);
BinaryTree* n7 = BuyBinaryTree(7);
//链接结点成二叉树
n1->left = n2;
n1->right = n4;
n2->left = n3;
n3->right = n7;
n4->left = n5;
n4->right = n6;
return 0;
}
下面我们就开始遍历二叉树。
前序遍历的顺序:
最后的打印结果就是
在四种遍历二叉树的方式都是要大家对递归有深刻的理解的。
将NULL也打印出来是为了大家更好的去理解代码递归的过程,大家也可以选择不打印NULL。
下面就是前序遍历的代码:
//前序遍历
void PrevOrder(BinaryTree* root)
{
if (!root)
{
printf("NULL ");
return;
}
//根结点
printf("%d ", root->val);
//遍历左子树
PrevOrder(root->left);
//遍历右子树
PrevOrder(root->right);
}
看不懂的建议根据代码画一下递归的展开图,因为后面的中序和后序也是同样的道理,都是换汤不换药。
后序遍历的顺序:
走的过程也是和前序遍历一样的,都是层层的递归先去找左子树,然后再去找根,接着就是右子树。
代码:
//后序遍历
void PostOrder(BinaryTree* root)
{
if (!root)
{
printf("NULL ");
return;
}
PostOrder(root->left);
PostOrder(root->right);
printf("%d ", root->val);
}
中序遍历的顺序:
顺带的提一嘴,不知道大家发现没有,这三种命名是有规律的,它们都是根据根结点的位置命名的。比如中序遍历,代表的就是根结点在中间的位置。
下面就是中序遍历的代码:
//中序遍历
void InOrder(BinaryTree* root)
{
if (!root)
{
printf("NULL ");
return;
}
InOrder(root->left);
printf("%d ", root->val);
InOrder(root->right);
}
层序遍历的过程和前三种遍历的方式有所不同,它是按照树的层来打印对应的结点。
按照上图中的二叉树结构进行层序打印的话,结果就是:
第一层只有根节点1
第二层是 2 4
第三层是 3 5 6
第四层是 7
第五层是空,这里就是不打印了,因为层序结构的逻辑遍历思想还是很简单的。
那么层序遍历如何实现呢?
这就需要借助我们之前学过的一种数据结构了—队列。
我们先回想一下队列的特点:先进的先出,一端为进数据,一端为出数据。
那么我们只需要按照根先队列,如何左子树进队列,接着右子树进队列。也就是说一层一层的进队列,结果就是这样的:
然后出队列即可。
大概的思路有了之后我们就需要用代码来实现一下,因为队列我们在之前就实现过了,所以这里我直接将之前的队列的代码copy过来作为,我们实现层序遍历的基础。
代码:
//层序遍历
void LeveInOrder(BinaryTree* root)
{
//先创建一个队列
Queue q;
QueueInit(&q);
//先将根结点入队列
if (root)
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
//先将1打印出来
BinaryTree* ps = QueueFront(&q);
printf("%d ", ps->val);
//销毁1
QueuePop(&q);
//如果左子树不为空就入队列
if (ps->left)
QueuePush(&q, ps->left);
//同样的如果右子树不为空就入队列
if (ps->right)
QueuePush(&q, ps->right);
}
printf("\n");
QueueDestroy(&q);
}
队列的代码在本专栏的前面的文章中。
遍历的内容讲完之后,后面接着就是二叉树的深度,和结点个数的问题。
也是尤为重要的内容。
有了前面遍历二叉树的四种方式之后,我想对于二叉树的深度的遍历讲起来,大家应该能更容易理解一点。
同样也是采用递归的方式,我们遍历完左右子树后,取较大的+1就是完整的二叉树的深度,+1加的是根节点。
图解如下:
细节的逻辑图:
因为3的左子树为空所以返回0。同样的5结点和6结点的左右子树为空和7结点一样。所以它们返回的都是0。
从下面向上层层的递归,选取最大的+1递归给上一层。
依次就能达到获取二叉树的深度的目的
代码:
int BinaryTreeHight(BinaryTree* root)
{
if (!root)
return 0;
//记录下左右子树最大的深度,不用再重复的递归计算
int lefthight = BinaryTreeHight(root->left);
int righthight = BinaryTreeHight(root->right);
return lefthight > righthight ? lefthight + 1 : righthight + 1;
}
有人觉得上面的写法太过于的复杂,于是优化成了下面的代码:
int BinaryTreeHight(BinaryTree* root)
{
if (!root)
return 0;
else
return BinaryTreeHight(root->left) > BinaryTreeHight(root->right) ?
BinaryTreeHight(root->left) + 1 : BinaryTreeHight(root->right) + 1;
}
大家思考一下这两种写法,哪一种更好。
要向获取第k层的结点个数,听起来好像很难办到,但是仔细思考一下,我们其实不必走到第k层,因为就算走到了第k层,也没法获取该层的结点个数,我们不容走到第k-1层,也就是k的上一层,然后通过左右吧孩子是否为空判断,为空的就返回0,不为空的话就返回1。
int TreeKLevelSize(BinaryTree* root, int k)
{
if (!root)
return 0;
if (k == 1)
return 1;
// k > 1 子树的k-1
return TreeKLevelSize(root->left, k - 1)
+ TreeKLevelSize(root->right, k - 1);
}
注意root是否为空的判断必须在k是否为1的判断之前,否则可能会引起空指针的引用。
叶子结点的判断就更好判断了,根据叶子结点的特点:左右子树都为空,就是返回1,遇到空结点的话就返回0。
逻辑图解:
还是根据递归,从下向上层层的返回值,最后便能确定下叶子结点的个数,和上第k层的结点个数,有异曲同工之妙。
代码:
int TreeLeafSize(BinaryTree* root)
{
if (root == NULL)
return 0;
if (root->left == 0 && root->right == 0)
return 1;
return TreeLeafSize(root->left) + TreeLeafSize(root->right);
}
要向判断一个二叉树是否为完全二叉树,就需要结合完全二叉树的特性来看。
完全二叉树必须满足叶子结点是连续的,而且前n-1层的结点个数是满的,其实也就是完全二叉树的所有结点都必须是满足连续的。
下面给出完全二叉树和不是完全二叉树的对比图:
那么我们按照前面层序遍历的思想,让二叉树的结点按层次的去入队列,然后再一层层的去出对列,如果遇到空结点的话,就去判断剩下的数据中是否都是空结点,如果是的话,就说明该二叉树为完全二叉树,否则就是空结点的后面还有非空结点,那么该二叉树肯定就不是完全二叉树了。
代码:
//完全二叉树的判断
bool TreeCompete(BinaryTree* root)
{
//利用层序遍历的思想取判断队列中的数据是否是连续的!
Queue q;
QueueInit(&q);
if (root)
QueuePush(&q, root);
while (!QueueEmpty(&q))
{
BinaryTree* ps = QueueFront(&q);
QueuePop(&q);
//如果遇到空结点的话,跳出检查一下队列中的剩下的数据是否为空
//如果是完全二叉树的话,后面的数据应该都为空才对
if (!ps)
{
if (QueueEmpty)
{
break;
}
}
else
{
QueuePush(&q, ps->left);
QueuePush(&q, ps->right);
}
}
//出循环之后还是有两种情况,第一次中是break跳出来的,第二种是循环条件结束的。
//第二种情况的话,说明这就是个完全的二叉树。
//第一种情况的话,就要取判断后面的数据是否全部为空。
while (!QueueEmpty(&q))
{
//这时候就不入队列了,只对队列中的数据进行一个检查是否都为空
BinaryTree* ps = QueueFront(&q);
QueuePop(&q);
//如果后续的数据出现非空,就说明不是完全二叉树。
if (ps)
{
QueueDestroy(&q);
return false;
}
}
QueueDestroy(&q);
return true;
}
需要值得我们注意的一点是,空结点的地址QueueEmpty()函数并不是识别为空指针,该函数判断的是队列中是否为空。
二叉树的销毁我们只需要按照 左 右 跟的顺序进行销毁即可,也就是后续连续的顺序。
代码很简单,我就再多说什么了。
代码:
//二叉树的销毁
void BinaryTreeDestroy(BinaryTree* root)
{
//按照后序遍历的顺序进行销毁的步骤--左 右 根
if (root == NULL)
return;
BinaryTreeDestroy(root->left);
BinaryTreeDestroy(root->right);
free(root);
}
这次的重点便是二叉树的链接,以及对二叉树结构的各种研究,其实大量的运用了递归的知识,如果觉得看不懂的,就跟着代码调试着去画递归的结构图。
多画点时间就能正真正的掌握二叉树的各种操作了,二叉树对后面的更复杂的数据结构的学习做了一个铺垫,熟练的掌握二叉树的操作才能更好的学习之后的知识。
最后将本文章所有的完整的代码链接给出(包阔队列)二叉树