@为什么要有树结构?
线性表是一对一的线性结构,可现实中很多一对多的情况需要处理,所以我们需要研究这种一对多的数据结构--->树。
@基础概念
1,树(Tree)是n(n>=0)个节点的有限集。
n=0时称为空树。
在任意一棵非空树中:(1)有且仅有一个特定的称为根(Root)的节点;(2)当n>1时,其余节点可分为m(m>0)个互不相交的有限集T1,T2...,Tm,其中每一个集合本身又是一颗树,并且称为根的子树(SubTree)。
2,树的定义(创建),其实就是我们在讲解栈时,提到的递归的方法。具体定义方法可以看下边实例代码。
3,二叉树是树的一种。
@二叉树的存储结构(物理结构):
---反映成分数据在计算机内部的存储安排。
二叉树的存储可分为两种:顺序存储结构和链式存储结构。
1. 顺序存储结构
把一个满二叉树自上而下、从左到右顺序编号,依次存放在数组内,可得到图6.8(a)所示的结果。设满二叉树结点在数组中的索引号为i,那么有如下性质。
(1) 如果i = 0,此结点为根结点,无双亲。
(2) 如果i > 0,则其双亲结点为(i -1) / 2 。(注意,这里的除法是整除,结果中的小数部分会被舍弃。)例如6.8图中,(5-1)/2=2,(6-1)/2=2,符合这个公式,即数组索引号减1除以2,即父节点索引号。
----注意,没有母节点。只有父节点。
(3) 结点i的左孩子为2i + 1,右孩子为2i + 2。例如:2的左孩子为5,2的右孩子为6。
(4) 如果i > 0,当i为奇数时,它是双亲结点的左孩子,它的兄弟为i + 1;当i为偶数时,它是双新结点的右孩子,它的兄弟结点为i – 1。
(5) 深度为k的满二叉树需要长度为2k-1的数组进行存储。例如图6.8中,深度为3,那么数组长度为2^3-1=7。(注意是满二叉树。)
通过以上性质可知,使用数组存放满二叉树的各结点非常方便,可以根据一个结点的索引号很容易地推算出它的双亲、孩子、兄弟等结点的编号,从而对这些结点进行访问,这是一种存储二叉满二叉树或完全二叉树的最简单、最省空间的做法。
为了用结点在数组中的位置反映出结点之间的逻辑关系,存储一般二叉树时,只需要将数组中空结点所对应的位置设为空即可,其效果如图6.8(b)所示。这会造成一定的空间浪费,但如果空结点的数量不是很多,这些浪费可以忽略。
一个深度为k的二叉树需要2k-1个存储空间,当k值很大并且二叉树的空结点很多时,最坏的情况是每层只有一个结点,再使用顺序存储结构来存储显然会造成极大地浪费,这时就应该使用链式存储结构来存储二叉树中的数据。
2. 链式存储结构
二叉树的链式存储结构可分为二叉链表和三叉链表。二叉链表中,每个结点除了存储本身的数据外,还应该设置两个指针域left和right,它们分别指向左孩子和右孩子(如图6.9(a)所示)。
当需要在二叉树中经常寻找某结点的双亲,每个结点还可以加一个指向双亲的指针域parent,如图6.9(b)所示,这就是三叉链表。
图6.10所示的是二叉链表和三叉链表的存储结构,其中虚线箭头表示parent指针所指方向。
二叉树还有一种叫双亲链表的存储结构,它只存储结点的双亲信息而不存储孩子信息,由于二叉树是一种有序树,一个结点的两个孩子有左右之 分,因此结点中除了存放双亲信息外,还必须指明这个结点是左孩子还是右孩子。由于结点不存放孩子信息,无法通过头指针出发遍历所有结点,因此需要借助数组来存放结点信息。图6.10(a)所示的二叉树使用双亲链表进行存储将得到图6.11所示的结果。由于根节点没有双亲,所以它的parent指针的值设为-1。
双亲链表中元素存放的顺序是根据结点的添加顺序来决定的,也就是说把各个元素的存放位置进行调换不会影响结点的逻辑结构。由图6.11可知,双亲链表在物理上是一种顺序存储结构。
二叉树存在多种存储结构,选用何种方法进行存储主要依赖于对二叉树进行什么操作来确定。而二叉链表是二叉树最常用的存储结构,下面几节给出的有关二叉树的算法大多基于二叉链表存储结构。
3,二叉树遍历(Traversal)就是按某种顺序对树中每个结点访问且只能访问一次的过程。访问的含义很广,如查询、计算、修改、输出结点的值。树遍历本质上是将非线性结构线性化,它是二叉树各种运算和操作的实现基础,需要高度重视。
我们是用递归的方法来定义二叉树的。每棵二叉树由结点(D)、左子树(L)、右子树(R)这三个基本部分组成,如果遍历了这三部分,也就遍历了整个二叉树。如图6.12所示,D为二叉树中某一结点,L、R分别为结点D的左、右子树,则其遍历方式有6种:
* 先左后右,L在前(共3种遍历算法) * 先右后左,R在前(共3种遍历算法)
先序 DLR DRL ----子树在后边,节点在前(前序)(先序)
中序 LDR RDL ----子树在两边,结点在中(中序)
后序 LRD RLD ----子树在前,节点在后(后序)
这里只讨论先左后右的三种遍历算法(即L在前的3种算法)。
如图6.13所示,在沿着箭头方向所指的路径对二叉树进行遍历时,每个节点会在这条搜索路径上会出现三次,而访问操作只能进行一次,这时就需要决定在搜索路径上第几次出现的结点进行访问操作,由此就引出了三种不同的遍历算法。
(1). 先序遍历
若二叉树为非空,则过程为:
(1) 访问根节点。
(2) 先序遍历左子树。
(3) 先序遍历右子树。
图6.13中,先序遍历就是把标号为(1)的结点按搜索路径访问的先后次序连接起来,得出结果为:ABDECF。A是根,B是左,D是左,E是右,C是右,F是右。先根,后左,再右。
(2). 中序遍历
若二叉树为非空,则过程为:
(1) 按中序遍历左子树。
(2) 访问根结点。
(3) 按中序遍历右子树。
图6.13中,先序遍历就是把标号为(2)的结点按搜索路径访问的先后次序连接起来,得出结果为:DBEACF。先左子树DBE(D也是左,B是节点,E是右),然后根结点A,CF是右子树(左为空,C是节点,F是右子树)。
(3). 后序遍历
若二叉树为非空,则过程为:
(1) 按后序遍历左子树。
(2) 按后序遍历右子树
(3) 访问根结点。
图6.13中,先序遍历就是把标号为(3)的结点按搜索路径访问的先后次序连接起来,得出结果为:DEBFCA。先左右,后根(DE是左右,B是根,左为空F为右,C为根,A为根。)。
4,实例代码(C#实现):
/// <summary>
/// 二叉树节点类
/// Node类专门用于表示二叉树中的一个节点,它和简单,只有三个属性:
/// Data表示结点中的数据;
/// Left表示这个结点的左孩子,它是Node类型;
/// Right表示这个结点的右孩子,它也是Node类型。
/// </summary>
public class Node
{
//成员变量
private object _data; //节点数据
private Node _left; //左孩子
private Node _right; //右孩子
public object Data //object表示C#中任意对象
{
get{ return _data; }
}
public Node Left //左孩子
{
get{ return _left;}
set{ _left=value;}
}
public Node Right //右孩子
{
get{ return _right;}
set{ _right=value;}
}
//构造方法
public Node(object data)
{
_data=data;
}
//重写ToString方法,当该类对象调用ToString方法时,返回节点数据
public override string ToString()
{
return _data.ToString();
}
}
/// <summary>
/// BinaryTree是一个二叉树集合类,它属于二叉链表。new一个BinaryTree对象,就是一个二叉树。
/// 二叉链表实际存储的信息只有一个头结点指针(Head),由于是链式存储结构,可以由Head指针出发遍历整个二叉树。
/// 为了便于测试及添加结点,假设BinaryTree类中存放的数据是字符类型,声明了一个字符串类型成员cStr,
/// 它用于存放结点中所有的字符。字符串由满二叉树的方式进行构造,空结点用‘#’号表示。
/// </summary>
public class BinaryTree
{
//成员变量
private Node _head; //头指针
private string cStr; //用于构造二叉树的字符串(将这个字符串按照索引号转换为一个二叉树)
public Node Head //头指针
{
get { return _head; }
}
//构造方法:传入一个构造字符串,并在Add()方法中根据这个字符串来构造二叉树中相应的结点。需要注意,这个构造方法只用于测试。
public BinaryTree(string constructStr)
{
cStr=constructStr;
_head=new Node(cStr[0]); //添加头结点
Add(_head,0); //给头结点添加孩子结点
}
/// <summary>
/// 添加节点方法:它的第一个参数parent表示需要添加孩子结点的双亲结点,
/// 第二个参数index表示这个双亲结点的编号(编号表示使用顺序存储结构时它在数组中的索引)。
/// 添加孩子结点的方法是先计算孩子结点的编号,然后通过这个编号在cStr中取出相应的字符,
/// 并构造新的孩子结点用于存放这个字符,接下来递归调用Add()方法给孩子结点添加它们的孩子结点。注意,这个方法只用于测试。
/// </summary>
/// <param name="parent"></param>
/// <param name="index"></param>
private void Add(Node parent,int index)
{
//例如添加字符串ABCDE#F
int leftIndex=2*index+1; //计算左孩子索引,例如第一次添加_head头结点,index索引为0,这里左孩子索引号就是1,即B
//如果不超出字符串索引,或者不为#,就一直递归添加
if(leftIndex<cStr.Length) //如果索引没有超过字符串长度,添加左孩子,根据上边索引号添加
{
if(cStr[leftIndex]!='#') //‘#’表示空节点,如果不为空,就添加
{
//初始化左孩子(new),调用Node类
parent.Left=new Node(cStr[leftIndex]);
//递归调用Add方法给左孩子添加孩子结点,注意这个方法就是Add方法。
Add(parent.Left,leftIndex); //这里leftIndex为1,递归调用该方法时,左孩子为3(即D),右孩子为4(即E),如果D或E有孩子,会继续递归添加孩子,否则不再添加
}
}
int rightIndex=2*index+2; //计算右孩子索引,例如第一次添加_head头结点,index索引为0,这里右孩子索引号就是2,即C
if(rightIndex<cStr.Length)
{
if(cStr[rightIndex]!='#')
{
//初始化右孩子(new),调用Node类
parent.Right=new Node(cStr[rightIndex]);
//递归调用Add方法给右孩子添加孩子结点
Add(parent.Right,rightIndex); //这里rightIndex为2,递归调用该方法时,左孩子5为空,右孩子为6(即F),如果F有孩子 ,会继续递归添加,否则不再添加
}
}
}
/// <summary>
/// 先序遍历:它的代码跟之前所讲解的先序遍历过程完全一样。
/// </summary>
/// <param name="node"></param>
public void PreOrder(Node node)
{
if(node!=null)
{
Console.Write(node.ToString()); //打印字符,注意这里在Node类里重写了ToString方法,否则不能这样转换。
PreOrder(node.Left); //递归
PreOrder(node.Right); //递归
}
}
/// <summary>
/// 中序遍历
/// </summary>
/// <param name="node"></param>
public void MidOrder(Node node)
{
if (node != null)
{
MidOrder(node.Left); //递归
Console.Write(node.ToString()); //打印字符
MidOrder(node.Right); //递归
}
}
/// <summary>
/// 后序遍历
/// </summary>
/// <param name="node"></param>
public void AfterOrder(Node node)
{
if (node != null)
{
AfterOrder(node.Left); //递归
AfterOrder(node.Right); //递归
Console.Write(node.ToString()); //打印字符
}
}
//以上三个方法都使用了递归来完成遍历,这符合二叉树的定义。
/// <summary>
/// 宽度优先遍历:不再需要使用递归,但需要借助队列来完成
/// --->先搜索所有兄弟和堂兄弟结点再搜索子孙结点
/// 队列是先入先出
/// </summary>
public void LevelOrder()
{
Queue<Node> queue=new Queue<Node>(); //声明一个队列
queue.Enqueue(_head); //把根结点压入队列---进队
Console.WriteLine("开始宽度优先遍历!");
Console.WriteLine("该树的头结点="+_head.ToString());
int i=1;
while(queue.Count>0) //只要队列不为空
{
Node node=(Node)queue.Dequeue(); //出队
Console.WriteLine("第"+i+"次循环,"+node.ToString()); //访问结点
/*
第一次循环,B和C入队列
第二次循环,B出列,D和E入列
第三次循环,C出列,F入列
第四次循环,D出列,D的左右孩子为空,没有对象入列
第五次循环,E出列,同上
第六次循环,F出列,同上
*/
if(node.Left!=null) //如果节点左孩子不为空
{
//把左孩子压入队列
queue.Enqueue(node.Left);
}
if(node.Right!=null) //如果结点右孩子不为空
{
//把右孩子压入队列
queue.Enqueue(node.Right);
}
i++;
}
}
}
---主函数中创建二叉树,然后深度优先遍历和宽度优先遍历。
//使用字符串构造二叉树
BinaryTree bTree=new BinaryTree("ABCDE#F");
//二叉树的深度优先遍历
//--->深度优先遍历的***搜索路径***是首先搜索一个结点的所有子孙结点,再搜索这个结点的兄弟结点。
bTree.PreOrder(bTree.Head); //先序遍历
Console.ReadKey();
bTree.MidOrder(bTree.Head); //中序遍历
Console.ReadKey();
bTree.AfterOrder(bTree.Head); //后序遍历
Console.ReadKey();
//运行结果:
//ABDECF
//DBEACF
//DEBFCA
//二叉树的宽度优先遍历
//--->先搜索所有兄弟和堂兄弟结点再搜索子孙结点
/*
***这个搜索过程不再需要使用递归,但需要借助队列来完成。***
(1) 将根结点压入队列之中,开始执行步骤(2)。
(2) 若队列为空,则结束遍历操作,否则取队头结点D。
(3) 若结点D的左孩子结点存在,则将其左孩子结点压入队列。
(4) 若结点D的右孩子结点存在,则将其右孩子结点压入队列,并重复步骤(2)。
*/
bTree.LevelOrder();
Console.ReadKey();
//运行结果
/*
开始宽度优先遍历!
该树的头结点=A
第1次循环,A
第2次循环,B
第3次循环,C
第4次循环,D
第5次循环,E
第6次循环,F
*/
@总结:
1,二叉树的结构和原理。
2,二叉树创建
3,二叉树深度优先遍历(前序,中序,后序)和宽度优先遍历。
4,C#中队列的使用。