跳转到内容

Java 之道/树

来自维基教科书,开放的书籍,开放的世界
tree
ADT
data structure

本章介绍了一种新的数据结构,称为树,它的一些用途和两种实现方法。

一个可能的混淆来源是 ADT、数据结构和 ADT 或数据结构实现之间的区别。没有通用的答案,因为在某一级是 ADT 的东西可能反过来是另一个 ADT 的实现。

diagram!implementation

为了帮助理清其中的一些内容,有时在 ADT 及其可能实现之间绘制一个图表以显示其关系是有用的。此图显示了树的两种实现方法


figure=figs/tree_adt.eps


图中的水平线代表 ADT 及其实现之间的抽象屏障。


树节点

[编辑 | 编辑源代码]
node
tree node
cargo
embedded reference
implementation!Tree
binary tree

与列表一样,树由节点组成。一种常见的树是二叉树,其中每个节点都包含对另外两个节点(可能为空)的引用。类定义如下所示

verbatim public class Tree

   Object cargo;
   Tree left, right;

verbatim

与列表节点一样,树节点包含货物:在本例中是一个泛型对象。其他实例变量分别命名为 left 和 right,符合表示树的标准图形方式


figure=figs/tree1.eps


树的顶部(由树引用的节点)称为根。为了符合树的隐喻,其他节点被称为分支,具有空引用的尖端节点被称为叶子。我们可能觉得将根部绘制在顶部,叶子绘制在底部很奇怪,但这不是最奇怪的事情。

root node
leaf node
parent node
child node
level

更糟糕的是,计算机科学家将另一个隐喻混杂在一起:家谱。顶端节点有时被称为父节点,它引用的节点被称为其子节点。具有相同父节点的节点称为兄弟节点,等等。

最后,还有一种几何词汇用于描述树。我已经提到了 left 和 right,但也有“向上”(朝向父节点/根节点)和“向下”(朝向子节点/叶子)。另外,所有与根节点距离相同的节点构成了树的一层。

我不知道为什么我们需要三种隐喻来描述树,但事实就是这样。

构建树

[编辑 | 编辑源代码]
tree!linked implementation

组装树节点的过程类似于组装列表的过程。我们有一个用于树节点的构造函数,用于初始化实例变量。

verbatim

   public Tree (Object cargo, Tree left, Tree right) 
       this.cargo = cargo;
       this.left = left;
       this.right = right;
   

verbatim

我们首先分配子节点

verbatim

   Tree left = new Tree (new Integer(2), null, null);
   Tree right = new Tree (new Integer(3), null, null);

verbatim

我们可以同时创建父节点并将其链接到子节点

verbatim

   Tree tree = new Tree (new Integer(1), left, right);

verbatim

此代码产生了上一图所示的状态。


遍历树

[编辑 | 编辑源代码]
tree!traversal
traverse

遍历树的最自然方法是递归。例如,要将树中的所有整数加起来,我们可以编写此类方法

verbatim

   public static int total (Tree tree) 
       if (tree == null) return 0;
       Integer cargo = (Integer) tree.cargo;
       return cargo.intValue() + total (tree.left) + total (tree.right);
   

verbatim

这是一个类方法,因为我们希望使用 null 来表示空树,并将空树作为递归的基准情况。如果树为空,则该方法返回 0。否则,它进行两次递归调用以找到其两个子节点的总值。最后,它添加了自己的货物并返回总值。

tree!empty

虽然此方法有效,但在将其适应面向对象设计方面存在一些困难。它不应出现在 Tree 类中,因为它要求货物是 Integer 对象。如果我们在 Tree.java 中做出这种假设,那么我们将失去泛型数据结构的优势。

另一方面,此代码访问了 Tree 节点的实例变量,因此它“知道”它应该知道的比树的实现更多。如果我们稍后更改该实现(我们将这样做),此代码将失效。

在本章的后面,我们将开发解决此问题的方法,允许客户端代码遍历包含任何类型对象的树,而不会破坏客户端代码和实现之间的抽象屏障。在我们到达那里之前,让我们先看看树的应用。


