考察數據結構——第三部分:二叉樹和BSTs[譯]

相關文檔 


考察數據結構——第一部分:數據結構簡介

考察數據結構——第二部分:隊列、堆棧和哈希表

 

原文鏈接:Part3: Binary Trees and BSTs


 

本文是

"考察數據結構"系列文章的第三部分,討論的是.Net Framework基類庫沒有包括的常用數據結構:

二叉樹。就像線形排列數據的數組一樣,我們可以將二叉樹想象爲以二維方式來存儲數據。其中一種特殊的二叉樹,我們稱爲二叉搜索樹(binary search tree),簡稱爲BST,它的數據搜索能力比一般數組更加優化。

 

目錄:

簡介

在樹中排列數據

理解二叉樹

BSTs改善數據搜索時間

現實世界的二叉搜索樹

 

簡介:

 

在本系列的第一部分,我們講述了什麼是數據結構,怎麼評估它們的性能,以及怎樣根據其性能選擇具體的數據結構來處理特定的算法。另外,我們複習了數據結構的基礎知識,瞭解了最常用的數據結構——數組及與其相關的ArrayList。在第二部分,我們講述了ArrayList的兩個兄弟——堆棧和隊列。它們存儲數據的方式與ArrayList非常相似,但它們訪問數據的方式受到了限制。我們還討論了哈希表,它可以以任意對象作爲其索引,而非一般所用的序數。

 

ArrayList,堆棧,隊列和哈希表從存儲數據的表現形式看,都可以認爲是一種數組結構。這意味着,這四種數據結構都將受到數組邊界的限制。回想第一部分所講的,數組在內存中以線形存儲數據,當數組容量到達最大值時,需要顯式地改變其容量,同時會造成線形搜索時間的增加。

 

本部分,我們講考察一種全新的數據結構——二叉樹。它以一種非線性的方式存儲數據。之後,我們還將介紹一種更具特色的二叉樹——二叉搜索樹(BST)。BST規定了排列樹的每個元素項的一些規則。這些規則保證了BSTs能夠以一種低於線形搜索時間的性能來搜索數據。

 

 

在樹中排列數據

 

如果我們看過家譜,或者是一家公司的組織結構圖,那麼事實上你已經明白在樹中數據的排列方式了。樹由許多節點的集合組成,這些節點又有許多相關聯的數據和“孩子”。子節點就是直接處於節點之下的節點。父節點則位於與節點直接關聯的上方。樹的根是一個不包含父節點的單節點。

 

1顯示了公司職員的組織結構圖。

圖一

 

例中,樹的根爲Bob Smith,是公司的CEO。這個節點爲根節點是因爲其上沒有父親。Bob Smith節點有一個孩子Tina Jones,公司總裁。其父節點爲Bob SmithTina Jones有三個孩子:

Jisun Lee, CIO

Frank Mitchell, CFO

Davis Johnson, VP of Sales

這三個節點的父親都是Tina Jones節點。

 

所有的樹都有如下共同的特性:

1、只有一個根;

2、除了根節點,其他所有節點又且只有一個父節點;

3、沒有環結構。從任意一個節點開始,都沒有回到起始節點的路徑。正是前兩個特性保證沒有環結構的存在。

 

對於有層次關係的數據而言,樹非常有用。後面我們會講到,當我們有技巧地以層次關係排列數據時,搜索每個元素的時間會顯著減少。在此之前,我們首先需要討論的是一種特殊的樹:二叉樹。

 

理解二叉樹

 

二叉樹是一種特殊的樹,因爲它的所有節點最多只能有兩個子節點。並且,對於二叉樹中指定的節點,第一個子節點必須指向左孩子,第二個節點指向右孩子。如圖二所示:

圖二

 

二叉樹(a)共有8個節點,節點1爲根。節點1的左孩子爲節點2,右孩子爲節點3。注意,節點並不要求同時具有左孩子和右孩子。例如,二叉樹(a)中,節點4就只有一個右孩子。甚至於,節點也可以沒有孩子。如二叉樹(b),節點456都沒有孩子。

 

沒有孩子的節點稱爲葉節點。有孩子的節點稱爲內節點。如圖二,二叉樹(a)中節點68爲葉節點,節點123457爲內節點。

 

不幸的是,.Net Framework中並不包含二叉樹類,爲了更好地理解二叉樹,我們需要自己來創建這個類。

 

第一步:創建節點類Node

 

節點類Node抽象地表示了樹中的一個節點。認識到二叉樹中節點應包括兩個內容:

1、  數據;

2、  子節點:0個、1個、2個;

 

節點存儲的數據依賴於你的實際需要。就像數組可以存儲整型、字符串和其他類類型的實例一樣,節點也應該如此。因此我們應該將節點類存儲的數據類型設爲object

 

