1. 程式人生 > >赫夫曼(赫夫曼樹、最優樹、赫夫曼編碼)

赫夫曼(赫夫曼樹、最優樹、赫夫曼編碼)

赫夫曼樹,別名“哈夫曼樹”、“最優樹”以及“最優二叉樹”。學習哈夫曼樹之前,首先要了解幾個名詞。

哈夫曼樹相關的幾個名詞

路徑:在一棵樹中,一個結點到另一個結點之間的通路,稱為路徑。圖 1 中,從根結點到結點 a 之間的通路就是一條路徑。

路徑長度:在一條路徑中,每經過一個結點,路徑長度都要加 1 。例如在一棵樹中,規定根結點所在層數為1層,那麼從根結點到第 i 層結點的路徑長度為 i - 1 。圖 1 中從根結點到結點 c 的路徑長度為 3。

結點的權:給每一個結點賦予一個新的數值,被稱為這個結點的權。例如,圖 1 中結點 a 的權為 7,結點 b 的權為 5。

結點的帶權路徑長度:指的是從根結點到該結點之間的路徑長度與該結點的權的乘積。例如,圖 1 中結點 b 的帶權路徑長度為 2 * 5 = 10 。

樹的帶權路徑長度為樹中所有葉子結點的帶權路徑長度之和。通常記作 “WPL” 。例如圖 1 中所示的這顆樹的帶權路徑長度為:

WPL = 7 * 1 + 5 * 2 + 2 * 3 + 4 * 3


圖1 哈夫曼樹

什麼是哈夫曼樹

當用 n 個結點(都做葉子結點且都有各自的權值)試圖構建一棵樹時,如果構建的這棵樹的帶權路徑長度最小,稱這棵樹為“最優二叉樹”,有時也叫“赫夫曼樹”或者“哈夫曼樹”。

在構建哈弗曼樹時,要使樹的帶權路徑長度最小,只需要遵循一個原則,那就是:權重越大的結點離樹根越近。在圖 1 中,因為結點 a 的權值最大,所以理應直接作為根結點的孩子結點。

構建哈夫曼樹

對於給定的有各自權值的 n 個結點,構建哈夫曼樹有一個行之有效的辦法:

  1. 在 n 個權值中選出兩個最小的權值,對應的兩個結點組成一個新的二叉樹,且新二叉樹的根結點的權值為左右孩子權值的和;
  2. 在原有的 n 個權值中刪除那兩個最小的權值,同時將新的權值加入到 n–2 個權值的行列中,以此類推;
  3. 重複 1 和 2 ,直到所以的結點構建成了一棵二叉樹為止,這棵樹就是哈夫曼樹。

圖 2 哈夫曼樹的構建過程
 

圖 2 中,(A)給定了四個結點a,b,c,d,權值分別為7,5,2,4;第一步如(B)所示,找出現有權值中最小的兩個,2 和 4 ,相應的結點 c 和 d 構建一個新的二叉樹,樹根的權值為 2 + 4 = 6,同時將原有權值中的 2 和 4 刪掉,將新的權值 6 加入;進入(C),重複之前的步驟。直到(D)中,所有的結點構建成了一個全新的二叉樹,這就是哈夫曼樹。

哈弗曼樹中結點結構

構建哈夫曼樹時,首先需要確定樹中結點的構成。由於哈夫曼樹的構建是從葉子結點開始,不斷地構建新的父結點,直至樹根,所以結點中應包含指向父結點的指標。但是在使用哈夫曼樹時是從樹根開始,根據需求遍歷樹中的結點,因此每個結點需要有指向其左孩子和右孩子的指標。

所以,哈夫曼樹中結點構成用程式碼表示為:

//哈夫曼樹結點結構
typedef struct 
{
  int weight;  //結點權重
  int parent, left, right;  //父結點、左孩子、右孩子在陣列中的位置下標
}HTNode, *HuffmanTree;

