數據結構:哈夫曼樹與哈夫曼編碼


哈夫曼編碼

我們都知道使用電報來傳遞信息在上個世紀來說是很自然的,但是由於技術問題,使得遠距離通信的數據傳輸效率顯得極其重要,美國數學家哈夫曼研究出哈夫曼編碼能夠使得數據傳輸得到優化。例如我現在要傳遞信息 “ABBCCCDDDDEEEEE”,如果利用二進制編碼來實現的話,就是傳遞“000 001 001 010 010 010 011 011 011 011 100 100 100 100 100”,有 45 個字符,但是我們觀察到這個編碼中有 5 個字母,每個字母出現的頻率都不一樣,這時我們就開始思考了,如果我們用一些其他的編碼去組織這 5 種字符,讓出現次數較多的字母用簡單的代號去表示,這樣會不會使得傳遞較少的數據,傳達同樣多的消息呢?

如圖所示,我們擁有了這么一棵二叉樹,我們發現這棵二叉樹在兩個結點間有個數字,我們用這些數字的組合來構造新密碼。

字母 編碼
A 101
B 100
C 00
D 11
E 01

那么“ABBCCCDDDDEEEEE”編碼的結果就是“101 100 100 00 00 00 11 11 11 11 01 01 01 01 01”共 33 個字符,相比原來的 45 個字符而言已經有不小的進步了。那么你可能要問了,這么做讀取的信息會不會出現二義性呢?答案是不會的,例如我收到了“011100100101”這個編碼,你對照上文的二叉樹,按照編碼順序從根結點向下解讀,讀取的結果是“EDCBA”,沒有二義性。
這種巧妙的方法就是哈夫曼編碼,而我們用來解密的二叉樹就被成為哈夫曼樹。與編碼對應的哈夫曼編碼生成的方式是,獲取需要編碼的字符集,將各個字符在數據中出現的次數整合為一個集合,以字符集作為葉子結點,以對應的頻率作為相應葉子結點的權值來構造一棵二叉樹,二叉樹樹的左分支代表 0,右分支代表 1,則從根結點到葉子結點所經過的路徑分支組成的 0 和 1 的序列便為該結點應字符的哈夫曼編碼。

1951年,哈夫曼和他在MIT信息論的同學需要選擇是完成學期報告還是期末考試。導師Robert M. Fano給他們的學期報告的題目是,尋找最有效的二進制編碼。由於無法證明哪個已有編碼是最有效的,哈夫曼放棄對已有編碼的研究,轉向新的探索,最終發現了基於有序頻率二叉樹編碼的想法,並很快證明了這個方法是最有效的。由於這個算法,學生終於青出於藍,超過了他那曾經和信息論創立者香農共同研究過類似編碼的導師。哈夫曼使用自底向上的方法構建二叉樹,避免了次優算法Shannon-Fano編碼的最大弊端──自頂向下構建樹。——百度百科

哈夫曼樹的相關概念

哈夫曼樹又稱最優二叉樹,是在路徑帶權的情況下長度最短的樹,在實際中有廣泛的用途。我們先來看一些概念的定義:

概念 定義
路徑 從樹中一個結點到另一個結點之間的分支構成這兩個結點之間的路徑
路徑長度 路徑上的分支數目
樹的路徑長度 從樹根到每一結點的路徑長度之和
賦予某個實體的一個量,是對實體的某個或某些屬性的數值化描述。在數據結構中,實體有結點(元素)和邊(關系)兩大類,所以對應有結點權和邊權。結點權或邊權具體代表什么意義,需要具體問題具體分析。如果在一棵樹中的結點上帶有權值,則對應的就有帶權樹等概念。
結點帶權路徑長度 從該結點到樹根之間的路徑長度與結點上權的乘積
樹的帶權路徑長度 樹中所有葉子結點的帶權路徑長度之和,通常記作 WPL

我們先暫停一下,看個例子理解 WPL,假設有如下兩個帶權的二叉樹:

