具体内容:
该文章的源代码仓库为:
- java:https://github.com/MeteorCh/DataStructure/tree/master/Java/DataStructure/src/BinaryTree
- C++:https://github.com/MeteorCh/DataStructure/tree/master/C%2B%2B/DataStructure/DataStructure/BinaryTree
二叉树的主要应用在后面要介绍的搜索树、赫夫曼编码等领域。本文先介绍二叉树的基本定义及存储结构。
一、二叉树定义
- 1.二叉树定义: 二叉树是每个结点最多有两个子树的树结构,我这里不啰嗦。
- 2.满二叉树: 所有的中间节点都有两个孩子,所有的叶子节点都在同一层(最下一层),如下:
- 3.完全二叉树: 对一棵具有n个节点的二叉树按层序编号,如果编号为i(1≤i≤n)的节点与同样深度的满二叉树中编号为i的结点在二叉树中位置完全相同,这样的树称为完全二叉树。如下,满二叉树是完全二叉树,完全二叉树不一定是满二叉树。
二、二叉树的存储结构
1.顺序存储
在上面的完全二叉树中,给结点编号过,用顺序存储二叉树,就是按照这个编号将节点存储到数组中编号所在的索引处。如下面的二叉树:
将其存储到数组中,如下:
对于完全二叉树,此种存储方式没什么问题,但对于普通的二叉树,此种方式会造成严重的空间浪费。故几乎没有人采用此种方式存储普通二叉树。
2.链式存储
这种是我们最常见的存储方式,其每个节点有三个数据:数据域,左孩子指针域,右孩子指针域。节点结果如下所示:
一棵二叉树用链式存储表达如下:
二叉树的操作
针对链式存储的二叉树,递归是最为常见的操作,一定要理解递归。二叉树有以下操作:
1.二叉树的遍历
二叉树的遍历有四种方式:
- (1) 前序遍历: 先访问根节点,再前序遍历左子树,最后前序遍历右子树。这种定义就是一种递归的定义,因为左右子树又是二叉树,对其再前序遍历,也是先访问根节点,再前序访问子树的左子树,前序访问子树的右子树。
- (2)中序遍历: 先中序访问左子树,再访问根节点,再中序访问右子树。
- (3)后序遍历: 先后序访问左子树,再后序访问右子树,再访问根节点。
- (4)层次遍历: 根据名字就可以看出来,层次遍历就是从左到右,一层一层地遍历二叉树。
上述四种遍历方法中,前三种用递归很好实现。以前序遍历为例,遍历的伪代码为:
void preTraverse(Node node){
print(node.data);
preTraverse(node.lChild);
preTraverse(node.rChild);
}
中序和后续遍历就是将==print(node.data)==这句代码,放到中间和最后。自己动手跑一遍应很容易理解。
层次遍历则需要借助两个队列来实现,我们来看下面的一颗二叉树:
它层次遍历输出的结果应该为:9,5,13,2,6,11,17。那怎么实现层次遍历呢,我们用两个队列queue1、queue2加两个循环来实现。具体流程是先将根节点(第1层进queue1),然后循环弹出队首元素,弹出时,如果弹出元素的孩子不为空,则将他们的孩子压入queue2,。queue1中的元素弹完后,将queue1和queue2交换。我们画个流程图来帮助理解吧:
上面的流程图应该很好理解了,具体的代码见下面的代码实现。
2.二叉树的高度
求高度仍然用递归来实现,对于一个根节点root,它左半部分树的高度为lHeight=1+getHeight(root.lChild),右半部分树的高度为rHeight=1+getHeight(root.rChild),lHeight和rHeight中哪个大,二叉树的高度就为哪个。具体求解看下面的代码。
3.二叉树中节点个数
求二叉树中有多少个节点,其实和求解二叉树高度很像,都是利用递归求左子树和右子树中的节点个数,总节点数就是左子树中的节点加右子树中的节点再加上根节点。
三、线索二叉树
用链式结构存储二叉树,存在很大的浪费,n个节点的二叉树,总的指针域有2n个,连接线有n-1条,说明有n-1个指针域是用到的,那就有n+1个指针域是没用到的,浪费的空间数竟然比使用的还多!不可忍,所以就有了线索二叉树的结构。
1.定义
在传统二叉树的基础上,利用空的指针域,当一个节点的左孩子为空时,让其指向其前驱节点;当节点的右孩子为空时,让其指向后继节点。 那这个前驱和后继是通过什么来判断的呢?二叉树的遍历,前序、中序、后续遍历的顺序均可,但层次遍历的不行,原因在后面线索二叉树的遍历中说。
将原先的空指针利用起来后,会存在一个问题:我们怎么知道一个节点的孩子指向的是它真正的孩子还是老王的孩子?hhh,开个玩笑,我们需要知道指针域指向的是真正的孩子还是线索节点,那我们就需要左右两边都需要一个bool型的标志变量来标志。线索二叉树的节点的结构应为:
其中,ltag和rtag就是上述的标志变量。
2.创建
线索二叉树的创建必须是在树建立好以后才创建的,其创建的过程其实就是一个遍历二叉树的过程,在遍历的过程中需要使用一个全局变量每次将前驱节点记录下来。具体的创建过程和递归遍历的结果很类似,我这里就不赘述了,具体看下面的java代码。创建好的线索二叉树应该如下图所示(下图是中序遍历创建的线索二叉树):
线索二叉树,其实是把二叉树变成了一个双向链表。而且二叉树的遍历过程,就是将二叉树线性化的过程
3.遍历
有了线索二叉树,遍历其实就会变得非常简单,以中序线索化二叉树为例。对于一个节点,我们实现两个函数:
- (1)first函数:对于一个节点(其实是以该节点为根的一棵子树),如果它有左子树,那我们返回以该节点为根的二叉树中左下角的节点,即以该节点为根的二叉树中的第一个元素。
- (2)getNextNode函数:对于一个节点,如果他有右子树,则通过first函数返回右子树中的第一个需要访问的节点;如果没有,则返回该节点的右孩子(此处其实是该线索中该节点中的后继)。通过该函数,就可以找到任意一个节点的后继。
遍历开始,我们通过first函数找到整个二叉树中的第一个节点,即整个二叉树中最左下角的节点。然后从该节点开始,每次先输出该节点,再通过next函数,找到该节点的后继。然后不断重复,直到最后输出。具体看下面的Java实现代码。
四、实现
我这里,通过C++实现普通的二叉树,用Java实现线索二叉树(hhh,太懒了,这样可以可以节约时间)。具体代码如下:
C++实现普通的链式二叉树:
template<class T>
class LinkBinaryTree
{
struct Node
{
T data;
Node* lChild;
Node* rChild;
Node(T data)
{
this->data = data;
lChild = rChild = NULL;
}
};
protected:
Node* root;
Node* createStrNode(LinkQueue<std::string>* datas,bool isInput = false)
{
std::string inputStr;
if (isInput)
{
std::cout << "请输入数据(#结束):";
std::getline(std::cin, inputStr);
}
else
{
inputStr = datas->deQueue();
}
Node *node;
if (inputStr == "#")
return NULL;
else
{
node = new Node(inputStr);
node->lChild = createStrNode(datas,isInput);
node->rChild = createStrNode(datas,isInput);
return node;
}
}
void preTraverse(Node* node)//前序遍历
{
if (node)
{
std::cout << node->data << ",";
preTraverse(node->lChild);
preTraverse(node->rChild);
}
}
void midTraverse(Node* node)//中序遍历
{
if (node)
{
midTraverse(node->lChild);
std::cout << node->data << ",";
midTraverse(node->rChild);
}
}
void postTraverse(Node* node)//后续遍历
{
if (node)
{
postTraverse(node->lChild);
postTraverse(node->rChild);
std::cout << node->data << ",";
}
}
void levelTraverse()//层次遍历
{
LinkQueue<Node*>* queue1=new LinkQueue<Node*>();
LinkQueue<Node*>* queue2 = new LinkQueue<Node*>();
if (root)
{
queue1->enQueue(root);
while (!queue1->isEmpty())
{
do
{
Node* node = queue1->deQueue