[非原創] 哈夫曼(Huffman )編碼

====================================================

  前言:

  本文是源於我在(上海交大)飲水思源BBS 的VC版解答其他網友提出的幫助請求。這是德國 DARMSTADT 工業大學C++作業題目之一,屬於非計算機系的題目,題目本身要求完成的那幾個輔助函數難度並不高。我在BBS上給出了這道題目的解答,但是同時我也想根據這個題目的說明文檔,來仔細回顧一下 Huffman 編碼。因此本文是以該題目的說明文檔爲基本框架的。我將對該文檔中的主要部分轉用中文敘述,當然裏面可能還增加有我個人的理解。同時該文檔將一併作爲附件提供

  該文檔是:

  PD Dr. Ulf Lorenz, 《Introduction to Mathematical Software Examination Sheet (winter term 2009/2010) 》, Department of Mathematics, TECHNISCHE UNIVERSITY DARMSTADT.

                               --hoodlum1980

   =====================================================

   Hufmann coding 是最古老,以及最優雅的數據壓縮方法之一。它是以最小冗餘編碼爲基礎的,即如果我們知道數據中的不同符號在數據中的出現頻率,我們就可以對它用一種佔用空間最少的編碼方式進行編碼,這種方法是,對於最頻繁出現的符號制定最短長度的編碼,而對於較少出現的符號給較長長度的編碼。哈夫曼編碼可以對各種類型的數據進行壓縮,但在本文中我們僅僅針對字符進行編碼。

 

  1. 壓縮數據。

  壓縮數據由以下步驟組成:

  a)檢查字符在數據中的出現頻率。

  b)構建哈夫曼樹。

  c)創建哈夫曼編碼表。

  d)生成壓縮後結果,由一個文件頭和壓縮後的數據組成。 

 

   下面介紹這些步驟的一些細節

 

  a)字符出現的頻率:

  我們對要壓縮的文本進行掃描,然後記錄下各個字符出現的次數(在這裏我們的輸入文本將僅僅有 ascii 字符構成) ,掃描完成後我們就得到了一個字符的頻率表。這個頻率表也是後面的文件頭的重要組成部分。爲了降低文件頭的尺寸,我們對字符頻率壓縮到用一個字節來表示。【注意】,等比例縮小字符頻率時,不能把在文本中出現的字符的頻率縮小成0!

 

  由以下方法來完成:我們首先提供一個用於填充頻率結果的數組(unsigned int freqs[NUM_CHARS],注意儘管這個數組是UINT類型,但是填充數據必須在0~255之間),元素在這個數組中的索引就代表了該字符的 ascii 碼。例如填充完畢後,字符‘a’的出現頻率即爲 freqs['a'];

  unsigned char* string: 輸入的文本。

  unsigned int size:輸入文本的字符數。 

  

code_create_freq_array
//給定一個字符串,把字符的出現頻率保存到freqs數組中
//Hint: Be carefull that you don’t scale any frequencies to zero for symbols that do appear in the string!

