目录
一、树和森林的定义
1.树的定义:
一棵树Tree是由一个或一个以上结点组成的有限集,其中有一个特定的结点Root称为Tree的根结点。集合(Tree-{Root})中的其余结点可被划分为 个不相交的子集、、...、,其中每个子集都是树,并且其相应的根结点、、...、 是 Root 的子结点,子集 ()称为树Tree的子树(subtree),子结点 () 称为结点Root的出度
2.森林
(1)森林的定义:森林是零个或多个树的一个有序集合。
F ={ ={, ,...} ,,...,},其中是是第一棵树的根结点,{ ,...}是一棵树的子树森林,{ ,...,}是除去第一棵树之后的剩余树组成的森林。
对比 树、森林、二叉树:
一个树绝不会为空,即它至少有一个结点,并且树中的每个结点都会有0、1、2、....、n 个子结点
一个森林可以由0、1、2、..、n个树组成,一个树的任何结点下面的直接子树又可以形成一个森林
一棵二叉树可以是空的,它的每个结点可以由0、1或2个子结点,同时我们还要区分“左”子结点和“右”子结点
(2)森林与二叉树的转换关系:
对森林做如下的操作:
Step1 将森林中每一棵树的根结点连接起来
Step2 将每个结点的子结点之间连接起来
Step3 去掉每个结点与除最左子结点之外的其他子结点之间的连线
此时得到的树的每个结点均满足仅有0、1或2个子结点,即一棵二叉树。
因此,对森林和二叉树的转换做如下规定,即可使得任何二叉树都对应一个唯一的森林。
根结点加左子树对应第一棵树,右子树对应新的森林构成的树,以次递归定义:
设 F=( ,,......, )是树的一个森林,对应于F的二叉树 B(F) 的严格定义如下:
(1)如果 n = 0,则B(F)为空。
(2)如果 n ≠ 0,则B(F)的根是root();
B(F)的左子树是 B(,,......,),其中,,......,是T树的子树;
B(F)的右子树是 B(,,......,)。
此时,任何二叉树都对应一个唯一的森林。
二、树的实现
1.树结点的ADT
public interface GTNode {
Object value();
boolean isLeaf();
GTNode getParent();
GTNode getLeftMostChild();
GTNode getRightSibling();
void setValue(Object value);
void setParent(GTNode parent);
void insertFirst(GTNode first); //在该结点的最左边孩子处插入一个结点
void insertNext(GTNode next); //在该结点的右兄弟处插入一个结点
void removeFirst(); //删除该结点的第一个孩子结点
void removeNext(); //删除该结点的右兄弟结点
}
//结点包含的数据类型
public class GTNode implements GTNodeADT{
public Object element;
public GTNode parent;
public GTNode leftMostChild;
public GTNode rightSibling;
}
2.树的遍历
(概念同二叉树的遍历)
(1)前序遍历:
先访问根结点,再依次由左至右前序遍历每棵子树。
图中所示树的前序遍历顺序为:ABCFGDEH
代码实现:
public void preOrder(GTNode rt){
if(rt==null)
return;
visit(rt);
GTNode temp=rt.leftMostChild;
while(temp!=null){
preOrder(temp);
temp=temp.rightSibling;
}
}
注意!不存在中序遍历!
(2)后序遍历:
先由左至右后序遍历每棵子树然后再访问根结点。
图中所示树的后序遍历顺序为:BFGCDHEA
代码实现:
public void postOrder(GTNode rt){
if(rt==null)
return;
GTNode temp=rt.leftMostChild;
while(temp!=null){
postOrder(temp);
temp=temp.rightSibling;
}
visit(rt);
}
(3)层序遍历:
图中所示树的层序遍历顺序为:ABCDEFGH
代码实现:
public void levelOrderTraversal(){
LQueue q = new LQueue();
GTNode node = root;
if (node == null) return; //若是空树则直接返回
q.enqueue(node);
while (!q.isEmpty()) {
node = (GTNode) q.dequeue();
visit(node);
if(node.leftMostChild!=null){
q.enqueue(node.leftMostChild);
GTNode temp=node.leftMostChild;
while(temp.rightSibling!=null){
q.enqueue(temp.rightSibling);
temp=temp.rightSibling;
}
}
}
}
3.树的表示方法
树的表示方法有三种:父指针表示法、子结点表表示法、左子结点-右兄弟结点表示法(又称作儿子-兄弟法)。
(1)父指针表示法
每个结点只保存一个指针域指向其父结点。
适用于:等价类问题的处理(eg.后文中具体介绍的并查集)
缺点:对找到一个结点的最左子结点或右侧兄弟结点这样的重要操作是不够的
(2)子结点表表示法
每个结点储存一个线性表的指针,该线性表用来储存该结点的所有子结点
优势:寻找某个结点的子结点非常方便
缺点:寻找某个结点的兄弟结点则比较困难
(3)左子结点-右兄弟结点表示法(又称作儿子-兄弟法)
每个结点都存储结点的值、最左子结点的位置和右侧兄弟结点的位置
优势:ADT中规定的基本操作都可以较容易的实现
对于不确定子结点上限的结点node的表示较为繁琐,故多采用儿子-兄弟法。
三、森林的遍历
森林的遍历本质上为树的遍历(将树的根去掉之后,就成为了森林)。对于森林的深度优先遍历,是一棵树遍历完了再去遍历另一棵树。
1.深度优先 先根遍历
步骤:
Step1 判断森林是否为空:
若森林F=Φ,则返回;
否则访问森林的第一棵树的根。
Step2 按照上述规则,再先根遍历森林第一棵树的根的子树森林{T11 ,..., T1k};
Step3 按照上述规则,再先根遍历森林中除第一棵树外其他树组成的森林{T2,...,Tm}。注意:对于森林的遍历,一棵树全部遍历完才去遍历下一颗树
2.深度优先后根遍历
步骤:
Step1 判断森林是否为空:
若森林F=Φ,则返回;
否则后根遍历森林F中第一棵树的根结点的子树森林{,....,}
Step2 访问森林F第一棵树的根结点;
Step3 按照上述规则,再后根遍历森林中除第一棵树外其他树组成的森林{ ,,..., }。注意:对于森林的遍历,一棵树全部遍历完才去遍历下一颗树
3.广度优先遍历
步骤:
Step1 判断森林是否为空:
若森林 F=Φ,返回;
否则依次遍历各棵树的根结点;
Step2 依次遍历各棵树根结点的所有子女;
Step3 依次遍历这些子女结点的子女结点;
.......Step n 直至该层无结点,返回。
四、不相交集(并查集)
并查集是由一组互不相交的集合组成的一个集合结构,并在此集合上定义了运算Union和Find。每一个要处理的元素都仅仅属于一个集合。
用途:主要用来解决等价问题
集合存储:可以用树结构表示集合,树的每个结点代表一个集合元素。同一棵树(拥有同一根结点)上的结点是连通的。
并查集所需要完成的操作:1、把连通的集合并在一起;2、查找元素是否属于该集合。
表示方法:父指针表示法:孩子结点中存储的是父亲结点的引用(即孩子结点指向父亲结点)。
存储方式:数组存储:数组中存储的元素:data、parent。parent中的内容为父亲结点的下标(负数表示当前结点为根结点)
//结点的定义
public class Node {
public int parent; //父结点下标
public Object element; //存储元素内容
public Node() {
this(null, -1);
}
public Node(Object element) {
this(element, -1);
}
public Node(Object element, int parent) {
this.element = element;
this.parent = parent;
}
}
运算:
1、查找:
查找某个元素所在的集合(用根结点表示)
步骤:
Step1 在集合中找该元素是否存在,找到则退出循环。(未找到则退出时的 i == maxSize)
Step2 根据找到的元素中存储的父亲结点下标索引,一直找到根结点。
Step3 返回包含给定元素的集合名字(以根结点下标)
时间复杂度为(当前元素在该树中的层次+ 遍历数组找到当前元素的花费(平均为 n/2))
2、并运算:
将两个集合合并。
步骤:
Step1 分别找到两个元素所在集合的根结点
Step2 如果它们不同根,则将其中一个根结点的父结点指针设置成另一个根结点的数组下标。
时间复杂度为(两个查找的花费+1)。1为合并操作的复杂度。
public class UnionFindTree {
public Node[] set;
public int maxSize=0;
public UnionFindTree(Node[] set){
this.set=set;
maxSize=set.length;
}
/**
* 返回包含给定元素的集合名字
*
* @param element 元素值
* @return 以根结点下标作为集合名字
*/
public int find(Object element){
//在集合中查找值为element的元素所属的集合,以该结点的根结点下标作为集合名字
int i=0;
while(i<maxSize && set[i].element!=element){ //在集合中找该元素是否存在,找到则退出循环;未找到则退出时的i == maxSize
i++;
}
if(i>=maxSize)
return -1; //未找到,返回-1
while(set[i].parent>=0){
i=set[i].parent; //将下标变为父结点的下标
}
return i;
}
/**
* 生成一个新的集合,该集合是element1所属的集合set1和element2所属的集合set2的并集
*
* @param element1
* @param element2
*/
public void union(Object element1,Object element2 ){
int root1,root2;
root1=find(element1);
root2=find(element2);
if(root1!=root2){
set[root2].parent=root1;
}
}
}
通过计算两个操作的时间复杂度可以发现:创建的树深度约小,操作的执行效率越高。
因此,对于并运算做如下的优化,方法1:为了改善合并以后的查找性能,可以采用小的集合合并到相对大的集合中(重量权衡平衡原则)。
如何记录该集合的高度?
在结点结构单元中记录:只需要在根结点中记录即可,用-i表示该集合生成的树高度为i。
此时,查找操作的时间复杂度为O(log N),并运算的时间复杂度为O(log N)。
优化方法2:路径压缩:
在查找某个元素是否属于某个集合时,将该结点到根结点路径上所有结点的父指针全部改为指向根结点,这种方式可以产生极浅的树。
public int findOp(Object element){
//在集合中查找值为element的元素所属的集合,以该结点的根结点下标作为集合名字
int i=0;
while(i<maxSize && set[i].element!=element){ //在集合中找该元素是否存在,找到则退出循环;未找到则退出时的i == maxSize
i++;
}
if(i>=maxSize)
return -1; //未找到,返回-1
int temp=i;
while(set[i].parent>=0){
i=set[i].parent; //将下标变为父结点的下标
}
set[temp].parent=i;
return i;
}
结合重量权衡原则来合并集合,则查找操作的时间复杂度为O(log*N),其中log*N为Akerman函数的逆运算,表达式值=在结果小于或等于1之前必须迭代地应用对数函数log N的次数。由于log*n的增长速度极其缓慢,故可将其看作常数。