表达式树

[编辑 | 编辑源代码]
tree!expression
expression tree
postfix
infix

树是表示表达式结构的自然方式。与其他表示法不同,它可以明确地表示计算。例如,中缀表达式 1 + 2 * 3 是模糊的,除非我们知道乘法在加法之前发生。

下图表示相同的计算


figure=figs/tree2.eps


节点可以是操作数,如 1 和 2,也可以是运算符,如 + 和 *。操作数是叶子节点;运算符节点包含对其操作数的引用(所有这些运算符都是二元的,意味着它们正好有两个操作数)。

查看此图,毫无疑问操作顺序是什么:乘法首先发生以计算加法的第一个操作数。

像这样的表达式树有许多用途。我们将要查看的示例是从一种格式(后缀)到另一种格式(中缀)的转换。编译器内部使用类似的树来解析、优化和转换程序。


tree!traversal
traverse
recursion
preorder
postorder
inorder

我已经指出递归提供了一种遍历树的自然方法。我们可以像这样打印表达式树的内容

verbatim

   public static void print (Tree tree) 
       if (tree == null) return;
       System.out.print (tree + " ");
       print (tree.left);
       print (tree.right);
   

verbatim

换句话说,要打印一棵树,首先打印根节点的内容,然后打印整个左子树,最后打印整个右子树。这种遍历树的方法称为前序遍历,因为根节点的内容出现在子节点内容之前。

对于示例表达式,输出为 + 1 * 2 3。这与后缀和中缀都不同;这是一种称为前缀的新表示法,其中运算符出现在其操作数之前。

你可能怀疑,如果我们以不同的顺序遍历树,我们将获得不同表示法的表达式。例如,如果我们先打印子树,然后打印根节点

verbatim

   public static void printPostorder (Tree tree) 
       if (tree == null) return;
       printPostorder (tree.left);
       printPostorder (tree.right);
       System.out.print (tree + " ");
   

verbatim

我们得到后缀表达式 (1 2 3 * +)! 正如之前方法的名称所暗示的那样,这种遍历顺序称为后序遍历。最后,要按中序遍历树,我们先打印左树,然后打印根节点,最后打印右树

verbatim

   public static void printInorder (Tree tree) 
       if (tree == null) return;
       printInorder (tree.left);
       System.out.print (tree + " ");
       printInorder (tree.right);
   

verbatim

结果为 1 + 2 * 3,这是中缀表达式。

公平地说,我必须指出我省略了一个重要的复杂情况。有时当我们在中缀表达式中编写表达式时,我们必须使用括号来保留操作顺序。因此,中序遍历不足以生成中缀表达式。

然而,经过一些改进,表达式树和三种递归遍历提供了一种通用的方法,用于将表达式从一种格式转换为另一种格式。


encapsulation
client
provider
abstract class
class!abstract

正如我之前提到的,我们一直用来遍历树的方式存在问题:它打破了客户端代码(使用树的应用程序)和提供者代码(树实现)之间的界限。理想情况下,树代码应该是通用的;它不应该了解表达式树的任何信息。生成和遍历表达式树的代码不应该了解树的实现。这种设计准则被称为对象封装,以区别于我们在封装一节中看到的封装,我们可能将其称为方法封装。

在当前版本中,树代码了解太多关于客户端的信息。相反,树类应该提供以各种方式遍历树的通用功能。在遍历时,它应该对每个节点执行由客户端指定的运算。


Visitable
abstract class!Visitable

为了促进这种利益分离,我们将创建一个新的抽象类,名为 Visitable。存储在树中的项将被要求是可访问的,这意味着它们定义了一个名为 visit 的方法,该方法执行客户端希望对每个节点执行的操作。这样,树就可以执行遍历,而客户端就可以执行节点运算。

以下是我们在客户端和提供者之间插入抽象类需要执行的步骤

枚举

定义一个抽象类,指定提供者代码需要在其组件上调用的方法。