void create_freq_array(unsigned int freqs[NUM_CHARS], unsigned char* string, unsigned int size)
{
    
int i, maxfreq = 0;
    
    
//初始化成0
    memset(freqs, 0sizeof(unsigned int* NUM_CHARS);
    
    
for(i=0; i<size; i++)
    {
        freqs[
string[i]]++;
        
        
if(freqs[string[i]] > maxfreq)
            maxfreq 
= freqs[string[i]];
    }
    
    
//把字符頻率壓縮到一個字節。 scaled freqs to (0~255)
    if(maxfreq > 0xff)
    {
        
for(i=0; i<NUM_CHARS; i++)
        {            
            
if(freqs[i])
            {
                freqs[i] 
= (int)(freqs[i] * 255.0 / maxfreq + 0.5);
            
                
//要確保不會被縮小成0!
                if(freqs[i] == 0)
                    freqs[i] 
= 1;
            }
        }
    }
}


 

  b)構建哈夫曼樹(huffman Tree);

  哈夫曼編碼的核心部分就在於構建哈夫曼樹,它是一個二叉樹。同時它的貪心策略也現在構建哈夫曼樹的方法中。 

  哈夫曼樹用下面的方式構建:首先,我們把所有出現的字符作爲一個單節點數,在節點上標識一個數字代表字符出現頻率。 

  例如如果我們要對字符串“aabbbccccdddddd" 進行編碼,則字符頻率表如下所示:

   ----------------------------

  |      a       b       c       d    |

   ----------------------------

  |      2       3       4       6    |

      ----------------------------

 

  一共有4個字符出現,因此最初我們有 4 個單節點的樹。然後就是體現貪心策略之處,每次我們選取具有最低頻率的兩個樹,並將他們合併,把兩個樹的頻率相加,賦給新樹的根節點。重複這個步驟,直到最後只剩下一棵樹,就是最終我們需要的哈夫曼樹。合併過程如下圖所示:
  

     

  最終的編碼方式是,每個 葉子節點代表了一個在原文中出現的字符。每個字符的編碼就是從根節點到該葉子節點的路徑。由於字節中的每一位由0,1兩種狀態,這也正是二叉樹尤其重要和常用的原因。從根節點出發,如果進入左子樹,則在編碼上填0,如果進入右子樹,則在編碼上填1,直到到達葉子節點,就完成了該字符的編碼。從上面的哈夫曼樹可見,最終的哈夫曼編碼表如下:

  =======================

    字符      頻率      編碼         碼長

  ------------------------------------

   a        2         110          3

        b        3         111          3

        c        4         10            2

        d        6         0             1

  ======================== 

 

  哈夫曼編碼是一種前綴碼,即任一個字符的編碼都不是其他字符編碼的前綴。從我們的編碼過程中可以很容易看到這一點,因爲所有字符都是哈夫曼樹中的葉子節點,所以每個字符所在的葉子節點的路徑都不會有重疊部分(即代表字符的節點之間不存在以下關係:某節點是另一節點的祖先或後代)。這個特徵能夠保證解碼的唯一性,不會產生歧義(在解碼時只需要找到葉子節點即可完成當前字符的解碼)。

  

  可以看出,出現頻率最高的字符,使用最短的編碼,字符出現頻率越低,編碼逐漸增長。這樣不同字符在文檔中出現的頻率差異越大,則壓縮效果將會越好。字符的出現頻率差異影響了它們最終在哈夫曼樹中的深度。

 

  因此字符出現頻率越大,我們希望給它的編碼越短(在哈夫曼樹中的深度越淺),即我們希望更晚的將它所在的樹進行合併。反之,字符頻率越低,我們希望給他的編碼最長(在哈夫曼樹中的深度越深),因此我們希望越早的將它所在的樹進行合併。因此,哈夫曼編碼的貪心策略就體現在合併樹的過程中,我們每一次總是選擇根節點頻率最小的兩個樹先合併,這樣就能達到我們所希望的編碼結果。

 

  在合併樹的過程中,爲了抽取最小頻率的樹,我們需要一種重要的數據結構作爲輔助:優先級隊列(Priority Queue)(最小堆)。什麼是優先級隊列?優先級隊列是指一種維護一組元素的數據結構,它的常用操作是從這些元素中抽取最小的元素,和插入新元素。即他維護了一個動態的元素集合,同時要求插入和抽取儘可能的快。實現優先級隊列使用的是數據結構中的堆(Heap)(注意:和內存管理中的堆的概念區別)。

 

  最小堆是一個數據結構,在存儲方式上使用的是一維線性表(一維數組)存儲元素,這些元素在邏輯上組成一個二叉樹。

  最小堆要求滿足以下特徵:

  對任何節點:左(右)子節點 >= 本節點。(顯然,集合中的最小元素是二叉樹的根節點。)

  (請注意上述特徵和二叉查找樹相區別,二叉查找樹的特徵是:左子節點 <= 本節點 <= 右子結點,其中序遍歷輸出就是排序結果。)

 

  最小堆的數組是以 1 爲起始索引的,注意,而不是 C / C++ 中習慣使用的 0-based 數組,因此在 C/C++中,第一個元素(索引爲0)通常被浪費。其目的完全是爲了能夠用下面的簡便方式在樹節點中導航

 

  對最小堆中的某個節點 x[i] :

 

  根節點:   x [ 1 ] ;  

  父節點:   x [ i / 2 ] ;

  左子節點:x [ i * 2 ] ;   

  右子節點:  x [ i * 2 + 1 ] ;

 

  一個最小堆的邏輯二叉樹如下圖所示:

  

    

 

  因此最小堆的最小元素就是根節點。由於最小堆需要經常性的做抽取最小元素和插入操作,因此實際上爲了維持堆的特徵,每次插入和抽取都要進行節點的調整,因此抽取和插入操作都耗時O(log n)。

 

  對於優先級隊列來說,主要需要實現兩種基本操作:插入新元素,抽取最小元素。他們的步驟如下:

  (1)插入新元素:把該元素放在二叉樹的末端,然後從該新元素開始,向根節點方向進行交換,直到它到達最終位置。

  (2)抽取最小元素:把根節點取走。然後把二叉樹的末端節點放到根節點上,然而把該節點向子結點反覆交換,直到它到達最終位置。

 

  實現優先級隊列的類代碼如下所示:  

 