左樹 WPL:1 × 2 + 3 × 2 + 2 × 2 + 4 × 3 + 5 × 3 = 39
右樹 WPL:1 × 1 + 2 × 2 + 3 × 3 + 4 × 4 + 5 × 4 = 50
理解了上述概念,描述哈夫曼樹就簡單了,所謂哈夫曼樹就是當我有 m 個權值的時候,構造一棵含有 n 個葉結點的二叉樹,每個葉結點都設有一個權,當帶權的路徑長度 WPL 最小的二叉樹稱為哈夫曼樹。

構造哈夫曼樹

模擬構造

現在我們擁有 4 個給定權值,擁有 4 個結點,現在要把這四個結點構造成哈夫曼樹。

首先我們先要把這些結點抽象成 4 棵只有根節點的二叉樹,這二叉樹構成了一個森林。現在我從森林中選擇 2 棵根結點的權值最小的樹作為左右子樹構造一棵新的二叉樹,而且這新的二叉樹的根結點的權為左右子樹上根結點權的和,並刪除這兩個被歸並的二叉樹,將新的二叉樹歸並到森林中,如圖所示。

重復上述操作,直至僅剩一棵二叉樹,所得的二叉樹即為哈夫曼樹。這是因為我們每次都選擇權較小的結點,從而保證了權較大的結點更加靠近根結點,當我們計算 WPL 時自然就會得到最小帶權路徑長度。這很明顯是使用了貪心算法


再來看個例子:

構造出的哈夫曼樹為:

算法實現

結點結構體定義

由於哈夫曼樹中不會出現度為 1 的結點,而且一棵有 n 個葉結點的哈夫曼樹的總結點數為 2n - 1 個,因此我們可以使用順序存儲結構來實現。一個結點除了要存儲權值,還要將結點的雙親的孩子結點都描述清楚,因此需要 3 個游標來進行描述。

typedef struct{
    int weight;    //權值
    int parent,lchild,rchild;    //指向雙親、左右孩子結點的游標
}HTNode,*HuffmanTree;

代碼實現

void CreatHuffmanTree(HuffmanTree &HT,int n)
{
    int m = 2 * n - 1;
    int idx1,idx2;
    int i;

    if(n < 0)    //樹的結點數為 0,即空樹
        return;
    /*二叉樹初始化*/
    HT = new HTNode[m + 1];    //0 號元素不使用,因此需要分配 m + 1 個單元,HT[m] 為根結點
    for(i = 1; i <= m; i++)
    {
        HT[i].parent = HT[i].lchild = HT[i].rchild = 0;    //初始化結點的游標均為 0
    }
    for(i = 1; i <= n; i++)
        cin >> HT[i].weight;    //輸入各個結點的權值
    
    /*建哈夫曼樹*/
    for(i = n + 1; i <= m; i++)
    {
        Select(HT,i - 1,idx1,idx2);
        /*在 HF[k](1 <= k <= i - 1)中選擇兩個雙親游標為 0 且權值最小的結點,
        並返回他們在 HT 中的下標 idx1,idx2*/
        HT[idx1].parent = HT[idx2].parent = i;
        //得到新結點 i,idx1 和 idx2 從森林中刪除,修改它們的雙親為 i
        HT[i].lchild = idx1;
        HT[i].rchild = idx2;    //結點 i 的左右子結點為 idx1 和 idx2
        HT[i].weight = HT[idx1].weight + HT[idx2].weight;    //結點 i 的權值為左右子結點權值之和
    }
}

Select 函數樣例

