資料結構與演算法:哈夫曼樹

小高飛發表於2020-10-22

哈夫曼樹

給定N個權值作為N個葉子結點,構造一棵二叉樹,若該樹的帶權路徑長度達到最小,稱這樣的二叉樹為最優二叉樹,也稱為哈夫曼樹(Huffman Tree)。哈夫曼樹是帶權路徑長度最短的樹,權值較大的結點離根較近

重要概念

路徑:從一個節點到它往下可以達到的節點所經shu過的所有節點,稱為兩個節點之間的路徑

路徑長度:即兩個節點的層級差,如A節點在第一層,B節點在第四層,那它們之間的路徑長度為4-1=3

權重值:為樹中的每個節點設定一個有某種含義的數值,稱為權重值(Weight),權重值在不同演算法中可以起到不同的作用

節點的帶權路徑長度:從根節點到該節點的路徑長度與該節點權重值的乘積

樹的帶權路徑長度:所有葉子節點的帶權路徑長度之和,也簡稱為WPL

哈夫曼樹判斷

判斷一棵樹是不是哈夫曼樹只要判斷該樹的結構是否構成最短帶權路徑。

在下圖中3棵同樣葉子節點的樹中帶權路徑最短的是右側的樹,所以右側的樹就是哈夫曼樹。

程式碼實現

案例:將陣列{13,7,8,3,29,6,1}轉換成一棵哈夫曼樹

思路分析:從哈夫曼樹的概念中可以看出,要組成哈夫曼樹,權值越大的節點必須越靠近根節點,所以在組成哈夫曼樹時,應該由最小權值的節點開始。

步驟

(1) 將陣列轉換成節點,並將這些節點由小到大進行排序存放在集合中

(2) 從節點集合中取出權值最小的兩個節點,以這兩個節點為子節點建立一棵二叉樹,它們的父節點權值就是它們的權值之和

(3) 從節點集合中刪除取出的兩個節點,並將它們組成的父節點新增進節點集合中,跳到步驟(2)直到節點集合中只剩一個節點

public class HuffmanTreeDemo {
    public static void main(String[] args) {
        int array[] = {13,7,8,3,29,6,1};
        HuffmanTree huffmanTree = new HuffmanTree();
        Node root = huffmanTree.create(array);
        huffmanTree.preOrder(root);
    }
}

//哈夫曼樹
class HuffmanTree{

    public void preOrder(Node root){
        if (root == null){
            System.out.println("哈夫曼樹為空,無法遍歷");
            return;
        }
        root.preOrder();
    }

    /**
     * 建立哈夫曼樹
     * @param array 各節點的權值大小
     * @return
     */
    public Node create(int array[]){
        //先將傳入的各權值轉成節點並新增到集合中
        List<Node> nodes = new ArrayList<>();
        for (int value : array){
            nodes.add(new Node(value));
        }

        /*
        當集合中的陣列只有一個節點時,即集合內所有節點已經組合完成,
        剩下的唯一一個節點即是哈夫曼樹的根節點
         */
        while (nodes.size() > 1){
            //將節點集合從小到大進行排序
            //注意:如果在節點類沒有實現Comparable介面,則無法使用
            Collections.sort(nodes);

            //在集合內取出權值最小的兩個節點
            Node leftNode = nodes.get(0);
            Node rightNode = nodes.get(1);
            //以這兩個節點建立一個新的二叉樹,它們的父節點的權值即是它們的權值之和
            Node parent = new Node(leftNode.weight + rightNode.weight);
            parent.left = leftNode;
            parent.right = rightNode;

            //再從集合中刪除已經組合成二叉樹的倆個節點,並把它們倆個的父節點加入到集合中
            nodes.remove(leftNode);
            nodes.remove(rightNode);
            nodes.add(parent);
        }

        //返回哈夫曼樹的根節點
        return nodes.get(0);
    }

}

