跳轉到內容

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 個依賴節點。

[根:] 樹中最頂端的節點,其他節點不引用它。

[葉子:] 樹中最底部的節點,它不引用其他節點。

[父節點:] 引用給定節點的節點。

[子節點:] 節點引用的節點之一。

[層級:] 與根節點距離相等的節點集。

[字首表示法:] 一種編寫數學表示式的方法,其中每個運算子都出現在其運算元之前。

[先序:] 遍歷樹的一種方式,在訪問子節點之前訪問每個節點。

[後序:] 遍歷樹的一種方式,在訪問節點本身之前訪問每個節點的子節點。

[中序:] 遍歷樹的一種方式,先訪問左子樹,然後訪問根節點,最後訪問右子樹。

[類變數:] 在任何方法之外宣告的靜態變數。它可以從任何方法訪問。

[二元運算子:] 接受兩個運算元的運算子。

[物件封裝:] 將兩個物件的實現儘可能分離的設計目標。兩個類都不應該知道另一個類的實現細節。

[方法封裝:] 將方法的介面與其實現細節分離的設計目標。

描述

華夏公益教科書