數據結構-樹


終於有機會重新回頭學習一下一直困擾自身多年的數據結構了,趕腳棒棒噠。一直以來,對數據結構的掌握基本局限於線性表,稍微對樹有一丟丟了解,而對於圖那基本上就是不懂(不可否認,很多的考試中回避了圖也是原因之一),而查找和排序只能算是了解點皮毛,簡單的面試能應付的水平。關於數據結構方面的教材和視頻有不少,首推嚴蔚敏老教授的書和視頻,尤其是視頻,記載的是其在清華大學的授課過程,全程通過不同的教具來演示不同的示例,非常直觀。自身由於懶惰,一直也沒堅持的把其看完,於是選擇了相對簡單的學習方法,就是選擇了程傑老師的《大話數據結構》一書,該書詼諧幽默,讓人的挫敗感下降了很多,老話說的好,"興趣是一切學習的老師",別小看任何入門書,它往往是決定你堅持你努力的初衷。隨着年齡的增長,會發現一個新的開始是越來越難,推薦一篇園內博文《學習新東西的唯一方法》http://kb.cnblogs.com/page/536332/

言歸正傳,本文主要介紹數據結構中的樹,可以說樹是數據結構最為承上啟下的部分,其可以轉化為線性表(通過二叉樹的線索化),也是學習圖的基礎,此外在實際中,數據庫索引使用的B+樹數據結構也是經典中的經典,不過這部分將放到之后的查找主題再介紹。

 

樹的定義:是n個結點的有限集,在任何非空樹中,有且僅有一個根結點,其余結點可以分為m個互不相交的有限集,其中的內一個集合又是一棵樹,並稱為根的子樹。一種簡單的描述為,樹是由一個根節點和若干子樹構成,樹中結點具有相同數據類型和層次關系,是一種類似鏈表的遞歸式結構。

樹中結點的定義:樹的結點包含一個數據元素及若干指向其子樹的分支,結點擁有的子樹數稱為結點的(Degree)。度為0的結點稱為葉子結點,大於0的為分支結點,樹的度是樹內各結點度的最大值。結點的子樹稱為該節點的孩子,相應的該節點稱為孩子的雙親(Parent)。同一個雙親的孩子之間互稱兄弟(Sibling),結點的祖先是從根到該節點所經分支上的所有節點。

此外,森林是m棵互不相交的樹的集合,對於任意一個結點來說,其子樹的集合即為森林。

圖 1樹的基本概念

了解了樹的基本概念,接下來學習樹的存儲結構,常見的包括雙親表示法、孩子表示法和孩子兄弟表示法。存儲結構的設計是一個非常靈活的過程,一個存儲結構設計的是否合適,取決於基於該存儲結構的運算是否合適、是否方便、時間復雜度好不好。不過一般來說,一個樹結點都具有數據域指針域,之后將分別介紹之前提及的三種表示法。

雙親表示法,相對比較簡單,之后的例子為了方便兄弟的查找還添加了右孩子指針。

 1 public class Tree1
 2 {
 3 public TreeNode[] NodeList { get; set; }
 4 public int NodeCount { get; set; }
 5 public int RootIndex { get; set; }
 6 
 7 private class TreeNode
 8 {
 9 public object Data { get; set; }
10 public TreeNode Parent { get; set; }
11 public TreeNode RightSibling { get; set; }
12 }
13 }
View Code

孩子表示法,由於每個結點可以有多個孩子,因此可以使用多重鏈表的表示法,在結點中,使用多個指針,每個指向一個孩子。此時有兩種解決方案來構建器存儲結構,一種是指針的個數就是樹的度,缺點是由於每個結點的度和樹的度可能差異很大,會造成大量的空間浪費;另一種是添加一個度域,然后根據結點的度來構建指針域,其缺點是每個結點的結構不同,造運算性能上的下降。那有沒有更好的方法,即保證結點結構相同並且避免空指針帶來的空間浪費。必須是有的嘛,那就是將每個結點的孩子結點排列起來,以單鏈表作存儲結構,n個節點構建n個孩子鏈表,葉子結點只有頭指針。然后將n個頭指針組成一個線性表,采用順序結構存放在一位數組中。因此需要設計頭結點鏈表的孩子結點兩種結構,整體方案如下所示。

圖 2樹的孩子表示法

 1 public class Tree2
 2 {
 3 public TreeNode[] NodeList { get; set; }
 4 public int NodeCount { get; set; }
 5 public int RootIndex { get; set; }
 6 
 7 public class TreeNode
 8 {
 9 public object Data { get; set; }
10 public TreeNode FirstChild { get; set; }
11 }
12 
13 public class LinkedNode
14 {
15 public int CurIndex { get; set; }
16 public TreeNode Next { get; set; }
17 }
18 }
View Code

