數據結構與算法——從零開始學習(五)樹和二叉樹

目錄 第五章 :樹和二叉樹

第一節:樹的定義及相關術語

1.1 定義
1.2 特點
1.3 形式化
1.4 相關術語
1.5 樹的基本操作

第二節:二叉樹

2.1 基本概念
2.2 存儲結構
2.3 二叉樹基本操作
2.4 二叉樹的遍歷

第三節:樹與森林

3.1 樹的存儲
3.2 樹、森林與二叉樹的相互轉換
3.3 樹和森林的遍歷

第四節:最優二叉樹——哈夫曼樹

4.1 基本概念
4.2 哈夫曼樹的構造算法
4.3 哈夫曼編碼
4.4 哈夫曼編碼算法實現

本章小結

第五章 :樹和二叉樹

樹和圖是兩種重要的非線性結構。線性結構中結點具有唯一前驅和唯一后繼的關系,而非線性結構中結點之間的關系不再具有這種唯一性。

其中,樹形結構中結點間的關系是前驅唯一而后繼不唯一,即元素之間是一對多的關系;在圖結構中結點之間的關系是前驅、后繼均不唯一,因此也就無所謂前驅、后繼了。

直觀地看,樹形結構既有分支關系,又具有層次關系,它非常類似于自然界中的樹。樹形結構在現實世界中廣泛存在,如:家譜、行政組織機構等都用樹形表示。計算機領域的DOS和Windows操作系統中對磁盤文件的管理就采用了樹形目錄結構;在數據庫中,樹形結構也是數據的重要組織形式之一。

第一節:樹的定義及相關術語
1.1 定義

樹(tree)是n(n>=0)個結點的有限集合。當n=0時,該集合滿足以下條件:

(1)有且只有一個特殊的結點稱為樹的根(root),根結點沒有直接前驅結點,但有零個或多個直接后繼結點。

(2)跟結點之外的其余n-1個結點被分成m(m>0)個互相不相交的集合T1、T2、···、Tm,其中每一個集合Ti(1<=i<=m)本身又是一棵樹。樹T1,T2,···,Tm稱為根節點的子樹。

*可以看出,在樹的定義中用了遞歸概念,即用樹來定義樹。因此,樹形結構的算法也常常使用遞歸方法。

1.2 特點

(1)樹的根結點沒有直接前驅,除根結點之外的所有結點有且只有一個直接前驅。

(2)樹中所有結點可以有零個或多個直接后繼。

1.3 形式化

樹的形式化二元組為:T = (D,R)。其中,D為樹T中結點的集合;R為樹中結點之間關系的集合。當樹T為空時,D為空;當樹T不為空樹時,有:D = {Root U Df },Root為樹T的根結點,Df為樹T的根Root的子樹集合。

當樹T的結點個數n<=1時,R為空;當樹T 中結點個數n>1時有:R={<Root,ri>,i=1,2,···,m}。其中,Root為樹T的根節點,ri 是樹T的根結點Root的子樹Ti 的根結點。

下圖是一顆具有9個結點(ABCDEFGHI)的數T:

image.png

二元組:T = ({A,B,C,D,E,F,G,H,I },{<A,B> ,<A ,C>,<B,D>,<B,E>,<B,F>,<C,G>,<E,H>,<E,I>})

其中,以<A,B>為例,A是B的直接前驅,B是A的直接后繼,也稱為樹的一條分支。

結點A為樹T的根結點,除根結點A之外的其余結點分為兩個不相交的集合:T1 = { B,D,E,F,H,I} 和 T2={C,G}。它們倆構成了結點A的兩棵子樹,T1和T2本身也是一棵樹,例如子樹T1的根結點為B,其余結點又分為三個不相交的集合即構成了結點B的三棵子樹。

1.4 相關術語

(1)結點:包含一個數據元素及若干指向其他結點的分支信息的數據結構。

(2)結點的度:結點所擁有的子樹的個數稱為該結點的度。

(3)葉子結點:度為0的結點稱為葉子結點,或者稱為終端結點。

(4)分支結點:度不為0的結點稱為分支結點,或者稱為非終端結點。一棵樹的結點除葉子結點外,其余的結點都是分支結點。

(5)孩子結點、雙親結點、兄弟結點:樹中一個結點的子樹的根結點稱為這個結點的孩子結點,這個結點稱為孩子結點的雙親結點。具有同一個雙親結點的孩子結點互稱為兄弟結點。

(6)路徑、路徑長度:設n1,n2,···,nk為一棵樹的結點序列,若結點ni是ni+i的雙親結點(1<=i <k),則把n1,n2,···,nk稱為一條由n1至nk的路徑。這條路徑的長度是k-1。

(7)祖先、子孫:在樹中,如果有一條路徑從結點M到結點N,那么M就稱為N的祖先,而N稱為M的子孫。

(8)結點的層次:規定樹的根結點的層數為1,其余結點的層數等于它的雙親結點層數加1。

(9)樹的深度(高度):樹中所有結點的層次的最大數稱為樹的深度。

(10)樹的度:樹中所有結點度的最大值稱為該樹的度。

(11)有序樹和無序樹:如果一棵樹中結點的各子樹從左到右是有次序的,即若交互了某結點各子樹的相應位置,則構成不同的樹,稱這棵樹為有序樹;反之,則稱為無序樹。

(12)森林:m(m>=0)棵不想交的樹的集合稱為森林。自然界中樹和森林是不同的概念,但在數據結構中,樹和森林只有很小的差別。任何一棵樹,刪去根結點就變成了森林;反之,給森林增加一個統一的根結點,森林就變成一棵樹。

1.5 樹的基本操作

通常有以下幾種:

  1. Initiate(t):初始化一棵樹t。
  2. Root(x):求結點x所在樹的根結點。
  3. Parent(t,x) :求樹t中結點x的雙親結點。
  4. Child(t,x,i):求樹t中結點x的第i個孩子結點。
  5. RightSibling(t,x):求樹t中結點x右邊的第一個兄弟結點,也稱右兄弟結點。
  6. Insert(t,x,i,s):把以s為根結點的樹插入到樹t中作為結點x的第i棵子樹。
  7. Delete(t,x,i):在樹t中刪除結點x的第i棵子樹。
  8. Traverse(t):是樹的遍歷操作,即按某種方式訪問樹t中的每個結點,且使每個結點只被訪問一次。遍歷操作是非線性結構中非常常用的基本操作,許多對樹的操作都是借助該操作實現的。

第二節:二叉樹

二叉樹是一種簡單又非常重要的樹形結構。由于任何數都可以轉換為二叉樹進行處理,而二叉樹又有許多好的性質,非常適合于計算機處理,因此二叉樹也是數據結構研究的重點。

2.1 基本概念

二叉樹(Binary Tree)是有n個結點的有限集合,該集合或者為空、或者由一個稱為根(Root)的結點及兩個不相交、被分別稱為根結點的左子樹和右子樹的二叉樹組成。當集合為空時,稱該二叉樹為空二叉樹。一顆二叉樹中每個結點只能含有0、1或2個孩子結點,而且孩子節點分左、右孩子。

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

完全二叉樹:一棵深度為k的有n個結點的二叉樹,對樹中的結點按從上至下、從左到右的順序進行編號,如果編號為i(i<=n)的結點與滿二叉樹中編號為i的結點在二叉樹中的位置相同,則這棵二叉樹稱為完全二叉樹。其特點是:葉子結點只能出現在最下層和次下層,且最下層的葉子結點在樹的左部。顯然,一棵滿二叉樹必定是一棵完全二叉樹,而完全二叉樹未必是滿二叉樹。

2.2 存儲結構

(1)順序存儲結構:用一組連續的存儲單元存放二叉樹中的結點。一般按照二叉樹結點從上至下、從左到右的順序存儲。對于一般的二叉樹,如果仍按從上至小、從左到右的順序將樹中的結點順序存儲在一維數組中,則數組元素下標之間的關系不能反映二叉樹中結點之間的邏輯關系,只有添加一些并不存在的空結點,使之成為一棵完全二叉樹的形式,然后用一維數組順序存儲。顯然,這種存儲對于需增加許多空結點才能將一棵二叉樹改造成為一棵完全二叉樹的存儲時,會造成空間的大量浪費,不宜用順序存儲結構。

(2)鏈式存儲結構:用鏈式結構來表示一棵二叉樹,即用鏈指針來指示其元素的邏輯關系。

二叉鏈表存儲,每個結點由三個域組成,除了數據域外,還有兩個指針域,分別用來給出該結點左孩子和右孩子所在的鏈結點的存儲地址。其中,data域存放結點的數據信息;Lchild 與Rchild分別存放指向左孩子和右孩子的指針,指針域為空為^/NULL。

image.png
2.3 二叉樹基本操作

1、Initiate(bt):建立一棵空二叉樹。

2、Create(x,lbt,rbt):生成一棵x為根結點的樹,以lbt、rbt為子樹。

3、InsertL(bt,x,parent):將結點x插入到樹bt中作為parent結點的左孩子結點。如果parent已經有左孩子,則將x作為左孩子結點的左孩子結點。

4、InsertR(bt,x,parent):插入到右孩子結點。于上同理。

5、DeleteL(bt,parent):在二叉樹bt中刪除結點parent的左子樹。

6、DeleteR(bt,parent):在二叉樹bt中刪除結點parent的右子樹。

7、Search(bt,x):在二叉樹bt中查找數據元素x。

8、Traverse(bt):按某種方式遍歷二叉樹bt中的全部結點。

2.4 二叉樹的遍歷

遍歷操作可以使非線性結構線性化。由二叉樹定義可知,一棵二叉樹由根結點及其左子樹和右子樹三部分組成。因此,只需依次遍歷這三部分即可遍歷整個二叉樹。若以D、L、R分別表示根、左、右,且以從左到右的順序遍歷為:(先)前序DLR、中序LDR、后序LRD。

(1)先序遍歷:先訪問根結點,然后遍歷根結點的左子樹,最后遍歷根結點的右子樹。

1、遞歸算法如下:

void PreOrder(BiTree bt) {
    if(bt ==null) return;
    Visit(bt ->data); //得到值
    PreOrder(bt->Lchild); //先左
    PreOrder(bt->Rchild);//再右
}

按先序所得到的結點序列為:ABDGCEF

2、非遞歸算法:要在遍歷左子樹之前保存右子樹根結點的地址指針,以便在完成左子樹的遍歷后,取出右子樹根結點的地址,去遍歷這棵右子樹。同樣在遍歷左子樹的左子樹之前,也要先保存左子樹的右子樹根結點的地址,以此類推??梢?,這些地址的保存和取出符合后進先出的原則,所以設置一個輔助作用的棧來保存右子樹根結點的地址。這個輔助棧保存所有經過的結點指針,包括空的根指針和空的孩子指針。算法如下:

void PreOrderNonRec(BiTree bt) {
    Stack s;//鏈表結點地址
    BiTree p;
    Init_Stack(&s);//初始化棧s
    
    Push_Stack(&s,bt);//根結點的地址bt入棧s,包括空的二叉樹
 
    while(!Empty_Stack(s)){//棧s非空執行循序體
 
        p =Top_Stack(s);//取棧頂元素
        while(p!=NULL){
            Visit( p->data);
            Push_Stack(&s,p->Lchild);//向左走到盡頭,空左孩子指針也入棧
            p = Top_Stack(s);//取棧頂元素
        }
        Pop_Stack(&s);//空指針退棧,棧中不可能有兩個連續空指針
        if(!Empty_stack(s)){
            p =Pop_Stack(&s);
            Push_stack(&s,p->Rchild);//向右走一步,右孩子地址入棧
        }
    }
}    

(2)中序遍歷:先遍歷根據的左子樹,再訪問根結點,最后遍歷根結點的右子樹。

1、遞歸算法:

void InOrder(BiTree bt) {
    if(bt ==NULL) return;
    InOrder(bt -> Lchild);
    Visit(bt ->data);
    InOrder(bt ->Rchild);
}

按中序遍歷所得到的結點序列為:DGBAECF

2、非遞歸算法:

void InOrderNonRec(BiTree bt){
    Stack s;//設棧類型為Stack
    BiTree p;
    Init_Stack(&s,bt);//初始化棧s
    Push_Stack(&s,bt);//根結點的指針bt入棧s
    
    while(!Empty_Stack(s)){
        p =Top_Stack(s);
 
        while(p!=NULL){
            Push_Stack(&s,p->Lchild);//向左走到盡頭,空左孩子指針也入棧
            p =Top_Stack(s);
        }
        p = Pop_Stack(&s);//空指針退棧
        if(!Empty_Stack(s)){
            p =Pop_Stack(&S);
            Visit(p ->data);//訪問當前根結點
            Push_Stack(&s,p->Rchild);//向右一步,右孩子指針入棧
        }
    }
}

(3)后序遍歷:先遍歷根結點的左子樹,再遍歷根結點的右子樹,最后訪問根結點。

1、遞歸排序算法:

void PostOrder(BiTree bt){
    if(bt == NULL) return;
    PostOrder(bt ->Lchild);
    PostOrder(bt ->Rchild);
    Visit(bt ->data);
}

2、非遞歸排序算法:

void PostOrderNonRec(BiTree bt){
    Stack s;
    BiTree p,q;
    Init_Stack(&s);
    p =bt;
    do{
        while(p){//向左走到盡頭,左孩子指針入棧
            Push_Stack(&s,p);
            p = p->Lchild;
        }
        q =NULL;
        while(!Empty_Stack(s)){        
            p=Top_Stack(s);
            if(p->Rchild =NULL)||(p->Rchild ==q)){
                visit(p ->data);        
                q = p ;
                Pop_Stack(&s);
            }else{
                p =p->Rchild;
                break;
            }
        }
    }while(!Empty_Stack(s));
}

(4)層次遍歷:是指從二叉樹的第一次根結點開始,從上至下逐層遍歷,在同一層中,則按從左到右的順序對結點逐個訪問。得到的結果序列為:ABCDEFG。因此,在進行層次遍歷時,對一層結點訪問完后,再按照它們的訪問次序對各個結點的左孩子和右孩子順序訪問,這樣一層一層進行,先遇到的結點先訪問,這與隊列的操作原則比較吻合。因此,在進行層次遍歷時,可設置一個隊列結構,遍歷從二叉樹的根結點開始,首先將根結點指針入隊列,然后從隊頭取出一個元素,每取出一個元素先訪問該元素所指的結點,若該元素所指的結點左右孩子指針非空,則將該元素所指結點的非空左孩子指針和右孩子指針順序入隊。若隊列非空,重復以上過程,當隊列為空時,二叉樹的層次遍歷結束。在下面算法中,二叉樹以二叉鏈表存儲,一維數組Queue[MAXNODE]用以實現隊列,變量front和rear分別表示當前隊列首元素和隊列尾元素在數組中的位置。

void LevelOrder(BiTree bt){
    BiTree Queue[MAXNODE];
    int front,rear;
    
    if(bt ==NULL )return;
    
    front =1; 
    //隊列初始化    
    rear = 0;
    queue[rear] =bt;//根結點入隊
    while(front!=rear){
        front++;
        Visit(queue[front] ->data);//訪問隊首結點的數據域
        if(queue[front]->Lchild!=NULL){
            rear++;
            queue[rear] = queue[front]->Lchild;
        }
        if(queue[front] -> Rchild!=NULL){
            rear++;
            queue[rear] = queue[front] -> Rchild;
        }
    }
}

第三節:樹與森林

3.1 樹的存儲

在計算機中,樹的存儲有很多種方式,即可以采用順序存儲結構,又可以采用鏈式存儲結構,但無論采用何種存儲方式,都要求存儲結構不但能存儲各結點本身的數據信息,還要能唯一地反映樹中各結點之間的邏輯關系。

1、雙親表示法:由樹的定義可以知道,樹中除根結點外的每個結點都有唯一的一個雙親結點,根據這一特性,可用一組連續的存儲空間即一維數組存儲樹中的各個結點,數組中的一個元素表示樹中的一個結點,數組元素為結構體類型,其中包括結點本身的信息及結構體類型,其中包括結點本身的信息及該結點的雙親結點在數組中的序號,樹的這種存儲方法稱為雙親表示法。存儲結構如下:

#define MAXNODE 100
typedef struct{
    elemtype data;
    int parent;
}NodeType;
NodeType t[MAXNODE];

如下圖所示,樹a的雙親表示b。其中b圖中用parent域的值為-1表示該結點無雙親結點,即該結點是一個根結點。

image.png

樹的雙親表示法對于實現Parent(t,x)操作和Root(x)操作很方便,但若求某結點的孩子結點,即實現Child(t,x,i)操作時,則需要查詢整個數組。此外,這種存儲方式不能反映各兄弟結點之間的關系,所以實現RightSibling(t,x)操作也比較困難。在實際中,如果需要實現這些操作,可在結點結構中增設存放第一個孩子的域和存放右兄弟的域,就能較方便地實現上述操作了。

2、孩子表示法:

image.png

如上圖,其主體是一個與結點個數一樣大小的一維數組,數組的每一個元素都由兩個域組成:一個域用來存放結點本身的信息,另一個用來存放指針(該指針指向由該結點孩子組成的單鏈表的首位置)。單鏈表的基本結構也由兩個域組成:一個存放孩子結點在一維數組中的序號,另一個是指針域,指向下一個孩子。顯然,在孩子表示法中查找雙親比較困難,查找孩子卻十分方便,故適用于對孩子操作多的應用。孩子表示法的存儲結構可描述如下:

#define MAXNODE 100
typedef struct ChildNode{
    int childcode;
    struct ChildNode *nextchild;
}
typedef struct{
    elemtype data;
    struct ChildNode *firstchild;
}NodeType;
NodeType t[MAXNODE];

3、孩子兄弟表示法:

在樹中,每個結點除其信息域外,再增加兩個分別指向該結點的第一個孩子結點和右兄弟結點的指針。在這種存儲結構下,樹中結點的存儲結構可描述如下:

typedef struct TreeNode{
    elemtype data;
    struct TreeNode *firstchild;
    struct TreeNode *nextsibling;
 }NodeType;
 
//定義一棵樹
NodeType *t;
image.png

如上圖所示,該存儲結構與二叉樹的二叉鏈表結構非常相似,而且事實上,如果剔除了字面上的含義,其實質是一樣的。因此樹、森林與二叉樹的轉換才得以方便地實現。

3.2 樹、森林與二叉樹的相互轉換

從樹的孩子兄弟表示法所示,如果設定一定的規則,就可用二叉樹結構表示樹、森林,這樣對樹的操作實現就可以借助二叉樹存儲,利用二叉樹上的操作來實現。

1、樹轉換為二叉樹:

對于一棵無序樹,樹中結點的各孩子結點的次序是無關緊要的,而二叉樹中結點的左右孩子結點是有區別的。為避免發生混淆,約定樹中每一個結點的孩子結點按從左到右的次序順序編號。將一棵樹轉換為二叉樹的方法是:

  • 樹中所有相鄰兄弟之間加一條連線;
  • 對樹中每個結點,只保留它與第一個孩子結點之間的連線,刪去它與其他孩子結點之間的連線;
  • 以樹的根結點為軸心,將整棵樹順時針轉動一定的角度,使之結構層次分明。

可以證明,樹這樣的轉換所構成的二叉樹是唯一的。轉換示意圖如下:

image.png

由上面的轉換可以看出,在二叉樹中,左分支上的各結點在原來的樹中是父子關系,而右分支上的各結點在原來的樹中是兄弟關系。由于樹的根結點沒有兄弟,所以變換后的二叉樹的根結點的右孩子必為空。

事實上,一棵樹采用孩子兄弟表示法所建立的存儲結構與它所對應的二叉樹的二叉鏈表存儲結構是完全相同的。

2、森林轉換為二叉樹:

由森林的概念可知,森林是若干棵樹的集合,只要將森林中各棵樹的根視為兄弟,每棵樹又可以用二叉樹表示,這樣森林同樣可以用二叉樹表示。方法如下:

  • 將森林中的每棵樹轉換成相應的二叉樹;
  • 第一棵二叉樹不動,從第二課二叉樹開始,依次把后一棵二叉樹的根結點作為前一棵二叉樹根結點的右孩子,當所有二叉樹連起來后,所得到的二叉樹就是由森林轉換得到的二叉樹。

該方法可形式化描述為:

如果F={T1,T2,···,Tm}是森林,則可按如下規則轉換成一顆二叉樹B={Root ,LB,RB}。

若F為空,即m=0,則B為空樹;

若F非空,即m!=0,則B的根Root即為森林中第一棵樹的根Root(T1);B的左子樹LB是從T1中根結點的子樹森林F1={T11,T12,···,T1m}轉換而成的二叉樹;其右子樹RB是從森林F = {T2,T3,···,Tm}轉換而成的二叉樹。轉換過程示意圖如下:

image.png

3、二叉樹轉換為樹和森林

樹和森林都可以轉換為二叉樹,二者不同的是:樹轉換成的二叉樹的根結點不同的是:樹轉換的二叉樹的根結點無右分支,而森林轉換后的二叉樹的根結點有右分支。顯然這一轉換過程是可逆的,即可以依據二叉樹的根結點有無右分支,將一棵二叉樹還原成樹或森林。方法如下:

若某結點是某雙親的左孩子,則把該結點的右孩子、右孩子的右孩子·····都與該結點的雙親結點用線連起來;
刪去原二叉樹中所有的雙親結點與右孩子結點的連線;
整理由前面兩步所得到的樹或森林,使之結構層次分明。
這一方法可形式化描述為:

如果B = {Root,LB,RB}是一棵二叉樹,則可按如下規則轉換成森林F={T1,T2,···,Tm};

若B為空,則F為空;

若B非空,則森林中第一棵樹T1的根Root(T1)即為B的根Root;T1中根結點的子樹森林F1是由B的左子樹LB轉換而成的森林;F中除T1之外其余樹組成的森林F ={T2,T3,···,Tm}是由B的右子樹RB轉換而成的森林。過程示意圖如下:

image.png
3.3 樹和森林的遍歷

1、樹的遍歷

(1)先根遍歷:訪問根結點,按照從左到右的順序先根遍歷根結點的每一棵樹。

(2)后根遍歷:按照從左到右的順序后根遍歷根結點的每一棵子樹,再訪問根結點。

根據樹與二叉樹的轉換關系及樹和二叉樹的遍歷定義可以推知,樹的先根遍歷與其轉換的相應二叉樹的先序遍歷的結果序列相同;樹的后根遍歷與其轉換的相應二叉樹的中序遍歷的結果序列相同。因此樹的遍歷算法是可以采用相應二叉樹的遍歷算法來實現的。

樹的遍歷算法實現:

void RootFirst(NodeTepe t){
    NodeType *p;
    if(t!=NULL){
        Visit(t -> data);//訪問根結點
        p=t->firstchild;//指向第一個孩子結點
        while(p){        
            RootFirst(p);//訪問孩子結點
            p =p->nextsibling;//指向下一個孩子結點,右兄弟結點
        }
    }
}

2、森林的遍歷

(1)前序遍歷:訪問森林中第一棵樹的根結點;前序遍歷第一棵樹的根結點的子樹;前序遍歷去掉第一棵樹后的子森林。

(2)中序遍歷:遍歷第一棵樹的根結點的子樹;訪問森林中第一棵樹的根結點;去掉第一棵樹后的子森林。

根據森林與二叉樹轉換關系及森林和二叉樹的遍歷定義可以推知,森林的前序遍歷和后序遍歷與所轉換的二叉樹的前序遍歷和中序遍歷的結果序列相同。

第四節:最優二叉樹——哈夫曼樹

4.1 基本概念

最優二叉樹也稱為哈夫曼樹(Huffman),是指對于一組帶有確定權值的葉子結點,構造的具有最小帶權路徑長度的二叉樹。權值是指一個與特定結點相關的數值。前面介紹過路徑和結點的路徑長度的概念,而二叉樹的路徑長度則是指由根節點到所有葉子結點的路徑長度之和。如果二叉樹中的所有葉子結點都具有一個特定權值,則可將這一概念加以推廣。

設二叉樹具有n個帶權值的葉子結點,那么從根結點到各個葉子結點的路徑長度與該葉子結點相應的權值的乘積之和叫做二叉樹的帶權路徑長度(Weighted Path Length,簡稱WPL = 路徑長度X權值)。

給定一組具有確定權值的葉子結點,可以 構造出不同的帶權二叉樹。例如,給出4個葉子結點,設其權值分別為:1,3,5,5,可以構造出形狀不同的多個二叉樹。這些形狀不同的二叉樹的帶權路徑長度各不相同。如下圖所示:

image.png

a:WPL = 1X2 + 3X2 + 5X2 +5X2 = 28;

b:WPL = 1X3 + 3X3 +5X2 +5X1 = 27;

c: WPL =1X2+3X3 +5X3 + 5X1 =31;

由此可見,由相同權值的一組葉子結點所構成的二叉樹由不同形態和不同的帶權路徑長度。根據哈夫曼樹定義,一棵二叉樹要使其WPL值最小,必須使權值越大的葉子結點越靠近根節點,而權值越小的葉子結點越遠離根結點。哈夫曼根據這一特點提出了一種構造最優二叉樹的方法,這種方法的基本思想是:

  • 由給定的n個權值{W1,W2,,Wn}構造n棵只有一個葉子結點的二叉樹,從而得到一個二叉樹的集合F={T1,T2,,Tn};
  • 在F中選取根結點的權值最小和次小的兩棵二叉樹作為左右子樹構造一棵新樹的二叉樹,這棵新的二叉樹根節點的權值為其左右子樹的根節點權值之和;
  • 在集合F中刪除作為左右子樹的兩棵二叉樹,并將新建立的二叉樹加入到集合F中;
  • 重復上面兩部后,當F中只剩下一棵二叉樹時,這棵二叉樹便是所要建立的哈夫曼樹。
    下圖給出了前面提到的葉子結點權值集合為W = {1,3,5,5}的哈夫曼樹的構造過程。WPL=3X3+1X3+5X2+5X1 =27

由此可見,對于同一組給定葉子結點所構造的哈夫曼樹,樹的形狀可能不同,但帶權路徑長度值是相同的,一定是最小的。

image.png