注意:在C# 2.0版中可以用泛型來創建強類型的節點類,這樣比使用object類型更好。要了解更多使用泛型的信息,請閱讀Juval Lowy的文章:An Introduction to C# Generics

 

下面是節點類的代碼:

 

public class Node

{

   private object data;

   private Node left, right;

 

   #region Constructors

   public Node() : this(null) {}

   public Node(object data) : this(data, null, null) {}

   public Node(object data, Node left, Node right)

   {

      this.data = data;

      this.left = left;

      this.right = right;

   }

   #endregion

 

   #region Public Properties

   public object Value

   {

      get

      {

         return data;

      }

      set

      {

         data = value;

      }

   }

 

   public Node Left

   {

      get

      {

         return left;

      }

      set

      {

         left = value;

      }

   }

 

   public Node Right

   {

      get

      {

         return right;

      }

      set

      {

         right = value;

      }

   }

   #endregion

}

 

注意類Node有三個私有成員:

1、  data,類型爲object:爲節點存儲的數據;

2、  leftNode類型:指向Node的左孩子;

3、  rightNode類型:指向Node的右孩子;

4、  類的其他部份爲構造函數和公共字段,訪問了這三個私有成員變量。注意,leftright私有變量爲Node類型,就是說Node類的成員中包含Node類的實例本身。

 

創建二叉樹類BinaryTree

 

創建好Node類後,緊接着創建BinaryTree類。BinaryTree類包含了一個私有字段——root——它是Node類型,表示二叉樹的根。這個私有字段以公有字段的方式暴露出來。

 

BinaryTree類只有一個公共方法Clear(),它用來清除樹中所有元素。Clear()方法只是簡單地將根節點置爲空null。代碼如下:

public class BinaryTree

{

   private Node root;

 

   public BinaryTree()

   {

      root = null;

   }

 

   #region Public Methods

   public virtual void Clear()

   {

      root = null;

   }

   #endregion

 

   #region Public Properties

   public Node Root

   {

      get

      {

         return root;

      }

      set

      {

         root = value;

      }

   }

   #endregion

}

 

下面的代碼演示了怎樣使用BinaryTree類來生成與圖二所示的二叉樹(a)相同的數據結構:

BinaryTree btree = new BinaryTree();

btree.Root = new Node(1);

btree.Root.Left = new Node(2);

btree.Root.Right = new Node(3);

 

btree.Root.Left.Left = new Node(4);

btree.Root.Right.Right = new Node(5);

 

btree.Root.Left.Left.Right = new Node(6);

btree.Root.Right.Right.Right = new Node(7);

 

btree.Root.Right.Right.Right.Right = new Node(8);

 

注意,我們創建BinaryTree類的實例後,要創建根節點(root)。我們必須人工地爲相應的左、右孩子添加新節點類Node的實例。例如,添加節點4,它是根節點的左節點的左節點,我們的代碼是:

btree.Root.Left.Left = new Node(4);

 

回想一下我們在第一部分中提到的數組元素,使存放在連續的內存塊中,因此定位時間爲常量。因此,訪問特定元素所耗費時間與數組增加的元素個數無關。

 

然而,二叉樹卻不是連續地存放在內存中,如圖三所示。事實上,BinaryTree類的實例指向root Node類實例。而root Node類實例又分別指向它的左右孩子節點實例,以此類推。關鍵在於,組成二叉樹的不同的Node實例是分散地放在CLR託管堆中。他們沒有必要像數組元素那樣連續存放。

圖三

 

如果我們要訪問二叉樹中的特定節點,我們需要搜索二叉樹的每個節點。它不能象數組那樣根據指定的節點直接訪問。搜索二叉樹要耗費線性時間,最壞情況是查詢所有的節點。也就是說,當二叉樹節點個數增加時,查找任意節點的步驟數也將相應地增加。

 

因此,如果二叉樹的定位時間爲線性,查詢時間也爲線性,那怎麼說二叉樹比數組更好呢?因爲數組的查詢時間雖然也是線性,但定位時間卻是常量啊?是的,一般的二叉樹確實不能提供比數組更好的性能。然而當我們有技巧地排列二叉樹中的元素時,我們就能很大程度改善查詢時間(當然,定位時間也會得到改善)。

 

BSTs改善數據搜索時間

 

二叉搜索樹是一種特殊的二叉樹,它改善了二叉樹數據搜索的效率。二叉搜索樹有以下屬性:對於任意一個節點n,其左子樹下的每個後代節點的值都小於節點n的值;而其右子樹下的每個後代節點的值都大於節點n的值。

 

所謂節點n的子樹,可以將其看作是以節點n爲根節點的樹。因此,子樹的所有節點都是節點n的後代,而子樹的根則是節點n本身。圖四演示了子樹的概念和二叉搜索樹的屬性。

圖四

 

圖五顯示了二叉樹的兩個例子。圖右,二叉樹(b),是一個二叉搜索樹(BST),因爲它符合二叉搜索樹的屬性。而二叉樹(a),則不是二叉搜索樹。因爲節點10的右孩子節點8小於節點10,但卻出現在節點10的右子樹中。同樣,節點8的右孩子節點4小於節點8,卻出現在了它的右子樹中。不管是在哪個位置,不符合二叉搜索樹的屬性規定,就不是二叉搜索樹。例如,節點9的右子樹只能包含值小於節點9的節點(84)。

圖五

 

從二叉搜索樹的屬性可知,BST各節點存儲的數據必須和另外的節點進行比較。給出任意兩個節點,BST必須能夠判斷這兩個節點的值是小於、大於還是等於。

 

現在,設想一下,我們要查找BST的某個特定的節點。例如圖五中的二叉搜索樹(b),我們要查找節點10BST和一般的二叉樹一樣,都只有一個根節點。那麼如果節點10存在於樹中,搜索這棵樹的最佳方案是什麼?有沒有比搜索整棵樹更好的方法?

 

如果節點10存在於樹中,我們從根開始。可以看到,根節點的值爲7,小於我們要查找的節點值。因此,一旦節點10存在,必然存在其右子樹。所以應該跳到節點11繼續查找。此時,節點10小於節點11的值,必然存在於節點11的左子樹中。移到節點11的左孩子,此時我們已經找到了目標節點,定位於此。

 

如果我們要查找的節點在樹中不存在,會發生問題?例如我們查找節點9。重複上述操作,直到到達節點10,它大於節點9,那麼如果節點9存在,必然是在節點10的左子樹中。然而我們看到節點10根本就沒有左孩子,因此節點9在樹中不存在。

 

正式地,我們的搜索算法如下所示。假定我們要查找節點n,此時已指向BST的根節點。算法不斷地比較數值的大小直到找到該節點,或指爲空值。每一步我們都要處理兩個節點:樹中的節點c,要查找的節點n,並比較cn的值。C的初始化值爲BST根節點的值。然後執行以下步驟:

1、  如果c值爲null,則n不在BST中;

2、  比較cn的值;

3、  如果值相同,則找到了指定節點n

4、  如果n的值小於c,那麼如果n存在,必然在c的左子樹中。因此回到第一步,將c的左孩子作爲c

5、  如果n的值大於c,那麼如果n存在,必然在c的右子樹中。因此回到第一步,將c的右孩子作爲c

 

分析BST搜索算法

 

通過BST查找節點,理想情況下我們需要檢查的節點數可以減半。如圖六的BST樹,包含了15個節點。從根節點開始執行搜索算法,第一次比較決定我們是移向左子樹還是右子樹。對於任意一種情況,一旦執行這一步,我們需要訪問的節點數就減少了一半,從15降到了7。同樣,下一步訪問的節點也減少了一半,從7降到了3,以此類推。

圖六

 

這裏一個重要概念就是算法的每一步在理想狀態下都將使被訪問的節點數減少一半。比較一下數組的搜索算法。搜索數組時,要搜索所有所有元素,每個元素搜索一次。也就是說,搜索有n個元素的數組,從第一個元素開始,我們要訪問n-1個元素。而有n個節點的二叉搜索樹,在訪問了根節點後,只需要再搜索n/2個節點。

 

搜索二叉樹與搜索排序數組相似。例如,你要在電話薄中查找是否有John King。你可以從電話薄的中間開始查找,即從以M開頭的姓氏開始查找。按照字母順序,K是在M之前,那麼你可以將M之前的部分在折半,此時,可能是字母H。因爲K是在H之後,那麼再將HM這部分折半。這次你找到了字母K,你可以馬上看到電話薄裏有沒有James King

 

搜索BST與之相似。BST的中點是根節點。然後從上到下,瀏覽你所需要的左孩子或右孩子。每一步都將節約一半的搜索時間。根據這一特點,這個算法的時間複雜度應該是log­2n,簡寫爲log n。回想我們在第一部分討論的數學問題,log­2n = y,相當於2y = n。即,節點數增加n,搜索時間只緩慢地增加到log­2n。圖七表示了log­2n和線性增長的增長率之間的區別。時間複雜度爲log­2n的算法運行時間爲下面那條直線。

 

圖七

 

可以看出,這條對數曲線幾乎是水平線,隨着N值的增加,曲線增長緩慢。舉例來說吧,搜索一個具有1000個元素的數組,需要查詢1000個元素,而搜索一個具有1000個元素的BST樹,僅需要查詢不到10個節點(log10 1024 = 10)。

 