code_PriorityQueue
// This class is used in the construction of the Huffman tree.
// 優先級隊列

class HuffNodePriorityQueue
{
public:
    HuffNode
* HuffNodes[NUM_CHARS];
    unsigned 
int size;

    
void init() 
    {
        size
=0;
    }

    
void heapify(int i) 
    {
        
int l,r,smallest;
        HuffNode
* tmp;
    
        l
=2*i; /*left child*/
        r
=2*i+1/*right child*/
    
        
if ((l < size)&&(HuffNodes[l]->freq < HuffNodes[i]->freq))
            smallest
=l;
        
else 
            smallest
=i;
        
if ((r < size)&&(HuffNodes[r]->freq < HuffNodes[smallest]->freq))
            smallest
=r;
    
        
if (smallest!=i) 
        {
            
/*exchange to maintain heap property*/
            tmp
=HuffNodes[smallest];
            HuffNodes[smallest]
=HuffNodes[i];
            HuffNodes[i]
=tmp;
            heapify(smallest);
        }
    }

    
void addItem(HuffNode* node) 
    {
        unsigned 
int i,parent;  
        size 
= size+1;
        i 
= size-1;
        parent 
= i/2;

        
/*find the correct place to insert*/
        
while ( (i > 0&& (HuffNodes[parent]->freq > node->freq) )
        {
            HuffNodes[i] 
= HuffNodes[parent];
            i 
= parent;
            parent 
= i/2;
        }
        HuffNodes[i]
=node;
    }

    HuffNode
* extractMin(void
    {
        HuffNode
* max;
        
if (isEmpty())
            
return 0;
        max
=HuffNodes[0];
        HuffNodes[
0]=HuffNodes[size-1];
        size
=size-1;
        heapify(
0);
        
return max;
    }

    
int isEmpty(void
    {
        
return size==0;
    }

    
int isFull(void)
    {
        
return size >= NUM_CHARS;
    }
};

 

 

  在上面的代碼中,使用的是 heapify 成員函數,將指定的節點交換到最終位置。

 

  構建哈夫曼樹的步驟如下:

  a)把所有出現的字符作爲一個節點(單節點樹),把這些樹組裝成一個優先級隊列;

  b)從該優先級隊列中連續抽取兩個頻率最小的樹分別作爲左子樹,右子樹,將他們合併成一棵樹(頻率=兩棵樹頻率之和),然後把這棵樹插回隊列中。

  c)重複步驟b,每次合併都將使優先級隊列的尺寸減小1,直到最後隊列中只剩一棵樹爲止,就是我們需要的哈夫曼樹。

 

  相關代碼如下:

  

 

 

code_build_Huffman_tree
// create the Huffman tree from the array of frequencies
// returns a pointer to the root node of the Huffman tree
// 根據字符頻率數組,創建一個huffman樹。返回根節點。

HuffNode
* build_Huffman_tree(unsigned int freqs[NUM_CHARS])
{
    
// create priority queue
    HuffNodePriorityQueue priority_queue;
    priority_queue.init();

    
for (unsigned int i = 0; i < NUM_CHARS; i++)
    {
        
if (freqs[i] > 0)
        {
            HuffNode
* node = new HuffNode;
            node
->= i;
            node
->freq = freqs[i];
            node
->left = NULL;
            node
->right = NULL;
            priority_queue.addItem(node);
        }
    }

    printf(
"number of characters: %d\n", priority_queue.size);

    
// create the Huffman tree
    while (priority_queue.size > 1)
    {
        HuffNode
* left = priority_queue.extractMin();
        HuffNode
* right = priority_queue.extractMin();

        HuffNode
* root = new HuffNode;
        root
->freq = left->freq + right->freq;
        root
->left = left;
        root
->right = right;
        priority_queue.addItem(root);
    }

    
// return pointer to the root of the Huffman tree
    return priority_queue.extractMin();
}

 

 

  d) 壓縮數據;

  我們已經建立了哈夫曼樹,並根據哈夫曼樹建立了字符的哈夫曼編碼表,因此現在壓縮數據的方法將是很顯而易見的,我們遍歷輸入的文本,對每個字符,根據編碼表依次把當前字符的編碼寫入到編碼結果中去。爲了能夠解壓縮,我們還需要在編碼時寫入一個文件頭,這樣我們在解碼時能夠重建(和編碼時同樣的)哈夫曼樹。最終的文件格式定義如下:

 

  File Header(文件頭):

    unsigned int size; 被編碼的文本長度(字符數);

    unsigned char freqs[ NUM_CHARS ]; 字符頻率表

 

  compressed; (Bits: 壓縮後的數據);

 

  注意:壓縮後的Bits實際上必須以字節爲最小單位。因此 Bits 需要向上取整到整數字節。

 

 

  2. 解壓縮數據;

  解壓縮數據的過程是:

  e) 讀取文件頭;

  f)根據文件頭重建哈夫曼樹;(和壓縮數據時的步驟一致,代碼是複用的)

  g)根據哈夫曼樹讀取並逐個字符解碼;

 

  e) 讀取文件頭:

  這一部是處於文件頭的信息,文件頭由輸入文本的字節數和(已等比例壓縮到一個字節)字符頻率表組成。根據這些信息構建出字符頻率表,這一步驟和壓縮數據時一樣。

  g) 解碼:

  我們遍歷編碼後的Bits,每一次都從哈夫曼樹的根節點出發,遇到0時,進入節點的左子樹,遇到1時進入節點的右子樹,直到到達葉子節點爲止,並取得最終的字符。重複這一過程,知道所有字符都已經解碼。

 

  總結:對上述的編碼解碼過程如下圖所示。其中編碼時的輸入是明文字符串,輸出是壓縮後的文件。對於解碼來說輸入和輸出和前者相反。

  

   

 

  最後,提供已經補充完整的代碼文件和原PDF文檔:

  http://files.cnblogs.com/hoodlum1980/Huffman.rar

  

  當我們使用上面的代碼對“aabbbccccdddddd”進行哈夫曼編碼時,程序產生的輸出如下:

  size of input: 15
  char: a freq: 2
  char: b freq: 3
  char: c freq: 4
  char: d freq: 6

  number of characters: 4

 

  character encodings:
  char: a code: 110
  char: b code: 111
  char: c code: 10
  char: d code: 0

 

  compressed string: (size: 32 bit) //注意後三個Bit 不攜帶信息,僅爲了補齊成 8 Bits 整數倍;
  11011011111111110101010000000101


 

  size of compressed string: 15
  number of characters: 4

  uncompressed string: (size: 120 bit)

  aabbbccccdddddd


 

  【備註】程序也可以接收一個命令行參數(文本文件的文件名)作爲輸入,在編碼後保存成一個二進制文件,然後再從該二進制文件解碼並保存到另一個新的文本文件。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章