根据新的抽象类编写提供者代码,而不是泛型对象。

定义一个属于抽象类的具体类,并根据客户端的要求实现所需的方法。

编写客户端代码以使用新的具体类。

枚举

接下来的几节将演示这些步骤。


定义抽象类

[edit | edit source]
abstract class!defining
interface

抽象类定义看起来很像具体类定义,只是它只指定每个方法的接口,而不是实现。Visitable 的定义是

逐字 public interface Visitable

   public void visit ();

verbatim

就是这样!关键字 interface 是 Java 中抽象类的关键字。visit 的定义看起来像任何其他方法定义,只是它没有主体。此定义指定任何实现 Visitable 的类都必须具有一个名为 visit 的方法,该方法不接受任何参数并返回 void。

body!method

与其他类定义一样,抽象类定义位于与类同名的文件中(在本例中为 Visitable.java)。


实现抽象类

[edit | edit source]
abstract class!implementing
Token class
class!Token

如果我们使用表达式树来生成中缀,那么访问节点意味着打印其内容。由于表达式树的内容是标记,我们将创建一个名为 Token 的新具体类,该类实现 Visitable

逐字 public class Token implements Visitable

   String str;
   public Token (String str) 
       this.str = str;
   
   public void visit () 
       System.out.print (str + " ");
   

verbatim

当我们编译此类定义(位于名为 Token.java 的文件中)时,编译器会检查提供的方法是否满足抽象类指定的条件。如果没有,它将生成错误消息。例如,如果我们拼写错误应该为 visit 的方法的名称,我们可能会得到类似“类 Token 必须声明为抽象类。它没有定义来自接口 Visitable 的 void visit()。”的东西。

下一步是修改解析器,以便将 Token 对象放入树中,而不是字符串。这里有一个小例子

verbatim

   String expr = "1 2 3 * +";
   StringTokenizer st = new StringTokenizer (expr, " +-*/", true);
   String token = st.nextToken();
   Tree tree = new Tree (new Token (token), null, null));

verbatim

此代码获取字符串中的第一个标记,将其包装在 Token 对象中,然后将 Token 放入树节点中。如果树要求货物是可访问的,它将转换 Token 以成为可访问的对象。当我们从树中移除 Visitable 时,我们将不得不将其强制转换为 Token。

作为练习,编写一个名为 visitPreorder 的 printPreorder 版本,该版本遍历树并按前序在每个节点上调用 visit。


树的数组实现

[edit | edit source]
tree!array implementation
implementation!Tree
ADT!Tree

实现树意味着什么?到目前为止,我们只看到了树的一种实现,一种类似于链表的链接数据结构。但是,我们想要识别的还有其他结构。任何能够执行基本树操作集的东西都应该被识别为树。

那么树操作是什么呢?换句话说,我们如何定义树 ADT?

描述

[构造函数:] 构建一棵空树。

[isEmpty:] 这棵树是空树吗?

[getLeft:] 返回此节点的左孩子。

[getRight:] 返回此节点的左孩子。

[getParent:] 返回此节点的父节点。

[getCargo:] 从此节点返回货物对象。

[setCargo:] 为此节点分配一个货物对象(如果需要,创建该节点)。

描述

在我们看到的实现中,空树由特殊值 null 表示。getLeft 和 getRight 通过访问节点的实例变量来执行,getCargo 和 setCargo 也是如此。我们还没有实现 getParent(你可以考虑如何实现它)。

树的另一种实现使用数组和索引,而不是对象和引用。为了了解它的工作原理,我们将从查看同时使用数组和对象的混合实现开始。

此图显示了一棵类似于我们一直在查看的树,尽管它是横向排列的,根在左边,叶在右边。在底部,有一个引用数组,引用树中的对象。货物对象表示为 null 引用。


图=figs/tree3.eps,宽度=5in


你可能会注意到,数组索引 1 指向根节点,而数组索引 0 是空的。原因很快就会变得清晰。

所以现在我们有一棵树,其中每个节点都有一个唯一的索引。此外,索引已根据特定模式分配给节点,以实现以下结果