在分析BST搜索算法中,我不斷地重複“理想地(ideally)”這個字眼兒。這是因爲BST實際的搜索時間要依賴於節點的拓撲結構,也就是說節點之間的佈局關係。象圖六中所示的二叉樹,每一步比較操作都可以使搜索時間減半。然而,我們來看看圖八所示的BST樹,它的拓撲結構是與數組的排列方式是同構的。

圖八

 

搜索圖八中的BST樹,仍然要耗費線性時間,因爲每比較一步,都緊緊減少了一個節點,而非像圖六中那樣減半。

 

因此,搜索BST所耗費的時間要依賴於它的拓撲結構。最佳情況下,耗費時間爲log2 n,最壞情況則要耗費線性時間。在下一節我們將看到,BST的拓撲結構與插入節點的順序有關。因此,插入節點的順序將直接影響BST搜索算法的耗時。

 

插入節點到BST

 

我們已經知道了在BST中查詢一個特定節點的方法,但是我們還應該掌握插入一個新節點的方法。向二叉搜索樹插入一個新節點,不能任意而爲,必須遵循二叉搜索樹的特性。

 

通常我們插入的新節點都是作爲葉節點。唯一的問題是,怎樣查找合適的節點,使其成爲這個新節點的父節點。與搜索算法相似,我們首先應該比較節點c和要插入的新節點n。我們還需要跟蹤節點c的父節點。初始狀態下,c節點爲樹的根節點,父節點爲null。定位一個新的父節點遵循如下算法:

1、  如果c指向null,則c節點作爲n的父節點。如果n的值小於父節點值,則n爲父節點新的左孩子,否則爲右孩子;

(譯註:原文爲If c is a null reference,then parent will be the parent of n.. If n’s value is less than parent’s value,then n will be parent’s new left child; otherwise n will be parent’s new right child. 那麼翻譯過來就是如果c的值爲空,當前父節點爲n的父節點。筆者以爲這似乎有誤。因爲如果c值爲空,則說明BST樹爲空,沒有任何節點,此時應爲後面講到的特殊情況。如果是說c指向null。那麼說明c爲葉節點,則新插入的節點應作爲c的孩子。即c作爲n的父節點,也不是原文所說的c的父節點作爲n的父節點)

2、  比較nc的值;

3、  如果c等於n,則用於試圖插入一個相同的節點。此時要麼直接拋棄該節點,要麼拋出異常。(注意,在BST中節點的值必須是唯一的。)

4、  如果n小於c,則n必然在c的左子樹中。讓父節點等於cc等於c的左孩子,返回到第一步。

5、  如果n大於c,則n必然在c的右子樹中。讓父節點等於cc等於c的右孩子,返回到第一步。

當合適的葉節點找到後,算法結束。將新節點放到BST中使其成爲父節點合適的孩子節點。插入算法中有種特例需要考慮。如果BST樹中沒有根節點,則父節點爲空,那麼添加新節點作爲父節點的孩子這一步就忽略。而且在這種情況下,BST的根節點必須分配爲新節點。

 

圖九描述了BST插入算法:

圖九

BST插入算法和搜索算法時間複雜度一樣:最佳情況爲log2 n,最壞情況爲線性時間。之所以相同,是因爲它爲插入的新節點定位所採取的策略是一致的。

 

節點插入順序決定BST的拓撲結構

 

既然新插入的節點是作爲葉節點插入的,則插入的順序將直接影響BST自身的拓撲結構。例如,我們依次插入節點:123456。當插入節點1時,作爲根節點。接着插入2作爲1的右孩子,插入3作爲2的右孩子,4作爲3的右孩子,以此類推。結果BST就形成如圖八那樣的結構。

 

如果我們有技巧地排列插入值123456的順序,則BST樹將伸展得更寬,看起來更像圖六所示的結構。理想的插入順序是:425236。這樣將4作爲根節點,2作爲4的左孩子,5作爲4的右孩子,13分別作爲2的左孩子和右孩子。而6則作爲5的右孩子。

 

既然BST的拓撲結構將影響搜索、插入和刪除(下一節介紹)操作的時間複雜度,那麼以升序或降序(或近似升序降序)的方式插入數據,會極大地破壞BST的效率。在本文的後面將詳細地討論。

 

BST中刪除節點

 

BST中刪除節點比之插入節點難度更大。因爲刪除一個非葉節點,就必須選擇其他節點來填補因刪除節點所造成的樹的斷裂。如果不選擇節點來填補這個斷裂,那麼二叉搜索樹就違背了它的特性。例如,圖六中的二叉搜索樹。如果刪除節點150,就需要某些節點來填補刪除造成的斷裂。如果我們隨意地選擇,比如選擇92,那麼就違背了BST的特性,因爲這個時候節點95111出現在了92的左子樹中,而它們的值是大於92的。

 

