樹、二叉樹

##樹的定義
定義: 樹是n(n>=0)個結點的有限集。n=0時稱爲空樹。在任意一棵非空樹中
(1)有且僅有一個特定的稱爲根的結點
(2)當n > 1時,其餘結點可分爲m(m > 0)個互不相交的有限集T1、T2、…、Tm,其中每一個集合本身又是一棵樹,並且稱爲根的子樹。

除了以上的定義,還需要強調兩點:
(1)n > 0時根結點是唯一的,不可能存在多個根結點。
(2)m > 0時子樹的個數沒有限制,但它們一定是互不相交的。
下面是一棵樹的示例:
樹

在明白樹的定義之後,還有一些概念需要明白:
(1)樹的分類:葉節點、分支結點

葉節點是指度爲0的結點
分支結點是指度不爲0的結點

結點的度是指該結點擁有的子樹的數量,比如上圖中D結點的度爲3,A結點的度爲2

(2)結點之間的關係:祖先、子孫、兄弟、堂兄弟

結點的祖先是指從根到該結點所經分支上的所有結點。比如G、H、I的祖先都爲A、B、D
結點的子孫是指以某個結點爲根的子樹中的任一結點都稱爲該結點的子孫。比如D結點的子孫爲G、H、I
兄弟是指同一個雙親的孩子結點之間稱爲兄弟。比如以A爲雙親(根結點),那麼B和C就是兄弟
堂兄弟是指雙親在同一層的結點的子結點稱爲堂兄弟。比如B和C在同一層,那麼D、E、F就是堂兄弟

(3)樹的度就是樹內各結點的度的最大值。上圖中數的度爲3
(4)樹的深度或高度就是樹中結點的最大層次。上圖中樹的深度爲4
> 結點的層次從根開始定義,根爲第一層,根的孩子爲第二層,以此類推

(5)森林是m(m >= 0)棵互不相干的數的集合。上圖中就是森林,因爲包含有T1、T2等子樹
(6)如果將樹中結點的各子樹看成從左至右是有次序的,不能互換的,則稱該樹爲有序樹,否則稱爲無序樹

##樹的抽象數據類型

ADT 樹(tree)
Data
	樹是由一個根結點和若干棵子樹構成。樹中結點具有相同數據類型及層次關係
Operation
	InitTree(*T):構造空樹T
	DestroyTree(*T):銷燬樹T
	CreateTree(*T,definition):按definition中給出樹的定義來構造樹
	ClearTree(*T):若樹T存在,則將樹T清爲空樹
	TreeEmpty(T):若樹T爲空樹,返回true,否則返回flase
	TreeDepth(T):返回樹的深度
	Root(T):返回T的根結點
	Value(T,cur_e):cur_e是樹T中一個結點,返回其結點的值
	Assign(T,cur_e,value):給樹T的結點cur_e賦值爲value
	Parent(T,cur_e):若cur_e是樹T的非根結點,則返回它的雙親,否則返回空
	LeftChild(T,cur_e):若cur_e是樹T的非葉結點,則返回它的最左孩子,否則返回空
	RightSibling(T,cur_e):若cur_e有右兄弟,則返回它的右兄弟,否則返回空
	InsertChild(*T,*p,i,c):其中p指向樹T的某個結點,i爲所指結點p的度加上1,非空樹c與T不相交,操作結果爲插入c爲樹T中p指結點的第i棵子樹
	DeleteChild(*T,*p,i):其中p指向樹T的某個結點,i爲所指結點p的度,操作結果爲刪除T中p所指結點的第i棵子樹
endADT

###樹的存儲結構
這裏我們介紹三種樹的表示方法:雙親表示法、孩子表示法、孩子兄弟表示法。它們分別從不同的角度去表示數結構,下面我們一一說明。

1.雙親表示法
我們假設以一組連續空間(數組)存儲樹的結點,同時在每個結點中,附設一個指示器指示其雙親結點在數組中的位置。也就是說每個結點除了知道自己是誰以外,還知道它的雙親在哪裏。
雙親結點
**其中data是數據域,存儲結點的數據信息。而parent是指針域,存儲該結點的雙親在數組中的下標。**以下是我們的雙親表示法的結點結構定義代碼:

