现实生活当中,我们每个家庭都会有一个家谱,来罗列家庭成员的关系。例如父亲下面的分支里有儿子或者女儿,而父亲又属于祖父祖母的下部分支。其实这个家谱在计算机科学中映射的就是树形的表示方法。可见在很久以前,我们的祖先其实就已经有意识地使用树形结构来进行数据记录了。
如图1所示,即为一个通用树的示意图。我们可以看到A点作为一个主节点,这里我们称其为根。而B、C、D、E四个节点属于A节点的子节点,我们称这四个节点为A节点的“孩子”,而A节点为这四个节点的“双亲”。和我们的家谱类似,有相同双亲的结点互为“兄弟”,图中的B、C、D就是E节点的兄弟。另外由于B、C、D、E四个节点同样可以有孩子,所以这四个节点其实也可以称为子树的根。另外,所有子树上的任何结点都是该结点的后裔,例如,F、G、H、I等都是A节点的后裔。从根结点到某个结点的路径上的所有结点都是该结点的祖先,例如A、B均为F节点的祖先。另外,没有子节点的节点我们称之为“叶子”,如图中的F、G、H等均是叶子节点。在这里,我们称结点拥有的子树的数目为“度”,而树的层数我们称之为高度,如图1中的树高度为3.
树的遍历
对于所有的数据结构进行操作时,我们首先想到的都是对这个数据结构进行遍历。对于树的遍历一般分为三种遍历方法,分别是先序遍历、中序遍历以及后续遍历。这里的先后顺序均是参照于根节点来定义的。如先序遍历即先对根节点进行遍历,然后按照先左后右的顺序再依次进行先序遍历;中序遍历就是按照“左中右”的顺序来进行遍历操作,此时根节点在中间;后序遍历的操作是按照“左右中”顺序,此时可以发现根节点的遍历优先级已经放在了最后面。
图1中的树按照先序遍历的顺序遍历结果是ABFCGHDIJEK;按照中序遍历的结果是FBGCHAIDJEK;按照后续遍历的顺序,其遍历结果又变成了FBGHCIJDKEA。
由于树中每个节点都可以包含很多子树,所以对于包含很多子树的节点,我们进行表示的时候就会很复杂,所以在计算机科学中我们一般采用的是二叉树来进行数据存储管理。二叉树是最多有两个子树的树结构,通常子树被称作“左子树”(left subtree)和“右子树”(right subtree)。
二叉树一般包含5种形态,其具体表现形式如图2所示。第5种包含左右子树的二叉树,我们通常称之为满二叉树,这是一类高度为h,并且由2h-1个结点组成的二叉树。而第2、3以及第5种形态的二叉树也可以称之为完全二叉树,完全二叉树的是这么定义的:一棵二叉树中,只有最下面两层结点的度可以小于2,并且最下层的叶结点集中在靠左的若干位置上,这样的二叉树称为完全二叉树。
通过上面的举例说明,我们也可以看出二叉树的一个特点:一棵满二叉树必定是一棵完全二叉树,而完全二叉树未必是满二叉树。
接下来,我们将通过程序操作二叉树,来完成进一步的介绍。在数据结构中,二叉树的结构定义如下:
typedef struct BinaryTreeNode
{
int Element;
BinaryTreeNode * LeftChild;
BinaryTreeNode * RightChild;
}BinaryTreeNode;
创建二叉树
在对二叉树进行操作之前,我们首先需要创造一个二叉树。二叉树的创建,最简单的思路是借用递归的思想来进行节点的重复创建。我们的场景是需要输入一个二叉树的根节点,然后通过键盘来依次对二叉树的所有节点进行赋值定义。由于二叉树中存在层级的概念,所以我们需要定义一个特殊字符来区分当前这个节点是否应该存放数据。本例程中我们采用“#”来表示当前这个节点是空节点,创建二叉树的过程中代码检测到“#”就不对当前的节点进行赋值,该节点也就是叶子节点。
本例中,我们按照先序遍历的顺序来创建二叉树。假设我们输入的字符串为“AB#D##C##”,按照我们创建二叉树的思想对此字符串进行分析可知,第一个字母A为根节点,第二个字母B为左孩子节点。接下来我们需要赋值的是B的左孩子节点,但键盘输入的是“#”,则B的左孩子节点即为空。第四个字母D为B节点的右孩子节点,然后我们需要进一步赋值D的左孩子节点,但此时键盘连续来了两个“#”,即D节点的左右孩子节点均为空节点。此时就返回到了A节点,我们开始对A节点的右子树进行赋值,我们把C给A的右子树。但是C后面连续又跟了两个“#”,这也就是说C节点又是一个叶子节点。该字符串输入后所组成的二叉树如图3所示:
具体代码实现如下:
void CreateBiTree(BinaryTreeNode* T)
{
//按先序遍历的次序输入二叉树节点的值
char ch;
scanf("%c",&ch);
if(ch=='#')
{
T=NULL;
}
else
{
T = (BinaryTreeNode*)malloc(sizeof(BinaryTreeNode));
T->Element=ch;
CreateBiTree(T->LeftChild);
CreateBiTree(T->RightChild);
}
}
插入二叉树结点
二叉树的节点插入一般是针对二叉搜索树来实现的。二叉搜索树是一种特殊的二叉树,它或者是一棵空树,或者是具有下列性质的二叉树: 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;它的左、右子树也分别为二叉排序树。
假设我们当前存在一个二叉搜索树,如果我们想向该二叉树内插入一个对应的节点,根据二叉搜索树的性质,我们首先需要对待插入的节点进行数据比较,并且还需要在插入新的节点以后,该二叉树还是一颗二叉搜索树。
在具体操作过程中,我们需要遵循这么一个规律:将带插入数据与各节点比较,若待插入节点小于当前节点,则当前节点转移到其左孩子的节点处,这是因为二叉搜索树中左边的节点要比右边以及根节点都要小;当带插入节点比当前节点大时,同理需要将当前节点移到到右孩子节点处。具体的代码实现如下所示:
BinaryTreeNode* InsertNode(BinaryTreeNode *root,int data)
{
BinaryTreeNode *newnode;
BinaryTreeNode *current;
BinaryTreeNode *Pre;
newnode=(BinaryTreeNode*)malloc(sizeof(BinaryTreeNode *));
if(newnode==NULL)
{
printf("\动态分配内存出错.\n");
}
newnode->Element=data;
newnode->LeftChild=NULL;
newnode->RightChild=NULL;
if(root==NULL)
{
return newnode;
}
else
{
current=root;
while(current!=NULL)
{
Pre=current;
if(current->Element > data)
current=current->LeftChild;
else
current=current->RightChild;
}
if(Pre->Element > data)
Pre->LeftChild=newnode;
else
Pre->RightChild=newnode;
}
return root;
}
删除二叉树结点
二叉树中删除一个节点最重要的是要找到待删除结点以及它的父结点。因为我们在删除一个节点过程中,和链表的操作类似,都是要将该节点的左右孩子链接到其祖先节点下。如果待删除节点没有孩子节点,则只需要进行节点“销毁”即可;否则,均需要将其孩子节点让其双亲节点“接养”。我们将二叉树的节点删除分三类情况进行分析。
1、删除的结点为叶子结点,此时由于叶子节点并没有子节点,或者说左右节点都是NULL。所以在这种情况下,只需要把删除节点的父节点中对应的指针指向NULL即可。然后释放掉删除节点的空间;
2、删除的结点有左子树或者右子树,并且只有一个。这种情况下,我们只需要将待删除节点的父节点中对应的指针指向删除节点的子节点即可。然后释放掉删除节点的空间;
3、删除的结点左右子树都有,这是最复杂的情况 。待删除节点包含有两个子节点,这种情况下,必须要找到一个替代删除节点的替代节点,并且保证二叉树的排序性。根据二叉树的排序性,可知替代节点的键值必须最接近待删除节点键值。也就是比待删除节点键值小的所有键值中最大的那个,或者是,比待删除节点键值大的所有键值中最小的那个。这两个键值所在的节点分别在删除节点的左子树中最右边的节点,删除节点右子树中最左边的节点;其代码具体实现如下所示。
BinaryTreeNode* DeleteNode(BinaryTreeNode *root,int data)
{
BinaryTreeNode *newnode;
BinaryTreeNode *current;
BinaryTreeNode *Pre;
newnode=(BinaryTreeNode*)malloc(sizeof(BinaryTreeNode *));
if(newnode==NULL)
{
printf("\动态分配内存出错.\n");
}
newnode->Element=data;
newnode->LeftChild=NULL;
newnode->RightChild=NULL;
if(root==NULL)
{
return newnode;
}
else
{
current=root;
while(current!=NULL)
{
Pre=current;
if(current->Element > data)
current=current->LeftChild;
else
current=current->RightChild;
}
if(Pre->Element > data)
Pre->LeftChild=newnode;
else
Pre->RightChild=newnode;
}
return root;
}
二叉树遍历
二叉树的遍历属于最基本的操作,即按照一定的顺序,在不重复访问同一节点的情况下,对所有节点都访问一遍。遍历采用的思想主要可以分为递归和非递归遍历两大类。递归的思想就是在运行当前程序的过程中再次调用当前程序,从递归的主要思想也可以看出需要使用递归的方法首先需要子问题和原始问题类似并且需要有一个出口来结束递归。二叉树正好满足这两个要求,每一个节点都是类似的,均包含当前键值以及左右孩子节点;并且每一颗二叉树均有叶子节点,这也正满足遍历过程中跳出递归的要求。
另外,遍历需要按照一定的顺序,而前文也介绍过二叉树的遍历主要有三种顺序:先序遍历、中序遍历以及后序遍历。但不论遍历顺序如何,其遍历的结果都是对所有节点进行访问。所以,在代码实现的过程中,我们可以通过先序遍历来举例说明。
首先我们先介绍非递归的二叉树遍历。非递归的遍历方法对于给定的根节点,按照既定顺序进行节点访问。先序遍历中我们按照根节点、左孩子节点再到右孩子节点的顺序来实现遍历。代码设计过程中,在访问完当前的根节点之后,首先进入左子树节点的判定,如果左子树节点为空,才进入右子树,直至访问到所有叶子节点。以下即为先序遍历的代码。
bool PrevListBinaryTree(BinaryTreeNode * root)
{
BinaryTreeNode *Current = root;
while(Current)
{
printf("The Element on this node is %d.\n",Current->Element);
if(Current->LeftChild != NULL)
{
Current = Current->LeftChild;
}
else
{
Current = Current->RightChild;
}
}
return OK;
}
当我们借用递归的思想来实现这个先序遍历时,工作量便大大缩减。递归遍历的思想就是将左右子树分别看成单独的树,再次进行先序遍历,这样依次进行,直至遍历完所有叶子节点。其代码实现如下:
bool PrevListBinaryTree(BinaryTreeNode * root)
{
if(root!= NULL)
{
printf("The Element on this node is %d.\n",root->Element);
PrevListBinaryTree(root->LeftChild);
PrevListBinaryTree(root->RightChild);
}
return OK;
}
参考文献
https://segmentfault.com/a/1190000008850005
https://blog.csdn.net/zhefutianxia/article/details/78702445
https://blog.csdn.net/xiaoquantouer/article/details/65631708
http://blog.51cto.com/9291927/2083190
https://baike.baidu.com/item/%E4%BA%8C%E5%8F%89%E6%A0%91%E9%81%8D%E5%8E%86/9796049