刪除節點算法的第一步是定位要刪除的節點。這可以使用前面介紹的搜索算法,因此運行時間爲log2 n。接着應該選擇合適的節點來代替刪除節點的位置,它共有三種情況需要考慮,在後面的圖十有圖例說明。

 

情況1:如果刪除的節點沒有右孩子,那麼就選擇它的左孩子來代替原來的節點。二叉搜索樹的特性保證了被刪除節點的左子樹必然符合二叉搜索樹的特性。因此左子樹的值要麼都大於,要麼都小於被刪除節點的父節點的值,這取決於被刪除節點是左孩子還是右孩子。因此用被刪除節點的左子樹來替代被刪除節點,是完全符合二叉搜索樹的特性的。

 

情況2:如果被刪除節點的右孩子沒有左孩子,那麼這個右孩子被用來替換被刪除節點。因爲被刪除節點的右孩子都大於被刪除節點左子樹的所有節點。同時也大於或小於被刪除節點的父節點,這同樣取決於被刪除節點是左孩子還是右孩子。因此,用右孩子來替換被刪除節點,符合二叉搜索樹的特性。

 

情況3:最後,如果被刪除節點的右孩子有左孩子,就需要用被刪除節點右孩子的左子樹中的最下面的節點來替代它,就是說,我們用被刪除節點的右子樹中最小值的節點來替換。

注意:我們要認識到,在BST中,最小值的節點總是在最左邊,最大值的節點總是在最右邊。

因爲替換選擇了被刪除節點右子樹中最小的一個節點,這就保證了該節點一定大於被刪除節點左子樹的所有節點,同時,也保證它替代了被刪除節點的位置後,它的右子樹的所有節點值都大於它。因此這種選擇策略符合二叉搜索樹的特性。

 

圖十描述了三種情況的替換選擇方案

 

 

 圖十

和搜索、插入算法一樣,刪除算法的運行時間與BST的拓撲結構有關。理想狀態下,時間複雜度爲log2 n,最壞情況下,耗費的爲線性時間。

 

BST節點的遍歷

 

對於線性的連續的數組元素,採用的是單向的迭代法。從第一個元素開始,依次向後迭代每個元素。而BST則有三種常用的遍歷方式:

1、  前序遍歷(Perorder traversal

2、  中序遍歷(Inorder traversal

3、  後序遍歷(Postorder traversal

 

當然,這三種遍歷工作原理幾乎相似。它們都是從根節點開始,然後訪問其子節點。區別在於遍歷時,訪問節點本身和其子節點的順序不同。爲幫助理解,我們看看圖十一所示的BST樹。(注意圖六和圖十一所示的BST樹完全相同。


圖十一

 

前序遍歷

 

前序遍歷從當前節點(節點c)開始,然後訪問其左孩子,再訪問右孩子。如果從BST樹的根節點c開始,算法如下:

1、  訪問c。(這裏所謂訪問時指輸出節點的值,並將節點添加到ArrayList中,或者其它地方。這取決於你遍歷BST的目的。)

2、  c的左孩子重複第一步;

3、  c的右孩子重複第一步;

 

設想算法的第一步打印出c的值。以圖十一所示的BST樹爲例,以前序遍歷的方法輸出的值是什麼?是的,我們在第一步首先輸出根節點的值。然後對根的左孩子執行第一步,輸出50。因爲第二步是反覆執行第一步操作,因此是對根節點的左孩子的左孩子訪問,輸出20。如此重複直到樹的最左邊底層。當到達節點5時,輸出其值。既然5沒有左、右孩子,我們又回到節點20,執行第三步。此時是對節點20的右孩子反覆執行第一步,即輸出2525沒有孩子節點,又回到20。但我們對20已經做完了三步操作,所以回到節點50。再對50執行第三步操作,即對50的右孩子重複執行第一步。這個過程不斷進行,直到遍歷完樹的所有節點。最後通過前序遍歷輸出的結果如下:

90, 50, 20, 5, 25, 75, 66, 80, 150, 95, 92, 111, 175, 166, 200

 

可以理解,這個算法確實有點讓人糊塗。或許我們來看看算法的代碼可以理清思路。下面的代碼爲BST類的PreorderTraversal()方法,這個類在文章後面會構建。注意這個方法調用了Node類的實例作爲輸出參數。輸出的節點就是算法步驟中所提到的節點c。執行前序遍歷就是從BST的根節點開始調用PreorderTraversal()方法。

 

protected virtual string PreorderTraversal(Node current, string separator)

{

   if (current != null)

   {

      StringBuilder sb = new StringBuilder();

      sb.Append(current.Value.ToString());

      sb.Append(separator);

 

      sb.Append(PreorderTraversal(current.Left, separator));

      sb.Append(PreorderTraversal(current.Right, separator));

      return sb.ToString();

   }

   else

      return String.Empty;

}

 

(譯註:實際上本方法就是一個遞歸調用)

注意遍歷後的結果放到字符串中,這個字符串時通過StringBuilder創建。首先將當前節點的值放到字符串中,然後再訪問當前節點的左、右孩子,將結果放到字符串中。

 

中序遍歷

 

中序遍歷是從當前節點的左孩子開始訪問,再訪問當前節點,最後是其右節點。假定BST樹的根節點爲c,算法如下:

1、  訪問c的左孩子。(這裏所謂訪問時指輸出節點的值,並將節點添加到ArrayList中,或者其它地方。這取決於你遍歷BST的目的。)

2、  c重複第一步;

3、  c的右孩子重複第一步。

 

InorderTraversal()方法的代碼和PreorderTraversal()相似,只是添加當前節點值到StringBuilder的操作之前,先遞歸調用方法本身,並將當前節點的左孩子作爲參數傳遞。

 

protected virtual string InorderTraversal

                (Node current, string separator)

{

   if (current != null)

   {

      StringBuilder sb = new StringBuilder();

      sb.Append(InorderTraversal(current.Left, separator));

 

      sb.Append(current.Value.ToString());

      sb.Append(separator);

 

      sb.Append(InorderTraversal(current.Right, separator));

      return sb.ToString();

   }

   else

      return String.Empty;

}

 

對圖十一所示BST樹執行中序遍歷,輸出結果如下:

5, 20, 25, 50, 66, 75, 80, 90, 92, 95, 111, 150, 166, 175, 200

 

可以看到返回的結果正好是升序排列。

 

後序遍歷

 

後序遍歷首先從訪問當前節點的左孩子開始,然後是右孩子,最後纔是當前節點本身。假定BST樹的根節點爲c,算法如下:

1、  訪問c的左孩子。(這裏所謂訪問時指輸出節點的值,並將節點添加到ArrayList中,或者其它地方。這取決於你遍歷BST的目的。)

2、  c的右孩子重複第一步;

3、  c重複第一步;

 

圖十一所示的BST樹經後序遍歷輸出的結果爲:

5, 25, 20, 66, 80, 75, 50, 92, 111, 95, 166, 200, 175, 150, 90

 

注意:本文提供的下載內容包括BSTBinaryTree類的完整源代碼,同時還包括對BST類的windows窗體的測試應用程序。尤其有用的是,通過Windows應用程序,你可以看到對BST進行前序、中序、後序遍歷輸出的結果。

 

這三種遍歷的運行時間都是線性的。因爲每種遍歷都將訪問樹的每一個節點,而其對每個節點正好訪問一次。因此,BST樹的節點數成倍增加,則遍歷的時間也將倍增。

 

實現BST

 

雖然JavaSDK包括了BST類(稱爲TreeMap),但.Net Framework基類庫卻不包括該類。因此我們必須自己創建。和二叉樹一樣,首先要創建Node類。我們不能對普通二叉樹中的Node類進行簡單地重用,因爲BST樹的節點是可比較的。因此,不僅僅是要求節點數據爲object類型,還要求數據爲實現IComparable接口的類類型。

 

另外,BST節點需要實現接口Icloneable,因爲我們必須允許開發者能夠對BST類進行克隆clone(即深度拷貝)。使Node類可克隆,那麼我們就可以通過返回根節點的克隆達到克隆整個BST的目的。Node類如下:

 

public class Node : ICloneable

{

   private IComparable data;

   private Node left, right;

 

   #region Constructors

   public Node() : this(null) {}

   public Node(IComparable data) : this(data, null, null) {}

   public Node(IComparable data, Node left, Node right)

   {

      this.data = data;

      this.left = left;

      this.right = right;

   }

   #endregion

 

   #region Public Methods

   public object Clone()

   {

      Node clone = new Node();

      if (data is ICloneable)

         clone.Value = (IComparable) ((ICloneable) data).Clone();

      else

         clone.Value = data;

 

      if (left != null)

         clone.Left = (Node) left.Clone();

     

      if (right != null)

         clone.Right = (Node) right.Clone();

 

      return clone;

   }

   #endregion

 

   #region Public Properties

   public IComparable Value

   {

      get

      {

         return data;

      }

      set

      {

         data = value;

      }

   }

 

   public Node Left

   {

      get

      {

         return left;

      }

      set

      {

         left = value;

      }

   }

 

   public Node Right

   {

      get

      {

         return right;

      }

      set

      {

         right = value;

      }

   }

   #endregion

}

 

注意BSTNode類與二叉樹的Node類有很多相似性。唯一的區別是data的類型爲Icomparable而非object類型,而其Node類實現了Icloneable接口,因此可以調用Clone()方法。

 

現在將重心放到創建BST類上,它實現了二叉搜索樹。在下面的幾節中,我們會介紹這個類的每個主要方法。至於類的完整代碼,可以點擊Download the BinaryTrees.msi sample file 下載源代碼,以及測試BST類的Windows應用程序。

 

搜索節點

 

BST之所以重要就是它提供得搜索算法時間複雜度遠低於線性時間。因此瞭解Search()方法是非常有意義的。Search()方法接收一個IComparable類型的輸入參數,同時還將調用一個私有方法SearchHelper(),傳遞BST的根節點和所有搜索的數據。

 

SearchHelper()對樹進行遞歸調用,如果沒有找到指定值,返回null值,否則返回目標節點。Search()方法的返回結果如果爲空,說明要查找的數據不在BST中,否則就指向等於data值的節點。

 

public virtual Node Search(IComparable data)

{

   return SearchHelper(root, data);

}

 

protected virtual Node SearchHelper(Node current, IComparable data)

{

   if (current == null)

      return null;   // node was not found

   else

   {

      int result = current.Value.CompareTo(data);

      if (result == 0)

         // they are equal - we found the data

         return current;

      else if (result > 0)

      {

         // current.Value > n.Value

         // therefore, if the data exists it is in current's left subtree

         return SearchHelper(current.Left, data);

      }

      else // result < 0

      {

         // current.Value < n.Value

         // therefore, if the data exists it is in current's right subtree

         return SearchHelper(current.Right, data);

      }

   }

}

 

添加節點到BST

 

和前面創建的BinaryTree類不同,BST類並不提供直接訪問根的方法。通過BSTAdd()方法可以添加節點到BSTAdd()接收一個實現IComparable接口的實例類對象作爲新節點的值。然後以一種迂迴的方式查找新節點的父節點。(回想前面提到的插入新的葉節點的內容)一旦父節點找到,則比較新節點與父節點值的大小,以決定新節點是作爲父節點的左孩子還是右孩子。

 

public virtual void Add(IComparable data)

{

   // first, create a new Node

   Node n = new Node(data);

   int result;

 

   // now, insert n into the tree

   // trace down the tree until we hit a NULL

   Node current = root, parent = null;

   while (current != null)

   {

      result = current.Value.CompareTo(n.Value);

      if (result == 0)

         // they are equal - inserting a duplicate - do nothing

         return;

      else if (result > 0)

      {

         // current.Value > n.Value

         // therefore, n must be added to current's left subtree

         parent = current;

         current = current.Left;

      }

      else if (result < 0)

      {

         // current.Value < n.Value

         // therefore, n must be added to current's right subtree

         parent = current;

         current = current.Right;

      }

   }

 

   // ok, at this point we have reached the end of the tree

   count++;

   if (parent == null)

      // the tree was empty

      root = n;

   else

   {

      result = parent.Value.CompareTo(n.Value);

      if (result > 0)

         // parent.Value > n.Value

         // therefore, n must be added to parent's left subtree

         parent.Left = n;

      else if (result < 0)

         // parent.Value < n.Value

         // therefore, n must be added to parent's right subtree

         parent.Right = n;

   }

}

 

Search()方法是對BST從上到下進行遞歸操作,而Add()方法則是使用一個簡單的循環。兩種方式殊途同歸,但使用while循環在性能上比之遞歸更有效。所以我們應該認識到BST的方法都可以用這兩種方法——遞歸或循環——其中任意一種來重寫。(個人認爲遞歸算法更易於理解。)

 

注意:當用戶試圖插入一個重複節點時,Add()方法的處理方式是放棄該插入操作,你也可以根據需要修改代碼使之拋出一個異常。

 

BST中刪除節點

 

BST的所有操作中,刪除一個節點是最複雜的。複雜度在於刪除一個節點必須選擇一個合適的節點來替代因刪除節點造成的斷裂。注意選擇替代節點必須符合二叉搜索樹的特性。

 

在前面“從BST中刪除節點”一節中,我們提到選擇節點來替代被刪除節點共有三種情形,這些情形在圖十中已經有了總結。下面我們來看看Delete()方法是怎樣來確定這三種情形的。

 

public void Delete(IComparable data)

{

   // find n in the tree

   // trace down the tree until we hit n

   Node current = root, parent = null;

   int result = current.Value.CompareTo(data);

   while (result != 0 && current != null)

   {           

      if (result > 0)

      {

         // current.Value > n.Value

         // therefore, n must be added to current's left subtree

         parent = current;

         current = current.Left;

      }

      else if (result < 0)

      {

         // current.Value < n.Value

         // therefore, n must be added to current's right subtree

         parent = current;

         current = current.Right;

      }

 

      result = current.Value.CompareTo(data);

   }

 

   // if current == null, then we did not find the item to delete

   if (current == null)

      throw new Exception("Item to be deleted does not exist in the BST.");

 

 

   // at this point current is the node to delete, and parent is its parent

   count--;

  

   // CASE 1: If current has no right child, then current's left child becomes the

   // node pointed to by the parent

   if (current.Right == null)

   {

      if (parent == null)

         root = current.Left;

      else

      {

         result = parent.Value.CompareTo(current.Value);

         if (result > 0)

            // parent.Value > current

            // therefore, the parent's left subtree is now current's Left subtree

            parent.Left = current.Left;

         else if (result < 0)

            // parent.Value < current.Value

            // therefore, the parent's right subtree is now current's left subtree

            parent.Right = current.Left;

      }

   }

   // CASE 2: If current's right child has no left child, then current's right child replaces

   // current in the tree

   else if (current.Right.Left == null)

   {

      if (parent == null)

         root = current.Right;

      else

      {

         result = parent.Value.CompareTo(current.Value);

         if (result > 0)

            // parent.Value > current

            // therefore, the parent's left subtree is now current's right subtree

            parent.Left = current.Right;

         else if (result < 0)

            // parent.Value < current.Value

            // therefore, the parent's right subtree is now current's right subtree

            parent.Right = current.Right;

      }

   }  

   // CASE 3: If current's right child has a left child, replace current with current's

   // right child's left-most node.

   else

   {

      // we need to find the right node's left-most child

      Node leftmost = current.Right.Left, lmParent = current.Right;

      while (leftmost.Left != null)

      {

         lmParent = leftmost;

         leftmost = leftmost.Left;

      }

 

      // the parent's left subtree becomes the leftmost's right subtree

      lmParent.Left = leftmost.Right;

     

      // assign leftmost's left and right to current's left and right

      leftmost.Left = current.Left;

      leftmost.Right = current.Right;

 

      if (parent == null)

         root = leftmost;

      else

      {

         result = parent.Value.CompareTo(current.Value);

         if (result > 0)

            // parent.Value > current

            // therefore, the parent's left subtree is now current's right subtree

            parent.Left = leftmost;

         else if (result < 0)

            // parent.Value < current.Value

            // therefore, the parent's right subtree is now current's right subtree

            parent.Right = leftmost;

      }

   }

}

 

注意:當沒有找到指定被刪除的節點時,Delete()方法拋出一個異常。

 

其他的BST方法和屬性

 

還有其他的BST方法和屬性在本文中沒有介紹。我們可以下載本文附帶的完整的源代碼來仔細分析BST類。其餘的方法包括:

Clear():移出BST的所有節點。

Clone():克隆BST(創建一個深度拷貝)。

ContainsIComparable):返回一個布爾值確定BST中是否存在其值爲指定數據的節點。

GetEnumerator():用中序遍歷算法對BST節點進行枚舉,並返回枚舉數。這個方法使BST可通過foreach循環迭代節點。

PreorderTraversal()/InorderTraversal()/PostorderTraversal():在“遍歷BST節點”一節中已經介紹。

ToString():使用BST特定的遍歷算法返回字符型的表示結果。

Count:公共的只讀屬性,返回BST的節點數。

 

現實世界的二叉搜索樹

 

二叉搜索樹理想的展示了對於插入、搜索、刪除操作在時間複雜度上低於線性時間的特點,而這種時間複雜度與BST的拓撲結構有關。在“插入節點到BST中”一節中,我們提到拓撲結構與插入節點的順序有關。如果插入的數據是有序的,或者近似有序的,都將導致BST樹成爲一顆深而窄,而非淺而寬的樹。而在很多現實情況下,數據都處於有序或近似有序的狀態。

 

BST樹的問題是很容易成爲不均衡的。均衡的二叉樹是指寬度與深度之比是優化的。在本系列文章的下一部份,會介紹一種自我均衡的特殊BST類。那就是說,不管是添加新節點還是刪除已有節點,BST都會自動調節其拓撲結構來保持最佳的均衡狀態。最理想的均衡狀態,就是插入、搜索和刪除的時間複雜度在最壞情況下也爲log2 n。我在前面提到過Java SDK中有一個名爲TreeMapBST類,這個類實際上就是派生於一種職能地、自我均衡的BST樹——紅黑樹(the red-black tree)。

 

在本系列文章的下一部分,我們就將介紹這種可自我均衡的BST樹,包括紅黑樹。重點介紹一種成爲SkipList的數據結構。這種結構體現了自我均衡的二叉樹的性能,同時並不需要對其拓撲結構進行重構。

 

先到此爲止,好好享受編程的樂趣吧!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章