//樹的雙親表示法結點結構定義
#define MAX_TREE_SIZE 100;
//樹結點的數據類型,目前暫定爲整型
typedef int TElemType;
//結點結構
typedef struct PTNode{
	TElemType data;
	int parent;
}PTNode;
//樹結構
typedef struct{
	PTNode nodes[MAX_TREE_SIZE];
	int r,n;
}PTree;

由於根結點沒有雙親,我們約定根結點的位置域爲-1。如下爲樹結構,及其雙親表示法:
樹
|下標|data|parent|
|—|---|
|0|A|-1|
|1|B|0|
|2|C|0|
|3|D|1|
|4|E|2|
|5|F|2|
|6|G|3|
|7|H|3|
|8|I|3|
|9|J|4|
這樣的存儲結構,我們查找一個結點的雙親的時間複雜度爲O(1),直到parent爲-1,表示找到了樹的根結點。但是如果我們要知道結點的孩子是什麼呢?我們只能遍歷整個結構才行。

當然也有別的改進的方法,存儲結構的設計是一個非常靈活的過程。一個存儲結構設計的是否合理,取決於基於該存儲結構的運算是否適合、是否方便,時間複雜度好不好等。比如針對上面我們要知道結點的孩子,我們可以爲結點增加一個結點最左邊孩子的域,沒有孩子的結點,該域爲-1。
|下標|data|parent|firstchild|
|—|---|—|
|0|A|-1|1|
|1|B|0|3|
|2|C|0|4|
|3|D|1|6|
|4|E|2|9|
|5|F|2|-1|
|6|G|3|-1|
|7|H|3|-1|
|8|I|3|-1|
|9|J|4|-1|
同理如果除了雙親,我們還關注結點的孩子、兄弟,我們可以爲結點添加相關的域,比如長子域、右兄弟域等等。

2.孩子表示法
在介紹孩子表示法之前,先介紹一下多重鏈表表示法,**多重鏈表表示法是指每個結點有多個指針域,其中每個指針指向一棵子樹的根結點。**不過,樹的每個結點的度不同的,所以有兩種實現方式:
(1)以樹的度來固定分配每個結點的指針域。在時間上,效率上稍微高一點;在空間上,如果各個結點的度相差較大時,會造成空間浪費,如果相差度小,則缺點反而成爲優勢。
(2)根據每個結點的度動態指定每個結點的指針域。在時間上,效率稍微低一點,因爲需要去動態的維護每個結點的指針域;在空間上,利用率較高

那麼對於上面的多重鏈表表示法,有什麼更好的方法,既可以減少空指針導致的空間浪費,又能使結點結構相同?那就是我們的孩子表示法。具體是:把每個結點的孩子結點排列起來,以單鏈表作存儲結構,則n個結點有n個孩子單鏈表,如果是葉子結點則此單鏈表爲空。然後n個頭指針又組成一個線性表,採用順序存儲結構,存放在一個一維數組中。
孩子表示法
以下是我們的孩子表示法的結構定義代碼:

//樹的孩子表示法結構定義
#define MAX_TREE_SIZE 100;
//孩子結點
typedef struct CTNode{
	int child;
	struct CTNode *next;
}*ChildPtr;

//表頭結構
typedef struct{
	TElemType data;
	ChildPtr firstchild;
}CTBox;
//樹結構
typedef struct{
	CTBox nodes[MAX_TREE_SIZE];
	//根的位置和結點樹
	int r,n;
}

當前孩子表示法對於我們要查找某個結點的孩子,或者查找某個結點的兄弟都是很方便的。如果要查找其雙親就麻煩,不過我們前面說過存儲結構的設計是非常靈活的,此時我們可以結合雙親表示法和孩子表示法,形成一種新的表示法雙親孩子表示法。

3.孩子兄弟表示法
剛纔我們是從雙親和孩子的角度去研究樹的存儲結構,如果從樹的結點的兄弟的角度來考慮呢?我們發現,任意一棵樹,它的結點的第一個孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此我們設置了兩個指針,分別指向該結點的第一個孩子和此結點(並不是指孩子)的右兄弟。
孩子兄弟表示法

以下是孩子兄弟表示法的結構定義:

typedef struct CSNode{
	TElemType data;
	struct CSNode *firstchild,*rightsib;
}

從上圖中我們發現,孩子兄弟表示法將一棵複雜的樹變成了一棵二叉樹,這樣我們就可以充分利用二叉樹的特性和算法了。

##二叉樹的定義
定義:二叉樹是n(n >= 0)個結點的有限集合,該集合或者爲空集(空二叉樹),或者由一個根結點和兩棵互不相交的、分別稱爲根結點的左子樹和右子樹的二叉樹組成。
二叉樹
二叉樹的特點:
(1)每個結點最多有兩棵子樹,所以二叉樹中不存在度大於2的結點。注意不是隻有兩棵子樹,而是最多有兩棵子樹
(2)左子樹和右子樹是由順序的,次序不能夠任意顛倒
(3)即使樹中某結點只有一棵子樹,也要區分它是左子樹還是右子樹

根據以上特點,我們總結出來二叉樹的五種基本形態:
(1)空二叉樹
(2)只有一個根結點
(3)根結點只有左子樹
(4)根結點只有右子樹
(5)根結點既有左子樹又有右子樹

在介紹完基本形態的二叉樹之後,我們來介紹一下特殊的二叉樹
1.斜樹
所有的結點都只有左子樹的二叉樹稱爲左斜樹,例如上圖中的第二棵二叉樹。同理,所有結點都只有右子樹的二叉樹叫作右斜樹,例如上圖中的最後一棵二叉樹。兩者統稱爲斜樹。

2.滿二叉樹
在一棵二叉樹中,如果所有分支結點都存在左子樹和右子樹,並且所有葉子結點都在同一層上,這樣的二叉樹稱爲滿二叉樹。

3.完全二叉樹
對一棵具有n個結點的二叉樹按層序編號,如果編號i(1<= i <= n)的結點與同樣深度的滿二叉樹中編號爲i的結點在二叉樹中的位置完全相同,則這棵二叉樹稱爲完全二叉樹。

滿二叉樹一定是完全二叉樹,但完全二叉樹不一定是滿二叉樹。

###二叉樹的性質
性質1:在二叉樹的第i層上至多有2i12^{i-1}個結點(i >= 1)。二叉樹的層數是從1開始的
性質2:深度爲k的二叉樹至多有2k12^k-1個結點(i >= 1)
性質3:對任何一棵二叉樹T,如果其終端結點數(葉子結點數)爲n0,度爲2的結點數爲n2,則n0=n2+1

性質3的推導過程:
n0、n1、n2分別表示結點的度爲0,1,2的結點
(1)樹T的總結點數爲n=n0+n1+n2
(2)樹T的分支線總數n1+2n2 = n - 1
說明一下,一個結點對應一根進入的分支線,而根結點沒有進入的分支線,所以分支總數也等於總結點數-1,即n-1
(3)根據上面兩個式子可得n0+n1+n2 - 1 = n1+2n2,推出n0=n2+1

性質4:具有n個結點的完全二叉樹的深度爲Math.floor(log2n)+1

性質4的推導過程:
(1)已知它是一棵完全二叉樹T,樹的深度爲k,則T的總結點數滿足以下條件2k12^{k-1}- 1< n <= 2k2^k- 1
(2)由上式可以推出2k12^{k-1}<= n < 2k2^k
(3)對上式取對數,得到k-1<= log 2 n < k
(4)而k也是整數,所以推出k=Math.floor(log2n)+1

性質5:如果對一棵有n個結點的完全二叉樹(其深度爲Math.floor(log2n)+1)的結點按層序編號(從第一層到第Math.floor(log2n)+1層,每層從左到右),對任一結點i(1 <= i <= n)有:
(1)如果i = 1,則結點i是二叉樹的根結點,無雙親;如果i > 1,則其雙親是結點Math.floor(i / 2)
(2)如果2i > n,則結點i無左孩子(結點i爲葉子結點);否則其左孩子是結點2i
(3)如果2i + 1 > n,則結點i無右孩子;否則其右孩子是結點2i+1

###二叉樹的存儲結構
二叉樹可以使用順序存儲,也可以使用鏈式存儲,下面我們分別來分析一下。
1.順序存儲結構
二叉樹的順序存儲結構使用一維數組存儲二叉樹中的結點,並且結點的存儲位置,也就是數組的下標要能體現結點之間的邏輯關係,比如雙親、孩子等。
二叉樹順序存儲
從上面我們可以發現,順序存儲結構能夠很好的表現出二叉樹結構。但是它也有明顯的缺點,如果這棵二叉樹是一棵比較特殊的,比如左斜樹、右斜樹的話,就會浪費很多的存儲空間。因此下面我們來看一下鏈式存儲結構。

2.鏈式存儲結構
二叉樹每個結點最多有兩個孩子,所以爲它設計一個數據域和兩個指針域是比較自然的想法,我們稱這樣的鏈表爲二叉鏈表
二叉鏈表結點
其中data是數據域,lchild和rchild是分別指向左孩子和右孩子的指針。

一下是二叉鏈表的結點結構定義:

typedef struct BiTNode{
	TElemType data;
	struct BiTNode *lchild,*rchild;
}

下面二叉鏈表的結構示意圖:
二叉鏈表

當然並不是說結構就固定如此,我們也可以根據需要添加相應的域,比如添加一個指向雙親的指針域等。

###二叉樹的遍歷
定義:**二叉樹的遍歷是指從根結點出發,按照某種次序依次訪問二叉樹中所有的結點,使得每個結點被訪問一次且僅被訪問依次。**二叉樹的遍歷方式有很多,如果我們限制了從左到右的習慣方式,那麼主要就分爲四種:前序遍歷、中序遍歷、後序遍歷、層序遍歷。下面我們一一介紹:
1.前序遍歷
規則:若二叉樹爲空,則空操作返回,否則先訪問根結點,然後前序遍歷左子樹,再謙虛遍歷右子樹。如下圖結果爲ABDGHCEIF:
前序遍歷
具體的算法實現:

void PreOrderTraverse(BiTree T){
	if(T == NULL){
		return ;
	}
	//顯示結點數據,可以更改爲其他對結點的操作
	printf("%c",T->data);
	//遍歷左子樹
	PreOrderTraverse(T->lchild);
	//遍歷右子樹
	PreOrderTraverse(T->rchild);
}

2.中序遍歷
規則:若二叉樹爲空,則空操作返回,否則從根結點開始(注意並不是先訪問根結點),中序遍歷根結點的左子樹,然後是訪問根結點,最後中序遍歷右子樹。如下圖結果爲GDHBAEICF:
中序遍歷
具體的算法實現:

void InOrderTraverse(BiTree T){
	if(T == NULL){
		return ;
	}
	//遍歷左子樹
	InOrderTraverse(T->lchild);
	//顯示結點數據,可以更改爲其他對結點的操作
	printf("%c",T->data);
	//遍歷右子樹
	InOrderTraverse(T->rchild);
}

3.後序遍歷
規則:若二叉樹爲空,則空操作返回,否則從左到右先葉子後結點的方式遍歷訪問左右子樹,最後訪問根結點。如下圖結果爲GHDBIEFCA:
後續遍歷
具體的算法實現:

void PostOrderTraverse(BiTree T){
	if(T == NULL){
		return ;
	}
	//遍歷左子樹
	PostOrderTraverse(T->lchild);
	//遍歷右子樹
	PostOrderTraverse(T->rchild);
	//顯示結點數據,可以更改爲其他對結點的操作
	printf("%c",T->data);
}

4.層序遍歷
規則:若二叉樹爲空,則空操作返回,否則從樹的第一層,也就是根結點開始訪問,從上而下逐層遍歷,在同一層中,按從左到右的順序對結點逐個訪問。如下圖結果爲ABCDEFGHI:
層序遍歷

總結:
(1)已知前序遍歷和中序遍歷可以唯一確定一棵二叉樹
(2)已知後序遍歷和中序遍歷可以唯一確定一棵二叉樹

注意:已知前序遍歷和後序遍歷是不能確定一棵唯一的二叉樹的

###二叉樹的建立
下面是二叉樹的建立過程:

//按前序輸入二叉樹中結點的值(此處假設爲一個字符)
//#表示空樹,構造二叉鏈表表示二叉樹T
void CreateBiTree(BiTree *T){
	TElemType ch;
	//將讀取到的鍵盤輸入賦值給ch
	scanf("%c",&ch);
	if(ch == '#'){
		*T = NULL;
	}else{
		*T = (BiTree)malloc(sizeof(BiTNode));
		if(!*T){
			exit(OVERFLOW);
		}
		//生成根結點
		(*T)->data = ch;
		//構造左子樹
		CreateBiTree(&(*T)->lchild);
		//構造右子樹
		CreateBiTree(&(*T)->rchild);
	}
}

##線索二叉樹
二叉鏈表本身存在一定的缺陷,主要是兩個方面:
(1)對於一個有n個結點的二叉鏈表,存在n+1個空指針域,這無疑是很浪費內存資源的。
(2)在二叉鏈表上,我們只能知道每個結點指向其左右孩子結點的地址,而不知道某個結點的前驅、後繼,要想知道必須先遍歷一遍,這在時間效率上,無疑是很低的

正是因爲二叉鏈表存在的這兩個問題,線索二叉樹出現了。所謂的線索二叉樹是指在二叉樹的基礎上加上線索,這個線索就是指向前驅、後繼的指針,加上線索的二叉鏈表稱爲線索鏈表,相應的二叉樹就稱爲線索二叉樹。

我們以中序遍歷爲參考,分別說明前驅線索和後繼線索:

  • 前驅線索
    前驅線索
    我們將這棵二叉樹的所有空指針域中的lchild改爲指向當前結點的前驅。因此H的前驅是NULL,I的前驅是D,以此類推。
  • 後繼線索
    後繼線索
    同理所有的空指針域中的rchild改爲指向它的後繼結點。因此H的後繼是D,I的後繼是B,以此類推。

經過線索化,把二叉樹轉變成了一個雙向鏈表,這樣對我們的插入刪除結點、查找某個結點都帶來了方便。但是問題並沒有徹底解決,我們如何知道某一個結點的lchild是指向它的左孩子還是前驅呢?所以我們要爲它添加標記位,標識是左孩子還是前驅,同理rchild也是。此時的結點結構如下:
結點結構
其中:
(1)ltag爲0時指向該結點的左孩子,爲1時指向該結點的前驅
(2)rtag爲0時指向該結點的右孩子,爲1時指向該結點的後繼

以下是二叉樹的線索存儲結構定義:

//二叉樹的二叉線索存儲結構定義
typedef enum{
	//Link == 0表示指向左右孩子指針
	Link,
	//Thread == 1表示指向前驅或後繼的線索
	Thread
}PointerTag;

//二叉線索存儲結點結構
typedef struct BiThrNode{
	TElemType data;
	struct BiThrNode *lchild,*rchild;
	PointerTag LTag;
	PointerTag RTag;
}BiThrNode,*BiThrTree;

線索化的實質就是將二叉鏈表中的空指針改爲指向前驅或後繼的線索。由於前驅和後繼的信息只有在遍歷該二叉樹時才能得到,所以線索化的過程就是在遍歷的過程中修改空指針的過程。

//中序遍歷進行中序線索化
//全局變量,始終指向剛剛訪問過的結點
BiThrTree pre;
void InThreading(BiThrTree p){
	if(p){
		//遞歸左子樹線索化
		InThreading(p->lchild);

		//沒有左孩子
		if(!p->lchild){
			//前驅線索
			p->LTag=Thread;
			//左孩子指向前驅
			p->lchild=pre;
		}

		//沒有右孩子
		if(!pre->rchild){
			//後繼線索
			pre->RTag=Thread;
			//前驅右孩子指針指向後繼(當前結點p)
			pre->rchild=p;
		}

		//遞歸右子樹線索化
		InThreading(p->rchild);
	}
}

我們對其中的主要部分作一下解釋:
(1)if(!p->lchild)表示如果某結點的左指針域爲空,因爲其前驅結點剛剛訪問過,賦值給了pre,所以可以將pre賦值給p->lchild,並修改p->LTag=Thread(也就是1)以完成前驅結點的線索化
(2)因爲此時p結點的後繼還沒有訪問,因此只能對它的前驅結點pre的右指針rchild做判斷,if(!pre->rchild)表示如果爲空,則p就是pre的後繼,於是pre->rchild=p,並且設置pre->RTag=Thread,完成後繼結點的線索化。

線索化完成之後,我們就可以遍歷了:

//T指向頭結點,頭結點左鏈lchild指向根結點,頭結點右鏈rchild指向中序遍歷的最後一個結點。
Status InOrderTraverse_Thr(BiThrTree T){
	BiThrTree p;
	//p指向根結點
	p = T -> lchild;
	//空樹或遍歷結束時,p == T
	while(p != T){
		
	}
}

總結:如果所用的二叉樹需經常遍歷或查找結點時需要某種遍歷序列中的前驅和後繼,那麼採用線索二叉鏈表的存儲結構就是非常不錯的選擇。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章