枚举

索引为 的节点的左孩子索引为 。

索引为 的节点的右孩子索引为 。

索引为 的节点的父节点索引为 (向下取整)。

枚举

使用这些公式,我们可以仅通过进行算术运算来实现 getLeft、getRight 和 getParent;我们不必使用引用!

由于我们不使用引用,我们可以删除它们,这意味着以前是树节点的东西现在只是货物,没有其他东西。这意味着我们可以将树实现为货物对象的数组;我们根本不需要树节点。

以下是一些实现的示例

verbatim public class Tree

   Object[] array;
   int i;
   public Tree () 
       array = new Object [128];
       i = 1;
   
   public Tree (Object[] array, int i) 
       this.array = array;
       this.i = i;
   

verbatim

到目前为止没有惊喜。实例变量是对象数组和一个整数,表示在数组中找到新节点的位置。

第一个构造函数使用任意初始大小初始化数组(我们以后可以随时调整它的大小)。第二个构造函数假设对象数组已经存在。构成树的所有节点都将共享同一个数组,并具有不同的 i 值。

我们使用 i 来访问给定节点的货物

verbatim

   public Object getCargo () 
       if (i >= array.length) return null;
       return array[i];
   
   public void setCargo (Object obj) 
       if (i >= array.length) 
           array = resize (array);
       
       array[i] = obj;
   

verbatim

两种方法都必须检查数组的长度。如果 getCargo 尝试访问数组中不存在的元素,它将返回 null 以指示空树。如果 getCargo 超过数组的长度,它将调整数组的大小以腾出空间(有关 resize 的可能实现,请参见 resize 一节)。

要检查节点是否是空树,我们将检查货物是否为 null。

verbatim

   public boolean empty () 
       return (getCargo() == null);
   

verbatim

getLeft、getRight 和 getParent 的实现只是算术运算

verbatim

   public Tree getLeft () 
       return new Tree (array, 2*i);
   
   public Tree getRight () 
       return new Tree (array, 2*i + 1);
   
   public Tree parent () 
       return new Tree (array, i/2);
   

verbatim

最后,我们准备构建一棵树。在另一个类(客户端)中,我们将编写

verbatim

   Tree tree = new Tree ();
   tree.setCargo ("cargo for root");

verbatim

构造函数构建一棵空树。调用 setCargo 会将字符串“根的货物”放入根节点中。

要向根节点添加子节点

verbatim

   tree.getLeft().setCargo ("cargo for left");
   tree.getRight().setCargo ("cargo for right");

verbatim

在树类中,我们可以提供一个按前序打印树内容的方法。

verbatim

   public void print () 
       if (isEmpty ()) return;
       System.out.println (array[i]);
       getLeft().print();
       getRight().print();
   

verbatim

通过将根作为参数传递,我们从客户端调用此方法。

verbatim

   tree.print (root);

verbatim

输出为

逐字 货物为根 货物为左 货物为右 逐字

此实现提供了定义树的基本操作。正如我指出的那样,树的链接实现提供了相同的操作,但语法不同。作为练习,修改链接树,使其实现 Tree ADT。

数组实现的一个问题是数组的初始大小是任意的,可能需要调整它的大小。最后一个问题可以通过用 Vector 替换数组来解决。


Vector 类

[edit | edit source]

向量

Vector class
class!Vector

Vector 是 java.util 包中的内置 Java 类。它是对象数组的实现,具有自动调整自身大小的附加功能,因此我们不必这样做。

Vector 类提供名为 get 和 set 的方法,类似于我们为 Tree 类编写的 getCargo 和 setCargo 方法。你应该通过查阅在线文档来回顾其他 Vector 操作。

在使用 Vector 类之前,你应该了解一些概念。每个 Vector 都有一个容量,它表示分配用于存储值的存储空间量,还有一个大小,它表示实际上在向量中的值数量。

下图是一个简单的向量图,它包含三个元素,但容量为七。