//因為要在節點的集合內,以節點的權值value,從小到大進行排序,所以要實現Comparable<>介面
class Node implements Comparable<Node>{
    int weight;//節點的權值
    Node left;
    Node right;

    public Node(int weight) {
        this.weight = weight;
    }

    public void preOrder(){
        System.out.println(this);
        if (this.left != null){
            this.left.preOrder();
        }
        if (this.right != null){
            this.right.preOrder();
        }
    }

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

    }

    @Override
    public int compareTo(Node o) {
        return this.weight - o.weight;
    }
}

 

哈夫曼編碼

定長編碼

固定長度編碼一種二進位制資訊的通道編碼。這種編碼是一次變換的輸入資訊位數固定不變。簡稱“定長編碼”。

如將字串 ”i like like like java do you like a java“ 轉換成二進位制,統計得出轉換後的二進位制長度達到了320。可以看出轉換後的長度不短,因為無論每個字元出現多少次,它們的編碼都是8位。

可變長度編碼

根據字元出現的頻率進行編碼,頻率越高編碼越短。

如上面的字串中字元 i 出現了5次,那麼可以將它編碼為0,而字元 d 只出現了1次,則將它編碼為0101.

哈夫曼編碼

在編碼優化時,不僅要縮短編碼後的長度,還有考慮是否符合字首編碼的要求,否則編碼後將不能恢復回原先資料。而哈夫曼編碼就是符合字首編碼要求的可變長度編碼。

字首編碼:字元的編碼都不能是其他字元編碼的字首,符合該條件的編碼稱為字首編碼。

哈夫曼編碼思路

將字元出現的次數作為權值,把字串轉換成哈夫曼樹,往左節點的路徑為0,往右節點的路徑為1。

如下圖,將字串"i like like like java do you like a java“ 中的字元轉換成哈夫曼樹,轉換後的字串的哈夫曼編碼為:

1010100110111101111010011011110111101001101111011110100001100001110011001101000011001111000100100100110111101111011100100001100001110

編碼長度為133,比定長編碼的長度縮短了大半,縮短率約為:(320-133) / 320 = 58.4%

注:轉換後的哈夫曼樹不一定要和下圖結構一致,只需樹的帶權路徑長度一致就行。

 

使用哈夫曼編碼解壓縮

基礎程式碼

建立哈夫曼樹節點類

class Node2 implements Comparable<Node2>{
    Byte data;//節點對應資料的位元組值
    int weight;//節點權值,即資料出現的次數
    Node2 left;
    Node2 right;

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

    public void preOrder(){
        System.out.println(this);
        if (this.left != null){
            this.left.preOrder();
        }
        if (this.right != null){
            this.right.preOrder();
        }
    }
    @Override
    public String toString() {
        return "Node2{" +
                "data=" + data +
                ", weight=" + weight +
                '}';
    }
    @Override
    public int compareTo(Node2 o) {
        return this.weight - o.weight;
    }
}

建立哈夫曼樹類,在類中建立一些基本 的屬性和遍歷方法

class HuffmanCodeTree{
    //儲存資料位元組對應的哈夫曼樹
    Map<Byte, String> huffmanCodes = new HashMap<>();
    //用於拼接字串
    StringBuilder stringBuilder = new StringBuilder();
       
    //前序遍歷
    public void preOrder(Node2 root){
        if (root == null){
            System.out.println("哈夫曼樹為空,無法遍歷");
        }else {
            root.preOrder();
        }
    }
}

壓縮程式碼

1.在哈夫曼樹類中建立一個生成樹的方法,根據傳入的資料位元組陣列生成哈夫曼樹

/**
 * 建立哈夫曼樹
 * @param bytes 需轉成哈夫曼樹的資料位元組陣列
 * @return
 */
public Node2 create(byte[] bytes){
    //儲存資料位元組在資料位元組組出現的次數
    Map<Byte, Integer> counts = new HashMap<>();
    //遍歷資料位元組組,如果counts有該位元組key則將次數+1,沒有則設定次數為1
    for (byte b : bytes){
        Integer count = counts.get(b);
        if (count == null){
            counts.put(b, 1);
        }else {
            counts.put(b, count+1);
        }
    }

    //根據counts對映建立節點集合
    List<Node2> node2s = new ArrayList<>();
    for (Map.Entry<Byte, Integer> entry : counts.entrySet()){
        node2s.add(new Node2(entry.getKey(), entry.getValue()));
    }

    while (node2s.size() > 1){
        Collections.sort(node2s);
        Node2 leftNode = node2s.get(0);
        Node2 rightNode = node2s.get(1);
        Node2 parent = new Node2(null, leftNode.weight + rightNode.weight);
        parent.left = leftNode;
        parent.right = rightNode;
        node2s.remove(leftNode);
        node2s.remove(rightNode);
        node2s.add(parent);
    }
    return node2s.get(0);
}

2.建立一個生成哈夫曼編碼的類,根據建立的哈夫曼樹生成哈夫曼編碼

//過載
public void getHuffmanCodes(Node2 root){
    if (root == null){
        return null;
    }
    getHuffmanCodes(root.left, "0", stringBuilder);
    getHuffmanCodes(root.right, "1", stringBuilder);
}

/**
 * 根據資料的哈夫曼樹獲取哈夫曼編碼
 * 如:{32=01, 97=100, 100=11000, 117=11001, 101=1110, 118=11011, 105=101, 121=11010, 106=0010, 107=1111, 108=000, 111=0011}
 * @param node2 節點
 * @param path 路徑:左節點為0,右節點為1
 * @param stringBuilder 用於拼接路徑
 */
public void getHuffmanCodes(Node2 node2, String path, StringBuilder stringBuilder){
    //為了不影響上個遞迴前進段,需建立一個新的StringBuilder
    StringBuilder stringBuilder2 = new StringBuilder(stringBuilder);
    //將路徑path拼接
    stringBuilder2.append(path);

    if (node2 != null){//如果該節點為空,則已經超過哈夫曼樹範圍
        //判斷該節點是不是葉子節點
        if (node2.data == null){
            //如果是非葉子節點則分別向左和向右進行遞迴
            getHuffmanCodes(node2.left, "0", stringBuilder2);
            getHuffmanCodes(node2.right, "1", stringBuilder2);
        }else {
            //如果是葉子節點,則說明該葉子節點的哈夫曼編碼已經完成,新增進哈夫曼編碼對映
            huffmanCodes.put(node2.data, stringBuilder2.toString());
        }
    }
}

3.在哈夫曼樹類中建立一個轉換方法,根據字元的哈夫曼編碼將資料位元組陣列轉換成哈夫曼編碼格式的字串,在將該字串儲存成位元組陣列

/**
 * 根據哈夫曼編碼將傳入的資料位元組組全部轉成哈夫曼編碼格式的字串,
 * 再將字串以8位為1位元組的格式儲存成位元組陣列(需將8位二進位制轉成整型再儲存)
 * 又因為壓縮時二進位制轉位元組和解壓時位元組轉二進位制會有所變動(解壓時說明),
 * 所以需要在切割時將最後一段二進位制的最後一個1前的所有0記錄下來,
 * 並把這些零的數量儲存在返回位元組陣列的最後
 * 如:[105,32,108,105,107,101,32,108,105,107,101,32,108,105,107,101,32,106,97,118,97,32,100,111,32,121,111,117,32,108,105,107,101,32,97,32,106,97,118,97] =>
 *    1010100010111111110010001011111111001000101111111100100101001101110001110000011011101000111100101000101111111100110001001010011011100 =>
 *    [-88, -65, -56, -65, -56, -65, -55, 77, -57, 6, -24, -14, -117, -4, -60, -90, 28, 0]
 * @param huffmanCodes 哈夫曼編碼對映
 * @param bytes 需轉碼的資料位元組陣列
 * @return 哈夫曼編碼格式字串的整形位元組陣列
 */