孩子雙親表示法,通過對之前樹進行觀察,不難發現,一個節點的第一個孩子如果存在就是唯一的,有兄弟也是如此,因而該表示法由數據域、firstchild指針和rightSibling指針組成,由於和之前類似,省略代碼,這里的重點是,通過這種表示法,你會發現該表示法其實就是將該樹轉化為了一個特殊的樹(二叉樹,樹結構中的核心),一個值域,兩個指向左右孩子的指針域,在下一節中將進行詳細介紹。

 

  • 二叉樹

二叉樹的定義:二叉樹(Binary Tree)是一個特殊的樹,由一個根結點和兩個互不相交的、分別被稱為左子樹和右子樹的二叉樹組成。仍然是一個遞歸的概念,在與樹有關的結構中,有很多往往最后都是通過轉化為二叉樹,再借助二叉樹相關算法,達到化繁為簡的效果。為了更好的學習二叉樹,之后將介紹幾種特殊的樹。

斜樹:所有的結點只有左或右子樹的二叉樹。

滿二叉樹:所有分支結點都存在左子樹和右子樹,且所有葉子都在同一層上的二叉樹。

完全二叉樹:最重要的一種特殊二叉樹,理解起來也有一定難度。定義是,對一顆具有n個結點的二叉樹按層序編號,如果編號為i的結點與相同深度的滿二叉樹中編號為i的結點在二叉樹上的位置完全相同,則稱其為完全二叉樹,相關的圖例如下。

圖 3特殊的二叉樹

可以發現完全二叉樹特點:葉子結點只能出現在最下兩層;最下層葉子一定集中在左側連續位置;倒數第二層葉子結點一定在右側連續位置;如果結點度為1,也該結點只有左孩子;同樣結點數的二叉樹,完全二叉樹深度最小。

 

二叉樹的性質

1.在二叉樹的第i層上至多有2i-1個結點。

2.深度為k的二叉樹至多有2i-1個結點。

3.對任意一顆二叉樹,如果其終端結點數為n0,度為2的節點數為n2,則n0=n2+1。終端結點也就是葉子結點,除此之外就是度為1或2的結點數了,有結點總數n=n0+n1+n2(式1)。通過連接線的角度來看,由於根結點只有分支出去,沒有分支進入,因此連接線數為m = n-1;再從度的角度出發,有連接數m = n1+2n2,因而推導出n-1= n1+2n2(式2),聯立式1、2得n0=n2+1

4.具有n個結點的完全二叉樹的深度為log2n + 1,符號為向下取整。

5.如果對一個n個結點完全二叉樹按層序編號,對任意結點i有:如果i=1,則結點i是二叉樹的根,如果i>1,則其雙親是結點i/2;如果2i>n,則結點i無左孩子,否則其左孩子為結點2i;如果2i+1>n,則結點i無右孩子,否則其右孩子為2i+1

Tip:

Word快捷操作, 指數——按組合鍵"Ctrl+Shift+ ="上標,重復操作則恢復常規輸入;下標—— 按組合鍵"Ctrl+ ="下標,重復操作則恢復常規輸入。

 

二叉樹的存儲結構:包括順序存儲和二叉鏈表存儲兩種方式。對於任意的二叉樹,我們把它模擬成一個對應的完全二叉樹,其按照層序編號,可以很方便的存放在一維數組中,請見以下示例。

圖 4二叉樹的順序存儲結構

其中^表示不存在的結點,非常的方便的就將一個樹的存儲簡化為了線性表的存儲。順序存儲的缺點是,如果該二叉樹是一個右斜樹,那么會造成存儲空間很大的浪費,此時需要考慮使用二叉鏈表的存儲結構。這種結構理解起來很簡單,即結點包含一個數據域和左右孩子兩個指針域。

 

遍歷二叉樹:二叉樹的遍歷(traveling binary tree)的定義為指從根結點出發,按照某種次序依次訪問二叉樹中所有的結點,且每個結點被有且僅有一次訪問。這個定義的重點是次序,其引申出二叉樹遍歷的4種方式:前序遍歷、中序遍歷、后序遍歷和層序遍歷,這兒的前中后均表示訪問父結點的次序,示例圖如下所示。

圖 5二叉樹的遍歷方法

