【算法】赫夫曼樹(Huffman)的構建和應用(編碼、譯碼)

個人博客即將入駐「雲棲社區」,誠邀技術同仁一同入駐。
 
參考資料
《算法(java)》                           — — Robert Sedgewick, Kevin Wayne
《數據結構》                                  — — 嚴蔚敏
 

赫夫曼樹的概念

要了解赫夫曼樹,咱們要首先從擴充二叉樹提及

二叉樹結點的度

結點的度指的是二叉樹結點的分支數目, 若是某個結點沒有孩子結點,即沒有分支,那麼它的度是0;若是有一個孩子結點, 那麼它的度數是1;若是既有左孩子也有右孩子, 那麼這個結點的度是2.

擴充二叉樹

對於一顆已有的二叉樹, 若是咱們爲它添加一系列新結點, 使得它原有的全部結點的度都爲2,那麼咱們就獲得了一顆擴充二叉樹, 以下圖所示:
 

 

其中原有的結點叫作內結點(非葉子結點), 新增的結點叫作外結點(葉子結點)
咱們能夠得出: 外結點數 = 內結點數 + 1
並進一步得出: 總結點數 = 2 × 外結點數 -1
 
擴充二叉樹,構成了赫夫曼樹的基本形態,而上面的公式,也是咱們構建赫夫曼樹的依據之一
 

赫夫曼樹的外結點和內結點

赫夫曼樹的外結點和內結點的性質區別:外節點是攜帶了關鍵數據的結點, 而內部結點沒有攜帶這種數據, 只做爲導向最終的外結點所走的路徑而使用
 
正因如此,咱們的關注點最後是落在赫夫曼樹的外結點上, 而不是內結點。
 

帶權路徑長度WPL

 
讓咱們思考一下: 在一顆在外結點上存儲了數據的擴充二叉樹中進行查找時,數據結點怎麼分佈才能儘量減小查找的開銷呢? 這裏咱們再加上一個前提:不一樣的數據結點搜索的頻率(或機率)是不一致的。
 
顯然, 咱們大體的思路是: 若是一個數據結點搜索頻率越高,就讓它分佈在離根結點越近的地方,也即從根結點走到該結點通過的路徑長度越短。 這樣就能從總體上優化整顆樹的性能。
 
頻率是個細化的量,這裏咱們用一個更加標準的一個詞描述它——「權值」。
 
綜上, 咱們爲擴充二叉樹的外結點(葉子結點)定義兩條屬性: 權值(w)和路徑長度(l)。同時規定帶權路徑長度(WPL)爲擴充二叉樹的外結點的權值和路徑長度乘積之和:
 
(注意只是外結點!)

赫夫曼樹(最優二叉樹)

由n個權值構造一顆有n個葉子結點的二叉樹, 則其中帶權路徑長度WPL最小的二叉樹, 就是赫夫曼樹,或者叫作最優二叉樹。
 
例以下圖中對a, b, c

 

對a: WPL = 7×2 + 5×2 + 2×2 + 4×2 = 36;
對b: WPL = 7×3 + 5×3 + 2×1 + 4×2 = 46;
對c: WPL = 7×1 + 5×2 + 2×3 + 4×3  = 35;
 
c中WPL最小, 能夠驗證, 它就是赫夫曼樹, 而a和b都不是赫夫曼樹
 
對於同一組權值的葉結點, 構成的赫夫曼樹能夠有多種形態, 可是最小WPL值是惟一的。

 

 
 

赫夫曼樹的構建

構建過程分四步:
1. 根據給定的n個權值{w1, w2, w3 ... wn }構成n棵二叉樹的集合, 每棵二叉樹都只包含一個結點
2. 在上面的二叉樹中選出兩顆根結點權值最小的樹, 同時另外取一個新的結點做爲這兩顆樹的根結點, 設新節點的權值爲兩顆權值最小的樹的權值和, 將獲得的這顆樹也加入到樹的集合中
3. 在2操做後, 從集合中刪除權值最小的那兩顆樹
4. 重複2和3,直到集合中的樹只剩下一棵爲止, 剩下的這顆樹就是咱們要求得的赫夫曼樹。
 
以下圖所示:

 

(注意a和b的分界線在4和7中間,圖中畫的不是很清晰)
 
咱們上面提到過WPL相同的狀況下, 赫夫曼樹不止一種,在咱們介紹的算法中,人爲要求某個內結點的左兒子的權值要比右兒子大, 這樣一來, 就將咱們算法中的赫夫曼樹變爲惟一一種了。
 
構建赫夫曼樹的的方法有多種,但基於實際應用的考慮(赫夫曼編碼和譯碼), 下面我給出基於數組存儲實現的赫夫曼樹:
 

Node類的設計

咱們首先須要一個編寫一個結點類, 結點類裏有5種實例變量: weight表示權值, data表示外結點存儲的字符,data屬性在下面的編碼/解碼中會用到。  而一樣由於赫夫曼編碼,解碼的需求,這裏咱們使用三叉鏈實現二叉樹,,即在left和right屬性的基礎上,爲結點增長了parent屬性,目的是可以從葉子結點上溯到根結點,從而實現赫夫曼編碼。
 
/**
 * @Author: HuWan Peng
 * @Date Created in 23:21 2018/1/14
 */
public class Node {
  char  data; // 數據
  int weight;  // 權值
  int left, right, parent; // 三條連接
  public Node (char data, int weight) {
    this.data = data;
    this.weight = weight;
  }
  public Node (int weight) {
    this.weight = weight;
  }
}

 

 

buildTree方法的設計

 
輸入參數和返回值
輸入參數: 一個由外結點對象組成的Node數組, 假設其爲nodes
返回值: 一個由內、外結點共同組成,且創建了連接關係的Node數組, 假設其爲HT(HuffmanTree)
 
具體操做
 
首先要作的事情是: 獲取輸入的nodes數組的長度 n , 建立一個長度爲 2n - 1的數組——HT,在數組HT中, 前n個元素用來存放外結點, 後n個元素用來存放內結點, 以下圖所示:
 
圖A
 
 
圖B
 

 

 
接下來要作的是:
1. 初始化HT中的結點對象,此時各個結點對象的weight都被置爲0
2. 將輸入的nodes數組中的各結點對象的權值賦給HT[0]~ HT[n-1], 如上圖所示
3.經過循環, 依次計算各個內結點的權值,同時創建該內結點和做爲它左右孩子的兩個外結點的連接關係。
易知:當最後一個內結點的權值也計算完畢後, 整顆赫夫曼樹也就構建完畢了。
 
如圖 (方框內數字表示結點對象的權值)

 

 
注意要點
要注意的是: 咱們爲Node設置的連接變量left/right/parent是整型的, 它指向的是某個結點對象在HT中的下標, 而不是結點對象自己! 這種實現方式和通常的樹是有區別的

 

 

具體代碼java

下面是buildTre方法的代碼:
(select方法還沒有給出)
  /**
   * @description: 構建赫夫曼樹
   */
  public  Node[] buildTree (Node [] nodes) {
    int s1, s2,p;
    int n = nodes.length; // 外結點的數量
    int m = 2*n - 1; // 內結點 + 外結點的總數量
    Node [] HT = new Node [m]; // 存儲結點對象的HT數組
    for (int i=0;i<m;i++) HT[i] = new Node(0); // 初始化HT數組元素
    for (int i=0;i<n;i++) {
      HT[i].data   = nodes[i].data;
      HT[i].weight = nodes[i].weight; //將給定的權值列表賦給外結點對象
    }
    for (int i=n;i<m;i++) {
      s1 = select(HT,i,0); // 取得HT數組中權值最小的結點對象的下標
      s2 = select(HT,i,1); // 取得HT數組中權值次小的結點對象的下標
      HT[i].left  = s1; // 創建連接
      HT[i].right = s2;
      HT[s1].parent = i;
      HT[s2].parent = i;
      HT[i].weight = HT[s1].weight + HT[s2].weight;// 計算當前外結點的權值
      selectStart+=2; // 這個變量表示以前「被刪除」的最小結點的數量和
    }
    return HT; // 將處理後的HT數組返回
  }

 

 
buildTree方法的用例:
  /**
   * @description: buildTree方法的用例
   */
  public static void main (String [] args) {
    Node [] nodes = new Node[4];
    nodes[0] = new Node('a',7);
    nodes[1] = new Node('b',5);
    nodes[2] = new Node('c',2);
    nodes[3] = new Node('d',4);
    HuffmanTree ht = new HuffmanTree();
    Node [] n = ht.buildTree(nodes);  // n是構建完畢的赫夫曼樹
  }
}

 

 

select方法的設計

buildTree方法的實現依賴於select方法:
private  int select (Node[] HT,int range, int rank)

 

 
上面代碼中調用select的部分爲:
s1 = select(HT,i,0); // 取得HT數組中權值最小的結點對象的下標
s2 = select(HT,i,1); // 取得HT數組中權值次小的結點對象的下標

 

思考3個問題:
1.  求給定權值排名的結點,能夠先對數組進行從小到大的快速排序, 而後就能夠取得給定排名的結點對象了, 可是若是直接對輸入的HT數組進行排序的話, 會改變HT數組元素的排列順序, 這將不利於咱們下面要介紹的赫夫曼編碼的方法的實現。 因此這裏咱們先將HT數組拷貝到一個輔助數組copyNodes中, 對copyNodes進行快排,並取得給定權值排名的結點對象。而後經過遍歷HT數組,比較獲得該結點對象在HT中的下標
 
2. 在上面咱們提到過, 在構建一顆新二叉樹後, 要把原來的兩顆權值最小的樹從集合中 」刪除「,這裏咱們經過類內的selectStart實例變量實現, selectStart初始值爲0, 每次構建一棵新二叉樹後都經過 selectStart+=2; 增長它的值。(見上文buildTree代碼) 這樣, 在select方法中就能夠經過copyNodes[selectStart + rank],去取得 "刪除" 後權值排名爲rank的結點對象了。
 
3.  引入range這一參數是爲了排除那些weight仍爲0,即還沒有使用到的內結點, 防止排序後取到它們。注意, 隨着循環中 i 的增加, range也是不斷增加的:
 

 

 
具體代碼
(QuickSort的代碼文末將給出)
  /**
   * @description: 返回權值排名爲rank的結點對象在HT中的下標(按權值從小到大排)
   */
  private  int select (Node[] HT,int range, int rank) {
    Node [] copyNodes = Arrays.copyOf(HT, range);// 將HT[0]~HT[range]拷貝到copyNodes中
    QuickSort.sort(copyNodes); // 對copyNodes進行從小到大的快速排序
    Node target = copyNodes[rank + selectStart]; // 取得「刪除」後權值排名爲rank的結點對象
    for (int j=0;j<HT.length;j++) {
      if (target == HT[j]) return j; // 返回該結點對象在數組HT中的下標
    }
    return -1;
  }

 

 
過程圖解
 

 

 
這樣,經過調用buildTree方法, 咱們的赫夫曼樹就構造好了。
 

赫夫曼樹的應用

赫夫曼樹能夠用於優化編碼, 在這以前, 先讓咱們瞭解下什麼是等長編碼和不等長編碼。
 

等長編碼和不等長編碼

等長編碼
例如一段電文爲 'A B A C C D A', 它只有4種字符,只須要兩個字符的串就能夠分辨, 因此咱們能夠按等長編碼的設計原則, 將A,B,C,D表示爲00、0一、十、11, 'A B A C C D A'就被編碼爲‘00010010101100’, 共14位。 它的優勢是: 由於間隔相同, 譯碼時不存在二義性的問題。 但缺點在於, 有些字符本能夠被設計爲更短的編碼, 也就是說,設計爲等長編碼, 咱們實際上浪費了一部分編碼的空間(長度)
 
不等長編碼
同上, 若是採用不等長編碼, 能夠把A,B,C,D表示爲0、00、一、01, 那麼 'A B A C C D A'就能夠被編碼爲‘000011010’, 總長只要9就夠了! 比起等長編碼咱們節約了5位的長度。 但問題是: 因爲長度間隔不一致, 譯碼時可能存在二義性,這致使沒法翻譯,例如 ‘00‘,究竟是當作'00'仍是’0‘ + ’0‘呢? 前者被翻譯爲B,然後者被翻譯爲A。
 

 

 

前綴編碼

因此,要設計長短不等的編碼, 則必須保證: 任意一個字符的編碼都不是另外一個字符的編碼的前綴,這種編碼就叫作前綴編碼
 

赫夫曼編碼的做用

赫夫曼編碼就是一種前綴編碼, 它能解決不等長編碼時的譯碼問題, 經過它,咱們既能儘量減小編碼的長度, 同時還可以避免二義性, 實現正確譯碼。
 
赫夫曼編碼是如何實現前綴編碼的 ?
假設有一棵以下圖所示的二叉樹, 其4個葉結點分別表示A,B,C,D這4個字符,且約定左分支表示字符'0', 右分支表明字符'1', 則能夠從根結點到葉子結點的路徑上的各分支字符組成的字符串做爲該葉子結點字符的編碼。 從而獲得A,B,C,D的二進制編碼分別爲0, 10, 110, 111。

 

具體以下圖所示:
 

 

赫夫曼編碼和解碼都要調用上文講述的buildTree方法
 

實現赫夫曼編碼(encode)

根據給定的字符和權值, 輸出字符和對應的赫夫曼編碼
 
注意要點
1. 咱們編寫一個HuffmanCode內部類用於存放字符(data實例變量)和它對應的二進制字符串(bit實例變量)
 
2. 要求全部字符對應的編碼時,若是採用從根結點下行到葉結點的思路處理,邏輯會相對複雜一些, 因此咱們用逆向的方式獲取: 按照從葉子結點到根結點的路徑累加二進制字符串
 

 

3.  由於 2 的緣由, 累加二進制字符串的時候也必須反向累加,例如寫成bit= "0" + bit;  而不是寫成bit= bit+ "0"; 
 
4. 上溯時候要作的工做是: 判斷當前通過的是 0 仍是 1, 判斷的方法以下圖所示:
假設P是X的父節點:
  • 若是P.left==X在HT中的下標,則說明X是P的左分支,說明通過的是 0
  • 若是P.right==X在HT中的下標,則說明X是P的右分支,說明通過的是 1
 

 

代碼以下:
 
import java.util.Arrays;
/**
 * @Author: HuWan Peng
 * @Date Created in 22:54 2018/1/14
 */
public class HuffmanTree {
  private class HuffmanCode {
    char data; // 存放字符,例如 'C'
    String bit; // 存放編碼後的字符串, 例如"111"
    public HuffmanCode (char data, String bit) {
      this.data = data;
      this.bit  = bit;
    }
  }
 
  /**
   * @description: 構建赫夫曼樹
   */
  public  Node[] buildTree (Node [] nodes) {
    // 具體代碼見上文
  }
 
  /**
   * @description: 進行赫夫曼編碼
   */
  public  HuffmanCode [] encode(Node [] nodes) {
    Node [] HT = buildTree(nodes); // 根據輸入的nodes數組構造赫夫曼樹
    int n = nodes.length;
    HuffmanCode [] HC = new HuffmanCode [n];
    String bit;
    for (int i=0;i<n;i++) { // 遍歷各個葉子結點
      bit = "";
      for (int c=i,f=HT[i].parent;f!=0;c=f,f=HT[f].parent) { // 從葉子結點上溯到根結點
        if(HT[f].left == c) bit= "0" + bit; // 反向編碼
        else                bit= "1" + bit;
      }
      HC[i] = new HuffmanCode(HT[i].data,bit); // 將字符和對應的編碼存儲起來
    }
    return HC;
  }
 
  /**
   * @description: encode方法的用例
   */
  public static void main (String [] args) {
    Node [] nodes = new Node[4];
    nodes[0] = new Node('A',7);
    nodes[1] = new Node('B',5);
    nodes[2] = new Node('C',2);
    nodes[3] = new Node('D',4);
    HuffmanTree ht = new HuffmanTree();
    HuffmanCode[] hc = ht.encode(nodes);
    // 對A,B,C,D進行編碼
    for (int i=0;i<hc.length;i++) { // 將赫夫曼編碼打印出來
      System.out.println(hc[i].data + ":" +hc[i].bit);
    }
  }
}

 

 
輸出結果:
A:0
B:10
C:110
D:111

 

 

赫夫曼譯碼(decode)

根據給定的字符和權值, 將輸入的赫夫曼編碼翻譯回原字符串
 
譯碼的時候,從根結點HT[HT.length -1] 開始, 向下行走, 經過charAt方法取得字符串當前的字符, 若是爲 '0'則向左走, 爲'1'則向右走, 當下行到葉子結點時候,取得葉子結點包含的字符, 添加到當前的譯碼字符串中,同時有回到根結點,繼續循環。
 
代碼以下:
import java.util.Arrays;
/** * @Author: HuWan Peng * @Date Created in 22:54 2018/1/14 */ public class HuffmanTree {   int selectStart = 0;   private class HuffmanCode {     char data; // 存放字符,例如 'C'     String bit; // 存放編碼後的字符串, 例如"111"     public HuffmanCode (char data, String bit) {       this.data = data;       this.bit  = bit;     }   }     /**    * @description: 構建赫夫曼樹    */   public  Node[] buildTree (Node [] nodes) {      // 代碼見上文   }     /**    * @description: 進行赫夫曼譯碼    */   public String decode (Node [] nodes, String code) {     String str="";     Node [] HT = buildTree(nodes);     int n =HT.length -1;     for (int i=0;i<code.length();i++) {       char c = code.charAt(i);       if(c == '1') {         n = HT[n].right;       }       else {         n = HT[n].left;       }       if(HT[n].left == 0) {         str+= HT[n].data;         n =HT.length -1;       }     }     return str;   }   /**    * @description: decode方法的用例    */   public static void main (String [] args) {     Node [] nodes = new Node[4];     nodes[0] = new Node('A',7);     nodes[1] = new Node('B',5);     nodes[2] = new Node('C',2);     nodes[3] = new Node('D',4);     HuffmanTree ht = new HuffmanTree();     // 對 010110111 進行譯碼     System.out.println(ht.decode(nodes,"010110111"));   } }

 

 
輸出:
ABCD

 

 

所有代碼:

代碼共三份:
1.HuffmanTree.java
2.Node.java
3.QuickSort.java
 

Node.java

/**
 * @Author: HuWan Peng
 * @Date Created in 23:21 2018/1/14
 */
public class Node {
  char  data;
  int weight;
  int left, right, parent;
  public Node (char data, int weight) {
    this.data = data;
    this.weight = weight;
  }
  public Node (int weight) {
    this.weight = weight;
  }
}

 

HuffmanTree.java

import java.util.Arrays;
/**
 * @Author: HuWan Peng
 * @Date Created in 22:54 2018/1/14
 */
public class HuffmanTree {
  int selectStart = 0;
  private class HuffmanCode {
    char data; // 存放字符,例如 'C'
    String bit; // 存放編碼後的字符串, 例如"111"
    public HuffmanCode (char data, String bit) {
      this.data = data;
      this.bit  = bit;
    }
  }
  /**
   * @description: 返回權值排名爲rank的結點對象在nodes中的下標(按權值從小到大排)
   */
  private  int select (Node[] HT,int range, int rank) {
    Node [] copyNodes = Arrays.copyOf(HT, range);// 將HT[0]~HT[range]拷貝到copyNodes中
    QuickSort.sort(copyNodes); // 對copyNodes進行從小到大的快速排序
    Node target = copyNodes[rank + selectStart]; // 取得「刪除」後權值排名爲rank的結點對象
    for (int j=0;j<HT.length;j++) {
      if (target == HT[j]) return j; // 返回該結點對象在數組HT中的下標
    }
    return -1;
  }
 
  /**
   * @description: 構建赫夫曼樹
   */
  public  Node[] buildTree (Node [] nodes) {
    int s1, s2,p;
    int n = nodes.length; // 外結點的數量
    int m = 2*n - 1; // 內結點 + 外結點的總數量
    Node [] HT = new Node [m]; // 存儲結點對象的HT數組
    for (int i=0;i<m;i++) HT[i] = new Node(0); // 初始化HT數組元素
    for (int i=0;i<n;i++) {
      HT[i].data   = nodes[i].data;
      HT[i].weight = nodes[i].weight; //將給定的權值列表賦給外結點對象
    }
    for (int i=n;i<m;i++) {
      s1 = select(HT,i,0); // 取得HT數組中權值最小的結點對象的下標
      s2 = select(HT,i,1); // 取得HT數組中權值次小的結點對象的下標
      HT[i].left  = s1; // 創建連接
      HT[i].right = s2;
      HT[s1].parent = i;
      HT[s2].parent = i;
      HT[i].weight = HT[s1].weight + HT[s2].weight;// 計算當前外結點的權值
      selectStart+=2; // 這個變量表示以前「被刪除」的最小結點的數量和
    }
    return HT; // 將處理後的HT數組返回
  }
 
  /**
   * @description: 進行赫夫曼編碼
   */
  public  HuffmanCode [] encode(Node [] nodes) {
    Node [] HT = buildTree(nodes); // 根據輸入的nodes數組構造赫夫曼樹
    int n = nodes.length;
    HuffmanCode [] HC = new HuffmanCode [n];
    String bit;
    for (int i=0;i<n;i++) { // 遍歷各個葉子結點
      bit = "";
      for (int c=i,f=HT[i].parent;f!=0;c=f,f=HT[f].parent) { // 從葉子結點上溯到根結點
        if(HT[f].left == c) bit= "0" + bit; // 反向編碼
        else                bit= "1" + bit;
      }
      HC[i] = new HuffmanCode(HT[i].data,bit); // 將字符和對應的編碼存儲起來
    }
    return HC;
  }
 
  /**
   * @description: 進行赫夫曼譯碼
   */
  public String decode (Node [] nodes, String code) {
    String str="";
    Node [] HT = buildTree(nodes);
    int n =HT.length -1;
    for (int i=0;i<code.length();i++) {
      char c = code.charAt(i);
      if(c == '1') {
        n = HT[n].right;
      }
      else {
        n = HT[n].left;
      }
      if(HT[n].left == 0) {
        str+= HT[n].data;
        n =HT.length -1;
      }
    }
    return str;
  }
  /**
   * @description: buildTree方法的用例
   */
  public static void main (String [] args) {
    Node [] nodes = new Node[4];
    nodes[0] = new Node('A',7);
    nodes[1] = new Node('B',5);
    nodes[2] = new Node('C',2);
    nodes[3] = new Node('D',4);
    HuffmanTree ht = new HuffmanTree();
    System.out.println(ht.decode(nodes,"010110111"));
  }
}

 

 QuickSort.java

/**
 * @Author: HuWan Peng
 * @Date Created in 22:56 2018/1/14
 */
public class QuickSort {
  /**
   * @description: 交換兩個數組元素
   */
  private static void exchange(Node [] a , int i, int j) {
    Node temp = a[i];
    a[i] = a[j];
    a[j] = temp;
  }
 
  /**
   * @description: 切分函數
   */
  private static int partition (Node [] a, int low, int high) {
    int i = low, j = high+1;      // i, j爲左右掃描指針
    int pivotkey = a[low].weight;  // pivotkey 爲選取的基準元素(頭元素)
    while(true) {
      while (a[--j].weight>pivotkey) {   if(j == low) break; }  // 右遊標左移
      while(a[++i].weight<pivotkey) {   if(i == high) break;  }  // 左遊標右移
      if(i>=j) break;    // 左右遊標相遇時候中止, 因此跳出外部while循環
      else exchange(a,i, j) ;  // 左右遊標未相遇時中止, 交換各自所指元素,循環繼續
    }
    exchange(a, low, j); // 基準元素和遊標相遇時所指元素交換,爲最後一次交換
    return j;  // 一趟排序完成, 返回基準元素位置
  }
  /**
   * @description: 根據給定的權值對數組進行排序
   */
  private static void sort (Node [] a, int low, int high) {
    if(high<= low) { return; } // 當high == low, 此時已經是單元素子數組,天然有序, 故終止遞歸
    int j = partition(a, low, high);  // 調用partition進行切分
    sort(a,  low,  j-1);   // 對上一輪排序(切分)時,基準元素左邊的子數組進行遞歸
    sort(a,  j+1,  high); // 對上一輪排序(切分)時,基準元素右邊的子數組進行遞歸
  }
 
  public static void sort (Node [] a){ //sort函數重載, 只向外暴露一個數組參數
    sort(a, 0, a.length-1);
  }
}

 

相關文章
相關標籤/搜索