到目前為止,我們一直在談論的數據結構都是“線性結構”,不論是普通鏈表、棧還是隊列,其中的每個元素(除了第一個和最后一個)都只有一個前驅(排在前面的元素)和一個后繼(排在后面的元素),但是在(9)中,我們發現有的時候“線性結構”是不能滿足我們的需求的,必然存在某些場景需要我們使用非線性的數據結構。而今天,我們要討論的就是典型的非線性數據結構——樹。
該從哪里開始談起樹是一個很麻煩的問題,我想了很久決定還是先給出樹的“模樣”,再說說樹可能應用的場景,最后說說樹的實現方法。
那么,樹長什么“模樣”呢?大概就長下面這個模樣
在數據結構樹中,元素與元素之間的關系不再是“一前一后”,而是“一前多后”,即除去最頂上的元素(它沒有前驅,就像線性表中的第一個元素,也沒有前驅),每個元素都只能有一個前驅,但可以有多個后繼或沒有后繼,比如上圖中的EIJCGH元素都沒有后繼。
我相信不只是我一個人在學習樹的時候有過這個疑惑:這種東西為什么叫“樹”?
其實是這樣的:你把數據結構樹垂直翻轉一下
你就會發現,它跟現實生活中的樹有那么一點點相似:數據結構樹中的每個元素就像現實樹的每一個分叉點。so,大家叫這種“一前多后”的數據結構為“樹”。當然,我個人認為你把它看作“樹根”或者“族譜”會更像。(下圖為三皇五帝族譜)
對於樹的抽象概念就講到這兒,接下來我們要說一說和樹有關的一些術語,畢竟你和別人交流還是得用標准的東西來說……
對於樹中的元素,我們稱之為“結點”,每一個結點的前驅,我們稱之為“父節點”,結點的后繼,我們稱之為“孩子”(是不是更覺得像族譜了?),沒有父結點的那個結點我們稱之為“根結點”或“根”(就是下圖中的A,是不是很奇怪,怎么又叫“根”了?所以我說這種結構與其叫樹不如叫樹根或者族譜,可能是外國人不認族譜這種東西,而樹根和根結點都叫root的話又怪怪的),而沒有孩子的結點我們稱之為“葉子”(如下圖中的EIJCGH)。
除了上面的稱呼術語外,我們還需要知道兩個概念:深度和高度。
所謂深度就是從根開始逐層向下數的層數,直白的說,A深度為0,BCD深度為1,EFGH深度為2,IJ深度為3,整棵樹的深度則取其中深度最大結點的深度,也就是3。
所謂高度,則是從深度最大的結點開始向上數的層數,直白的說,IJ高度為0,EFGH高度為1,BCD高度為2,A高度為3,整棵樹的高度也就是3。
至於你問我為什么要從0開始數,嗯……類似於數組第一個元素的下標為0的道理,有時候從0開始數有利於編程實現,就醬。
哦,對了,還有一件事要說,就是什么是“子樹”,所謂“子樹”呢其實就是樹中的樹,比如我們拋開其它結點,單獨看B和B的兒子、孫子們,你會發現它們也是一棵樹,而它們這棵樹從屬於根為A的這棵樹,所以根為B的這棵樹就是根為A的樹的“子樹”。並且!就像線性表中可以只有一個元素一樣,樹也可以只有一個結點,所以葉子結點也可以是“一棵樹”。不過我個人覺得過分追究這些東西很容易讓人糾結於其中,所以我們對樹的各種概念的介紹就到此打住吧。
接下來,我們要說說樹這種數據結構可能用於什么場景。當然,如果你需要存儲族譜的話,用樹是肯定的╮(╯_╰)╭。但是除了族譜,我們身邊還有一種常見的和計算機相關的事物是樹型結構的。那就是——文件系統
在文件系統中,一個文件夾下可以有多個文件夾或文件,而一個文件(夾)只屬於一個文件夾,這是典型的樹結構。
此外,我們之后還會討論到一些特殊的樹,它們又可以用於一些特殊的用途(主要是用於快速查找、搜索)。
好了,對於樹的概念和可能應用就講到這兒,接下來我們要談談如何實現一棵樹的存儲(准確的說,是在內存中的存儲)。根據我們在鏈表中所學的知識,像這樣隨時可能增加、刪除某個元素的數據結構,肯定是需要“鏈”的,也就是需要每個結點保存着“下一個結點”的地址。在線性的鏈表中,這一點非常容易實現,每個元素都使用結構體,令結構體中保存元素的數據和一個指向本結構體類型的指針。但是在樹中,這種做法存在一個問題:結構體中該有多少個指針呢?
這個問題乍一看好像很難解,因為你不知道一個結點到底會有幾個孩子,可能沒有孩子,也可能有成百上千個孩子。但其實我們可以換個思路,稍微借鑒一下鏈表的思想,那就是:某個結點的孩子們可以看成是一個線性表,結點只需要知道第一個孩子在哪即可,第一個孩子知道第二個孩子在哪,第二個孩子知道第三個孩子在哪,以此類推。這樣一來,一個結點就只需要兩個指針,一個指向自己的第一個孩子,另一個指向自己的下一個“兄弟”。
如上圖,A有6個孩子BCDEFG,但它只通過son指針保存了B的地址,其它5個孩子則通過兄弟間的brother指針來獲得。
至此,樹中的結點應該如何定義,我們已經知道了,如下(我們假設我們制作一個簡單的模擬文件管理器,詳細代碼會在最后給出):
typedef struct treeNode{ bool IsFile; //IsFile用於判斷本結點是文件還是文件夾,如果是文件則不支持向其插入孩子 char name[NAMESIZE]; //用於存儲結點(文件(夾))名 struct treeNode *son; struct treeNode *brother; }treeNode; typedef treeNode* Tree;
有了樹的結點定義后,我們接下來要討論討論可以對樹做的操作,首先當然是初始化。
初始化的思路很簡單,通過malloc分配一個結點的空間並初始化,然后將該結點保存於主程序中的“根指針”
//初始化樹t,使其為文件夾且名為root void Initialize(Tree *t) { *t=(Tree)malloc(sizeof(treeNode)); (*t)->IsFile=false; strcpy_s((*t)->name,NAMESIZE,"root"); (*t)->son=NULL; (*t)->brother=NULL; }
插入的思路很簡單:
為新結點分配空間並初始化,然后找到要插入該結點的父結點
若父結點的son==NULL,則直接令父節點的son指向新分配的結點
若父結點的son!=NULL,則暫存父結點的son,令父節點的son指向新結點后,再令新結點的brother指向暫存的那個son。大致如下圖。
插入的代碼較長,所以不給出,因為“如何找到目標父結點”需要視情況而定,可以直接令使用者輸入完整路徑,也可以從根節點起,逐層令使用者選擇文件夾,直至使用者到達目標文件夾為止。
具體的插入代碼將會在最后給出。
知道了如何初始化樹,如何向樹中插入結點后,我們接着學習另一種操作了,叫做“遍歷”,其意思就是“走遍整棵樹中的所有結點並進行相應操作,如輸出結點信息”。對於線性表來說,遍歷是一個很簡單的操作,我們只要從第一個元素開始一直向后操作就可以,但是對於樹來說,遍歷的操作變得有點“復雜”。
首先我們假設遍歷時對每個結點的操作就是輸出結點信息,接下來我們看看對於樹,可以如何遍歷。
對於上圖中的樹,我們可以“直觀地”選擇“逐層遍歷”,先輸出同一深度的所有結點,然后再輸出深度+1的所有結點,即遍歷順序為ABCDEFGHIJ。
但是逐層遍歷有兩個問題,第一個問題是:這種順序對於文件系統來說,不能反映出文件(夾)間的從屬關系。
假設根文件夾為root,其下有兩個文件夾QingHua和Nchu,QingHua下有文件xxx.exe,Nchu下有文件XieWei.666,對於每個結點,我們根據其深度打印\t的個數(越深的結點打印越多\t),然后打印其名字
那么顯然的,我們希望遍歷后輸出的樣子長這樣,因為這樣可以反映出文件(夾)間的從屬關系
可是如果我們按照“逐層遍歷”,我們輸出的會是這樣
如果采用逐層遍歷,那么我們就沒能“反映出結點間的從屬關系”。
可能你會認為,對於不需要反映結點間從屬關系的情況來說,逐層遍歷也沒有問題。但是,逐層遍歷還有一個更嚴重的問題,那就是:代碼更不好寫╮(╯_╰)╭(並不是說逐層遍歷的代碼無法給出,而是更不好寫)
所以,我們對於樹,往往采用一種叫做“先序遍歷”的遍歷方法。什么是“先序遍歷”呢?關鍵詞就是“先序”,所謂先序,意思就是“對於每一個結點,我們都按照先處理其本身,再處理其孩子的順序執行”,這個“先”就是指“結點的處理先於其孩子”。
上面的話可能有一點繞口,什么叫結點的處理先於其孩子?逐層遍歷時我們不也是先處理了A,再處理的BCD嗎?如果你有這樣的困惑,請注意上面的一小段話:“對於每一個結點”。在逐層遍歷中,我們的確先處理了A再處理A的孩子,但是對於B呢?C呢?我們都沒有嚴格的按照處理完結點就去處理其孩子。
那么,先序遍歷的代碼好寫嗎?當然好寫,因為每一個結點的處理方法都是一樣的,所以遞歸可以很好地運用在先序遍歷中:
//先序輸出樹 //調用者將參數layer設為0,該參數即當前結點在樹中的深度 void PrintPreOrder(Tree t,int layer) { //遞歸一定要有基准情形,這在遞歸簡論博文中提到過 if(t==NULL) return; //按照先序遍歷的要求,我們先處理當前結點 for(int i=0;i<layer;++i) //根據當前結點在樹中的層決定打印多少個制表符 putchar('\t'); printf("%s\n",t->name); //然后我們對當前結點的孩子們再逐一進行先序遍歷 Tree temp=t->son; while(temp!=NULL) { PrintPreOrder(temp,layer+1); temp=temp->brother; } }
但是先序遍歷也不能適應所有需要遍歷的情況,比如說當我們想要統計文件系統的總大小時。一個文件夾的大小等於其中所有文件(夾)的大小之和,而我們如果不“先去看看子文件(夾)們的大小”,又如何統計出它們總共的大小呢?
這個時候,我們就需要和先序遍歷相反的遍歷方法了,那就是“后序遍歷”,其名字的解釋與先序遍歷相似,就是“對於每一個結點,我們都按照先處理其孩子,再處理其本身的順序執行”。這樣一來,我們就能在處理結點本身之前,獲取到其下所有孩子們的信息。而且先序遍歷的代碼轉換為后序遍歷也比較簡單,只需要將對當前結點的操作轉移到對孩子們的操作之后就可以了。(下面的代碼中我們假設結點存在size)
int CountSize(Tree t) { int total = 0; //如果t為NULL則返回total,即0,此處作為遞歸基准情形 if (t == NULL) return total; //如果t不為NULL則對其下每個孩子進行CountSize,並將返回值加到total上 Tree son = t->son; while (son != NULL) { total += CountSize(son); son = son->brother; } //此時total已經為t下所有孩子的大小之和,只需要讓total加上t本身大小即可得出整個t(假設為文件夾)的大小 total += t->size; return total; }
類似的,后序遍歷也可以應用於釋放樹的操作中
//釋放樹中的每一個結點 void FreeTree(Tree t) { if (t == NULL) return; Tree temp = NULL; Tree t_son = t->son; while (t_son != NULL) { temp = t_son->brother; FreeTree(t_son); t_son = temp; } free(t); }
當然,釋放樹也可以采用先序遍歷的方法實現(可以稍微比較與后序遍歷實現的差異,可以看出兩者差異基本上就只是對當前結點的處理放在孩子們的前面還是后面的區別)
//釋放樹中的每一個結點 void FreeTree(Tree t) { if (t == NULL) return; Tree t_son = t->son; Tree temp = NULL; free(t); while (t_son != NULL) { temp = t_son->brother; FreeTree(t_son); t_son = temp; } }
刪除某個結點的操作就比較簡單了,至少對於我們的模擬文件系統來說是,因為刪除一個結點我們默認將其下所有結點也一並刪除,所以只需要像插入操作一樣,找到要刪除的結點,然后對該結點進行FreeTree操作即可,當然,指向被刪除結點的指針(可能是父結點的son,也可能是兄弟結點的brother)也需要相應地改動,但是都不難,就不做具體介紹了。
樹的簡介就到這兒,接下來是給出模擬文件管理器的代碼:
https://github.com/nchuXieWei/BlogUse------AnalogFileSystem
在上面的模擬文件管理器中,使用了一點本文沒有討論的技術——將樹存儲到文件中。
這個技術其實並不難,關鍵點就是如何將非線性的樹存儲為線性表,並且令表中的每個結點均保存有father所在的表中位置(下標)。如果想要了解這個技術如何實現,請查看XWTree.cpp中的store(),storeToArray()和Load()