哈弗曼樹中的查詢演算法

構建哈夫曼樹時,需要每次根據各個結點的權重值,篩選出其中值最小的兩個結點,然後構建二叉樹。

查詢權重值最小的兩個結點的思想是:從樹組起始位置開始,首先找到兩個無父結點的結點(說明還未使用其構建成樹),然後和後續無父結點的結點依次做比較,有兩種情況需要考慮:

  • 如果比兩個結點中較小的那個還小,就保留這個結點,刪除原來較大的結點;
  • 如果介於兩個結點權重值之間,替換原來較大的結點;


實現程式碼:

//HT陣列中存放的哈夫曼樹,end表示HT陣列中存放結點的最終位置,s1和s2傳遞的是HT陣列中權重值最小的兩個結點在陣列中的位置
void Select(HuffmanTree HT, int end, int *s1, int *s2)
{
  int min1, min2;
  //遍歷陣列初始下標為 1
  int i = 1;
  //找到還沒構建樹的結點
  while(HT[i].parent != 0 && i <= end)
  {
    i++;
  }
  min1 = HT[i].weight;
  *s1 = i;
  i++;
  while(HT[i].parent != 0 && i <= end)
  {
    i++;
  }
  //對找到的兩個結點比較大小,min2為大的,min1為小的
  if(HT[i].weight < min1)
  {
    min2 = min1;
    *s2 = *s1;
    min1 = HT[i].weight;
    *s1 = i;
  }
  else
  {
    min2 = HT[i].weight;
    *s2 = i;
  }
  //兩個結點和後續的所有未構建成樹的結點做比較
  for(int j=i+1; j <= end; j++)
  {
    //如果有父結點,直接跳過,進行下一個
    if(HT[j].parent != 0)
    {
      continue;
    }
    //如果比最小的還小,將min2=min1,min1賦值新的結點的下標
    if(HT[j].weight < min1)
    {
      min2 = min1;
      min1 = HT[j].weight;
      *s2 = *s1;
      *s1 = j;
    }
    //如果介於兩者之間,min2賦值為新的結點的位置下標
    else if(HT[j].weight >= min1 && HT[j].weight < min2)
    {
      min2 = HT[j].weight;
      *s2 = j;
    }
  }
}

 

注意:s1和s2傳入的是實參的地址,所以函式執行完成後,實參中存放的自然就是哈夫曼樹中權重值最小的兩個結點在陣列中的位置。

構建哈弗曼樹的程式碼實現

//HT為地址傳遞的儲存哈夫曼樹的陣列,w為儲存結點權重值的陣列,n為結點個數
void CreateHuffmanTree(HuffmanTree *HT, int *w, int n)
{
  if(n <= 1) 
    return;   // 如果只有一個編碼就相當於0
  int m = 2*n-1;   // 哈夫曼樹總節點數,n就是葉子結點
  *HT = (HuffmanTree) malloc((m+1) * sizeof(HTNode));   // 0號位置不用
  HuffmanTree p = *HT;
  // 初始化哈夫曼樹中的所有結點
  for(int i = 1; i <= n; i++)
  {
    (p+i)->weight = *(w+i-1);
    (p+i)->parent = 0;
    (p+i)->left = 0;
    (p+i)->right = 0;
  }
  //從樹組的下標 n+1 開始初始化哈夫曼樹中除葉子結點外的結點
  for(int i = n+1; i <= m; i++)
  {
    (p+i)->weight = 0;
    (p+i)->parent = 0;
    (p+i)->left = 0;
    (p+i)->right = 0;
  }
  //構建哈夫曼樹
  for(int i = n+1; i <= m; i++)
  {
    int s1, s2;
    Select(*HT, i-1, &s1, &s2);
    (*HT)[s1].parent = (*HT)[s2].parent = i;
    (*HT)[i].left = s1;
    (*HT)[i].right = s2;
    (*HT)[i].weight = (*HT)[s1].weight + (*HT)[s2].weight;
  }
}

哈夫曼編碼

哈夫曼編碼就是在哈夫曼樹的基礎上構建的,這種編碼方式最大的優點就是用最少的字元包含最多的資訊內容。

根據傳送資訊的內容,通過統計文字中相同字元的個數作為每個字元的權值,建立哈夫曼樹。對於樹中的每一個子樹,統一規定其左孩子標記為 0 ,右孩子標記為 1 。這樣,用到哪個字元時,從哈夫曼樹的根結點開始,依次寫出經過結點的標記,最終得到的就是該結點的哈夫曼編碼。

文字中字元出現的次數越多,在哈夫曼樹中的體現就是越接近樹根。編碼的長度越短。


圖 3 哈夫曼編碼
 

如圖 3 所示,字元 a 用到的次數最多,其次是字元 b 。字元 a 在哈夫曼編碼是 0 ,字元 b 編碼為 10 ,字元 c 的編碼為 110 ,字元 d 的編碼為 111 。

使用程式求哈夫曼編碼有兩種方法:

  1. 從葉子結點一直找到根結點,逆向記錄途中經過的標記。例如,圖 3 中字元 c 的哈夫曼編碼從結點 c 開始一直找到根結點,結果為:0 1 1 ,所以字元 c 的哈夫曼編碼為:1 1 0(逆序輸出)。
  2. 從根結點出發,一直到葉子結點,記錄途中經過的標記。例如,求圖 3 中字元 c 的哈夫曼編碼,就從根結點開始,依次為:1 1 0。


採用方法 1 的實現程式碼為:

//HT為哈夫曼樹,HC為儲存結點哈夫曼編碼的二維動態陣列,n為結點的個數
void HuffmanCoding(HuffmanTree HT, HuffmanCode *HC, int n)
{
  *HC = (HuffmanCode) malloc((n+1) * sizeof(char *));
  char *cd = (char *)malloc(n*sizeof(char)); //存放結點哈夫曼編碼的字串陣列
  cd[n-1] = '\0';//字串結束符
  for(int i=1; i<=n; i++)
  {
    //從葉子結點出發,得到的哈夫曼編碼是逆序的,需要在字串陣列中逆序存放
    int start = n-1;
    //當前結點在陣列中的位置
    int c = i;
    //當前結點的父結點在陣列中的位置
    int j = HT[i].parent;
    // 一直尋找到根結點
    while(j != 0)
    {
      // 如果該結點是父結點的左孩子則對應路徑編碼為0,否則為右孩子編碼為1
      if(HT[j].left == c)
        cd[--start] = '0';
      else
        cd[--start] = '1';
      //以父結點為孩子結點,繼續朝樹根的方向遍歷
      c = j;
      j = HT[j].parent;
    }
    //跳出迴圈後,cd陣列中從下標 start 開始,存放的就是該結點的哈夫曼編碼
    (*HC)[i] = (char *)malloc((n-start)*sizeof(char));
    strcpy((*HC)[i], &cd[start]);
  }
  //使用malloc申請的cd動態陣列需要手動釋放
  free(cd);
}

 

 

採用第二種演算法的實現程式碼為:

//HT為哈夫曼樹,HC為儲存結點哈夫曼編碼的二維動態陣列,n為結點的個數
void HuffmanCoding(HuffmanTree HT, HuffmanCode *HC, int n)
{
  *HC = (HuffmanCode) malloc((n+1) * sizeof(char *));
  int m = 2*n-1;
  int p = m;
  int cdlen = 0;
  char *cd = (char *)malloc(n*sizeof(char));
  //將各個結點的權重用於記錄訪問結點的次數,首先初始化為0
  for (int i=1; i<=m; i++) 
  {
    HT[i].weight = 0;
  }
  //一開始 p 初始化為 m,也就是從樹根開始。一直到p為0
  while (p) 
  {
    //如果當前結點一次沒有訪問,進入這個if語句
    if (HT[p].weight == 0) 
    {
      HT[p].weight = 1;  //重置訪問次數為1
      //如果有左孩子,則訪問左孩子,並且儲存走過的標記為0
      if (HT[p].left != 0) 
      {
        p = HT[p].left;
      }
      else if(HT[p].right == 0) // 當前結點沒有左孩子,也沒有右孩子,說明為葉子結點,直接記錄哈夫曼編碼
      {
        (*HC)[p] = (char*)malloc((cdlen+1)*sizeof(char));
        cd[cdlen] = '\0';
        strcpy((*HC)[p], cd);
      }
    }
    else if(HT[p].weight == 1) // 如果weiget為1,說明訪問過一次,即使是左孩子返回的
    {
      HT[p].weight = 2;  //設定訪問次數為2
      //如果有右孩子,遍歷右孩子,記錄標記值 1
      if (HT[p].right != 0) 
      {
        p = HT[p].right;
        cd[cdlen++] = '1';
      }
    }
    else //如果訪問次數為2,說明左孩子都遍歷完了,返回父結點
    {
      HT[p].weight = 0;
      p = HT[p].parent;
      --cdlen;
    }
  }
}

本節完整程式碼(可執行)

#include<stdlib.h>
#include<stdio.h>
#include<string.h>

//哈夫曼樹結點結構
typedef struct 
{
  int weight;//結點權重
  int parent, left, right;//父結點、左孩子、右孩子在陣列中的位置下標
}HTNode, *HuffmanTree;
//動態二維陣列,儲存哈夫曼編碼
typedef char **HuffmanCode;
//HT陣列中存放的哈夫曼樹,end表示HT陣列中存放結點的最終位置,s1和s2傳遞的是HT陣列中權重值最小的兩個結點在陣列中的位置
void Select(HuffmanTree HT, int end, int *s1, int *s2)
{
  int min1, min2;
  //遍歷陣列初始下標為 1
  int i = 1;
  //找到還沒構建樹的結點
  while(HT[i].parent != 0 && i <= end)
  {
    i++;
  }
  min1 = HT[i].weight;
  *s1 = i;
  i++;
  while(HT[i].parent != 0 && i <= end)
  {
    i++;
  }
  //對找到的兩個結點比較大小,min2為大的,min1為小的
  if(HT[i].weight < min1)
  {
    min2 = min1;
    *s2 = *s1;
    min1 = HT[i].weight;
    *s1 = i;
  }
  else
  {
    min2 = HT[i].weight;
    *s2 = i;
  }
  //兩個結點和後續的所有未構建成樹的結點做比較
  for(int j=i+1; j <= end; j++)
  {
    //如果有父結點,直接跳過,進行下一個
    if(HT[j].parent != 0)
    {
      continue;
    }
    //如果比最小的還小,將min2=min1,min1賦值新的結點的下標
    if(HT[j].weight < min1)
    {
      min2 = min1;
      min1 = HT[j].weight;
      *s2 = *s1;
      *s1 = j;
    }
    else if(HT[j].weight >= min1 && HT[j].weight < min2)   // 如果介於兩者之間,min2賦值為新的結點的位置下標
    {
      min2 = HT[j].weight;
      *s2 = j;
    }
  }
}

//HT為地址傳遞的儲存哈夫曼樹的陣列,w為儲存結點權重值的陣列,n為結點個數
void CreateHuffmanTree(HuffmanTree *HT, int *w, int n)
{
  if(n<=1) 
    return;     // 如果只有一個編碼就相當於0
  int m = 2*n-1;   // 哈夫曼樹總節點數,n就是葉子結點
  *HT = (HuffmanTree)malloc((m+1) * sizeof(HTNode)); // 0號位置不用
  HuffmanTree p = *HT;
  // 初始化哈夫曼樹中的所有結點
  for(int i = 1; i <= n; i++)
  {
    (p+i)->weight = *(w+i-1);
    (p+i)->parent = 0;
    (p+i)->left = 0;
    (p+i)->right = 0;
  }

  //從樹組的下標 n+1 開始初始化哈夫曼樹中除葉子結點外的結點
  for(int i = n+1; i <= m; i++)
  {
    (p+i)->weight = 0;
    (p+i)->parent = 0;
    (p+i)->left = 0;
    (p+i)->right = 0;
  }

  //構建哈夫曼樹
  for(int i = n+1; i <= m; i++)
  {
    int s1, s2;
    Select(*HT, i-1, &s1, &s2);
    (*HT)[s1].parent = (*HT)[s2].parent = i;
    (*HT)[i].left = s1;
    (*HT)[i].right = s2;
    (*HT)[i].weight = (*HT)[s1].weight + (*HT)[s2].weight;
  }
}

//HT為哈夫曼樹,HC為儲存結點哈夫曼編碼的二維動態陣列,n為結點的個數
void HuffmanCoding(HuffmanTree HT, HuffmanCode *HC, int n)
{
  *HC = (HuffmanCode) malloc((n+1) * sizeof(char *));
  char *cd = (char *)malloc(n*sizeof(char)); //存放結點哈夫曼編碼的字串陣列
  cd[n-1] = '\0';//字串結束符
  for(int i=1; i<=n; i++)
  {
    //從葉子結點出發,得到的哈夫曼編碼是逆序的,需要在字串陣列中逆序存放
    int start = n-1;
    //當前結點在陣列中的位置
    int c = i;
    //當前結點的父結點在陣列中的位置
    int j = HT[i].parent;
    // 一直尋找到根結點
    while(j != 0)
    {
      // 如果該結點是父結點的左孩子則對應路徑編碼為0,否則為右孩子編碼為1
      if(HT[j].left == c)
        cd[--start] = '0';
      else
        cd[--start] = '1';
      //以父結點為孩子結點,繼續朝樹根的方向遍歷
      c = j;
      j = HT[j].parent;
    }
    //跳出迴圈後,cd陣列中從下標 start 開始,存放的就是該結點的哈夫曼編碼
    (*HC)[i] = (char *)malloc((n-start)*sizeof(char));
    strcpy((*HC)[i], &cd[start]);
  }
  //使用malloc申請的cd動態陣列需要手動釋放
  free(cd);
}

//列印哈夫曼編碼的函式
void PrintHuffmanCode(HuffmanCode htable, int *w, int n)
{
  printf("Huffman code : \n");
  for(int i = 1; i <= n; i++)
    printf("%d code = %s\n",w[i-1], htable[i]);
}

int main(void)
{
  int w[5] = {2, 8, 7, 6, 5};
  int n = 5;
  HuffmanTree htree;
  HuffmanCode htable;
  CreateHuffmanTree(&htree, w, n);
  HuffmanCoding(htree, &htable, n);
  PrintHuffmanCode(htable, w, n);

  return 0;
}

執行結果
Huffman code :
2 code = 100
8 code = 11
7 code = 01
6 code = 00
5 code = 101

 

本節中介紹了兩種遍歷哈夫曼樹獲得哈夫曼編碼的方法,同時也給出了各自完整的實現程式碼的函式,在完整程式碼中使用的是第一種逆序遍歷哈夫曼樹的方法。

總結


圖 4 程式執行效果圖


本節的程式中對權重值分別為 2,8,7,6,5 的結點構建的哈夫曼樹如圖 4(A)所示。圖 4(B)是另一個哈夫曼樹,兩棵樹的帶權路徑長度相同。

程式執行效果圖之所以是(A)而不是(B),原因是在構建哈夫曼樹時,結點 2 和結點 5 構建的新的結點 7 儲存在動態樹組的最後面,所以,在程式繼續選擇兩個權值最小的結點時,直接選擇了的葉子結點 6 和 7 。