經過觀察,無論是那種遍歷方式,都將樹這個復雜的邏輯結構轉化為了簡單的線性結構,稱為了計算機真正可以處理的數據結構。接下來更進一步,通過代碼來描述遍歷方法,由於幾種方式形式相近,這兒就選取中序遍歷為例。

 1 public class BinaryTree
 2 {
 3 public TreeNode Root { get; set; }
 4 public void MiddleOrderTravel() { TreeManager.MiddleOrderTravel(Root); }
 5 }
 6 
 7 public class TreeNode
 8 {
 9 public object data { get; set; }
10 public TreeNode LeftChild { get; set; }
11 public TreeNode RightChild { get; set; }
12 }
13 
14 public class TreeManager
15 {
16 public static void MiddleOrderTravel(TreeNode tree)
17 {
18 if (null == tree) return;
19 MiddleOrderTravel(tree.LeftChild);
20 Console.WriteLine(tree.data);
21 MiddleOrderTravel(tree.RightChild);
22 }
23 }
View Code

接下里介紹一個很常見的數據結構考題,用於熟悉二叉樹遍歷的相關概念。一顆二叉樹的前序遍歷為ABDCFG,中序遍歷為DBAFCG,那么這顆樹后續遍歷的結果是什么?其結構是怎么樣的?分析過程如下所示。

通過前序遍歷的ABDCFG,可以該樹的根結點為A;再通過中序遍歷可知A的左子樹為DB,右子樹為FCG;分析左子樹DB,從前序遍歷ABD可以看出B是該子樹的根,通過中序遍歷DBA可知,D為B的左孩子;對於右子樹FCG,通過前序遍歷ABDC可知,C為該子樹的根,再通過中序遍歷FCG可知,F為左子樹,G為右子樹;即為之前圖5中的二叉樹,后續遍歷為DBFGCA。

此外,需要知道的是,已知前序遍歷和中序遍歷、后序遍歷和中序遍歷可以唯一確認一個二叉樹,而前序遍歷和后序遍歷不能。例如前序遍歷為ABC,后序遍歷為CBA,該樹的中序遍歷可以為BAC,也可以是CBA等。

 

  • 線索二叉樹

之前介紹通過二叉鏈表的方式來存儲二叉樹,可以比較好的利用空間,但仍然有很多的空指針域存在(對於n個結點的二叉樹,其指針域數量為2n,分支數為n-1,其空指針域有2n – (n-1) = n + 1個),造成空間的浪費,那么有木有什么好的辦法將其利用起來呢?

那么就是空指針域記錄結點的前驅和后繼(和線性表一樣),也稱這些指針為線索,加上線索的二叉鏈表稱為線索鏈表,相應的二叉樹為線索二叉樹(Threaded Binary Tree),以下通過以中序遍歷為例,構建一個線索二叉樹。

圖 6二叉樹的線索化

上圖比較簡陋,望見諒。簡單來說,就是將結點空閑的左孩子指針記錄其前驅,右孩子指針記錄其后繼,添加一個頭結點指針方便遍歷。此外,需要為結點增加左、右兩個標識域,用於記錄其指針是指向的其孩子還是其前驅/后繼,線索二叉樹的結構和創建代碼如下所示。

 1 public class ThreadedBinaryTree
 2 {
 3 public ThreadedBinaryTreeNode Root { get; set; }
 4 public void MiddleOrderTravel() { ThreadedBinaryManager.InThreading(Root); }
 5 }
 6 
 7 public class ThreadedBinaryTreeNode
 8 {
 9 public object data { get; set; }
10 public ThreadedBinaryTreeNode LeftChild { get; set; }
11 public ThreadedBinaryTreeNode RightChild { get; set; }
12 public TagType LeftTag { get; set; }
13 public TagType RightTag { get; set; }
14 }
15 
16 public enum TagType : short
17 {
18 Child = 0,
19 Thread = 1
20 }
21  
22 public class ThreadedBinaryManager
23 {
24 //用於記錄前驅結點
25 public static ThreadedBinaryTreeNode PreNode { get; set; }
26 //中序遍歷場景
27 public static void InThreading(ThreadedBinaryTreeNode tree)
28 {
29 if (null == tree) return;
30 InThreading(tree.LeftChild);
31 if (null == tree.LeftChild)
32 {
33 tree.LeftTag = TagType.Thread; //前驅線索
34 tree.LeftChild = PreNode; //指向前驅
35 }
36 if (null == tree.RightChild)
37 {
38 tree.RightTag = TagType.Thread; //后繼線索
39 tree.RightChild = tree; //指向后繼,即當前結點p
40 }
41 PreNode = tree;
42 
43 InThreading(tree.RightChild);
44 }
45 }
View Code
  • 樹、森林與二叉樹的轉換