void Select(HuffmanTree HT, int k, int& idx1, int& idx2)
{                        //在 HF[k](1 <= k <= i - 1)中選擇兩個雙親游標為 0 且權值最小的結點,
    int min1, min2;      //並返回他們在 HT 中的下標 idx1,idx2

    min1 = min2 =  999999;
    for (int i = 1; i < k; i++)
    {
        if (HT[i].parent == 0 && min1 > HT[i].weight) 
        {
            if (min1 < min2)
            {
                min2 = min1;
                idx2 = idx1;
            }
            min1 = HT[i].weight;
            idx1 = i;
        }
        else if (HT[i].parent == 0 && min2 > HT[i].weight)
        {
            min2 = HT[i].weight;
            idx2 = i;
        }
    }
}

根據哈夫曼樹求哈夫曼編碼

算法解析

在擁有哈夫曼對應的哈夫曼樹的時候,求哈夫曼編碼的思想是:依次從葉結點向上回溯至根結點,回溯時走左分支生成編碼 0,走右分支生成編碼 1。根據這個思想,對於每個字符得到的編碼順序是從右向左的,因此存儲編碼的數據結構的邏輯順序應道從后往前。我們可以用順序表來存儲哈夫曼編碼表,為了方便操作,我們使用數組來實現,數組的 0 號單元不使用。由於每個字符的編碼的具體長度不能被確定,但是字符編碼長度一定小於哈夫曼編碼的長度,因此可以分配以該長度作為數組上限的一維數組來存儲。

代碼實現

typedef char **HuffmanCode;    //哈弗曼編碼表存儲結構
void CreatHuffmanCode(HuffmanTree HT, HuffmanCode &HC, int n)
{
    int start;
    int c,f;

    HC = new char *[n + 1];    //分配 n 個字符編碼的空間,存儲字符編碼表
    cd = new char [n];    //臨時存儲每個字符的編碼的數組
    cd[n - 1] = '/0';    //放置結束符
    for(int i; i <= n; i++)    //求所有字符的哈夫曼編碼
    {
        start = n - 1;    //start 為初始指向編碼結束符的游標
        c = i
        f = HF[i].parent;    //f 指向 c 結點的雙親
        while(f != 0)    //從葉結點向上回溯,直至根結點
        {
            --start;    //回溯 start 前一個位置
            if(HF[f].lchild == c)    //判斷 c 結點是 f 的哪個孩子
                cd[start] = '0';    //左孩子,編碼 0
            else
                cd[start] = '1';    //右孩子,編碼 1
            c = f;
            f = HF[f].parent;    //向上回溯
        }
        //到此,求出第 i 個字符編碼
        HC[i] = new char[n - start];
        strcpy(HC[i],&cd[start]);    //將編碼拷貝
    }
    delete cd;
}

應用舉例

修理牧場(哈夫曼樹實現)

情景模擬

我們如果按照分割木塊的思想去分析,會顯得很不直觀,所以我們直接通過我們的目標來組合出最花費的情況,那么這就很顯然要使用貪心算法來解決。如圖所示用圓形來表示小木塊。

要保證花費更小,就需要讓較短的木塊后生成,較長的木塊先生成,因此我選擇較小的兩個木塊,還原為它們被分割出來之前的狀態。即選擇兩個長度為 1 的木塊,組合成一個長度為 2 的木塊。

接下來我要去生成下一個木塊,選擇兩個目前最短的木塊,還是兩個 1 長度的木塊,組合成長度為 2 的木塊。

重復上述操作。





模擬完畢,觀察一下我們發現,我們已經還原出了木塊的每一次應該如何切割,由於木塊切割費用與其長度相等,因此我們把黃色源泉的數字都加起來,發現正好是 49。此時我可以說,問題已經解決了,我們把目標木塊當成一個個結點,長度當做權,這道題不就是要建一棵哈夫曼樹嘛。

代碼實現

將上述函數封裝好,並添加讀取數據的代碼即可實現。

參考資料

哈夫曼編碼
《大話數據結構》—— 程傑 著,清華大學出版社
《數據結構教程》—— 李春葆 主編,清華大學出版社
《數據結構(C語言版|第二版)》—— 嚴蔚敏 李冬梅 吳偉民 編著,人民郵電出版社


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM