數據結構-樹
概念
定義
- 根結點只有一個
- 除根結點以外其他所有結點有且僅有一個前驅
- 所有結點都可以用任意個后驅
術語
以A-B-E-K路徑為例:
-
祖先結點:結點到根結點路徑上的所有前驅,A,B,E都是K的祖先結點。
-
子孫結點:結點的所有后驅,B,E,K都是A的子孫結點。
-
雙親結點:結點的直接前驅,E是K的雙親結點。
-
孩子結點:結點的直接后驅,K是E的孩子結點。
-
兄弟結點:相同雙親的結點,E,F是兄弟結點。
-
結點的度:結點的子結點個數。
-
樹的度:結點的最大度數。
-
分支結點:度大於0的結點。
-
葉子結點:度等於0的結點。
-
結點的層次:從樹根開始數層數。
-
結點的深度:自上向下。
-
結點的高度:自下向上。
-
樹的高度(深度):結點的最大層數。
-
有/無序樹:結點的子樹是否可以有序。
-
平衡/豐滿樹:除最底層,其他層都是滿的。
-
森林:不相交樹的集合。
性質
- 樹的結點數等於所有結點的度之和+1。
二叉樹
定義
- 最大度為2
- 可以為空
- 有序樹
特殊
幾個特殊的二叉樹:
- 滿二叉樹:葉子結點都集中在最后一層的二叉樹。
- 完全二叉樹:如果對滿二叉樹的結點進行編號,如上圖所示。編號連續的滿二叉樹子集稱為完全二叉樹。
- 二叉排序樹:左子樹結點的關鍵字均小於右子樹的結點繁榮關鍵字。
- 平衡二叉樹:樹中任意一個結點的左右子樹的深度差不超過1。
性質
- 非空二叉樹上的葉子結點數等於雙分支結點數加1。
- 二叉樹第i層上最多有\(2^{i-1}\)個結點。
- 完全二叉樹對各結點從上到下,從左到右分別從1開始進行編號則對\(a_i\)有:
- 若i≠1,雙親結點編號為[i/2]。
- 若2i≤n,a左孩編號為2i,反之無左孩。
- 若2i+1≤n,a右孩編號為2i+1,反之無右孩。
若0開始編號,雙親[i/2]-1,左孩2i+1,右孩2i+2。
存儲
存儲結構一般分兩種,順序或者鏈式。
- 順序存儲
因為我們已經知道了完全二叉樹是滿足一定性質的,這樣即使是順序存儲也能很方便的找到其雙親和孩子結點。但是對於非完全二叉樹的情況會很浪費存儲空間。 - 鏈式存儲
typedef struct BNode{
int data;
struct BNode *lchild;
struct BNode *rchild;
}BNode, *BTree;
遍歷
遞歸
遍歷有先序,中序,和后序三種方式,區別在於訪問根結點的順序。
遞歸遍歷比較簡單,這里就舉一個前序的例子。假設visit
是對結點的操作。
void PreOrder(BTree T){ //先序遍歷
if(T==NULL) return;
visit(T); //訪問根結點
PreOrder(T->lchild); //遞歸遍歷左子樹
PreOrder(T->rchild); //遞歸遍歷右子樹
}
時間復雜度O(n),空間復雜度O(n)。
以上圖為例:
- 前序:1 2 4 6 3 5
- 中序:2 6 4 1 3 5
- 后序:6 4 2 5 3 1
非遞歸
重點在於非遞歸的實現方式:
前序:
這里要利用到棧的性質,我們向左一直遍歷樹,然后保存這些左結點的,等遍歷到了左下角,開始彈棧,轉向遍歷右結點。
void PreOrder(BTree T){
InitStack(S); BTree p=T;
while(p||!isEmpty(S)){
while(p){
visit(p);
stack.push(p);
p=p.lchild;
}
p=stack.pop();
p=p.rchild;
}
}
中序:
中序和后序唯一的區別就是:訪問根結點的順序不一樣。
void PreOrder(BTree T){
InitStack(S); BTree p=T;
while(p||!isEmpty(S)){
while(p){
Push(S,p);
p=p.lchild;
}
Pop(S,p);
visit(p); // 彈棧后才訪問根結點
p=p.rchild;
}
}
后序:
后序的情況稍微復雜一點。
void PreOrder(BTree T){
InitStack(S); BTree p=T; BTree last=NULL;
while(p||!isEmpty(S)){
while(p){
Push(S,p);
p=p.lchild;
}
GetTop(S, p);
if(p.rchild==NULL && p==last){
visit(p);
Pop(S);
last=p;
p=NULL;
}
else{
p=p.rchild;
}
}
}
層次遍歷
逐層遍歷二叉樹
void LevelOrder(BTree T){
InitQueue(Q); BTree p;
EnQueue(Q,T);
while(!IsEmpty(Q)){
DeQueue(Q, p);
visit(p);
if(p->lchild != NULL) EnQueue(Q, P->lchild);
if(p->rchild != NULL) EnQueue(Q, P->rchild);
}
}
遍歷構造
給定前序+中序或者后序+中序的遍歷序列,根據序列構造二叉樹。注意:前序和后序不一定唯一確定二叉樹。
BNode* create(vector<int> &inorder, vector<int> &postorder, int is, int ie, int ps, int pe){
if(ps > pe){
return nullptr;
}
BNode* node = new BNode(postorder[pe]);
int pos;
for(int i = is; i <= ie; i++){
if(inorder[i] == node->val){
pos = i;
break;
}
}
node->left = create(inorder, postorder, is, pos - 1, ps, ps + pos - is - 1);
node->right = create(inorder, postorder, pos + 1, ie, pe - ie + pos, pe - 1);
return node;
}
如果方便對數組進行切割的話,代碼會更簡單,舉個例子:
def buildTree(self, inorder, postorder):
if not inorder or not postorder:
return None
root = TreeNode(postorder.pop())
inorderIndex = inorder.index(root.val)
root.right = self.buildTree(inorder[inorderIndex+1:], postorder)
root.left = self.buildTree(inorder[:inorderIndex], postorder)
return root
注意如果是前序+中序的話,right和left的位置要調換。
線索二叉樹
在二叉樹中,存在大量空指針域,可以利用這些空指針域來加快遍歷二叉樹。
定義
線索規則:
- 若
ptr->lchild
為空,則lchild
指向其中序遍歷的前驅結點。 - 若
ptr->lchild
為空,則rchild
指向其中序遍歷的后繼結點。
typedef struct ThreadNode{
int data;
struct ThreadNode *lchild, *rchild;
int ltag, rtag;
}ThreadNode, *ThreadTree
這里的ltag和rtag用於指示指針指向的是子結點還是線索。
構造
在中序遞歸遍歷中插入線索:
void CreateInThread(ThreadTree T){
ThreadTree pre=NULL;
InThread(T,pre);
pre->rchild=NULL;
pre->rtag=1;
}
void InThread(ThreadTree &p, ThreadTree &pre){
if(p!NULL){
InThread(p->lchild,pre); //線索化左子樹
// 線索化過程,除了線索化,其他跟普通的遍歷二叉樹一樣
if(p->lchild==NULL){
p->lchild=pre;
p->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=p;
pre->rtag=1;
}
pre=p;
// 線索化結束
InThread(p->rchild,pre); //線索化右子樹
}
}
遍歷
這里可以看出,二叉樹被線索化之后近似於一個線性的結構。
//t指向頭結點,頭結點左鏈lchild指向根結點,頭結點右鏈rchild指向中序遍歷的最后一個結點。
//中序遍歷二叉線索樹表示二叉樹t
int InOrder(BTree T)
{
BTree *p;
*p = t->lchild; //p指向根結點
while(p != t) //空樹或遍歷結束時p == t
{
while(p->ltag == Link) //當ltag = 0時循環到中序序列的第一個結點
{
p = p->lchild;
}
printf("%c ", p->data); //顯示結點數據,可以更改為其他對結點的操作
while(p->rtag == Thread && p->rchild != t)
{
p = p->rchild;
printf("%c ", p->data);
}
p = p->rchild; //p進入其右子樹
}
return OK;
}
樹與森林
轉化
樹轉二叉樹
樹轉化為二叉樹可以理解為使用一個二叉鏈表來存儲樹的結構,使得鏈表中的指針一個指向自己的孩子結點一個指向自己的兄弟結點,這樣這課樹就表示成了二叉樹。
這種存儲結構一般稱之為孩子兄弟存儲結構。
過程如下:
- 將同一結點的孩子串起。
- 將每個結點的分支從左到右除第一個以外全部剪掉。
二叉樹轉化樹
這個其實就是樹轉二叉樹的逆操作。
- 將二叉樹從左上到右下進行斜向的分層。
- 為每層的結點找到父結點。
- 連接父結點,並刪除層之間的結點連接。
森林轉二叉樹
根據孩子兄弟表示法,根結點是只有左孩子但是沒有右兄弟的,所以可以把第二棵樹接到第一個棵樹的右孩上,第三棵樹接到第二課樹根結點的右孩上,以此類推。
- 先將森林中的樹按照樹轉二叉樹的步驟進行二叉樹轉化
- 將根結點的右孩與其他樹進行拼接。
二叉樹轉森林
- 斷開二叉樹的右孩,重復此操作直到所有二叉樹都沒有右孩。
- 把這些二叉樹按照二叉樹轉樹的操作轉化為樹
遍歷
樹的遍歷
遍歷分先序和后序,也叫先跟和后根。區別在於對跟結點的訪問在遍歷子樹之前還是之后。
先序:ABEFCGDHIJ
后序:EFBGCHIJDA
當樹轉化為二叉樹之后,樹的先序對應二叉樹的先序,樹的后序對應二叉樹的中序。
森林的遍歷
森林遍歷與樹同理。
對於樹與森林,中序遍歷和后序遍歷是一個意思。
哈夫曼樹
概念
哈夫曼樹是帶權路徑長度(WPL)最小的樹。
那么首先明確帶權路徑長度(WPL)的概念。
w為結點的權值,l為路徑長度。
對於上圖有WPL:
a: 7x2+5x2+2x2+4x2=36
b: 7x3+5x3+2x1+4x2=46
c: 7x1+5x2+2x3+4x3=35
構造
給定n個權值,利用這n個權值構造哈夫曼二叉樹。
- 將這n的權值視作n棵根為n的樹,記做F集合。
- 從F選擇兩棵根結點權值最小的樹構造新的二叉樹(新的根結點的權值等於兩個根結點之和)。
- 從F刪去這兩個結點,並加入新結點。
- 重復2,3直到F中只剩一棵樹。
於是可以看出:
- 權值越大離根越近。
- 沒有度為1的結點,也叫正則(嚴格)二叉樹
- 樹的帶權路徑最短
哈夫曼編碼
哈夫曼樹最常用的一個例子就是利用哈夫曼樹進行文件壓縮。
我們可以根據字符出現次序為其進行哈夫曼編碼,次數越多越短,否則反之。
如果有一個文本,a出現了45次,b13,c12,f5,e9,d16。共100個。
可以構造得到哈夫曼樹及其編碼。
結點
計算WPL得到是224,比起3x100來壓縮了76個字符的長度。
哈夫曼n叉樹
注意哈夫曼樹不一定是二叉樹,也有可能是多叉樹,但有可能需要0權值的結點來補齊,構造過程與二叉樹區別在於從集合拿出樹的個數。
小結
習題
在一棵度為4的樹T中,若有20個度為4的結點,10個度為3的結點,1個度為2的結點,10個度為1的結點,則樹T的葉結點的個數是():
答案:82
解析:
結點度數之和為:\(20\times 4+10\times 3+1\times 2+10\times 1=122\)。
樹的結點數量為結點度數之和+1,即123個結點。
葉結點即度數為0的結點,度數大於0的結點數量為:\(20+10+1+10=41\),總結點數量-度數大於0結點的數量,即82。