資料結構-王道-樹和二叉樹
樹和二叉樹
樹:是\(N(N\geq0)\)個結點的有限集合,\(N=0\)時,稱為空樹,這是一種特殊情況。在任意一棵非空樹中應滿足:
- 有且僅有一個特定的稱為根的結點。
- 當\(N>1\)時,其餘結點可分為\(m(m>0)\)個互不相交的有限集合\(T_1,T_2,\ldots,T_m\),其中每一個集合本身又是一棵樹,並且稱為根結點的子樹。
顯然樹的定義是遞迴的,是一種遞迴的資料結構。樹作為一種邏輯結構,同時也是一種分層結構,具有以下兩個特點:
- 樹的根結點沒有前驅結點,除根結點之外的所有結點有且僅有一個前驅結點。
- 樹中所有結點可以有零個或者多個後繼結點。
樹適合於表示具有層次結構的資料。樹中的某個結點(除了根結點之外)最多之和上一層的一個結點(其父結點)有直接關係,根結點沒有直接上層結點,因此在n個結點的樹中最多隻有n-1條邊。而樹中每個結點與其下一層的零個或者多個結點(即其子女結點)有直接關係。
- 對K來說:根結點A到K的唯一路徑上的任意結點,稱為K的祖先結點。如結點B是K的祖先節點,K是B的子孫結點。路徑上最接近K的結點E稱為K的雙親結點,K是E的孩子結點。根A是樹中唯一沒有雙親的結點。有相同雙親的結點稱為兄弟節點,如K和L有相同的雙親結點E,即K和L是兄弟結點。
- 樹中一個結點的子結點個數稱為該結點的度,樹中結點最大度數稱為樹的度。如B的度為2,但是D的度為3,所以該樹的度為3.
- 度大於0的結點稱為分支結點(又稱為非終端結點);度為0(沒有子女結點)的結點稱為葉子結點(又稱終端結點)。在分支結點中,每個結點的分支數就是該節點的度。
- 結點的高度,深度和層次。
- 結點的層次從樹根開始定義,根節點為第一層(有些教材將根節點定義為第0層),它的子結點為第2層,以此類推。
- 結點的深度是從根節點開始自頂向下逐層累加的。
- 結點的高度是從葉節點開始自底向上逐層累加的。
- 樹的高度(又稱深度)是樹中結點的最大層數。
- 有序書和無序樹:樹中結點的子樹從左到右是有次序的,不能交換,這樣的樹稱為有序樹。有序樹中,一個結點其子結點從左到右順序出現是有關聯的。反之稱為無序樹。在上圖中,如果將子結點的位置互換,則變為一棵不同的樹。
- 路徑和路徑長度:樹中兩個結點之間的路徑是由這兩個節點之間所經過的結點序列構成的,而路徑長度是路徑上所經過的邊的個數。A和K的路徑長度為3.路徑為B,E。
森林:森林是m棵互不相交的樹的集合。森林的概念和樹的概念十分相近,因為只要把樹的根節點刪掉之後就變成了森林。反之,只要給n棵獨立的樹加上一個結點,並且把這n棵樹作為該結點的紫書,怎森林就變成了樹。
樹具有如下最基本的性質。
- 樹中結點數等於所有節點的度數+1.
- 度為m的樹中第i層上之多有\(m^{i-1}\)個結點\((i\geq1)\)。
- 高度為h的m叉樹至多有\(\frac{m^h-1}{m-1}\)個結點。
具有n個結點的m叉樹的最小高度為\(\log_m(n(m-1)+1)\)。
二叉樹和度為2的有序樹的區別:二叉樹的概念
- 度為2的樹至少有3個結點,而二叉樹則可以為空;
度為2的有序樹的孩子結點的左右次序是相對於另一個孩子結點而言的,如果某個結點只有一個孩子結點,這個孩子結點就無需區別其左右次序,但是二叉樹無論孩子數是否為2,均需要確定其左右次序,也就是說二叉樹結點次數不是相對於另一個結點而言,而是確定的。
單解釋一下完全二叉樹:設一個高度為h,有n個結點的二叉樹,當且僅當其每一個結點都與高度為h的滿二叉樹中編號一一對應是稱為完全二叉樹。
二叉樹的遍歷
先序遍歷
void PreOrder(BitTree T)
{
if(T!=NULL)
{
printf("%d\n",T->data);
PreOrder(T->lchild);
PreOrder(T->rchild);
}
}
中序遍歷
void InOrder(BitTree T)
{
if(T!=NULL)
{
InOrder(T->lchild);
printf("%d\n",T->data);
InOrder(T->rchild)
}
}
後序遍歷
void PostOrder(BitTree T)
{
if(T!=NULL)
{
PostOrder(T->lchild);
PostOrder(T->rchild);
printf("%d\n",T->data);
}
}
三種遍歷演算法中遞迴遍歷左子樹和右子樹的順序都是固定的,只是訪問根節點的順序不同。不管採用何種遍歷方法,每個結點都是訪問一次,所以時間複雜度就是\(O(n)\)。 在遞迴遍歷中,遞迴工作棧的深度恰巧是樹的深度,所以在最壞的情況下,二叉樹是有n個結點且深度為n的單支樹,遞迴遍歷演算法的時間複雜度是\(O(n)\)。
@[中序遍歷的非遞迴演算法如下]
typedef struct BiTNode
{
int data;
struct BiTNode *lchild,*rchild;
}*BitTree;
typedef struct
{
char data[MaxSize];
int top;
}SqStack;
void InitStack(SqStack &S)
{
S.top=-1;
}
void InOrder2(BitTree T)
{
InitStack(S);
BitTree p=T;
while(p||IsEmpty(s))
{
if(p)
{
Push(S,p);
p=p->lchild;
}
else
{
Pop(s,p);
printf("%d\n",p->data);
p=p->rchild;
}
}
}
線索二叉樹
遍歷二叉樹就是以一定的規則將二叉樹中的結點排列為一個線性序列,從而得到二叉樹中結點的各種遍歷序列。其實質就是對一個非線性結構進行線性化操作,使在這個訪問序列中的每一個結點(除了最後一個和第一個)都有一個直接前驅結點或者後繼結點。 傳統的鏈式儲存能夠體現出一種父子關係,不能直接得到結點在遍歷中的前驅或者後繼。通過觀察,我們發現在二叉連結串列表示的二叉樹中存在大量的空指標,若是利用這些空鏈域存放指向其直接的前驅或者後繼的指標,則可以更加方便的運用某些二叉樹的操作演算法。引入線索二叉樹是為了加快查詢節點的前驅和後繼的速度。 前面提到,在N個節點的二叉樹中,有N+1個空指標。這是因為每個葉節點都有兩個空指標,而每一個度為1的節點有一個空指標。總的空指標數目為\(2N_0+N_1\),又有\(N_0=N_2+1\)。意思是二倍的葉子節點加上1被的一個孩子的節點的數目。
線索二叉樹的構造。 線索二叉樹的儲存結構描述如下:
typedef struct ThreadNode
{
int data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
\(ltag=0\)表示lchild指向的是結點的左孩子 \(ltag=1\)表示lchild指向的是結點的前驅 \(rtag=0\)表示rchild指向的是結點的右孩子 \(rtag=1\)表示rchild指向的是結點的後繼
這種結點結構構成的二叉連結串列作為二叉樹的儲存結構,叫做線索連結串列,其中指向結點前驅和後繼的指標,稱為線索。加上線索的二叉樹稱為線索二叉樹。對二叉樹進行以某種次序遍歷使其變為線索二叉樹的過程叫做線索化。
@[線索化二叉樹的構造] 對二叉樹的線索化,實質上就是遍歷一次二叉樹,只是在遍歷的過程中檢查當前節點的左右指標是否為空,若為空,將他們改為指向前驅節點或者後繼節點的線索。
@[P109] 度為2的有序樹不是是二叉樹:二叉樹中如果某個節點只有一個孩子節點,那麼這個孩子節點的左右次數是確定的,但是在有序樹中如果某個節點只有一個孩子節點則這個節點無需區分其左右次序,所以度為2的樹不是二叉樹。 完全二叉樹的節點數目和高度的關係是\([\log_2N]+1\)。 完全二叉樹的節點排列是從左到右從上到下,所以如果一個節點沒有左孩子,則它必定是葉節點。 二叉排序樹 後面補上。 ---- 設層次遍歷的結果為A,B,C。 則先序遍歷的結果為A,B,C。 則中序遍歷的結果為B,A,C。 則後續遍歷的結果為B,C,A。
二叉樹的中序遍歷的最後一個節點一定是從根開始沿右子女指標鏈走到最低的結點。
樹的儲存結構
> 雙親表示法
這種儲存方式採用一組連續空間來儲存每個節點,同時在每個節點中增設一個尾指標指示雙親結點在陣列中的位置。根節點下標為0,其偽指標域為-1.
typedef struct // 數的結點定義
{
int data; // 資料元素
int parent; // 雙親位置域
}PTNode;
typedef struct // 樹的型別定義
{
PTNode nodes[Max_Tree_Size];
int n; // 雙親表示
}PTree;
這種結構利用了每個節點(根結點除外)只有唯一雙親的性質,可以很快的得到每個節點的雙親節點,但是求節點的孩子時卻要遍歷整個結構。
並查集
並查集是一種簡單的集合表示,它支援一下三種操作:
- $Union(S,root1,root2); $把集合S中的子集合Root2,併入Root1中。要求Root1和Root2互不相交,否則不執行合併。
- \(Find(S,x);\)查詢集合S中單元素x所在的子集合,並返回該子集合的名字。
\(Initial(S);\)將集合S中每一個元素都初始化為只有一個單元素的子集合。 通常用樹(森林)的雙琴表示作為並查集的儲存結構,每個子集合以一棵樹表示。所有表示子集合的樹,構成表示全集合的森林,存放在雙親表示陣列中。
並查集的結構定義如下:
#define Size 100
int UFSets[Size];
void Initial(int S[])
{
for(int i=0;i<Size;i++)
S[i]=-1;
}
int Find(int S[],int x)
{
while(S[x]>=0)
x=S[x];
return x;
}
void Union(int S[],int Root1,int Root1)
{
S[Root1]=Root2;
}
森林
由於二叉樹和樹都可以用二叉連結串列作為儲存結構,則以二叉連結串列作為媒介可以匯出樹與二叉樹的一個對應關係,即給定一棵樹,可以找出唯一的一棵二叉樹與之對應。從物理結構上看,樹的孩子兄弟表示法和二叉樹的二叉連結串列表示法相同,即每個節點共有兩個指標,分別指向結點的第一個孩子節點和結點的下一個兄弟節點,而二叉連結串列可以使用雙指標。因此,就可以用同意儲存結構的不同解釋將一棵樹轉換為二叉樹。 樹轉換為二叉樹的規則:每個節點的左指標指向她的第一個孩子節點,右指標指向它在書中的相鄰兄弟結點,可表示為左孩子有兄弟。由於根節點沒有兄弟,所以由樹轉換而得的二叉樹沒有右子樹。
樹和二叉樹的應用
二叉排序樹:簡稱(BST),也稱為二叉查詢樹。二叉排序樹或者是一個空樹,或者是一棵具有一下特性的非空二叉樹:
- 若左子樹非空,則左子樹上所有節點關鍵字值均小於根節點的關鍵字值。
- 若右子樹非空,則右子樹上所有結點關鍵字值均大於根節點的關鍵字值。
左,右子樹本身也是一棵二叉排序樹 由二叉排序樹的定義,有\(左子樹根節點值<根結點值 <右子樹結點值\),所以,對二叉樹進行中序遍歷,可以得到一個遞增的有序序列。
二叉排序樹的查詢是從根節點開始的,沿某一分值逐層向下進行比較的過程。若二叉排序樹非空,將給定值與根結點的關鍵字比較,若相等,則查詢成功;若不等免責當根節點的關鍵字較大的時候,在根節點的左子樹中繼續查詢,否則在右子樹中查詢。這顯然是一個遞迴的過程。 二叉排序樹的查詢
BSTNode *BST_Search(BitTree T,int key,BSTNode *&p)
{ // 返回指向關鍵字為key的結點指標,若不存在則返回NULL
p=NULL; // p指向被查詢結點的雙親,用於插入和刪除操作中。
while(T!=NULL&&key!=T->data)
{
p=T;
if(key<T->data)
T=T->lchild;
else
T=T->rchild;
}
return T;
}
二叉排序樹的插入
int BST_Insert(BitTree &T,int k)
{
if(T==NULL) // 原樹為空,新插入的記錄為根節點。
{
T=(BitTree)malloc(sizeof(BSTNode));
T->data=k;
T->lchild=T->rchild=NULL;
return 1;
}
else if(k==T->data) // 存在相同的結點。
return 0;
else if(k<T->data) // 插入到T的左子樹中
return BST_Insert(T->lchild,k);
else
return BST_Insert(T->rchild,k);
}
由此可見,插入的新節點一定是某個葉節點。在一個二叉排序樹先後依次插入結點28和58,虛線表示的邊是其查詢的路徑。
二叉排序樹的構造
void Create_BST(BitTree &T,int str[],int n)
{
T=NULL;
int i=0;
while(i<n)
{
BST_Insert(T,str[i]);
i++;
}
}
二叉排序樹的刪除
在二叉排序樹中刪除一個結點時,不能把以該結點為根的子樹上的結點都刪除,必須先把被刪除結點從儲存二叉排序樹的連結串列上摘下來,將因刪除結點而斷開的二叉連結串列重新連結起來,同時確保二叉排序樹的性質不會丟失。 刪除操作的實現過程按照以下三種情況進行處理:
- 如果被刪除結點z是葉節點,則直接刪除,不會破壞二叉排序樹的性質。
- 如果結點z只有一顆左子樹後者右子樹,則讓z的子樹稱為z父結點的子樹,替代z的位置。
若結點z有左右兩棵子樹,則令z的直接後繼(或者直接前驅)替代z,然後從二叉排序樹中刪去這個直接後繼(或者直接前驅),這樣就變成了第一種或者第二種情況。
平衡二叉樹 為了避免樹的高度增長過快,降低二叉排序樹的效能,我們規定在插入和刪除二叉樹節點時,要保證任意節點的左右子樹的高度差的絕對值不超過1,將這樣的二叉樹稱為平衡二叉樹,簡稱平衡樹(AVL)。定義節點左子樹和右子樹的高度差為該節點的平衡因子,則平衡二叉樹節點的平衡因子只能是-1,0,1。如圖所示:
平衡二叉樹的插入:二叉排序樹保證平衡的基本思想:每當在二叉排序樹中插入(或刪除)一個節點時,首先要檢查其在插入路徑上的節點是否因此次操作導致了不平衡。如果導致了不平衡,則先找到插入路徑上距離插入節點最近的平衡因子絕對值大於1的節點A,再對以A為根的子樹,在保持二叉排序樹特性的前提下調整各節點的位置關係,使之重新達到平衡。 注意:每次調整的物件都是最小不平衡子樹,即在插入路徑上距離插入節點最近的平衡因子的絕對值大於1的結點作為根的子樹。
LR平衡旋轉又稱為先左後右雙旋轉:由於在A的左孩子L的右子樹R上插入新節點,A的平衡因子由1增至2,導致以A為根的子樹失去平衡,需要進行兩次旋轉操作,先左旋轉然後右旋轉。先將A結點的左孩子B的右子樹的根節點C向上旋轉提升至B結點的位置,然後再把該C結點向右上旋轉提升至A結點的位置,如圖所示。
RL平衡旋轉又稱先右後左雙旋轉。由於在A的右孩子R的左子樹L上插入新節點,A的平衡因子由-1減至-2,導致以A為根的子樹失去平衡,需要進行兩次旋轉操作,先右旋轉後左旋轉。先將A結點的右孩子B的子樹的根節點C向右上旋轉提升至B結點的位置,然後再把C結點向左上旋轉提升到A結點的位置,如圖所示。
平衡二叉樹的查詢
在平衡二叉樹上進行查詢的過程和二叉排序樹相同,因此,在查詢過程中和給定值進行標膠的關鍵字個數不超過樹的深度。假設以\(N_h\)表示深度為h的平衡樹中含有最少結點數。顯然,\(N_0=0,N_1=1,N_2=2\),並且有\(N_h=N_{h-1}+N_{h-2}+1\)。易得含有n個結點的平衡二叉樹的最大深度為\(\log_2n\),因此,平衡二叉樹的平均查詢長度為\(O(\log_2n)\)。
哈夫曼(Huffman)樹和哈夫曼編碼
在許多實際應用中,樹中結點常常被賦予一個表示某種意義的數值,成為該節點的權。從樹根結點到任意結點的路徑長度(經過的邊數)與該節點上的權值的乘積稱為該結點的帶權路徑長度。樹中所有葉節點的帶權路徑長度之和稱為該樹的帶權路徑長度。記為:
哈夫曼樹的構造
給定N個權值分別為\(w_1,w_2,w_3,\ldots,w_N\)的結點。通過哈夫曼演算法可以構造出最優二叉樹,演算法的描述如下:
- 將這個N個結點分別作為N棵僅含有一個結點的二叉樹,構成森林F。
- 構造一個新節點,並從F中選取兩顆根結點權值最小的樹,作為新節點的左,右子樹,並且將新節點的權值設定為左右子樹上根節點的權值之和。
- 從F中刪除剛才選取的兩棵樹,同時將新得到的樹加入F中。
- 重複步驟2,3。森林中剩下唯一一棵樹為止。
從上述步驟中可以看出哈夫曼樹具有如下的特點:
1. 每個初始結點最終都會稱為葉節點,並且權值越小的結點路徑長度越大。
2. 構造過程中共新建了$N-1$個結點,因此哈夫曼樹中結點的總數為$2N-1$。
3. 每次構造都選擇兩棵樹作為新節點的孩子,因此哈夫曼樹中不存在度為1的結點。
哈夫曼樹編碼
對待處理一個字串序列,如果每個字元用同樣長度的二進位制位來表示,則這種方式稱為固定長度編碼。若允許對不同字元用不等長的二進位制位表示,則這種編碼方式稱為可變長度編碼。可變長度編碼比固定長度編碼好得多,其特點是對頻率高的字元賦予段編碼,而對頻率較低的字元則賦予一些較長的編碼,從而可以是字元的平均編碼長度被縮短,起到壓縮資料的效果。哈夫曼編碼是一種被廣泛應用而且非常有效的資料壓縮編碼方法。 如果沒有一個編碼是另一個編碼的字首,則稱這樣的編碼為字首編碼。如0,101,100是字首編碼。對字首編碼的解碼也是很簡單的。因為沒有一個碼是其他碼的字首。所以可以識別出第一個編碼,將他翻譯為原始碼,在對雨下的編碼檔案重複同樣的操作。如\('00101100'\)可被唯一的分析為0,0,101,100。 由哈夫曼樹得到哈夫曼編碼是很自然的過程,首先將每個出現的字元當作一個獨立的結點,其權值為它出現的頻度(或者是次數),構造出對應的哈夫曼樹。顯然所有字元結點都出現在葉節點中。我們可以將字元的編碼解釋為從根至該字元的路徑上邊標記的序列,其中邊標記為0表示“轉向左孩子”,標記為1表示“轉向右孩子”。