public byte[] getHuffmanCodeBytes(Map<Byte, String> huffmanCodes, byte[] bytes){
    StringBuilder stringBuilder = new StringBuilder();
    //根據傳入的資料位元組陣列從哈夫曼編碼對映中取出相應的編碼,並拼接
    for (int i=0; i<bytes.length; i++){
        stringBuilder.append(huffmanCodes.get(bytes[i]));
    }
    /*
    雖然將資料位元組陣列轉成了哈夫曼編碼格式,但是轉換後的字串長度並沒有比原先短,反而更長了,
    所以要將轉換後的字串再以8位為1位元組(1位元組最多隻能儲存8位)轉換成整型存進新的位元組陣列
     */
    int len;//轉換後新的位元組陣列的長度
    int strLength = stringBuilder.length();//赫夫曼編碼格式字串的長度
    int zeroCount = 0;//解壓時需補零的數量
    int subIndex = 0;//判斷補零數量的開始切割索引
    //因為轉換後的字串不一定剛好能被8整除,所以為了防止超出範圍異常,需判斷
    if (stringBuilder.length() % 8 == 0){
        //因為需要儲存補零的數量,所以需+1
        len = strLength / 8 +1;
        //當字串長度能被8整除時,那補零數量判斷的開始索引就是字串長度-8
        subIndex = strLength - 8;
        /*
        補零數量判斷邏輯:
        從開始索引進行判斷,當等於0時,就把補零的數量+1,並將開始索引往後移動一位
        因為當開始索引到字串最後一位時,仍然是0的話,即最後一段二進位制全部都是0,
        這時轉換成位元組整型為0,當解壓時重新把位元組轉換成二進位制字串時也是0,
        所以字串長度最後一位無論是不是零都不影響,無需判斷
         */
        while (subIndex<strLength-1 && stringBuilder.substring(subIndex, subIndex+1).equals("0")) {
            zeroCount++;//補零數量+1
            subIndex++;//並將切割開始索引往後移動一位
        }
    }else {
        //如果不能被8整除,位元組陣列的長度還需+1
        len = strLength / 8 + 2;
        //不能被8整除時,補零長度判斷的開始索引就是字串數量減去它們的餘數
        subIndex = strLength - strLength % 8;
        while (subIndex<strLength-1 && stringBuilder.substring(subIndex, subIndex+1).equals("0")) {
            zeroCount++;
            subIndex++;
        }
    }

    //儲存哈夫曼編碼格式的資料字串轉換後的位元組
    byte[] huffmanCodeBytes = new byte[len];
    //將補零數量儲存在位元組陣列的最後的位置
    huffmanCodeBytes[len-1] = (byte)zeroCount;
    int index = 0;//位元組陣列的下標索引
    //遍歷哈夫曼編碼格式的資料字串, 每次遞增8位
    for (int i=0; i<strLength; i+=8){
        String strBytes;//儲存從字串中切割出來的二進位制
        //為了防止範圍超出,需判斷字串從i下標到轉換後的位元組陣列末尾的長度是否>8
        if (i+8 < strLength){
            strBytes = stringBuilder.substring(i, i+8);
        }else {
            //如果剩餘不足8位,則把剩下的二進位制取出即可
            strBytes = stringBuilder.substring(i);
        }
        //將切割出的二進位制轉換成整型,並儲存進轉換後的位元組陣列
        huffmanCodeBytes[index] = (byte)Integer.parseInt(strBytes, 2);
        index++;
    }
    return huffmanCodeBytes;
}

4.在哈夫曼樹類中建立一個壓縮方法,呼叫上面的所有方法

//使用哈夫曼編碼進行壓縮
public byte[] huffmanZip(byte[] bytes){
    Node2 root = create(bytes);
    getHuffmanCodes(root);
    return getHuffmanCodeBytes(huffmanCodes, bytes);
}

解壓程式碼

1.在哈夫曼樹類中建立一個位元組轉二進位制的方法,將傳入的位元組轉換成二進位制字串