figure=figs/vector.eps


一般来说,在调用 set 或 get 之前,由客户端代码确保向量具有足够的大小。如果尝试访问不存在的元素(在本例中是索引为 3 到 6 的元素),将收到一个 ArrayIndexOutOfBounds 异常。

exception!ArrayIndexOutOfBounds
ArrayIndexOutOfBounds

向量方法 add 和 insert 会自动增加向量的尺寸,但 set 不会。resize 方法在向量的末尾添加空元素,以达到给定尺寸。

大多数情况下,客户端不需要担心容量。只要向量的尺寸发生改变,容量就会自动更新。出于性能原因,某些应用程序可能希望控制此功能,这就是为什么存在用于增加和减少容量的额外方法的原因。

由于客户端代码无法访问向量的实现,因此不清楚我们应该如何遍历它。当然,一种可能性是使用循环变量作为向量中的索引。

verbatim

       for (int i=0; i<v.size(); i++) 
           System.out.println (v.get(i));
       

verbatim

这没什么问题,但还有另一种方法可以用来演示 Iterator 类。向量提供了一个名为 iterator 的方法,该方法返回一个 Iterator 对象,使遍历向量成为可能。


Iterator 类

[edit | edit source]

iterator

Iterator class
class!Iterator
abstract class

Iterator 是 java.util 包中的一个抽象类。它指定了三个方法。

描述

[hasNext:] 此迭代还有更多元素吗?

[next:] 返回下一个元素,如果不存在,则抛出异常。

[remove:] 从集合中删除最后返回的元素。

描述

以下示例使用迭代器遍历并打印向量的元素。

verbatim

       Iterator iterator = vector.iterator ();
       while (iterator.hasNext ()) 
           System.out.println (iterator.next ());
       

verbatim

一旦创建了 Iterator,它就成为与原始 Vector 分开的对象。Vector 中的后续更改不会反映在 Iterator 中。事实上,如果在创建 Iterator 后修改 Vector,则 Iterator 将失效。如果再次访问 Iterator,它将导致 ConcurrentModification 异常。

exception!ConcurrentModification
ConcurrentModification

在前面的部分中,我们使用 Visitable 抽象类允许客户端遍历数据结构,而无需了解其实现的详细信息。迭代器提供了另一种执行相同操作的方法。在第一种情况下,提供者执行迭代并调用客户端代码来``访问每个元素。在第二种情况下,提供者向客户端提供一个对象,客户端可以使用它来一次选择一个元素(尽管顺序由提供者控制)。

作为练习,编写一个名为 PreIterator 的具体类,它实现 Iterator 接口,并为 Tree 类编写一个名为 preorderIterator 的方法,该方法返回一个 PreIterator,它以先序选择 Tree 的元素。


词汇表

[edit | edit source]
binary tree
root node
leaf node
parent node
child node
level
prefix
preorder
postorder
inorder
class variable
encapsulation

描述

[二叉树:] 一棵树,其中每个节点引用 0、1 或 2 个依赖节点。

[根:] 树中最顶端的节点,其他节点不引用它。

[叶子:] 树中最底部的节点,它不引用其他节点。

[父节点:] 引用给定节点的节点。

[子节点:] 节点引用的节点之一。

[层级:] 与根节点距离相等的节点集。

[前缀表示法:] 一种编写数学表达式的方法,其中每个运算符都出现在其操作数之前。

[先序:] 遍历树的一种方式,在访问子节点之前访问每个节点。

[后序:] 遍历树的一种方式,在访问节点本身之前访问每个节点的子节点。

[中序:] 遍历树的一种方式,先访问左子树,然后访问根节点,最后访问右子树。

[类变量:] 在任何方法之外声明的静态变量。它可以从任何方法访问。

[二元运算符:] 接受两个操作数的运算符。

[对象封装:] 将两个对象的实现尽可能分离的设计目标。两个类都不应该知道另一个类的实现细节。

[方法封装:] 将方法的接口与其实现细节分离的设计目标。

描述

华夏公益教科书