1. 程式人生 > 實用技巧 >資料結構與演算法(十四):赫夫曼編碼

資料結構與演算法(十四):赫夫曼編碼

一、什麼是赫夫曼編碼

哈夫曼編碼(Huffman Coding),又稱霍夫曼編碼,是一種編碼方式,可變字長編碼(VLC)的一種。Huffman於1952年提出一種編碼方法,該方法完全依據字元出現概率來構造異字頭的平均長度最短的碼字,有時稱之為最佳編碼,

使用赫夫曼編碼可以有效的壓縮資料,通常可以節省20%~90%的空間。

在理解赫夫曼編碼前,我們需要對通訊領域的兩種編碼方式有個粗略的瞭解。

假設我們需要傳輸 I'm a jvav programmer and I love programming這樣一句話,我們有兩種傳輸方式:

  1. 定長編碼

    直接轉換為對應長度的二進位制格式

    01101111 00100000 00100000 00100000 00100111 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100111 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000 00100000
    

    總長度為296個字元

  2. 變長編碼

    按照各個字元出現的次數進行編碼,按出現次數編碼,出現次數越多的,則編碼越小:

    比如空格出現最多次,然後是a,以此類推......

    0=  ,1=a,10=i,11=o......
    

    當傳輸的資訊越多的時候,變長編碼實際傳輸的長度相對定長編碼就越小

另外,我們還需要了解一下什麼是補碼:

計算機裡面只有加法器,沒有減法器,所以減法必須用加法來完成。
對於 100 以內的十進位制數,“-1”就可以用"+99"代替。比如 25 - 1 = 24,可以寫成 25 + 99 = (1)24。
如果限定了兩位數,那“-1”和“+99”就是等效的。同樣,“-2”可以用“+98”代替。

它們之間,稱為補數,而100就稱為

對於8位二進位制數:0000 0000~1111 1111(255),模為256
-1,可以用 +255(1111 1111)代替。
-2,可以用 +254(1111 1110)代替

這些二進位制數,就稱為負數的補碼

二、赫夫曼編碼思路

同樣舉個例子,我們要傳輸 i like like like java do you like a java這段話

  1. 統計各字元的出現次數

    d:1 y:1 u:1 j:2 v:2 o:2 l:4 k:4 e:4 i:5 a:5 :9
    
  2. 將字元出現次數作為節點的權,構建一個赫夫曼樹(這裡步驟同上一篇文章

  3. 我們使用0和1來描述某個節點在樹中往左或往右的路徑,比如j,從根節點出發抵達j的路徑就是0000,抵達i的路徑就是101

    於是現在對所有字元的路徑進行統計,就有:

    o: 1000     u: 10010     d: 100110     y: 100111    i: 101    a : 110
    k: 1110     e: 1111      j: 0000       v: 0001      l: 001      : 01
    
  4. 按照上面的路徑,我們將其轉為二進位制

    1010100110111101111010011011110111101001101111011110100001100001110011001111000011001111000100100100110111101111011100100001100001110
    

    直接轉為二進位制長度為359,而經過赫夫曼編碼長度則是133,與直接轉為二進位制相比,縮短了62.9%

三、程式碼實現

1.建立節點類

/**
 * @Author:CreateSequence
 * @Date:2020-07-18 13:28
 * @Description:赫夫曼編碼用節點
 */
public class HuffmanCodeNode implements Comparable<HuffmanCodeNode>{

    //字元
    Byte data;
    //權值
    int weight;
    HuffmanCodeNode left;
    HuffmanCodeNode right;

    public HuffmanCodeNode(Byte data, int weight) {
        this.data = data;
        this.weight = weight;
    }

    public HuffmanCodeNode(HuffmanCodeNode left, HuffmanCodeNode right) {
        //計運算元節點權值之和
        this.weight = left.weight + right.weight;
        this.left = left;
        this.right = right;
    }

    @Override
    public int compareTo(HuffmanCodeNode o) {
        //從小到大排序
        return this.weight - o.weight;
    }

    @Override
    public String toString() {
        return "{" +
            "data=" + data +
            ", weight=" + weight +
            '}';
    }
    
}

2.統計字元出現次數,並組裝節點列表

對應思路中的第一步:

/**
 * 統計字元在字串中的出現次數,並組裝節點列表
 * @param str 字串
 * @return
 */
private List<HuffmanCodeNode> getNodes(String str) {
    //將字串轉為字串陣列
    byte[] strBytes = str.getBytes();

    //遍歷位元組陣列,並且統計某一字元出現次數
    Map<Byte, Integer> counts = new HashMap<>(24);
    for (byte b : bytes) {
        Integer count = counts.get(b);
        //判斷某一字元是否第一次出現
        if (count == null) {
            counts.put(b, 1);
        }else {
            //不是就讓出現次數+1
            counts.put(b, count ++);
        }
    }

    //將map轉為節點集合
    List<HuffmanCodeNode> nodes = new ArrayList<>();
    for (Map.Entry<Byte, Integer> entry : counts.entrySet()) {
        nodes.add(new HuffmanCodeNode(entry.getKey(), entry.getValue()));
    }

    return nodes;
}

3.生成赫夫曼樹

對應思路中的第二步:

/**
 * 構建赫夫曼樹
 * @param nodes 節點集合
 * @return 最終生成的樹的根節點
 */
private HuffmanCodeNode createTree(List<HuffmanCodeNode> nodes) {
    //構建樹
    while (nodes.size() > 1) {
        //從小到大排序
        Collections.sort(nodes);
        //取出最小的兩個數構建樹
        HuffmanCodeNode left = nodes.get(0);
        HuffmanCodeNode right = nodes.get(1);
        HuffmanCodeNode parant = new HuffmanCodeNode(left, right);
        //刪除兩個節點
        nodes.remove(left);
        nodes.remove(right);
        //將根節點新增至集合
        nodes.add(parant);
    }
    //返回樹的根節點
    return nodes.get(0);
}

當然,這個時候可以通過前序遍歷來檢查是否構建成功

/**
 * 前序遍歷
 * @param node 樹的根節點
 */
private void preOrder(HuffmanCodeNode node) {
    //遞迴
    System.out.println(node.toString());
    if (node.left != null) {
        preOrder(node.left);
    }
    if (node.right != null) {
        preOrder(node.right);
    }
}

4.得到赫夫曼編碼

對應思路中的第三步:

我們已經得到了赫夫曼樹,現在我們需要獲得從根節點到各個葉子結點的路徑,也就是赫夫曼編碼

/**
 * 生成赫夫曼樹對應的赫夫曼編碼集合
 */
private Map<Byte, String> huffmanCodes = new HashMap<>();
/**
 * 儲存某個葉子節點的拼接路徑
 */
private StringBuilder stringBuilder = new StringBuilder();

/**
 * 將傳入的節點作為樹的根節點,找到其所有的葉子結點的赫夫曼編碼,並放入赫夫曼編碼集合
 * @param node 節點
 * @param way 葉子結點的路徑,左為0,右為1
 * @param builder 用於拼接路徑
 */
private Map<Byte, String> getCodes(HuffmanCodeNode node, String way, StringBuilder builder) {
    StringBuilder stringBuilder = new StringBuilder(builder);
    //建路徑拼接至上一路徑
    stringBuilder.append(way);
    if (node != null) {
        //判斷當前是否為葉子節點
        if (node.data == null) {
            //向左右遞迴直到找到葉子結點
            getCodes(node.left, "0", stringBuilder);
            getCodes(node.right, "1", stringBuilder);
        }else {
            //已經是葉子結點,將路徑存入集合
            huffmanCodes.put(node.data, stringBuilder.toString());
        }
    }
    return huffmanCodes;
}

public Map<Byte, String> getCodes() {
    //構建赫夫曼樹
    HuffmanCodeNode root = createTree();
    //處理左右子樹
    getCodes(root.left, "0", stringBuilder);
    getCodes(root.right, "1", stringBuilder);
    //返回赫夫曼編碼
    return huffmanCodes;
}

5.將得到的赫夫曼編碼轉回位元組陣列

對應思路中的第四步,也就是最後一步:

我們得到了赫夫曼編碼表,也就是這玩意: Map<Byte, String> huffmanCodes,每串赫夫曼編碼字串都對應一個字元,我們需要處理赫夫曼編碼的每一個字元,將其轉為二進位制後再轉為byte,最後處理完得到一隊位元組陣列。

/**
 * 將字串對應的byte陣列,轉換為經過赫夫曼編碼壓縮後的byte陣列
 * @param bytes
 * @param huffmanCodes
 * @return
 */
private byte[] zip(byte[] bytes, Map<Byte, String> huffmanCodes) {
    //獲取赫夫曼編碼
    StringBuilder stringBuilder = new StringBuilder();
    //遍歷byte陣列,一個byte表示一個字元
    for (Byte b : bytes) {
        //將字元轉為赫夫曼編碼格式,一個字元對應8位編碼
        stringBuilder.append(huffmanCodes.get(b));
    }

    //一個字元對應對應的8位的赫夫曼編碼,如果赫夫曼編碼無法被8整除,就直接補齊赫夫曼編碼不足八位的那一個字元
    int len = stringBuilder.length() % 8 == 0 ? stringBuilder.length() / 8 : stringBuilder.length() / 8 + 1;
    //System.out.println("有幾個字元:"+len);

    //將壓縮後的赫夫曼編碼按字元分開儲存
    byte[] huffmanCodeBytes = new byte[len];
    //計錄已處理幾個字元
    int index = 0;
    //每8位編碼對應一個byte,所以步長為8
    //每迴圈一次處理一個byte,也就是一個字元
    for (int i = 0; i < stringBuilder.length(); i += 8) {
        String strBytes;
        //判斷編碼長度是否超過8位
        if (i + 8 < stringBuilder.length()) {
            //超過8位就從赫夫曼編碼擷取八位(也就是一個字元)
            strBytes = stringBuilder.substring(i, i + 8);
        }else {
            //否則就有多少截多少
            strBytes = stringBuilder.substring(i);
        }
        //將赫夫曼編碼轉為二進位制,存入byte陣列
        huffmanCodeBytes[index] = (byte) Integer.parseInt(strBytes, 2);

        //位已處理字元數+1
        index++;
    }

    //迴圈結束後,返回赫夫曼編碼按字元轉換得到的位元組陣列
    return huffmanCodeBytes;
}

public byte[] zip() {
    byte[] bytes = str.getBytes();
    Map<Byte, String> huffmanCodes = getCodes();
    return zip(bytes, huffmanCodes);
}

6.解碼

資訊被赫夫曼編碼處理後我們會得到一隊位元組陣列,如果要解碼,我們需要先把位元組陣列按字元一個位元組一個位元組的轉為二進位制,然後通過赫夫曼編碼表把二進位制和字元位元組一一找出:

/**
 * 將byte轉成二進位制字串
 * @param isComple 是否需要補高位。最後一個位元組無需補位
 * @param b 要轉換的位元組
 * @return
 */
private String byteToString(boolean isComplate, byte b) {
    int temp = b;
    //判斷是否需要補齊高位
    if (isComplate) {
        temp |= 256;
    }
    //返回temp對應的二進位制補碼
    String str = Integer.toBinaryString(temp);
    return isComplate ? str.substring(str.length() - 8) : str;
}

/**
 * 解碼
 * @param huffmanCodes 赫夫曼編碼表
 * @param huffmanBytes 赫夫曼編碼處理過的位元組陣列
 * @return 原來未被轉為赫夫曼編碼的的字串位元組素組
 */
private byte[] decode(Map<Byte, String> huffmanCodes, byte[] huffmanBytes) {

    //將赫夫曼編碼處理過byte陣列轉為二進位制字串
    StringBuilder stringBuilder = new StringBuilder();
    for (int i = 0; i < huffmanBytes.length; i++) {

        boolean isComplate = true;
        //如果是最後一個位元組就不用補高位了
        if (i == huffmanBytes.length - 1) {
            isComplate = false;
        }
        //拼接位元組轉的二進位制字串
        stringBuilder.append(byteToString(isComplate, huffmanBytes[i]));
    }

    //把字串按照指定赫夫曼編碼進行解碼
    //原本赫夫曼編碼表是<位元組,二進位制字串>,現在要轉為<二進位制字串,位元組>以通過轉換得到的二進位制字串取出對應的位元組
    Map<String, Byte> reHuffmanCodes = new HashMap<>();
    for (Map.Entry<Byte, String> entry : huffmanCodes.entrySet()) {
        reHuffmanCodes.put(entry.getValue(), entry.getKey());
    }

    List<Byte> bytes = new ArrayList<>();
    //由於無法確認拼接後的二進位制字串每八位一定就能和某個位元組對應,所以需要進行字串匹配
    //這裡可以簡單理解為雙指標,一號指標從i開始,二號指標從i+1開始
    //一號指標先指向字串第i字元,然後二號指標從i+1個字元開始不斷後移,然後進行進行匹配
    //比如:i=0,j=1,第一次擷取並匹配的字元就是[0,1),也就是0;第二次是[0,2),也就是01;然後是[0,3).....以此類推
    //直到找到以後,比如[2,7),就移動一號指標到7,二號指標移動到8
    for (int i = 0, j = 1; i < stringBuilder.length(); i = --j) {
        String key = "";
        while (!reHuffmanCodes.containsKey(key)) {
            key = stringBuilder.substring(i, j);
            j++;
        }
        bytes.add(reHuffmanCodes.get(key));
    }

    //由集合轉為位元組陣列
    byte b[] = new byte[bytes.size()];
    for (int i = 0; i < b.length; i++) {
        b[i] = bytes.get(i);
    }

    return b;
}

四、完整程式碼

具體程式碼和測試用例可以去GitHub上看,這裡就放實現類:

import java.util.*;

/**
 * @Author:CreateSequence
 * @Date:2020-07-18 13:26
 * @Description:赫夫曼編碼
 */
public class HuffmanCode {

    private String str;

    public String getStr() {
        return str;
    }

    public HuffmanCode(String code) {
        if (code.length() == 0 || code == null) {
            throw new RuntimeException("字串必須有至少一個字元!");
        }
        this.str = code;
    }

    /**
     * 統計字元在字串中的出現次數,並組裝節點列表
     * @return
     */
    public List<HuffmanCodeNode> getNodes() {
        //將字串轉為字串陣列
        byte[] bytes = str.getBytes();

        //遍歷位元組陣列,並且統計某一字元出現次數
        Map<Byte, Integer> counts = new HashMap<>(24);
        for (byte b : bytes) {
            Integer count = counts.get(b);
            //判斷某一字元是否第一次出現
            if (count == null) {
                counts.put(b, 1);
            }else {
                //不是就讓出現次數+1
                counts.put(b, count ++);
            }
        }

        //將map轉為節點集合
        List<HuffmanCodeNode> nodes = new ArrayList<>();
        for (Map.Entry<Byte, Integer> entry : counts.entrySet()) {
            nodes.add(new HuffmanCodeNode(entry.getKey(), entry.getValue()));
        }

        return nodes;
    }

    /**
     * 構建赫夫曼樹
     * @param nodes
     * @return
     */
    private HuffmanCodeNode createTree(List<HuffmanCodeNode> nodes) {
        //構建樹
        while (nodes.size() > 1) {
            //從小到大排序
            Collections.sort(nodes);
            //取出最小的兩個數構建樹
            HuffmanCodeNode left = nodes.get(0);
            HuffmanCodeNode right = nodes.get(1);
            HuffmanCodeNode parant = new HuffmanCodeNode(left, right);
            //刪除兩個節點
            nodes.remove(left);
            nodes.remove(right);
            //將根節點新增至集合
            nodes.add(parant);
        }
        //返回樹的根節點
        return nodes.get(0);
    }
    public HuffmanCodeNode createTree(){
        return createTree(getNodes());
    }

    /**
     * 前序遍歷
     * @param node 樹的根節點
     */
    private void preOrder(HuffmanCodeNode node) {
        //遞迴
        System.out.println(node.toString());
        if (node.left != null) {
            preOrder(node.left);
        }
        if (node.right != null) {
            preOrder(node.right);
        }
    }
    public void preOrder(){
        HuffmanCodeNode root = createTree(getNodes());
        preOrder(root);
    }

    /**
     * 生成赫夫曼樹對應的赫夫曼編碼集合
     */
    private Map<Byte, String> huffmanCodes = new HashMap<>();
    /**
     * 儲存某個葉子節點的拼接路徑
     */
    private StringBuilder stringBuilder = new StringBuilder();

    /**
     * 將傳入的節點作為樹的根節點,找到其所有的葉子結點的赫夫曼編碼,並放入赫夫曼編碼集合
     * @param node 節點
     * @param way 葉子結點的路徑,左為0,右為1
     * @param builder 用於拼接路徑
     */
    private Map<Byte, String> getCodes(HuffmanCodeNode node, String way, StringBuilder builder) {
        StringBuilder stringBuilder = new StringBuilder(builder);
        //建路徑拼接至上一路徑
        stringBuilder.append(way);
        if (node != null) {
            //判斷當前是否為葉子節點
            if (node.data == null) {
                //向左右遞迴直到找到葉子結點
                getCodes(node.left, "0", stringBuilder);
                getCodes(node.right, "1", stringBuilder);
            }else {
                //已經是葉子結點,將路徑存入集合
                huffmanCodes.put(node.data, stringBuilder.toString());
            }
        }
        //返回赫夫曼編碼
        return huffmanCodes;
    }

    public Map<Byte, String> getCodes() {
        //構建赫夫曼樹
        HuffmanCodeNode root = createTree();
        //處理左右子樹
        getCodes(root.left, "0", stringBuilder);
        getCodes(root.right, "1", stringBuilder);
        //返回赫夫曼編碼
        return huffmanCodes;
    }

    /**
     * 將字串對應的byte陣列,轉換為經過赫夫曼編碼壓縮後的byte陣列
     * @param bytes
     * @param huffmanCodes
     * @return
     */
    private byte[] zip(byte[] bytes, Map<Byte, String> huffmanCodes) {
        //獲取赫夫曼編碼
        StringBuilder stringBuilder = new StringBuilder();
        //遍歷byte陣列,一個byte表示一個字元
        for (Byte b : bytes) {
            //將字元轉為赫夫曼編碼格式,一個字元對應8位編碼
            stringBuilder.append(huffmanCodes.get(b));
        }

        //一個字元對應對應的8位的赫夫曼編碼,如果赫夫曼編碼無法被8整除,就直接補齊赫夫曼編碼不足八位的那一個字元
        int len = stringBuilder.length() % 8 == 0 ? stringBuilder.length() / 8 : stringBuilder.length() / 8 + 1;
        //System.out.println("有幾個字元:"+len);

        //將壓縮後的赫夫曼編碼按字元分開儲存
        byte[] huffmanCodeBytes = new byte[len];
        //計錄已處理幾個字元
        int index = 0;
        //每8位編碼對應一個byte,所以步長為8
        //每迴圈一次處理一個byte,也就是一個字元
        for (int i = 0; i < stringBuilder.length(); i += 8) {
            String strBytes;
            //判斷編碼長度是否超過8位
            if (i + 8 < stringBuilder.length()) {
                //超過8位就從赫夫曼編碼擷取八位(也就是一個字元)
                strBytes = stringBuilder.substring(i, i + 8);
            }else {
                //否則就有多少截多少
                strBytes = stringBuilder.substring(i);
            }
            //將赫夫曼編碼轉為二進位制,存入byte陣列
            huffmanCodeBytes[index] = (byte) Integer.parseInt(strBytes, 2);

            //位已處理字元數+1
            index++;
        }

        //迴圈結束後,返回赫夫曼編碼按字元轉換得到的位元組陣列
        return huffmanCodeBytes;
    }
    public byte[] zip() {
        byte[] bytes = str.getBytes();
        Map<Byte, String> huffmanCodes = getCodes();
        return zip(bytes, huffmanCodes);
    }

    /**
     * 將byte轉成二進位制字串
     * @param isComple 是否需要補高位。最後一個位元組無需補位
     * @param b 要轉換的位元組
     * @return
     */
    private String byteToString(boolean isComplate, byte b) {
        int temp = b;
        //判斷是否需要補齊高位
        if (isComplate) {
            temp |= 256;
        }
        //返回temp對應的二進位制補碼
        String str = Integer.toBinaryString(temp);
        return isComplate ? str.substring(str.length() - 8) : str;
    }

    /**
     * 解碼
     * @param huffmanCodes 赫夫曼編碼表
     * @param huffmanBytes 赫夫曼編碼處理過的位元組陣列
     * @return 原來未被轉為赫夫曼編碼的的字串位元組素組
     */
    private byte[] decode(Map<Byte, String> huffmanCodes, byte[] huffmanBytes) {

        //將赫夫曼編碼處理過byte陣列轉為二進位制字串
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < huffmanBytes.length; i++) {

            boolean isComplate = true;
            //如果是最後一個位元組就不用補高位了
            if (i == huffmanBytes.length - 1) {
                isComplate = false;
            }
            //拼接位元組轉的二進位制字串
            stringBuilder.append(byteToString(isComplate, huffmanBytes[i]));
        }

        //把字串按照指定赫夫曼編碼進行解碼
        //原本赫夫曼編碼表是<位元組,二進位制字串>,現在要轉為<二進位制字串,位元組>以通過轉換得到的二進位制字串取出對應的位元組
        Map<String, Byte> reHuffmanCodes = new HashMap<>();
        for (Map.Entry<Byte, String> entry : huffmanCodes.entrySet()) {
            reHuffmanCodes.put(entry.getValue(), entry.getKey());
        }

        List<Byte> bytes = new ArrayList<>();
        //由於無法確認拼接後的二進位制字串每八位一定就能和某個位元組對應,所以需要進行字串匹配
        //這裡可以簡單理解為雙指標,一號指標從i開始,二號指標從i+1開始
        //一號指標先指向字串第i字元,然後二號指標從i+1個字元開始不斷後移,然後進行進行匹配
        //比如:i=0,j=1,第一次擷取並匹配的字元就是[0,1),也就是0;第二次是[0,2),也就是01;然後是[0,3).....以此類推
        //直到找到以後,比如[2,7),就移動一號指標到7,二號指標移動到8
        for (int i = 0, j = 1; i < stringBuilder.length(); i = --j) {
            String key = "";
            while (!reHuffmanCodes.containsKey(key)) {
                key = stringBuilder.substring(i, j);
                j++;
            }
            bytes.add(reHuffmanCodes.get(key));
        }

        //由集合轉為位元組陣列
        byte b[] = new byte[bytes.size()];
        for (int i = 0; i < b.length; i++) {
            b[i] = bytes.get(i);
        }

        return b;
    }

    public byte[] decode(byte[] huffmanBytes) {
        return decode(huffmanCodes, huffmanBytes);
    }

}