未验证 提交 af1cb429 编写于 作者: 飞龙 提交者: GitHub

Merge pull request #6 from biubiubiuboomboomboom/master

第4、5章翻译
# 第四章 序列和树的实现
在第二和第三章我们看见了相当一部分关于List接口以及框架的实现。
现在,我们回顾一下这些接口的标准化表示(具体实现)并观察一些特殊队列的接口及其实现。
## 4.1 用数组实现List接口
很多“生产式”的程序语言都内置了一些数据结构,比如Java的数组 --- 一种以整数作为索引的、随机访问变量的队列。数组这种数据结构有两个主要的性能优势。
首先,它是一种紧凑的(节省空间的)变量序列,通常只占用比组成变量本身更小的空间。其次,在访问序列中的变量时,随机访问的速度非常快,时间复杂度只有常数级别。其主要的缺点是在改变序列大小时,速度会变的很慢(在最坏的情况下)。不过,只要稍微注意一下,我们就能发现其实最后分摊在数组支持的列表上的操作开销都是固定的。
一种Java内置的数组类型是 java.util.ArrayList (部分的实现如图4.11 所示)。你可以使用他的构造函数去创建一个新的 ArrayList 并且选择它的初始大小。然后你可以使用add方法去添加对象,并将根据你的需要去扩张数组。我能对关于 ArrayList 的操作代价说什么呢?显然,读取和扩容的复杂度都是 θ(1);有趣的一点是,如你所见, ArrayList 的容量永远是正的。add方法在数组需要根据数据进行展开时会调用 ensureCapacity 方法实现,使得 ArrayList 的容量在需要扩张时加倍。让我们一起来看看选择这种设计背后的原因吧。我们只考虑在调用 A.add(x) 即 A.add(A.size(),x) 的情况。
假设我们替换掉原有的长度
```
if (count + 1 > data.length)
ensureCapacity (data.length * 2);
```
并选择最小的扩张
```
ensureCapacity (count+1);
```
这种情况下,当初始的数组容量用完时,每个 add 操作都将展开数组的数据。
让我们来测量向数组元素添加多个值的开销。在Java中,我们可以用新对象[K]来代替Θ(K)来表示开销。当我们为了增加开销而将前一个数组元素复制到现有数组中时,(使用System.arraycopy),这也不会改变。因此,在最坏情况下的开销 Ci 取决于 A.add(x) ,我们可以用一个简单的增量表达式来概括:
Ci(K,M)={ α1, if M>k; α2(K+1),if K=M , 上式中K的值为A.size(), M >= K,其表示的是 A 当前的数据量(即 A.data.length) 且 αi 是一个恒定的量。
所以,我们可以保守的说 C(K, M, I)∈Θ(K).
现在让我们考虑实现的成本Cd,如图4.1所示,当必须增加容量时,我们总是将容量加倍。即 Cd(K, M) ={α1,ifM > K;α3(2K+ 1),ifM=K 。 最坏情况下的成本看起来是一样的;大小增加2的因子只是改变了常数因子,我们仍然可以使用与之前相同的公式:Cd(K, M)∈O(K)。
所以从这个最坏情况的角度来看,似乎这两个策略有相同的成本。然而,我们应该保持怀疑。我们采用每次扩容大小只加1的策略。应该考虑的是整个序列的add操作,而不是仅仅考虑单个的操作情况。相比之下,使用双倍大小策略,随着数组的增长,我们的扩展越来越少,因此大多数添加调用都在固定的时间内完成。那么,把它们描述成时间与k成正比真的准确吗?
考虑对a .add(x)的一系列N个调用,从一个初始容量为M0< N的空ArrayList开始采用按1递增的策略调用号M0(从0开始编号)、M0+1、M0+2...的花费分别与M0+1、M0+2的时间成比例 因此从初始大小为M0的空列表开始的N个> M0操作的总成本Cincr为 Cincr∈Θ(M0+M0+ 1 +. . .+N)= Θ((N+M0)·N/2)= Θ(N2) 其中M0为固定值。
换句话说,成本在增加的项目数量上是二次的。
现在考虑加倍策略。我们将分析其使用的潜在函数,从第1章第4小节中展示的数据中选择一个常数作为 ai,取一个花费为均值的操作 i,再找一个合适的潜在的 Φ ,Φ>0。则有: ai=ci+ Φi+1−Φi , 其中 ci 表示 第 ith 个加法操作的实际开销。 在这种情况下,一个潜在的表达式是 Φi= 4i−2Si+ 2S0 ,Si 表示的是在第 ith 个操作之前数组的容量。在第一次扩容后,我们总是能得到 2i >= Si ,所以对于所有的 i 总有 Φi≥0。
我们可以取数组中第i个加法之前的项数为i,假设我们从0开始进行加法。第i次加法的实际成本ci,如果i < Si,则为1个时间单位,否则(当i=Si时)分配一个多倍数组、复制所有现有项,然后再添加一个的成本,我们可以将其作为2Si时间单位(当然要选择合适的“时间单位”)。因此,在当 i < Si 时, 我们得到:
ai=ci+ Φi+1−Φi= 1 + 4(i+ 1)−2Si+1+ 2S0−(4i−2Si+ 2S0)= 1 + 4(i+ 1)−2Si+ 2S0−(4i−2Si+ 2S0)= 4
在 i =Si 时 我们可以得到 :
ai=ci+ Φi+1−Φi= 2Si+ 4(i+ 1)−2Si+1+ 2S0−(4i−2Si+ 2S0)= 2Si+ 4(i+ 1)−4Si+ 2S0−(4i−2Si+ 2S0)= 4
所以ai= 4,证明在加倍策略下数组末尾相加的平摊代价确实是常数。
## 4.2 链表结构
“链式结构”一词通常指的是组合的、动态增长的数据结构,其中包含用于通过指针(链接)连接在一起的各个成员的小对象。
### 4.2.1 单向链表
抽象语言有一个普遍存在的复合数据结构,即序对(pair)或首尾结构(cons cell),它可以表示任何可以想象的数据结构。也许它最常见的用途是表示事物列表,如图4.2a所示。每一对由两个容器组成,其中一个容器用于存储指向数据项的指针,另一个容器用于存储指向列表中下一对的指针,即末尾的空指针。在Java中,一个大致相当于pair的类如下:
```
class Entry{
Entry (Object head , Entry next){
this.head = head;
this.next = next;
}
Object head;
Entry next;
}
```
我们调用由这样一对单链结形成的列表,因为每一对都携带一个指向另一对的指针(链接)。
改变链表(它的一组容器)的结构涉及到俗称的“指针摆动”。图4.2回顾了作为列表使用的对的插入和删除的基本操作。
### 4.2.2 哨兵
如图4.2所示,链表在开头插入或删除的过程与在中间插入或删除的过程不同,因为它是变量L,而不是改变的下一个字段:
```
L= L.next // 删除 L 指向的链表的首项
L= new Entry ("aardvark", L); // 在链表首部增加一项
```
我们可以使用一种称为哨兵节点的聪明技巧,避免对列表开头的特殊情况使用这种方法。
标记背后的思想是使用一个额外的对象,一个不携带存储的集合项之一的对象,以避免出现任何特殊情况。图4.3演示了结果。
哨兵的使用改变了一些测试。例如,测试是否链表L在没有标记的情况下为空只是简单地将L与null进行比较,其中使用标记的列表的测试将L.next与null进行比较。
### 4.2.3 双向链表
单链表易于创建和操作,但是对于完全实现Java List接口来说,单链表并不理想。一个明显的问题是,前面对列表迭代器的操作没有在单链接结构上快速实现。另一个问题则是指针在迭代时会被迫返回到列表的开始,然后跟随适当数量的next字段,几乎不会直接完全返回到当前位置,这需要与列表大小成比例的时间。在列表迭代器上的remove操作的实现中出现了一些更细微的麻烦。要从单链表中删除项目p,您需要一个指向p之前项目的指针,因为它是必须修改的对象的下一个字段。
通过向列表结构中的对象添加一个前辈链接,使列表中给定项的前项和后项具有同等的可访问性,这两个问题都很容易解决。与单链结构一样,前端和端哨兵的使用进一步简化了操作,消除了从链表的首或尾添加或删除的特殊情况。在双向链结构的情况下,另一种方法是使整个列表循环,即在列表的前面和后面都使用一个标记。这个可爱的技巧节省了少量的空间,否则将会浪费首节点的哨兵和尾部的下一节点指针。图4.4说明了结果表示及其上的主要操作。
## 4.2.4 列表接口中链表的实现
双重链接结构支持实现Java List接口所需的所有操作。链接的类型(LinkedList.Entry)对实现是私有的。链表对象本身只包含一个指向链表标记的指针(一旦创建,它就不会更改)和一个包含链表中项数的整数变量。当然,从技术上讲,后一种方法是多余的,因为总可以计算列表中的项数,但是保持这种可变性可以使得列表的大小是一个常量时间的操作。图4.5演示了所涉及的三个主要数据结构:LinkedList、LinkedList.Entry,以及迭代器LinkedList.LinkedIter。
## 4.2.5 特殊列表
列表的一个常见用途是表示只在一端或两端操作和检查的项目序列。其中,最熟悉的是:
1、栈 (或者是先进后出的队列) ,只支持在首或尾的一端增加或删除。
2、队列 (或者是先进后出的队列),只支持在一端添加或在另一端删除。
3、有两个端点的队列,只支持在两端进行插入和删除。
图4.8中展示了他们的操作。
## 4.3 栈
Java提供了Java.util类型。作为java.util.Vector类型的扩展(本身是ArrayList的一种变体):
```
package java.util;
public class Stack<T> extends Vector<T>{
/** An empty Stack */
public Stack(){}
public boolean empty(){ return isEmpty();}
public T peek (){ check(); return get(size()-1);}
public T pop (){ check(); return remove(size()-1);}
public T push (T x){add(x);return x;}
public int search (Object x){
int r = lastIndexOf(x);
return r == -1? -1:size()-r;
}
private void check(){
if(empty()) throw new EmptyStackException();
}
}
```
但是,因为它是库中较老的类型之一。java.util.Stack并不那么完美。特别是,没有单独的接口描述“堆栈性”。相反,只有Stack类,它不可避免地结合了接口和实现。图4.9显示了如何设计堆栈接口(在Java意义上)
堆栈有许多用途,部分原因是它们与递归和回溯搜索关系密切。例如,考虑一个寻找迷宫出口的简单策略。我们假设某个Maze类,以及一个表示迷宫中某个位置的Position类。在迷宫中的任何位置,你都可以向四个不同的方向移动(用数字0-4表示,可能代表罗盘指向北、东、南和西)。我们的想法是把面包屑放在我们已经去过的每个地方。从我们参观的每一个地方,我们试着走每一个可能的方向,并从那个点继续。如果我们发现我们已经访问过一个职位,或者从某个职位出发没有偏离方向,我们就会回溯到我们之前访问过的最后一个职位,并继续从之前的职位开始我们还没有尝试过的方向,当我们到达出口时停止(参见图4.10)。作为一个程序(使用我希望具有启发性的方法名),我们可以用两种等价的方式编写它。首先,递归地:
```
/** Find an exit from M starting from PLACE. */
void findExit(Maze M,Postion place){
if(M.isAnExit (place))
M.exitAt(place);
if (! M.isMarkedAsVisited (place)) {
M.markAsVisited (place);
for (dir = 0; dir < 4; dir += 1)
if (M.isLegalToMove (place, dir))
findExit (M, place.move (dir));
}
}
```
然后,迭代一下:
```
import ucb.util.Stack;
import ucb.util.ArrayStack;
/** Find an exit from M starting from PLACE. */
void findExit(Maze M, Position place0) {
Stack<Position> toDo = new ArrayStack<Position> ();
toDo.push (place0);
while (! toDo.isEmpty ()) {
Position place = toDo.pop ();
if (M.isAnExit (place))
M.exitAt (place);
if (! M.isMarkedAsVisited (place)) {
M.markAsVisited (place);
for (dir = 3; dir >= 0; dir -= 1)
if (M.isLegalToMove (place, dir))
toDo.push (place.move (dir));
}
}
}
```
其中 ArrayStack 是 ucb.util.Stack 的实现(见 §4.5)
迭代版本背后的思想是toDo堆栈保存place_values的值,这些值作为参数出现在递归版本中,以查找Exitin。两个版本以相同的顺序访问相同的位置(这就是为什么循环在迭代版本中向后运行)。实际上,toDo在递归版本中扮演调用stackin的角色。实际上,递归过程的典型实现也为此使用堆栈,尽管它对程序员来说是不可见的
### 4.4.2 先进先出 及 双端 队列
先近先出就是我们通常所说的在排队的意思:人们或事物在队列的一端加入队列,然后在另一端离开队列,这样第一个到达(或进入队列)的人就会第一个离开(或离开队列)。队列在程序中广泛出现,它们可以表示需要服务的请求序列。Java库(从Java 2开始,版本1.5)提供了一个标准的FIFO队列接口,但它是专门针对程序可能不得不等待一个元素被添加到队列中的情况而设计的。图4.11显示了一个更“经典”的可能接口。deque是最通用的双端队列,在程序中可能很少显式使用。它比FIFO队列使用更多的列表接口,因此专门化的需求不是特别迫切。不过,为了完整起见,我在图4.12中包含了一个可能的接口。
## 4.5 栈、队列 及双端队列的实现
我们可以为ucb.util实现一个具体的堆栈类。栈接口如图4.13所示:作为ArrayList的扩展,就像java.util一样。Stack是java.util.Vector的一个继承前的版本。正如您所看到的,堆栈接口方法的名称是这样的,我们可以简单地从ArrayList继承size、isEmpty和lastIndexOf的实现。
但是让我们用一些泛化来增加ArrayStack实现的趣味性。图4.14演示了一种有趣的类,称为适配器或包装器(第三章开始介绍的另一种设计模式)。这里显示的类StackAdapter将使列表对象看起来像一个堆栈。图中还显示了一个使用它从ArrayList类生成具体堆栈表示的示例。
同样,对于List接口的任何实现,我们都可以轻松地提供Queue或Deque的实现,但是有一个问题。基于数组和基于链表的List实现都将同样好地支持我们的堆栈接口,提供在恒定平摊时间内操作的push和pop方法。但无论怎样选择,使用一个类似ArrayListin一样的方式实现队列的或双端队列接口都将会带来很差的性能。问题是显而易见的:正如我们所看到的,我们可以添加或删除从anarray很快的结束(高指数),但删除其他(索引0)需要在所有的元素数组,这需要时间Θ(N), Nis队列的大小。当然,我们可以简单地坚持使用LinkedLists,它没有这个问题,但是也有一个聪明的技巧,可以用数组高效地表示一般队列。
当我们删除第一个队列时,我们不改变队列的项,而是改变数组中队列开始的位置。我们在数组中保留两个索引,一个指向第一个进入队列的项,另一个指向最后一个。这两个索引在数组中“互相追赶”,当它们通过高索引端时,就会回到索引0,反之亦然。这种排列被称为环状缓冲区。图4.15说明了这种情况。图4.16显示了一个可能的实现。
练习:
4.1 实现Deque类型,作为java.util.Vector的扩展。到java.util所需的操作。AbstractList、addfirst、last、insertFirst、insertLast、removeFirst、removeLast,这样做的方式是,对向量的所有操作都继续有效(例如,get(0)继续得到与first()相同的元素),并且所有这些操作的平摊代价保持不变。
4.2 用构造函数实现一种类型的列表:
public ConcatList (List L0, List L1){…}不支持添加和删除对象的可选操作,但给出了L0和L1连接的视图。也就是说,这样一个列表上的get(i)在执行get操作时,在L0和L1的连接中给出元素i(也就是说,对L0和L1引用的列表的更改反映在连接的列表中)。还要确保iterator和listIterator能够工作
4.3 单链表结构可以是循环的。也就是说,列表中的某个元素可以有一个tail (next)字段,该字段指向列表中的较早项(不一定是列表中的第一个元素)。提出一种方法来检测列表中的某个地方是否存在这样的循环。但是,不要在任何数据结构中使用任何破坏性操作。也就是说,您不能使用其他数组、列表、向量、哈希表或类似的东西来跟踪列表中的项。只使用simplelist指针,而不更改任何列表的任何字段。请参见hw5目录中的CList.java。
4.4 图4.6和LinkedList中LinkedList的实现。图4.7中的LinkedIter不提供对底层列表的并发修改检查。因此,一个代码片段,例如
```
for (ListIterator<Object> i = L.listIterator ();i.hasNext (); )
{if (bad (i.next ()))
L.remove (i.previousIndex ());
}
```
会产生意想不到的效果。根据LinkedList的特殊定义,应该发生的是,当你调用L.remove时,i就无效了,并且对i方法的后续调用将抛出concurrentmodificationexception
a.对于theLinkedListclass,上面的循环出了什么问题,为什么?
b.修改我们的linkedlist实现方法来执行并发修改检查(因此上面的循环抛出ConcurrentModificationException)
4.5 设计一个类似于StackAdapter的DequeAdapter类,允许从任意列表对象创建deques(或队列)。
4.6 提供ArrayDeque的there size方法的实现(图4.16)。如果需要展开,您的方法应该将用于表示循环缓冲区的ArrayList的大小加倍。注意!您需要做的不仅仅是增加数组的大小,否则表示将会中断。
# 第五章 树
在本章中,我们将从接口到库的定义中休息一下,开始研究用于表示对象、表达式和其他层次结构的可搜索集合的基本数据结构工具之一——树。
术语的树指的是几种不同的变体,我们稍后将称之为连通的、非循环的、无向图。
不过,不过,现在我们先不这么叫它,而是集中研究两种有根的树。
定义: 一颗有序的树有如下特点:
A、节点,它可能包含一段称为标签的数据。在应用程序上解除挂起状态时,节点可以代表任意数量的事物,并且它的数据标记可以任意详细说明。树的节点部分称为根节点或根。
B、0棵或更多树的序列,其根节点称为根的子节点。树中的每个节点最多是一个节点的子节点——它是发散的。任何节点的子节点都是彼此的兄弟节点。
一个节点的子节点数称为该节点的度。没有子节点的节点称为叶子(节点)、外部节点或终端节点;所有其他节点称为内部节点或非终端节点。
我们通常认为在每个节点及其子节点之间存在着称为边的连接,我们通常将沿着边从父节点到子节点或从子节点返回父节点的过程称之为遍历或者回溯。
从任意节点r开始,在以r为根的树中,有一个从r到任意节点n的唯一的、不重复的路径或边缘序列。
这条路径上的所有节点,包括r和n,都被称为r的后代和n的祖先。如果r的后代不是r本身,那么它就是正确的后代;类似地定义了正确的祖先。树中的任何节点都是该树的子树的根。
同样,树的正确子树是不等于(因此小于)树的子树。任何一组不相交的树(如扎根于所有结点上的树)都称为“森林”。
从节点n到树的根r的距离,即从n到r必须经过的边的数量,被称为是树中该节点的级别(或深度)。
树中所有节点的最大级别称为树的高度。
树中所有节点级别的和是树的路径长度。
我们还将内部(外部)路径长度定义为所有内部(外部)节点的级别之和。
图5.1说明了这些定义。
图中显示的所有级别都相对于节点0。“节点7在以节点1为根的树中的级别”也有意义,即2。
如果您仔细研究有序树的定义,您会发现它必须至少有一个节点,这样就不存在空的有序树。因此,具有k个>个子节点的子节点j的子节点j总是非空的。
更改定义以允许空树是很容易的:
定义:位置树分为:
A、空的
B、一个节点(通常标记),对于每个非负整数j,一个位置树的第j个子节点
节点的度是非空子节点的个数。如果树中的所有节点都只在小于 k的位置上有子节点,我们说它是k-ary树。叶节点是那些没有非空子节点的节点;所有其他的都是内部节点。
也许最重要的位置树是二叉树,其中k= 2。对于二叉树,我们通常将子树0和子树1分别称为左子树和右子树。
一个完整的k-ary树是一个所有的内部节点,除了可能最右底部的节点都有递减。树是完整的,如果它是满的,并且它的所有叶子节点都是在从上到下、从左到右读取时最后出现的,如图5.2c所示。
完整的二叉树之所以有趣,是因为在某种意义上,它们最大程度上是“浓密的”;对于任意给定数量的内部节点,它们都最小化了树的内部路径长度,这很有趣,因为它与每次从树的内部节点移动到另一内部节点所需的总时间成正比。
## 5.1 表达式树
当试图表示递归定义的类型时,树通常很有趣。
一个熟悉的例子是表达式树,我们可以递归地将一个表达式定义为:
1、有一个标识符或常量
2、一个运算符(代表k个参数的某个函数)和k个表达式(它的操作数)
根据这个定义,表达式可以方便地由树表示,树的内部节点包含操作符,外部节点包含标识符或瞬间。图5.3展示了表达式x*(y + 3) - z
上图说明了如何处理树,以及表达式的求值。通常情况下,表达式所表示的值的定义与表达式的结构密切相关:
1、常数的值就是它表示为数字的值。变量的值是其当前定义的值。
2、由运算符和操作数表达式组成的表达式的值是将运算符应用于操作数表达式的值的结果
我们可以立即根据定义得到一个程序:
```
/** The value currently denoted by the expression E (given
* current values of any variables). Assumes E represents
* a valid expression tree, and that all variables
* contained in it have values. */
static int eval(Tree E){
if (E.isConstant ())
return E.valueOf ();
else if (E.isVar ())
return currentValueOf (E.variableName ());
else
return perform (E.operator (),eval (E.left ()), eval (E.right ()));
}
```
在这里,我们假设存在一个定义树,它提供了操作符,用于检测E是表示常量还是变量的叶子和提取存储在E的数据值、变量名或操作符名,以及用于查找E的左子节点和右子节点(用于内部节点)。我们还假设执行接受一个操作符名称(例如,“+”)和两个整数值,并对这些整数执行指定的计算。通过对树的结构进行归纳(在节点的子节点上扎根的树总是节点树的子树),并观察表达式值的定义与程序之间的匹配,可以立即得出该程序的正确性。
## 5.2 基本树原语
可以在树上定义许多可能的操作集,就像序列一样。图5.4显示了一个可能的类(假设是整数标签)。通常,在给定的应用程序中只会实际提供一些显示的操作。对于二叉树,我们可以更加专门化,如图5.5所示。在实践中,我们通常不将binarytree定义为Tree的扩展,我在这里这样做只是为了说明。
到目前为止,所有操作都假定对树进行“根向下”处理,在这种处理中,有必要从父树继续到子树。当采用另一种方法更合适时,以下操作可以作为树的构造函数和子方法的补充(或替代)。
```
/** The parent of T, if any (otherwise null). */
public Tree<T> parent() ...
/** Sets parent() to P. */
public void setParent(Tree<T> P);
/** A leaf node with label L and parent P */
public Tree(T L, Tree<T> P);
```
## 5.3 表示树
通常,我们对树的表示在很大程度上取决于对树的使用。
### 5.3.1 基于指针的二叉树
对于在下面(§5.4)中描述的二叉树上执行遍历,递归定义的直接抄写通常是合适的,因此有:
```
T L; /* Data stored at node */
BinaryTree<T> left,right; /* Left and right children */
```
正如我所说的关于BinaryTree的示例定义,这种特殊的表示在实践中比简单地重用Tree的实现更为常见。当然,如果要支持父节点操作,我们可以添加一个额外的指针:
`
BinaryTree<T> parent; // or Tree , as appopriate
`
### 5.3.2 基于指针的有序树
用于BinaryTree的字段对于某些非二叉树也很有用,这得益于最左子树和右兄弟树的表示。假设我们表示一个有序树,其中每个内部节点可以有任意数量的子节点。我们可以将任何节点指向节点的子#0,并将右节点指向节点的下一个同级节点(如果有的话),如图5.6所示
举一个小例子。考虑计算节点包含整数的树中所有节点值的和的问题(我们将对其使用library类Integer,因为我们的标签必须是对象)。树中所有节点的和是根节点的值加上子节点的值之和。我们可以这样写:
```
/** The sum of the values of all nodes of T, assuming T is anordered tree with no missing children. */
static int treeSum(Tree<Integer> T){
int S;
S = T.label();
for (int i = 0; i < T.degree(); i += 1)
S += treeSum(T.child(i));
return S;
}
```
(java在这里默认地将Interger类型转换成int)
一个有趣的发现是,这个程序的归纳证明没有包含明显的基本情况。上面的程序几乎是直接抄写“根值的和加上所有子节点值的和”。
### 5.3.3 叶子结点的表示
对于父节点操作很重要而叶子节点操作不重要的应用程序,我们可以使用不同的表示形式。
```
T label;
Tree<T> parent; /* Parent of current node */
```
这样,就不能再对子节点有任何操作了
这种表示法有一个相当有趣的优点:它占用更少的空间;每个节点少一个指针。仔细想想,这乍一看可能有点奇怪,因为每条边都需要一个指针。不同之处在于,“父”表示不需要所有外部节点中的空指针,只需要根节点中的空指针。我们将在后面的章节中看到这种表示的应用。
### 5.3.4 一颗完整的树的数组表示
当树完成时,使用数组会有一个特别紧凑的表示。考虑图5.2c中的完整树。父节点的编号k > 0,图节点数量⌊(k−1) / 2⌋(或Java (k - 1) / 2或(k - 1) > > 1);左子节点k是2 k + 1和2 k + 2。我们从1节点编号而不是0,这些公式就更简单:⌊k / 2⌋父母,2 k的左子节点和2 k + 1的。因此,我们可以将这些完整的树表示为只包含标签信息的数组,并使用数组中的索引作为指针。父节点操作和子节点操作都变得简单。当然,必须小心维护完整性属性,否则数组中会出现间隙(实际上,对于某些不完整的树,可能需要一个包含2h - 1个元素的数组来表示具有h节点的树)。
不幸的是,实现这种表示所需的头与上面的头稍有不同,因为以这种方式访问由数组表示的树的元素需要三个信息—数组、上界和索引—而不仅仅是一个指针。此外,我们可能还需要一些路由来为新树分配空间,预先指定树的大小。下面是一个示例,其中还提供了一些主体。
```
/** A BinaryTree2<T> is an entire binary tree with labels of type T.
The nodes in it are denoted by their depth-first number ina complete tree. */
class BinaryTree2<T> {
protected T[] label;
protected int size;
/** A new BinaryTree2 with room for N labels. */
public BinaryTree2(int N) {
label = (T[]) new Object[N];
size = 0;
}
public int currentSize() { return size; }
public int maxSize() { return label.length; }
/** The label of node K in breadth-first order.
* Assumes 0 <= k < size. */
public T label(int k) { return label[k]; }
/** Cause label(K) to be VAL. */
public void setLabel(int k, T val) { label[k] = val; }
public int left(int k) { return 2*k+1; }
public int right(int k) { return 2*k+2; }
public int parent(int k) { return (k-1)/2;
/** Add one more node to the tree, the next in breadth-first
* order. Assumes currentSize() < maxSize(). */
public void extend(T label) {
this.label[size] = label;
size += 1;
}
}
```
稍后处理堆数据结构时,我们将看到这个数组表示。图5.2c中的二叉树表示如图5.7所示。
### 5.3.5 空树的替代表示
在我们的表示中,空树往往具有特殊的状态。例如,我们可以构造一个用于访问树T的左节点的方法T.left(),但是如果T是一各空树,我们就不能编写一个方法,使T. isempty()为真。相反,我们必须写T == null。当然,原因是我们用null指针表示空树,并且没有在null指针的动态类型上定义实例方法(更具体地说,如果我们尝试,我们会得到一个NullPointer exception)。如果空树由普通对象指针表示,它就不需要特殊的状态 。
例如,我们可以从§5.2开始扩展树的定义,如下所示:
```
class Tree<T> {
...
public final Tree<T> EMPTY = new EmptyTree<T> ();
/** True iff THIS is the empty tree. */
public boolean isEmpty () { return false; }
private static class EmptyTree<T> extends Tree<T> {
/** The empty tree */
private EmptyTree () { }
public boolean isEmpty () { return true; }
public int degree() { return 0; }
public int numChildren() { return 0; }
/** The kth child (always an error). */
public Tree<T> child(int k) {
throw new IndexOutOfBoundsException ();
}
/** The label of THIS (always an error). */
public T label () {throw new IllegalStateException ();}
}
}
```
这里只有一个空树(因为EmptyTree类对tree类是私有的,这是单例设计模式的一个例子,所以可以保证它是空树),但是这棵树是一个成熟的对象,我们不需要对null进行特殊测试来避免异常。我们将在树遍历的讨论中进一步扩展这种表示(参见§5.4.2)。
## 5.4 树的遍历
§5.1中的函数eval遍历(或遍历)它的参数——也就是说,它处理树中的每个节点。遍历按处理树节点的顺序进行分类。在程序eval中,我们首先遍历(即然后对这些遍历的结果和节点中的其他数据执行一些处理。后一种处理通常称为访问节点。因此,eval的模式是“遍历节点的子节点,然后访问节点”,这是一种称为postorder的顺序。可以通过postorder遍历以反向波兰格式打印表达式树,其中访问节点意味着打印其内容(图5.3中的树将输出为“x y 3 + * z -”)。如果对每个节点的主处理(“visitation”)发生在子节点的主处理之前,给出“访问节点,然后遍历其子节点”的模式,我们就得到了所谓的preorder遍历。最后,图5.1和图5.2中的节点都按级别顺序或宽度优先顺序编号,其中在树的给定级别上的节点在访问下一个节点之前访问。
到目前为止,所有遍历顺序对于我们考虑过的任何类型的树都是有意义的。还有一种标准遍历顺序只适用于二进制树:顺序遍历(或对称遍历)。这里的模式是“遍历节点的左子节点,访问节点,然后遍历右子节点”。例如,在表达式树的情况下,将按照中缀顺序再现所表示的表达式。事实上,这并不是很准确,因为正确表达式括号,精确操作必须像“编写一个左括号,然后遍历左子,然后写操作符,然后遍历右子,然后写一个右括号"这样。
然而,尽管这样的例子至少导致了一次为遍历引入更通用表示法的尝试,但我们通常只是将它们近似地归类到上面描述的类别之一中,并就此打住。
图5.8演示了几个按preorder、inorder和postorder顺序遍历二叉树的情况。
### 5.4.1 广度遍历
我故意对“访问”的含义含糊其辞,因为树遍历是一个通用的概念,并不特定于树节点上的任何特定操作。事实上,我们可以编写一个遍历的通用定义,并将树在每个节点上“访问”的操作作为参数。在与Scheme类似的语言有类似的函数闭包,我们只需将访问参数设为函数参数即可。如:
```
;; Visit the nodes of TREE, applying VISIT to each in inorder
(define inorder-walk (tree visit)
(if (not (null? tree))
(begin (inorder-walk (left tree) visit)
(visit tree)
(inorder-walk (right tree) visit))))
```
例如,打印树的所有节点:
(inorder-walk myTree (lambda (x) (display (label x)) (newline)))
在Java中,我们使用的则是对象而不是函数(就像Java .util.Comparator 接口 §2.2.4)。例如,我们可以定义如下接口:
```
public interface TreeVisitor<T> {void visit (Tree<T> node);}
public interface BinaryTreeVisitor<T> {void visit (BinaryTree<T> node);}
将上面的改写为
static <T> BinaryTreeVisitor<T> inorderWalk (BinaryTree<T> tree,BinaryTreeVisitor<T> visitor){
if (tree != null) {
inorderWalk (tree.left (), visitor);
visitor.visit (tree);
inorderWalk (tree.right (), visitor);}
return visitor;
}
```
这里调用的方法是:
`inorderWalk (myTree, new PrintNode ());`
我们可以再定义一个 BinaryTree<String>
```
class PrintNode implements BinaryTreeVisitor<String> {
public void visit (BinaryTree<String> node) {
System.out.println (node.label ());}}
```
显然,PrintNode类也可以与其他类型的traverals一起使用。最后,我们可以让访问者匿名,就像在最初的Scheme程序中一样:
```
inorderWalk (myTree,new BinaryTreeVisitor<String> () {
public void visit (BinaryTree<String> node) {
System.out.println (node.label ());}});
```
像这样封装一个操作并将其放在集合中的其他方法中的做法,有一个简单的名字,叫Visitor模式。
通过向访问者添加状态,我们可以得到:
```
/** A TreeVisitor that concatenates the labels of all nodes it* visits. */
public class ConcatNode implements BinaryTreeVisitor<String> {
private StringBuffer result = new StringBuffer ();
public void visit (BinaryTree<String> node) {
if (result.length () > 0)result.append (", ");
result.append (node.label ());}
public String toString () { return result.toString (); }}
```
根据这个方法,我们可以按顺序打印树中以逗号分隔的项:
` System.out.println (inorderWalk (myTree, new ConcatNode()));`
(这个例子说明了为什么我让inorderWalk返回它的访问者参数。我建议你把这个例子的所有细节都练一遍。)
5.4.2 访问空树
我们将§5.4.1中的inorderWalk方法定义为一个静态(类)方法,而不是实例方法,这在一定程度上是为了清除对空树的处理。另一方面,如果我们使用§5.3.5的另一种空树表示,我们可以避免对空树进行特殊的大小写处理,并使遍历方法成为tree类的一部分。例如,有这么一个preorder-walk方法:
```
class Tree<T> {
···
public TreeVisitor<T> preorderWalk (TreeVisitor<T> visitor) {
visitor.visit (this);
for (int i = 0; i < numChildren (); i += 1)
child(i).preorderWalk (visitor);
return visitor;}
···
private static class EmptyTree<T> extends Tree<T> {
···
public TreeVisitor<T> preorderWalk (TreeVisitor<T> visitor) {return visitor;}}}
```
在这里,您可以看到根本没有针对空树的显式调用;在调用preorderWalk的两个版本中,所有内容都是隐式的。
一种可能的方法是使用堆栈并简单地转换遍历的递归结构,就像我们在§4.4.1中展示的findExit过程那样。我们可能会得到如图5.9所示的BinaryTrees的迭代器。
另一种方法是使用带有父链接的树数据结构,如图5.10所示。如您所见,这个实现跟踪fieldnext中要访问的下一个节点(按postorder)。它通过查看父节点,并根据next是其父节点的左子节点还是右子节点来决定下一步要访问哪个节点。因为这个迭代器执行postorder遍历,如果next是一个右子节点,那么next之后的节点就是next的父节点,否则它就是父节点右子节点最深、最左的后代。
练习
5.1 实现一个迭代器,它按顺序枚举树节点的标签,使用一个堆栈,如图5.9所示
5.2 实现一个迭代器,使用父链接按顺序枚举树节点的标签,如图5.10所示
5.3 实现一个对一般类型树(而不是binarytree)进行操作的预置迭代器。
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册