4.2 哈夫曼樹的構造算法
在構造哈夫曼樹時,可以設置一個結構數組HuffNode保存哈夫曼樹中各結點的信息,根據二叉樹的性質可知,具有n個葉子結點的哈夫曼樹共有2n-1個結點,所以數組HuffNode的大小設置為2n-1,數組元素的結構形式如下:

weight、lchild、rchild、parent。

其中,weight域保存結點的權值,lchild和rchild域分別保存該結點的左右孩子結點在數組HuffNode中序號,從而建立起結點之間的關系。為了判定一個結點算法已加入到要建立的哈夫曼樹中,可通過parent域的值來確定。初始時parent的值為-1,當結點加入到樹中時,該結點parent的值為其雙親結點在數組HuffNode中的序號,就不會是-1了。

構造哈夫曼樹時,首先將由n個字符形成的n個葉子結點存放到數組HuffNode的前n個分量中,然后根據前面介紹的哈夫曼方法的基本思想,不斷將兩個小子樹合并為一個較大的子樹,每次構成的新子樹的根結點順序放到HuffNode數組中前n個分量的后面。算法如下:

#define MAXVALUE 1000;//定義最大權值
#define MAXLEAF 30; // 定義哈夫曼樹中葉子結點的最大個數
#define MAXNODE MAXLEAF*2-1 ;//定義哈夫曼樹中結點的最大數
typedef struct{
    int weight;
    int parent;
    int lchild;
    int rchild;
 }HNode,HuffmanTree[MAXNODE];
 
void CrtHuffmanTree(HuffmanTree ht,int w[],int n){//數組w[] 傳遞n個權值
    int i,j,m1,m2,x1,x2;
    for(i=0;i<2*n-1;i++){//ht初始化
        ht[i].weight =0;
        ht[i].parent =.1;
        ht[i].lchild =.1;
        ht[i].rchild =.1;
    }
    for(i=0;i<n;i++){
        ht[i].weight =w[i];//賦予n個葉子結點的權值
    }
    for(i=0;i<n-1;i++){
        m1 =m2=MAXVALUE;//構造哈夫曼樹
        x1 =x2 =0;
        for(j=0;j=n+1;j++){
            //尋找權值最小和次小的兩棵子樹
            if(ht[j].weight<m1 && ht[j].parent ==.1){
                m2 = m1;
                x2=x1;
                x1=j;   
            }else if(ht[j].weight<m2 &&ht[j].parent ==.1){
                m2=ht[j].weight;
                x2 = j;
            }
        }
        //將找出的兩棵子樹合并為一棵子樹
        ht[x1].parent = n+i;
        ht[x2].parent = n+i; 
        ht[n+i].lchild =x1;
        ht[n+i].rchild =x2;
    }
}
4.3 哈夫曼編碼

在數據通信中,經常需要將傳遞的文字轉換成由二進制字符0、1組成二進制串,即進行符號的二進制編碼。常見的如ASCII碼就是8位的二進制編碼,此外,還有漢字國際碼、電報明碼等。

ASCII碼是一種定長編碼,即每個字符用相同數目的二進制位表示。為了縮短數據文件報文長度,可采用不定長編碼。例如,假設要傳遞的報文為ABACCDA,報文中只含A、B、C、D四種字符。如下圖所示:

image.png

a編碼,報文的代碼為0000 1000 0100 1001 11000,長度為21;

b編碼,報文的代碼為0001 0010 101100,長度為14;這兩種編碼均是定長編碼,碼長分別為3和2。

c編碼,報文的代碼為0110 0101 01110,長度為13;

d編碼,報文的代碼為0101 0010 0100 11001,長度為17;

顯然,不同的編碼方案,其最終形成的報文代碼總長度是不同的。如何使最終的報文最短,可以借鑒哈夫曼思想,在編碼時考慮字符出現的頻率,讓出現頻率高的字符采用盡可能短的編碼,出現頻率低的字符采用稍長的編碼,構造的不定長編碼,則報文的代碼就可能達到更短。

因此,利用哈夫曼樹來構造編碼方案,就是哈夫曼樹的典型應用。具體做法如下:設需要編碼的字符集合為{d1,d2,···,dn},它們在報文中出現的次數或頻率集合為{w1,w2,···,wn},以d1,d2,···dn為葉子結點,w1,w2,···,wn為它們的權值,構造一棵哈夫曼樹,規定對哈夫曼樹中的左分支賦予0,右分支賦予1,則從根結點到每個葉子結點所經過的路徑分支組成的0和1序列便為該葉子結點對應字符的編碼,稱為哈夫曼編碼,這樣的哈夫曼樹也稱為哈夫曼編碼樹。

在哈夫曼編碼樹中,樹的帶全路徑長度的含義是各個字符的碼長與其出現次數的乘積之和,也就是報文的代碼總長,所以采用哈夫曼樹構造的編碼是一種能使報文代碼總長最短的不定長編碼。

在建立不定長編碼時,必須使任何一個字符的編碼都不是另一個字符編碼的前綴,這樣才能保證譯碼的唯一性。例如d編碼方案,字符A的編碼01是字符B的編碼010的前綴部分,這樣對于代碼串0101001,既是AAC的代碼,又是ABA和BDA的代碼,因此,這樣的編碼不能保證譯碼的唯一性,稱為具有二義性的譯碼。同時把滿足“任意一個符號的編碼都不是其他的編碼的前綴”這一條件的編碼稱為前綴編碼。

采用哈夫曼樹進行編碼,則不會產生上述二義性問題。因為,在哈夫曼樹中,每個字符結點都是葉子結點,它們不可能在根結點到其他字符結點的路徑上,所以一個字符的哈夫曼編碼不可能是另一個字符的哈夫曼編碼的前綴,從而保證了譯碼的非二義性。

設ABCD出現的頻率分別為0.4,0.3,0.2,0.1,則得到的哈夫曼樹和二進制前綴編碼如下圖所示:

image.png

按此編碼,前面的報文可轉換成總長為14bit的二進制位串“01001101101110”,可以看出,這一種不定長的前綴編碼能將報文唯一地無二義性地翻譯成原文。當原文較長、頻率很不均勻時,這種編碼可使傳送的報文縮短很多。當然,也可以在哈夫曼樹中規定左分支表示“1”,右分支表示“0”,得到的二進制前綴編碼雖然不一樣,但使用效果一樣。

4.4 哈夫曼編碼算法實現

編碼表存儲結構:

typedef struct codenode{
    char ch;     //存放要表示符號
    char *code; //存放相應代碼
}CodeNode;
 
typedef CodeNode HuffmanCode[MAXLEAF];

哈夫曼編碼的算法思路:在哈夫曼樹中,從每個葉子結點開始,一直往上搜索,判斷該結點是其雙親結點的做孩子還是右孩子。若是左孩子,則相應位置上的代碼為0,反之為1。直到搜索到根據點為止,具體算法如下:

void CrtHuffmanCode(HuffmanTree ht ,HuffmanCode hc,int n){
    //從葉子結點到根,逆向搜索求每個葉子結點對應符號的哈夫曼編碼
    char *cd;
    int i,c,p,start;
    cd = malloc (n*sizeof(char));//為當前工作區分配空間
    cd[n-1] ="\0";//從右到左逐位存放編碼,首先存放結束符
    
    for(i =1; i<=n ;i++){//求n個葉子結點對應的哈夫曼編碼
        start = n-1;
        c =1;
        p =ht[i].parent;
        while(p!=0){
            --start;
            if(ht[p].lchild ==c){
                cd[start] ="0";//左分支標0
            }else{
                cd[start] ="1";//右1
            }
            c = p;
            p =ht[p].parent;//向上倒退
        }
        hc[i] = malloc((n-start) * sizeof(char));//為第i個編碼分配空間
        scanf("%c",&(hc[i]).ch)  ;  //輸入相應待編碼字符
        strcpy(hc[i],&ch[start]);  //將工作區中編碼復制到編碼表中
   }
    free(cd);
}

本章小結

本章主要介紹了樹與森林、二叉樹的定義、性質、操作和相關算法的實現。特別是二叉樹的遍歷算法,它們是許多二叉樹應用的算法設計基礎,必須熟練掌握。對于樹的遍歷算法,由于樹的先根遍歷次序與對應二叉樹表示的前序遍歷次序一致;樹的后根遍歷次序與對應二叉樹的中序遍歷次序一致,因此可以根據此得出樹的遍歷算法。

本章最后討論的哈夫曼樹是一種擴充的二叉樹,即在終端結點上帶有相應的權值,并使其帶權路徑長度最短。作為哈夫曼樹的應用,引入了哈夫曼編碼。通常讓哈夫曼的左分支代表編碼“0”,右分支代表編碼“1”,得到哈夫曼編碼。這是一種不定長編碼,可以有效地實現數據壓縮。

原文鏈接:https://blog.csdn.net/csdn_aiyang/java/article/details/84977814

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372