這部分內容有一些繁雜,需要耐心理解,其主要意圖還是通過將樹、森林轉化為二叉樹來簡化問題,包括:樹轉化為二叉樹,森林轉化為二叉樹,二叉樹轉化為樹,二叉樹轉換為森林和樹與森林的遍歷,將重點介紹樹轉化為二叉樹,之后的幾種轉化和前者有相似之處,只做簡要介紹。

樹轉化為二叉樹,包含3個步驟:加線,在所有兄弟結點之間加一條連線;去線,對樹中每個結點,只保留它與第一個孩子結點的連線,三處它與其他孩子結點之間的連線;層次調整,以樹的根結點為軸心,將整棵樹順時針旋轉一定角度,使其層次分明。相關示例如下所示。

圖 7樹轉化為二叉樹

這兒需要提及的一點是,轉換后的二叉樹的左子樹為其第一個孩子,右子樹為其兄弟,雖然以上的轉化結果看起來怪怪的,但確實是這樣的結果。

森林轉化為二叉樹

森林由若干樹組成,可以認為其中的樹互為兄弟,先分別按照樹轉化為二叉樹的方式轉化,然后按序將后一棵二叉樹添加為前一棵樹的右孩子。

二叉樹轉化為樹

其是樹轉化為二叉樹的逆過程,步驟為:加線,若某結點的左孩子存在,則將結點的右孩子、右孩子的右孩子…於該結點連線;去線,去除原二叉樹中所有結點與右孩子的連線;層次調整,使得之層次分明。

二叉樹轉化為森林:樹的根節點有右孩子就是森林,否則就是二叉樹。轉化步驟為:從根結點開始,若右孩子存在,則把右孩子結點的連線刪除,再遞歸的進行此步驟;將分離的二叉樹轉化為樹。

這部分,最后補充樹與森林的遍歷,其均支持先根遍歷和后根遍歷兩種方式,過程和之前介紹的二叉樹遍歷相似。需要注意的是,樹和森林的前序遍歷和二叉樹的前序遍歷結果一致,森林的后續遍歷和二叉樹的中序遍歷的結果相同。這說明當使用二叉鏈表作為樹的存儲結構時,樹和森林的遍歷可以轉化為二叉樹的相關遍歷,將復雜問題簡單化。

 

  • 赫夫曼樹及其應用

提到赫夫曼樹,不得不提赫夫曼編碼,它是一種基本的壓縮編碼方式,基於赫夫曼樹這一數據結構。在介紹其定義之前,需要了解一個路徑長度的定義:從樹中一個結點到另一個結點的分支構成兩個結點間的路徑,路徑上的分支數目叫路徑長度。如果考慮路徑的權重,則結點的帶權路徑長度為該結點到樹根之間的路徑長度與結點上權的乘積,整棵樹的帶權路徑長度為所有葉子結點的帶權路徑長度之和。

圖 8帶權路徑長度

通過上圖可以,看到樹B比樹A帶權路徑短,那么還有更短的么?在這引入赫夫曼樹的概念,帶權路徑長度WPL最小的二叉樹即為赫夫曼樹,其構建過程包括如下4個步驟,具體過程請見圖9。

1.根據給定的n個權值構建對應的n個二叉樹集合F(只包含根結點,也是葉子結點)。

2.在F中選取兩棵權值最小的樹作為左右子樹構成一棵新的二叉樹,且將該新樹的根結點的權值設為左右子樹之和。

3.在F集合中刪除被組合的兩棵樹,新增組合后的新樹。

4.重復2、3步驟,直到F集合只包含一棵樹為止,此時,該樹即為赫夫曼樹。

圖 9構建赫夫曼樹

 

在學習了赫夫曼樹之后,接着來學習赫夫曼編碼。在1950年左右,那時的通信主要是通過電報進行,因而對優化數據傳輸的編碼方式,節省信道資源非常重視。先舉一個示例,例如用戶甲想發送字符串ABBCCCD給用戶乙,常見的,將其按照unicode編碼發送,這將占用14個字節,56個Bit位,非常的浪費。最簡單的可以使用2個bit位表示一個字母,那么結果是14bit,那么應用哈夫曼編碼呢?

其編碼結果為用100表示A,用11表示B、用0表示C、用101表示D,大小為13bit(這兒例子為了簡化,數據太小,可能不能很好的顯示其優勢)。此外,可以發現赫夫曼編碼的每一個碼值均不為其他碼值的前綴,保證了解碼的正確性。

 

參考資料:

  1. 程傑. 大話數據結構[M]. 北京:清華大學出版社, 2011.
  2. 嚴蔚敏, 吳偉民. 數據結構(C語言版)[M]. 北京:清華大學出版社, 2004.


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM