(数据结构与算法分析笔记])第四章 树

概述

对于大量的数据输入,链表的线性访问时间太慢,二叉查找树结构的数据大部分运行的平均时间为:$O(\log N)$,二叉查找树是两种库集合类TreeSet和TreeMap实现的基础。
树可以用几种方式定义,定义树的一种自然方式是递归的方式。一棵树是一些节点的集合。这个集合可以是空集,若不是空集,则树由称做根的节点r以及0个或多个非空的(子)树$T_1,T_2,T_3,…,T_k$组成,这些子树中的每一棵的根都被来自根r的一条有向的边所连接.
每一棵子树的根叫做根r的儿子(child),而r是每一棵子树的根的父亲(parent).

树的实现

实现树的一种方法可以是在每一个节点除数据外还有一些链,使得该节点的每一个儿子都有一个链指向它,代码如下:

1
2
3
4
5
class TreeNode{
Object element;
TreeNode firstChild;
TreeNode nextChild;
}

二叉树

二叉树是一棵树,其中每个节点都不能有多余两个儿子。二叉树的一个性质是一棵平均二叉树的深度要比节点个数N小得多,这个性质很重要。分析表明,其平均深度为$O(\sqrt[2]{N})$而对于特殊二叉树,即二叉查找树深度的平均值为$O(\log N)$
二叉树的结构代码如下:

1
2
3
4
5
class BinaryNode{
Object element;
BinaryNode left;
BinaryNode right;
}

查找树ADT —-二叉查找树

二叉树成为二叉查找树的性质是:对于树中的每一个节点X,它的左子树中所有的项小于X中的项,而它的右子树中的所有项的值大于X中的项。这意味着该树所有的元素可以用某种一致的方式排序。
二叉查找树要求所有的项能够排序,要写出一个一般的类,我们需要提供一个interface来表示这个性质,该接口就是Comparable,通过CompareTo方法进行比较。由此我们确定所有的关系,特别是我们不能使用equals方法,而是根据两项相等当且仅当compareTo方法返回0来判断,二叉树结构类如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class BinaryNode<T> {

T mT;
BinaryNode<T> mLeft;
BinaryNode<T> mRight;

public BinaryNode(T t){
this(t,null,null);
}

public BinaryNode(T t,BinaryNode<T> left,BinaryNode<T> right){
this.mT = t;
this.mLeft = left;
this.mRight = right;
}

}

二叉查找树架构如下代码所示,其中唯一的数据域是对根节点的引用,这个引用对于子空树来说为null。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
public class BinarySearchTree <T> {
private BinaryNode<T> mRoot;
private Comparator cmp;

public BinarySearchTree() {
this(null);
}

public BinarySearchTree(Comparator<? super T> c) {
mRoot = null;
cmp = c;
}

public void Empty() {
mRoot = null;
}

public boolean isEmpty() {
return mRoot == null;
}

public void printTree() {

}

private int compareTo(T elementA, T elementB) {
if (cmp != null) {
return cmp.compare(elementA, elementB);
}
return ((Comparable) elementA).compareTo(elementB);
}

private boolean contains(T t, BinaryNode<T> element) {
if (t == null) {
return false;
}

int compareResult = compareTo(t, element.mT);
if (compareResult > 0) {
return contains(t, element.mRight);
} else if (compareResult < 0) {
return contains(t, element.mLeft);
} else {
return true;
}
}

private BinaryNode<T> findMin(BinaryNode<T> element) {
if (element == null) {
return null;
}
if (element.mLeft == null) {
return element;
}
return findMin(element.mLeft);
}

private BinaryNode<T> findMax(BinaryNode<T> element) {
if (element == null) {
return null;
}

if (element.mRight == null) {
return element;
}

return findMax(element.mRight);
}

private BinaryNode<T> insert(T t, BinaryNode<T> element) {
if (element == null) {
return new BinaryNode<T>(t, null, null);
}
int compareResult = compareTo(t, element.mT);
if (compareResult > 0) {
return insert(t, element.mRight);
} else if (compareResult < 0) {
return insert(t, element.mLeft);
} else {

}

return element;
}

private BinaryNode<T> remove(T t, BinaryNode<T> element) {
if (t == null) {
return null;
}
int compareResult = compareTo(t, element.mT);
if (compareResult > 0) {
return remove(t, element.mRight);
} else if (compareResult < 0) {
return remove(t, element.mLeft);
} else if (element.mLeft != null && element.mRight != null) {
element.mT = findMin(element.mRight).mT;
element.mRight = remove(element.mT, element.mRight);
} else {
element = (element.mLeft != null) ? element.mLeft : element.mRight;
}

return element;
}

private void printTree(BinaryNode<T> element) {

}

补充说明:当需要排序的集合或数组不是单纯的数字型时,通常可以使用Comparator或Comparable,JDK声明如下:

1
2
3
4
5
6
7
public interface Comparator<T>{
int compare(T t1,T t2);
}

public interface Comparable<T>{
public int compareTo(T t);
}

AVL树

AVL树是带有平衡条件的二叉查找树,这个平衡条件必须保持,一棵AVL树是其每个节点的左子树和右子树的高度最多差1的二叉查找树(空树的高度定义为-1)。
当对AVL树进行插入操作时,我们需要更新通向根节点路劲上那些节点的所有平衡信息,而插入操作隐含着困难的原因在于:插入一个节点可能破坏AVL树的特性,发生这个情况,就需要考虑旋转使得AVL树再次平衡。,插入后不平衡可能出现在一下四种情况:
1.对节点X的左儿子的左子树进行一次插入
2.对节点X的左儿子的右子树进行一次插入
3.对节点X的右儿子的左子树进行一次插入
4.对节点X的右儿子的右子树进行一次插入
情形1和4的情况都是插入发生在”外边“,该情况通过对树的一次单旋转而完成调整,第二种情况是插入是发生在”内部“,需要通过较复杂的双旋转来处理。代码实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class AVLNode <T>{

T mT;
AVLNode<T> mLeft;
AVLNode<T> mRight;
int mHeight;

public AVLNode(T t){
this(t,null,null);
}

public AVLNode(T t,AVLNode<T> left,AVLNode<T> right){
mT = t;
mLeft = left;
mRight = right;
mHeight = 0;
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
private  AVLNode<T> insert(T t,AVLNode<T> element){
if(t == null){
return new AVLNode<T>(t,null,null);
}

int compareResult = compare(t,element.mT);
if(compareResult > 0){
element.mRight = insert(t,element.mRight);
if(height(element.mRight) - height(element.mLeft) == 2){
if( compare(t,element.mRight.mT) > 0 ){
element = rotateWithRightChild(element);
}else{
element = doubleWithRightChild(element);
}
}
}else if(compareResult < 0){
element.mLeft = insert(t,element.mLeft);
if(height(element.mLeft) - height(element.mRight) == 2){
if( compare(t,element.mLeft.mT) < 0 ){
element = rotateWithLefttChild(element);
}else{
element = doubleWithLeftChild(element);
}
}
}else{

}
element.height = Math.Max(height(element.mLeft),height(element.mRight))+1;
return element;
}

private int height(AVLNode<T> element){
return element == null ? -1 : element.height;
}

private AVLNode<T> rotateWithLeftChild(AVLNode<T> element){
AVLNode<T> element1 = element.mLft;
element.mLeft = element1.mRight;
element1.mRight = element;
element.height = Math.Max(height(element.mLeft),height(element.mRight)) + 1;
element1.height = Math.Max(height(element1.mLeft),height(element.height)) + 1;
return element1;
}

private AVLNode<T> doubleWithLeftChild(AVLNode<T> element){
element.mLeft = rotateWithRightChild(element.mLeft);
return rotateWithLeftChild(element);
}
伸展树Splay Tree

伸展树(Splay Tree)是一种二叉排序树,它能在O(log n)内完成插入、查找和删除操作。它由Daniel Sleator和Robert Tarjan创造。它的优势在于不需要记录用于平衡树的冗余信息。在伸展树上的一般操作都基于伸展操作。
为什么需要伸展树(Splay Tree)各种查找树存在不足。比如:对于一个有n个节点的平衡树,虽然最坏情况下每次查找的时间复杂度不会超过O(logn),但是如果访问模式不均匀,平衡树的效率就会受到影响。此外,它们还需要额外的空间来存储平衡信息。
这些查找树的设计目标都是减少最坏情况下单次操作时间,但是查找树的典型应用经常需要执行一系列的查找操作,此时更关心的性能指标是所有这些操作总共需要多少时间。对于此类应用,更好的目标就是降低操作的摊平时间,此处的摊平时间是指在一系列最坏情况的操作序列中单次操作的平均时间。获得摊平效率的一种方法就是使用“自调整”的数据结构。
什么是伸展树(Splay Tree)假设想要对一个二叉查找树执行一系列的查找操作。为了使整个查找时间更小,被查频率高的那些条目就应当经常处于靠近树根的位置。于是想到设计一个简单方法,在每次查找之后对树进行重构,把被查找的条目搬移到离树根近一些的地方。splay tree应运而生。splay tree是一种自调整形式的二叉查找树,它会沿着从某个节点到树根之间的路径,通过一系列的旋转把这个节点搬移到树根去。
伸展树的基本想法是:当一个节点被访问后,它就要经过一系列AVL树的旋转被推到根上。如果一个节点很深,那么在其路劲上就存在许多也相对较深的节点,通过重新构造可以减少对所有这些节点进一步访问所花费的时间。所以如果节点太深,那么我们需要重新构造应具有平衡这棵树的左右。
伸展树的一个简单的想法(不能直接使用):对上面描述的重新构造实行单旋转,从底向上进行。旋转效果是会把节点X一直推向树根,使得对节点X的进一步访问很容易,但这样也容易把其他的节点推向更深
伸展树的展开想法:思路类似上面介绍的旋转想法,但在旋转的选择上我们有余地,是单旋转还是双旋转,我们仍然从底部沿着访问路劲旋转,展开操作不仅把访问的节点移动到根处,而且还把访问路劲上的大部分节点的深度大致减少一半(某些浅的节点最多向下推后两层)。对伸展树的进一步学习将在后面继续提供~~~~

树的遍历

由于二叉查找树中对信息进行的排序,因而按照排序的顺序列出所有的项很简单。
前序遍历:首先访问根结点然后遍历左子树,最后遍历右子树
中序遍历:首先遍历左子树,然后访问根结点,最后遍历右子树
后序遍历:首先遍历左子树,然后遍历右子树,最后访问根结点
picture
按顺序前序遍历二叉查找树例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void printTree(){
if(isEmpty()){
System.out.println("Empty Tree");
}else{
printTree(mRoot);
}
}

private void printTree(BinaryNode<T> element){
if( element != null ){
printTree(element.mLeft);
System.out.println( element.mT);
printTree(element.mRight);
}
}

使用后序遍历二叉查找树高度的例子:

1
2
3
4
5
6
7
private int height( BinaryNode<T> element){
if(element == null){
return -1;
}

return 1 + Math.Max(height(element.mLeft),height(element.mRight));
}

B树

B树,M叉查找树,一棵M叉查找树可以有M路分支,随着分支的增加,输的深度在减少,一棵完全二叉树的高度大约为$\log_2 N$,而一棵完全M叉树的高度大约为$\log_M N$
阶为M的B树具有一下特性:
1.数据项存储在树叶上
2.非叶节点存储直到M-1个关键字以指示搜索的方向;关键字i代表子树i+1中的最小的关键字
3.树的根或者是一片树叶,或者其儿子树在2和M之间
4.除根外,所有的非树叶节点的儿子数在[M/2]和M之间
5.所有的树叶都在相同的深度上并有[L/2]和L之间个数据项
B树即二叉搜索树:
1.所有非叶子结点至多拥有两个儿子(Left和Right);
2.所有结点存储一个关键字;
3.非叶子结点的左指针指向小于其关键字的子树,右指针指向大于其关键字的子树;
如:
picture
B树的搜索,从根结点开始,如果查询的关键字与结点的关键字相等,那么就命中;否则,如果查询关键字比结点关键字小,就进入左儿子;如果比结点关键字大,就进入右儿子;如果左儿子或右儿子的指针为空,则报告找不到相应的关键字;如果B树的所有非叶子结点的左右子树的结点数目均保持差不多(平衡),那么B树的搜索性能逼近二分查找;但它比连续内存空间的二分查找的优点是,改变B树结构(插入与删除结点)不需要移动大段的内存数据,甚至通常是常数开销;
如:
picture
但B树在经过多次插入与删除后,有可能导致不同的结构:
picture
右边也是一个B树,但它的搜索性能已经是线性的了;同样的关键字集合有可能导致不同的树结构索引;所以,使用B树还要考虑尽可能让B树保持左图的结构,和避免右图的结构,也就是所谓的“平衡”问题;实际使用的B树都是在原B树的基础上加上平衡算法,即“平衡二叉树”;如何保持B树结点分布均匀的平衡算法是平衡二叉树的关键;平衡算法是一种在B树中插入和删除结点的策略;
B-树是一种多路搜索树(并不是二叉的):
1.定义任意非叶子结点最多只有M个儿子;且M>2;
2.根结点的儿子数为[2, M];
3.除根结点以外的非叶子结点的儿子数为[M/2, M];
4.每个结点存放至少M/2-1(取上整)和至多M-1个关键字;(至少2个关键字)
5.非叶子结点的关键字个数=指向儿子的指针个数-1;
6.非叶子结点的关键字:K[1], K[2], …, K[M-1];且K[i] < K[i+1];
7.非叶子结点的指针:P[1], P[2], …, P[M];其中P[1]指向关键字小于K[1]的子树,P[M]指向关键字大于K[M-1]的子树,其它P[i]指向关键字属于(K[i-1], K[i])的子树;
8.所有叶子结点位于同一层;
如:(M=3)
picture
B-树的搜索,从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点;B-树的特性:
1.关键字集合分布在整颗树中;
2.任何一个关键字出现且只出现在一个结点中;
3.搜索有可能在非叶子结点结束;
4.其搜索性能等价于在关键字全集内做一次二分查找;
5.自动层次控制;
由于限制了除根结点以外的非叶子结点,至少含有M/2个儿子,确保了结点的至少利用率,其最底搜索性能为:
picture
其中,M为设定的非叶子结点最多子树个数,N为关键字总数;所以B-树的性能总是等价于二分查找(与M值无关),也就没有B树平衡的问题;由于M/2的限制,在插入结点时,如果结点已满,需要将结点分裂为两个各占M/2的结点;删除结点时,需将两个不足M/2的兄弟结点合并;
B+树B+树是B-树的变体,也是一种多路搜索树:
1.其定义基本与B-树同,除了:
2.非叶子结点的子树指针与关键字个数相同;
3.非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树(B-树是开区间);
4.为所有叶子结点增加一个链指针;
5.所有关键字都在叶子结点出现;
如:(M=3)
picture
B+的搜索与B-树也基本相同,区别是B+树只有达到叶子结点才命中(B-树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找;
B+的特性:
1.所有关键字都出现在叶子结点的链表中(稠密索引),且链表中的关键字恰好是有序的;
2.不可能在非叶子结点命中;
3.非叶子结点相当于是叶子结点的索引(稀疏索引),叶子结点相当于是存储(关键字)数据的数据层;
4.更适合文件索引系统;
B*树是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针;
picture
B树定义了非叶子结点关键字个数至少为(2/3)M,即块的最低使用率为2/3(代替B+树的1/2);
B+树的分裂:当一个结点满时,分配一个新的结点,并将原结点中1/2的数据复制到新结点,最后在父结点中增加新结点的指针;B+树的分裂只影响原结点和父结点,而不会影响兄弟结点,所以它不需要指向兄弟的指针;
B树的分裂:当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父结点增加新结点的指针;所以,B树分配新结点的概率比B+树要低,空间使用率更高;

标准库中的集合与映射

List容器ArrayList和LinkedList用于查找的效率很低,所以Collection API提供了两个附加容器Set和Map,他们对于插入,删除,查找等基本操作提供有效的实现

1
2
3
4
5
6
7
8
9
10
11
Collection
├List
│├LinkedList
│├ArrayList
│└Vector
│ └Stack
└Set
Map
├Hashtable
├HashMap
└WeakHashMap

关于Set接口

Set接口代表不允许重复元的Collection。Set is a Collection。

1
2
3
public interface Set<E> extends Collection<E>{
...
}

实现类分别是:
hashSet通过hashcode和equal方法,用自己的关键字段来重写是否实例一致,因为当使用HashSet时,hashCode()方法就会得到调用,判断已经存储在集合中的对象的hash code值是否与增加的对象的hash code值一致;如果不一致,直接加进去;如果一致,再进行equals方法的比较,equals方法如果返回true,表示对象已经加进去了,就不会再增加新的对象,否则加进去。
TreeSetTreeSet使用元素的自然顺序对元素进行排序,或者根据创建 set 时提供的 Comparator 进行排序,具体取决于使用的构造方法,通俗一点讲,就是可以按照排序后的列表显示,也可以按照指定的规则排序

关于Map接口

Map接口代表关键字以及它们的值组成的一些项的集合。关键字必须是唯一的,但若干个关键字可以映射到一些相同的值,所以值不是唯一的。Map不提供迭代器。所以提供一下三种方法将Map对象的视图作为Collection对象返回,由于这些对象本身就是Collection,因此它们可以迭代:

1
2
3
Set<KeyType> keySet();
Collection<ValueType> values();
Set<Map.Entry<KeyType,ValueType>> entrySet();

Map.Entry对象包含的方法:

1
2
3
KeyType getKey();
ValueType getValue();
ValueType setValue(ValueType newValue);

TreeSet类和TreeMap类的实现

JDK中的TreeSet和TreeMap支持基本的add,remove,contains操作以对数最坏情况时间完成,所以基本的实现方法就是平衡二叉查找树。一般来书我们不使用AVL树,而是自顶向下的红黑树。

文章目录
  1. 1. 概述
  2. 2. 树的实现
  3. 3. 二叉树
  4. 4. 查找树ADT —-二叉查找树
  5. 5. AVL树
  6. 6. 伸展树Splay Tree
  7. 7. 树的遍历
  8. 8. B树
  9. 9. 标准库中的集合与映射
    1. 9.1. 关于Set接口
    2. 9.2. 关于Map接口
    3. 9.3. TreeSet类和TreeMap类的实现
,