/**
 * 將位元組轉換成二級制字串
 * 因為在壓縮時除了最後一段二進位制都是滿8位的,而在解壓時轉換後的二進位制會自動去掉最後一位1前面的所有0
 * 如:壓縮: 01001101 => 77,解壓: 77 => 1001101,差了個0,
 * 所以在轉換成二進位制字串時,需要進行補高位,即在最後一個1前面補0直到長度等於8位
 * 但是有一個是例外的,即是轉換位元組組除開補零數的最後一個數,因為在壓縮時它可能是不滿8位二進位制的,要額外處理
 * @param b 需轉換的位元組
 * @param flag 是否是陣列中最後一個資料
 * @param zeroCount 補零的長度
 * @return 返回位元組對應的二進位制字串
 */
public String byteToBinaryString(byte b, boolean flag, int zeroCount){
    //將位元組轉成整型,以便使用Integer的方法轉成二進位制字串
    int temp = b;

    /*
    因為除了陣列的最後一個,其它資料在壓縮時的二進位制都是滿足8位的,
    所以在解壓時(除外最後一個資料),只需在不足8位的時候補高位,即只要按位或|256 (100000000),
    如:77 => 1001101  按位或256(100000000) =>  101001101
    然後再取字串的最後8位即可獲得原先壓縮時的二進位制字串
     */
    //如果不是最後一個則按位或|256,不足8位的補高位,夠8位則不會變
    if (!flag){
        temp |= 256;
    }
    //將整型轉成二級制字串
    String binaryString = Integer.toBinaryString(temp);
    if (!flag){//判斷是否是最後一個
        //不是,則因為按位或256的原因,只需取二進位制字串的最後8位
        return binaryString.substring(binaryString.length() - 8);
    }else {//不是則補零後返回
        //補零的字串
        String zeroStr = "";
        for (int i=0; i<zeroCount; i++){
            zeroStr += "0";
        }
        //將補零字串新增到轉換後的二進位制字串前面
        return (zeroStr + binaryString);
    }
}

2.在哈夫曼類中建立一個解壓方法,呼叫上面的方法將整形位元組陣列轉成二進位制字串,再將二進位制字串通過位元組的哈夫曼編碼對映恢復回原先資料位元組陣列

/**
 * 使用根據哈夫曼編碼進行解壓
 * @param huffmanCodes 資料對應的哈夫曼編碼對映
 * @param bytes 需解壓的位元組陣列
 * @return 解壓後資料的位元組
 */
public byte[] decode(Map<Byte, String> huffmanCodes, byte[] bytes){
    //用於拼接需解壓位元組轉換後的二進位制字串
    StringBuilder stringBuilder = new StringBuilder();
    int zeroCount = bytes[bytes.length-1];//將補零數量從位元組組中取出
    //遍歷需解壓位元組陣列(最後一位是補零數量,要除開)
    for (int i=0; i<bytes.length-1; i++){
        //判斷是否是陣列中除開補零數量的最後一個數
        boolean flag = (i == bytes.length-2);
        //把轉換後的二進位制字串進行拼接
        stringBuilder.append(byteToBinaryString(bytes[i], flag, zeroCount) );
    }

    //因為解壓時需通過二進位制字串獲取哈夫曼編碼對應的位元組,所以需將哈夫曼編碼對映關係倒過來
    Map<String, Byte> decodeHuffmanCodes = new HashMap<>();
    for (Map.Entry<Byte, String> entry : huffmanCodes.entrySet()){
        decodeHuffmanCodes.put(entry.getValue(), entry.getKey());
    }

    //存放二進位制字串通過哈夫曼編碼對映轉換後的位元組
    List<Byte> list = new ArrayList<>();
    /*
    將拼接二級制字串根據赫夫曼編碼轉換成位元組的邏輯:
    1. 設定倆個指標用於切割二級制字串,一個指向起點(開始為0),一個指向終點(開始為起點+1)
    2. 根據兩個指標切割二進位制字串得到一段二進位制,再以該段二進位制為key從哈夫曼編碼對映中取值
    3.1 如果取到的值為空,則表示為該段二進位制沒有對應的位元組,則將終點指標後移一位,繼續步驟2的操作
    3.2 如果取到的值不為空,則將該值新增進解壓結果集合,並將起點指標指向終點
     */
    for (int i=0; i<stringBuilder.length();){
        //儲存根據二進位制從哈夫曼編碼對映中取出的位元組
        Byte b = null;
        //切割終點指標
        int end = i;
        while (b==null){//判斷取值是否為空
            end ++;//為空,將終點指標向後移一位
            //從拼接後的二進位制字串切割除一段二進位制
            String key = stringBuilder.substring(i, end);
            //根據切割出的二進位制從哈夫曼編碼對映中取出相應的值
            b = decodeHuffmanCodes.get(key);
        }
        //當取值不為空時,將取值新增進解壓結果集,並把起點指標指向終點
        list.add(b);
        i = end;
    }

    //將結果集合轉換成位元組陣列的形式,再返回
    byte[] decodeResult = new byte[list.size()];
    for (int i=0; i<decodeResult.length; i++){
        decodeResult[i] = list.get(i);
    }
    return decodeResult;
}

對檔案進行解壓縮

因為在壓縮和解壓的方法中,資料都是用位元組陣列的格式,所以在壓縮和解壓前需將檔案的內容轉換成位元組陣列的格式,在位元組陣列壓縮或解壓後再將它重新寫入新的檔案,注意,再壓縮時位元組的哈夫曼編碼對映也要一起寫入新的檔案中,不然會導致壓縮檔案無法解壓復原。

/**
 * 壓縮檔案
 * @param srcFile 需壓縮的檔案路徑
 * @param dstFile 壓縮後的檔案存放路徑
 */
public void fileZip(String srcFile, String dstFile){
    InputStream is = null;
    OutputStream os = null;
    ObjectOutputStream oos = null;
    try {
        is = new FileInputStream(srcFile);
        //將檔案轉換成位元組陣列
        byte[] bytes = new byte[is.available()];
        is.read(bytes);
        //對位元組陣列進行壓縮
        byte[] huffmanBytes = huffmanZip(bytes);
        os = new FileOutputStream(dstFile);
        oos = new ObjectOutputStream(os);
        //將壓縮後的位元組陣列和位元組的哈夫曼編碼寫入檔案中
        oos.writeObject(huffmanBytes);
        oos.writeObject(huffmanCodes);
    }catch (Exception e){
        System.out.println(e.getMessage());
    }finally {
        try {
            oos.close();
            os.close();
            is.close();
        }catch (IOException e){
            System.out.println(e.getMessage());
        }
    }
}

/**
 * 解壓檔案
 * @param srcFile 需解壓的檔案路徑
 * @param dstFile 解壓後的檔案存放路徑
 */
public void unzipFile(String srcFile, String dstFile){
    InputStream is = null;
    ObjectInputStream ois = null;
    OutputStream os = null;
    try{
        is = new FileInputStream(srcFile);
        ois = new ObjectInputStream(is);
        //將檔案壓縮後的整形位元組陣列和位元組的哈夫曼編碼從檔案中取出
        byte[] huffmanBytes = (byte[])ois.readObject();
        Map<Byte, String> huffmanCodes = (Map<Byte, String>)ois.readObject();
        byte[] unzipResult = decode(huffmanCodes, huffmanBytes);
        os = new FileOutputStream(dstFile);
        //將解壓後的位元組陣列寫入檔案
        os.write(unzipResult);
    }catch (Exception e){
        System.out.println(e.getMessage());
    }finally {
        try{
            os.close();
            ois.close();
            is.close();
        }catch (IOException e){
            System.out.println(e.getMessage());
        }
    }
}

完整程式碼

哈夫曼編碼解壓縮

相關文章