參考:浙大數據結構(陳越、何欽銘)課件
1、樹與樹的表示
什么是樹?
客觀世界中許多事物存在層次關系
- 人類社會家譜
- 社會組織結構
- 圖書信息管理
分層次組織在管理上具有更高的效率!
數據管理的基本操作之一:查找(根據某個給定關鍵字K,從集合R 中找出關鍵字與K 相同的記錄)。一個自然的問題就是,如何實現有效率的查找?
- 靜態查找:集合中記錄是固定的,沒有插入和刪除操作,只有查找
- 動態查找:集合中記錄是動態變化的,除查找,還可能發生插入和刪除
靜態查找——方法一:順序查找(時間復雜度O(n))
int SequentialSearch(StaticTable * Tbl, ElementType K) { // 在表Tbl[1]~Tbl[n] 中查找關鍵字為K的數據元素 int i; Tabl->Element[0] = K; // 建立哨兵 for(i = Tbl->Length; Tbl->Element[i] != K; i--) ; return i; // 查找成功返回所在單元下標;不成功返回0 }
靜態查找——方法二:二分查找(時間復雜度O(logn))
二分查找的啟示?
二分查找判定樹:
- 判定樹上每個結點需要的查找次數剛好為該結點所在的層數
- 查找成功時查找次數不會超過判定樹的深度
- n 個結點的判定樹的深度為⌊log2n⌋+1
樹的定義
樹(Tree):n(n ≥ 0)個結點構成的有限集合。
當n = 0 時,稱為空樹。
對於任一顆非空樹(n > 0),它具備以下性質:
- 樹中有一個稱為"根(Root)"的特殊結點,用r 表示;
- 其余結點(與r 相關聯的)可分為m(m > 0)個互不相交的有限集T1,T2,...,Tm,其中每個集合本身又是一顆樹,稱為原來樹的"子樹(SubTree)"。
樹與非樹?
- 子樹是不相交的
- 除了根結點外,每個結點有且僅有一個父節點
- 一顆N個結點的樹有N-1條邊(我認為可以用構造性的存在性證明或是數學歸納法來證明這一點)
樹的一些基本術語:
- 結點的度(Degree):結點的子樹個數
- 樹的度:樹的所有結點中最大的度數
- 葉結點(Leaf):度為0的結點
- 父結點(Parent):有子樹的結點是其子樹的根結點的父結點
- 子結點(Child):若A結點是B結點的父結點,則稱B結點是A結點的子結點;子結點也稱孩子結點
- 兄弟結點(Sibling):具有同一父結點的各結點彼此是兄弟結點
- 路徑和路徑長度:從結點n1到nk的路徑為一個結點序列n1,n2,...,nk,ni是ni+1的父結點,路徑所包含的邊的個數為路徑的長度
- 祖先結點(Ancestor):沿樹根到某一結點路徑上的所有結點都是這個結點的祖先結點
- 子孫結點(Descendant):某一結點的子樹中的所有結點都是這個結點子孫
- 結點的層次(Level):規定根結點在1層,其它任一結點的層數時其父節點的層數加1
- 樹的深度(Depth):樹中所有結點中的最大層次是這棵樹的深度
樹的表示
為可節省空間,最常用的表示樹的方法是兒子-兄弟表示法。
2、二叉樹及存儲結構
二叉樹的定義
二叉樹T:一個有窮的結點集合
這個集合可以為空
若不為空,則它是由根結點和稱為其左子樹TL和右子樹TR的兩個不相交的二叉樹組成
- 二叉樹具有五種基本形態(空、單根、根+TL、根+TR、根+TL+TR)
- 二叉樹的子樹有左右順序之分
特殊二叉樹
斜二叉樹(Skewed Binary Tree)、完美二叉樹(Perfect Binary Tree)/滿二叉樹(Full Binary Tree)、完全二叉樹(Complete Binary Tree)
這里重點介紹下CBT:有n 個結點的二叉樹,對樹中結點按從上至下、從左至右順序進行編號,編號為i (1 ≤ i ≤ n)結點與滿二叉樹中編號為i 結點在二叉樹中位置相同
二叉樹幾個重要性質
- 一個二叉樹第i 層的最大結點數為:2i-1,i ≥ 1
- 深度為k 的二叉樹有最大結點總數為:2k-1,k ≥ 1
- 對任何非空二叉樹T,若n0 表示葉結點的個數,n2 是度為2 的非葉結點個數,那么兩者滿足關系n0 = n2 + 1(證明見這里)
二叉樹的抽象數據類型
重要操作:
- BinTree CreateBinTree():創建一個二叉樹
- Boolean IsEmpty(BinTree BT):判別BT 是否為空
- void Traversal(BinTree BT):遍歷,按某順序訪問每個結點
常用的遍歷方法有:
- void PreOrderTraversal(BinTree BT):先序——根、左子樹、右子樹
- void InOrderTraversal(BinTree BT):中序——左子樹、根、右子樹
- void PostOrderTraversal(BinTree BT):后序——左子樹、右子樹、根
- void LevelOrderTraversal(BinTree BT):層次遍歷——從上到下、從左到右
二叉樹的存儲結構
順序存儲結構
依完全二叉樹的形式存儲:按從上到下、從左到右順序存儲。
n 個結點的完全二叉樹的節點父子關系:
- 非根節點(序號i > 1)的父結點序號是⌊i/2⌋
- 結點(序號為i)的左孩子結點的序號是2i(若2i ≤ n,否則沒有左孩子)
- 結點(序號為i)的右孩子結點的序號是2i+1(若 2i+1 ≤ n,否則沒有右孩子)
應當注意的一點是:一般二叉樹也可以采用這種結構,但會造成空間浪費
鏈表存儲
typedef struct TreeNode *BinTree; typedef BinTree Position; struct TreeNode{ ElementType Data; BinTree Left; BinTree Right; }
3、二叉樹的遍歷
二叉樹的遞歸遍歷
先序遍歷:訪問根結點;先序遍歷其左子樹;先序遍歷其右子樹
void PreOrderTraversal(BinTree BT) { if(BT) { printf("%d", BT->data); PreOrderTraversal(BT->Left); PreOrderTraversal(BT->Right); } }
中序遍歷:中序遍歷其左子樹;訪問根結點;中序遍歷其右子樹
void InOrderTraversal(BinTree BT) { if(BT) { InOrderTraversal(BT->Left); printf("%d", BT->Data); InOrderTraversal(BT->Right); } }
后序遍歷:后續遍歷其左子樹;后續遍歷其右子樹;訪問根結點
void PostOrderTraversal(BinTree BT) { if(BT) { PostOrderTraversal(BT->Left); PostOrderTraversal(BT->Right); printf("%d", BT->Data); } }
附注:先序、中序和后序遍歷過程:遍歷過程經過結點的路線一樣,只是訪問各結點的時機不同。下圖在從入口到出口的曲線上用ⓧ、★和△三種符號分別標記出了先序、中序和后序訪問各結點的時刻
先序:當曲線第一次經過一個結點時,就列出這個結點;中序:當曲線第一次經過一個樹葉時,就列出這個樹葉,當曲線第二次經過一個內點時就列出這個內點;后序:當曲線最后一次經過一個結點而返回這個結點的父親時,就列出這個結點。
二叉樹的非遞歸遍歷
非遞歸遍歷算法實現的基本思路:使用堆棧。我們以中序遍歷的非遞歸算法為例:
- 遇到一個結點,就把它壓棧,並去遍歷它的左子樹
- 當左子樹遍歷結束后,從棧頂彈出這個結點並訪問它
- 然后按其右指針再去中序遍歷該結點的右子樹
void InOrderTraversal(BinTree BT) { BinTree T = BT; Stack S = CreateStack(MaxSize); // 創建並初始化堆棧 while(T || !IsEmpty(S)) { while(T) // 一直向左並將沿途結點壓入堆棧 { Push(S, T); T = T->Left; } if(!IsEmpty(S)) // 不是必須的,因為while入口處已經判斷過了 { T = Pop(S); // 結點彈出堆棧 printf("%5d", T->Data); // (訪問)打印結點 T = T->Right; // 轉向右子樹 } } }
注意到先序的非遞歸算法只要在中序非遞歸算法的基礎上做一下調整就好了:printf語句放到Push操作之前。而后續遍歷就比較繁瑣了,因為當指針T指向一個結點時,不能馬上對它進行訪問,而要先遍歷它的左子樹,因而要將此結點的地址進棧保存。當其左子樹遍歷完畢之后,再次搜索到該結點時(退棧),還不能對它訪問,還需要遍歷它的右子樹,所以,再一次將此結點的地址進棧保存。為了區別同一結點的兩次進棧,需要引入一個標志變量,比如flag為0表示該結點暫不訪問,為1表示該結點可以訪問。
層序遍歷
層序遍歷基本過程:先根結點入隊,然后:
- 從隊列中取出一個元素
- 訪問該元素所指結點
- 若該結點所指結點的左、右孩子結點非空,則將其左、右孩子的指針順序入隊
void LevelOrderTraversal(BinTree BT) { Queue Q; BinTree T; if(!BT) // 若是空樹直接返回 return; Q = CreateQueue(MaxSize); // 創建並初始化隊列Q AddQ(Q, BT); while(!IsEmpty(Q)) { T = Delete(Q); printf("%d\n", T->Data); // 訪問取出隊列的結點 if(T->Left) AddQ(Q, T->Left); if(T->Right) AddQ(Q, T->Right); } }
遍歷二叉樹的應用
輸出二叉樹中的葉子結點
在二叉樹的遍歷算法中增加檢測結點的"左右子樹是否都為空"
void PreOrderPrintLeaves(BinTree BT) { if(BT) { if(!BT->Left && !BT->Right) printf("%d", BT->Data); PerOrderPrintLeaves(BT->Left); PerOrderPrintLeaves(BT->Right); } }
求二叉樹的高度
需要注意到Height = Max(HL, HR) + 1
int PostOrderGetHeight(BinTree BT) { int HL, HR, MaxH; if(BT) { HL = PostOrderGetHeight(BT->Left); // 求左子樹的深度 HR = PostOrderGetHeight(BT->Right); // 求右子樹的深度 MaxH = (HL > HR) ? HL : HR; // 取左右子樹中較大的深度 return MaxH + 1; // 返回樹的深度 } else return 0; // 空樹深度為0 }
由先序和中序遍歷序列來確定一顆二叉樹
- 根據先序遍歷序列第一個結點確定根結點
- 根據根結點在中序遍歷序列中的位置分隔出左右兩個子序列
- 對左子樹和右子樹分別遞歸使用相同的方法繼續分解
類似地,后序和中序遍歷序列也可以確定一顆二叉樹
4、二叉搜索樹
先來回顧一下之前提到的查找問題(靜態查找與動態查找),針對動態查找,數據如何組織?
什么是二叉搜索樹
二叉搜索樹(BST,Binary Search Tree),也稱二叉排序樹或二叉查找樹:一顆二叉樹,可以為空;如果不為空,滿足以下性質:
- 非空左子樹的所有鍵值小於其根結點的鍵值
- 非空右子樹的所有鍵值大於其根結點的鍵值
- 左、右子樹都是二叉搜索樹
二叉搜索樹操作的特別函數
- Postion Find(ElementType X, BinTree BST):從二叉搜索樹BST中查找元素X,返回其所在結點的地址
- Postion FindMin(BinTree BST):從二叉搜索樹BST中查找並返回最小元素所在結點的地址
- Position FindMax(BinTree BST):從二叉搜索樹BST中查找並返回最大元素所在結點的地址
- BinTree Insert(ElementType X, BinTree BST)
- BinTree Delete(ElementType X, BinTree BST)
二叉搜索樹的查找操作:Find
- 查找從根結點開始,如果樹為空,返回NULL
- 若搜索樹非空,則根結點關鍵字和X進行比較,並進行不同處理:
若X小於根結點鍵值,只需在左子樹中繼續搜索
若X大於根結點的鍵值,在右子樹中繼續進行搜索
若兩者比較結果相等,搜索完成,返回指向此結點的指針。
Positon Find(ElementType X, BinTree BST) { if(!BST) return NULL; // 查找失敗 if(X > BST->Data) return Find(X, BST->Right); // 在右子樹中繼續查找 else if return Find(X, BST->Left); // 在左子樹中繼續查找 else // X == BST->Data return BST; // 查找成功,返回結點的地址 }
上面程序中的兩處遞歸調用都是尾遞歸,因此可以方便的改寫為迭代函數,以便提高執行效率(注意到,查找的效率取決於樹的高度)
Position IterFind(ElementType X, BinTree BST) { while(BST) { if(X > BST->Data) BST = BST->Right; // 向右子樹中移動,繼續查找 else if(X < BST->Data) BST = BST->Left; // 向左子樹中移動,繼續查找 else // X == BST->Data return BST; // 查找成功,返回結點的地址 } return NULL; // 查找失敗 }
查找最大和最小元素
只需注意到以下事實:
- 最大元素一定在樹的最右分支的端結點上
- 最小元素一定在樹的最左分支的端節點上
查找最小元素的遞歸函數
Postion FindMin(BinTree BST) { if(!BST) return NULL; // 空的二叉搜索樹,返回NULL else if(!BST->Left) return BST; // 找到最左葉結點並返回 else return FindMin(BST->Left); // 沿左分支繼續查找 }
查找最大元素的迭代函數
Position FindMax(BinTree BST) { if(BST) while(BST->Right) BST = BST->Right; // 沿右分支繼續查找,直到最右結點 return BST; }
二叉搜索樹的插入
關鍵是要找到元素應該插入的位置,可以采用與Find類似的方法
BinTree Insert(ElementType X, BinTree BST) { if(!BST) { // 若原樹為空,生成並返回一個結點的二叉搜索樹 BST = malloc(sizeof(struct TreeNode)); BST->Data = X; BST->Left = BST->Right = NULL; } else // 開始找要插入元素的位置 { if(X < BST->Data) BST->Left = Insert(X, BST->Left); // 遞歸插入左子樹 else if(X > BST->Data) BST->Right = Insert(X, BST->Right); // 遞歸插入右子樹 // else X已經存在,什么都不做 } return BST; }
關於上面的代碼,多說一點,就是關於遞歸調用返回的時候需要賦值給左子樹或右子樹,這在大多數賦值的情況下顯得多余(就像是說,把當前樹的左子樹賦值給它的左子樹),但是它是必須的,因為在插入元素的時候我們需要知道它的父結點的左指針或右指針。我們也可以消除不必要的賦值,但是它是以增加邏輯判斷為代價的,還不如原先的方式顯得清晰、美觀。
二叉搜索樹的刪除
要考慮三種情況
- 要刪除的是葉節點:直接刪除,並再修改其父結點指針,置為NULL
- 要刪除的結點只有一個孩子結點:將其父結點的指針指向要刪除結點的孩子結點
- 要刪除的結點有左、右兩顆子樹:用另一結點替代被刪除結點:右子樹的最小元素或者左子樹的最大元素
BinTree Delete(ElementType X, BinTree BST) { Position Tmp; if(!BST) printf("要刪除的元素未找到"); else if(X < BST->Data) BST->Left = Delete(X, BST->Left); // 左子樹遞歸刪除 else if(X > BST->Data) BST->Right = Delete(X, BST->Right); // 右子樹遞歸刪除 else // 找到要刪除的結點 { if(BST->Left && BST->Right) // 被刪除結點有左右兩個子結點 { Tmp = FindMin(BST->Right); // 在右子樹中找最小的元素填充刪除結點 BST->Data = Tmp->Data; BST->Right = Delete(BST->Data, BST->Right); // 在刪除結點的右子樹中刪除最小元素 } else // 被刪除結點有一個或無子結點 { Tmp = BST; if(!BST->Left) // 有右孩子或無子結點 BST = BST->Right; else if(!BST->Right) // 有左孩子或無子結點 BST = BST->Left; free(Tmp); } } return BST; }
5、平衡二叉樹
什么是平衡二叉樹
搜索樹結點不同插入次序,將導致不同的深度和平均查找程度,這促使二叉樹"平衡"這個概念的出現。二叉樹平衡與否的度量由"平衡因子"(Balance Factor,簡稱BF:BF(T) = HL - HR,其中HL和HR分別為T的左、右子樹的高度)來決定。
平衡二叉樹(Balanced Binary Tree)(AVL樹):
空樹,或者任一結點左、右子樹高度差的絕對值不超過1,即|BF(T)| ≤ 1
我們之所以想要二叉樹在一定程度上達到平衡,就是奔着它的效率去的,那么很自然的一個問題是:平衡二叉樹的高度能達到log2n嗎?
設nh 為高度為h 的平衡二叉樹的最少結點數。結點數最少時:nh = nh-1 + nh-2 + 1。
可以看到,其形式非常類似於斐波那契數列。我們結合初始條件n0 = 1,n1 = 2不難得出nh = Fh+2 - 1。於是我們可以說h = O(log2n)。通俗的說就是,給定結點數為n 的AVL樹的最大高度為O(log2n)。
平衡二叉樹的調整
AVL樹的調整分為四種情況,分別為左單旋、右單旋、左右雙旋、右左雙旋。值得注意的一點是:有時候插入元素即便不需要調整結構,也可能需要重新計算一些平衡因子。
何老師給的圖很好,簡潔明了的表達了需要調整的情況並且給出了具體調整的方法:
下面是程序實現:
typedef struct AVLTreeNode *AVLTree; typedef struct AVLTreeNode{ ElementType Data; AVLTree Left; AVLTree Right; int Height; }; AVLTree AVL_Insertion(ELementType X, AVLTree T) { // 將X插入AVL樹中,並且返回調整后的AVL樹 if(!T) // 若插入空樹,則新建一個包含一個結點的樹 { T = (AVLTree) malloc(sizeof(struct AVLTreeNode)); T->Data = X; T->Height = 0; T->Left = T->Right = NULL; } // if(插入空樹)結束 else if(X < T->Data) // 插入T的左子樹 { T->Left = AVL_Insertion(X, T->Left); if(GetHeight(T->Left) - GetHeight(T->Right) == 2) { // 需要左旋 if(X < T->Left->Data) T = SingleLeftRotation(T); // 左單旋 else T = DoubleLeftRightRotation(T); // 左-右雙旋 } } // else if(插入左子樹)結束 else if(X > T->Data) // 插入T的右子樹 { T->Right = AVL_Insertion(X, T->Right); if(GetHeight(T->Left) - GetHeight(T->Right) == -2) { // 需要右旋 if(X > T->Right->Data) T = SingleRightRotation(T); // 右單旋 else T = DoubleRightLeftRotation(T); // 右-左雙旋 } } //else if(插入右子樹)結束 // else X == T->Data, 無須插入 T->Height = Max(GetHeight(T->Left), GetHeight(T->Right)) + 1; // 更新樹高 return T; } AVLTree SingleLeftRotation(AVLTree A) { // 注意:A必須有一個左子結點B // 將A與B做如圖所示的左單旋,更新A與B的高度,返回新的根結點B AVLTree B = A->Left; A->Left = B->Right; B->Right = A; A->Height = Max(GetHeight(A->Left), GetHeight(A->Right)) + 1; B->Height = Max(GetHeight(B->Left), A->Height) + 1; return B; } AVLTree DoubleLeftRightRotation(AVLTree A) { // 注意:A必須有一個左子結點B,且B必須有一個右子結點C // 將A、B與C做如圖所示的兩次單旋,返回新的根結點C A->Left = SingleRightRotation(A->Left); // 將B與C做右單旋,C被返回 return SingleLeftRotationO(A); // 將A與C做左單旋,C被返回 }
6、堆
什么是堆
優先隊列(Priority Queue):特殊的"隊列",取出元素的順序是依照元素的優先權(關鍵字)的大小,而不是元素進入隊列的先后順序。
問題:如何組織優先隊列?
- 一般的數組、鏈表?
- 有序的數組、鏈表?
- 二叉搜索樹?AVL樹?
對於堆來說,主要就是兩個操作,插入和刪除,而無論是一般的數組、鏈表,還是有序的數組、鏈表其中至少有一個操作是需要O(n) 的時間來完成的。可以考慮能否采用二叉樹存儲結構?如果采用這種存儲結構的話,我們更應該關注插入還是刪除操作?樹結點順序怎么安排?樹結構怎樣?
堆的兩個特性:
- 結構性:用數組表示的完全二叉樹
- 有序性:任一結點的關鍵字是其子樹所有結點的最大值(或最小值)
堆的抽象數據類型
以最大堆為例,其主要操作有:
- MaxHeap Create(int MaxSize):創建一個空的最大堆
- Boolean IsFull(MaxHeap H):判斷最大堆H是否已滿
- void Insert(MaxHeap H, ElementType item):將元素item插入最大堆H
- Boolean IsEmpty(MaxHeap H):判斷最大堆是否為空
- ElementType DeleteMax(MaxHeap H):返回H中最大元素(高優先級)
最大堆的創建
typedef struct HeapStruct *MaxHeap; struct HeapStruct{ ElementType *Elements; // 存儲堆元素的數組 int Size; // 堆的當前元素個數 int Capacity; // 堆的最大容量 } MaxHeap Create(int MaxSize) { // 創建容量為MaxSize的空的最大堆 MaxHeap H = malloc(sizeof(struct HeapStruct)); H->Elements = malloc((MaxSize+1) * sizeof(ElementType)); H->Size = 0; H->Capacity = MaxSize; H->Elements[0] = MaxData; // 定義"哨兵"為大於堆中所有可能元素的值,便於以后更快操作 return H; }
注意到,把MaxData換成小於堆中所有元素的MinData,同樣適用於創建最小堆。
最大堆的插入
思路:首先默認插入位置在完全二叉樹的下一個位置,通過向下過濾結點的方式,從其父結點到根結點的有序序列中尋找合適的位置進行插入操作
void Insert(MaxHeap H, ElementType item) { // 將元素item插入最大堆H,其中H->Elements[0]已經定義為哨兵 int i; if(IsFull(H)) { printf("最大堆已滿"); return; } i = ++H->Size; // i指向插入后堆中的最后一個元素的位置 for(; H->Elements[i/2] < item; i /= 2) H->Elements[i] = H->Elements[i/2]; // 向下過濾結點,這種方式比交換數據來得快 H->Elements[i] = item; // 將item插入 }
上述代碼中,H->Elements[0]是哨兵元素,它不小於堆中的最大元素,控制循環結束。時間復雜度O(logN)。
最大堆的刪除
思路:取出根結點(最大值),同時刪除它,方法就是用堆中的最后一個元素代替之(和插入操作一樣,這里的代替只是形式上方便理解的說辭,實際上我們只是用一個臨時變量保存其值而已,這比真實的替代更省時),但是其位置不一定正確,因此需要從根結點開始向上過濾下層結點。
ElementType DeleteMax(MaxHeap H) { // 從最大堆H中取出鍵值為最大的元素,並刪除一個結點 int Parent, Child; ElementType MaxItem, temp; if(IsEmpty(H)) { printf("最大堆已為空"); return; } MaxItem = H->Elements[1]; // 取出根結點最大值 // 用最大堆中最后一個元素從根結點開始向上過濾下層結點 temp = H->Elements[H->Size--]; for(Parent = 1; Parent*2 <= H->Size; Parent = Child) { Child = Parent*2; if((Child != H->Size) && (H->Elements[Child] < H->Elements[Child+1])) Child++; // Child指向左右子結點中的較大者 if(temp >= H->Elements[Child]) break; else // 移動temp元素到下一層 H->Elements[Parent] = H->Elements[Child]; } H->Elements[Parent] = temp; return MaxItem; }
最大堆的建立
建立最大堆:將已經存在的N個元素按最大堆的要求存放在一個一維數組中
- 方法一:通過插入操作,將N個元素一個個相繼插入到一個初始為空的堆中去,其時間代價為O(NlogN)
- 方法二:線性時間復雜度下建立最大堆
- 將N個元素按輸入順序存入,先滿足完全二叉樹的結構特性
- 調整各結點位置,以滿足最大堆的有序特性。
重點說下方法二,從完全二叉樹的倒數第二層開始調整,因為其左、右子樹只有一個結點,本身構成了一個堆,因此可以用過濾的方式以當前層為根,將根放到合適的位置。經過一輪調整,可以從倒數第三層開始(其左、右子樹仍然各自構成一個堆),續行此法,直到完全二叉樹的Root放置到合適的位置為止。
可以證明(由每層的結點數和該層的最多交換次數找出一般規律,利用錯位相消可解出閉形式)這種方法的時間復雜度是線性的,即T(N) = O(N)。
7、哈夫曼樹與哈夫曼編碼
什么是哈夫曼樹
我們需要先引入一個概念——帶權路徑長度(WPL):
設二叉樹有n個葉子結點,每個葉子結點帶有權值Wk,從根結點到每個葉子結點的長度為Lk,則每個葉子結點的帶權路徑長度之和就是WPL = Σ(k=1~n)WkLk
最優二叉樹或哈夫曼樹就是WPL最小的二叉樹,因此哈夫曼樹說白了就是根據結點不同的查找頻率構造的最有效的搜索樹。
哈夫曼樹的構造
基本思路:每次把權值最小的兩顆二叉樹合並,把兩者的和作為當前樹新的權值,再取兩個權值最小的二叉樹合並,續行此法,直至結點取空。
下面是時間復雜度為O(NlogN)的做法:
typedef struct TreeNode *HuffmanTree; struct TreeNode{ int Weight; HuffmanTree Left, Right; } HuffmanTree Huffman(MinHeap H) { // 假設H->Size個權值已經存在H->Elements[]->Weight里 int i; HuffmanTree T; BuildMinHeap(H); // 將H->Elements[]按權值調整為最小堆 for(i = 1; i < H->Size; i++) // 做H->Size-1次合並 { T = malloc(sizeof(struct TreeNode)); // 建立新結點 T->Left = DeleteMin(H); // 從最小堆中刪除一個結點,作為新T的左子結點 T->Right = DeleteMin(H); // 從最小堆中刪除一個結點,作為新T的右子結點 T->Weight = T->Left->Weight + T->Right->Weight; // 計算新權值 Insert(H, T); // 將新T值插入最小堆 } T = DeleteMin(H); return T; }
哈夫曼樹的特點
- 沒有度為1的結點
- n個葉子結點的哈夫曼樹共有2n-1個結點
- 哈夫曼樹的任意非葉結點的左右子樹交換后仍是哈夫曼樹
- 對同一組權值{W1,W2,...,Wn},是否存在不同構的兩顆哈夫曼樹呢?
答案是肯定的,比如對於一組權值{1,2,3,3},不同構的兩顆哈夫曼樹如下:
容易算出二者的WPL值均為18,之所以出現這樣的結果是因為3個權值為3的結點合並的時機不同導致的。
哈夫曼編碼
考慮這樣一個問題:用位串來編碼英語字母表里的字母(不區分大小寫),可以用長度為5的位串來表示每個字母,這樣用來編碼數據的總數是5乘以文本中的字符數,有沒有可能找出這些字母的編碼方案,使得在編碼數據時使用的位更少?若可能,那么就可以節省存儲空間。
哈夫曼編碼作為一種不等長的編碼形式,一個需要解決的關鍵性問題就是如何避免二義性。為了保證無二義地解碼,我們采用前綴碼——任何字符的編碼都不是另一個字符編碼的前綴。用二叉樹進行編碼:左右分支0、1;字符只在葉節點上。
8、集合及運算
集合的表示
- 集合運算:交、並、補、差,判定一個元素是否屬於某一集合
- 並查集:集合並、查某元素屬於什么集合
- 並查集問題中集合存儲如何實現?可以用樹結構表示集合,樹的每個結點代表一個集合元素,采用雙親表示法:孩子指向父結點。
更加簡便的方法是采用數組存儲形式,數組中有兩個域:Data和Parent。其中Parent為負數表示根結點,非負數表示雙親結點的下標。數組中每個元素的類型描述如下:
typedef struct{ ElementType Data; int Parent; }SetType;
集合運算
查找某個元素所在的集合(用根結點表示)
int Find(SetType S[], ElementType X) { // 在數組中查找值為X的元素所屬的集合 // MaxSize是全局變量,為數組S的最大長度 int i; for(i = 0; i < MaxSize && S[i].Data != X; i++) ; if(i >= MaxSize) return -1; // 未找到X,返回-1 for(; S[i].Parent >= 0; i = S[i].Parent) ; return i; // 找到X所屬集合,返回數根結點在數組S中的下標 }
集合的並運算
- 分別找到X1和X2兩個元素所在集合樹的根結點
- 如果它們不同根,則將其中一個根結點的父結點指針設置成另一個根結點的數組下標
void Union(SetType S[], ElementType X1, ElementType X2) { int Root1, Root2; Root1 = Find(S, X1); Root2 = Find(S, X2); if(Root1 != Root2) S[Root2].Parent = Root1; }
效率優化
注意到上面的Union操作可能導致的一個問題就是樹傾斜問題嚴重,導致Find操作低效,因此一個自然的想法,就是把小的集合合並到大的集合中,為此可以為數據結構增加一個域代表該集合的元素個數,但是這是沒有必要的,因為除了根結點外,其他的結點無需保存集合元素個數,這樣一個更好的方法便是:將集合的根結點的Parent設置為當前集合元素個數的負數。這樣的話,合並的時候只需要取其絕對值參與運算即可。
說完了Union的優化,我們來考慮一下Find操作是否可以優化,答案是肯定的,就是所謂的路徑壓縮,每次查找一個結點的時候,將其查找路徑上的全部結點直接指向其父節點。后面我會寫一篇PAT樹部分的習題解答,關於這兩個操作優化后的具體實現可以在里面找到。
(END_XPJIANG)