跳到主要内容

16、数据结构与算法 - 实战:哈夫曼树和哈夫曼编码

1、先掌握几个概念

先听一遍哈夫曼树的概念:给定n个权值作为n个叶子结点,构造一棵二叉树,若该树的带权路径长度(WPL)达到最小,称这样的二叉树为最优二叉树,也成为哈夫曼树(Huffman Tree)。好的,知道你懵逼了,下面还是先学习几个概念。

1.1 什么是路径?

在一棵树中,从一个结点到另一个结点所经过的所有结点,成为两个结点之间的路径。
 
比如上图这颗二叉树,从根结点A到结点H的路径,就是A、B、D、H。

1.2 什么是路径长度?

在一棵树中,从一个结点到另一个结点所经过的“边”的数量,成为两个结点之间的路径长度。
 
比如从根结点A到叶子结点H,共经历了3个边,因此路径长度为3。

1.3 什么是结点的带权路径长度?

树的每一个结点,都可以拥有自己的权重(Weight),权重在不同算法中起到不同的作用。结点的带权路径长度指的是该结点的路径和权重的乘积。
 
比如,结点G的带权路径长度为:2×8=16。

1.4 什么是树的带权路径长度?

在一棵树中,所有叶子结点(强调:是叶子节点)的带权路径长度之和,称为树的带权路径长度,英文缩写为WPL。
 
 
比如上面这棵树的带权路径长度WPL=3×3+3×6+2×1+2×4+2×8=53。

2、哈夫曼树

2.1 概念

现在再听一遍哈夫曼树的概念:给定n个权值作为n个叶子结点,构造一棵二叉树,若该树的带权路径长度(WPL)达到最小,称这样的二叉树为最优二叉树,也成为哈夫曼树(Huffman Tree)。这下懵逼程度已经减少了50%。下面用通俗的话来解释什么是哈夫曼树:
假设存在6个结点,这6个结点的权重从小到大排列分别为{1,3,4,6,8}。以这6个结点作为叶子结点的二叉树有无数个,比如下面随便凑两个:
 
树A和树B的叶子结点都是这6个结点的组合。那这跟哈夫曼树有什么关系呢?别急,我们先计算一下树A和树B的带权路径长度,计算可得树A的WPL为46,树B的WPL为53。数学验证这6个数字组成的二叉树最小WPL就是46,因此,树A就是哈夫曼树。
现在我们再来听一遍哈夫曼树的概念:给定n个权值作为n个叶子结点(例子里面的6个数字作为6个叶子结点),构造一棵二叉树,若该树的带权路径长度(WPL)达到最小(例子里的树A),称这样的二叉树为最优二叉树,也成为哈夫曼树(Huffman Tree)。这下懵逼程度已经减少到0了。
强调:一组结点构成的哈夫曼树可不止一棵,比如例子里的这6个结点,我改成一下三种树:
 
这三棵树都是这6个结点对应的哈夫曼树,因为WPL值相同且都是最小,但明显不是同一棵树。

2.2 图解构造哈夫曼树

构造哈夫曼树的过程很简单,小学生都看得懂。
比如有一个结点数组arr = {2,7,18,3,9,25},把每一个数字看成结点的权重。
第一步,根据权重大小从小到大排序{2,3,7,9,18,25}
 
第二步:,构建森林,把每一个叶子结点都当成一棵只有根结点的树,于是形成一个森林:
 
上图左边是辅助队列,按照权重大小存储,右边是叶子节点的森林。
第三步:借助辅助队列,找出最小权重的两个结点,明显就是辅助队列的前面两个,生成父结点,父节点的权重是这两个结点权重之和:
 
第四步:删除上一步选择的两个最小结点,把新的父结点加入到辅助队列中,并对辅助排列再次进行排列,以保证辅助队列是从小到大的:
 
循环操作第三步、第四步,直到辅助队列只剩下一个结点。
 
此时,辅助队列只有一个结点,说明整个森林已经合并成一棵树,而这棵树就是这以{2,7,18,3,9,25}为权重的6个结点所对应的哈夫曼树。对于这些中间生成的结点,是没有什么作用的,我们做这么多计算,只是为了获得路径:
 
反思:其实整个过程是计算{2,7,18,3,9,25}的最小WPL,本质就是计算每个数字的乘积因子。

2.3 代码实现

结点类:

package cn.klb.datastructures.tree;

/**
 * @author DDKK.COM 弟弟快看,程序员编程资料站
 * @Description: 二叉树结点类
 * @Date: Create in 2023/4/10 16:06
 * @Modified By:
 */
public class Node implements Comparable<Node> {
   
     
    public int id;  
    public String data;
    public Node left;
    public Node right;

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

    public Node(int id, String data) {
   
     
        this.id = id;
        this.data = data;
    }

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

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

哈夫曼树类:

package cn.klb.datastructures.tree;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * @author DDKK.COM 弟弟快看,程序员编程资料站
 * @Description: 哈夫曼树
 * @Date: Create in 2023/4/15 15:36
 * @Modified By:
 */
public class HuffmanTree {
   
     

    private List<Node> nodes;

    public HuffmanTree(List<Node> nodes){
   
     
        this.nodes = nodes;
    }

    /**
     * 生成哈夫曼树
     *
     */
    public void generate() {
   
     
        // 当nodes剩下一个结点时,说明生成完毕
        while (nodes.size() > 1) {
   
     
            // 对结点列表进行升序排列
            Collections.sort(nodes);

            // 取出id最小的前两个结点(把结点看成没有子结点的二叉树)
            Node left = nodes.get(0);
            Node right = nodes.get(1);

            // 把取出的两个结点生成新的二叉树
            Node parent = new Node(left.id + right.id);
            parent.left = left;
            parent.right = right;

            // 删除这处理完的这两个结点
            nodes.remove(left);
            nodes.remove(right);

            // 把新的二叉树添加回去
            nodes.add(parent);
        }
    }

3、哈夫曼编码

3.1 背景

上面讲了一堆概念来介绍哈夫曼树,那么,哈夫曼树有什么作用呢?一个牛逼的作用就是哈夫曼编码。
比如有一句字符串:“i like like like java do you like a java”,总共40个字符(包括空格),那么,转成ASCII编码就是:[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],统计这个字节数组,其实总共有12种字节,用json来表示就是{32:9,97:5,100:1,101:4,117:1,118:2,105:5,121:1,106:2,107:4,108:4,111:2}。冒号前面表示某一种字节,冒号后面表示重复次数。如:32:9表示字节32出现了9次(ASCII的32表示空格,数一下字符串果然有9个空格)。
统计这个有什么用呢?仔细观察,如果我把32看成结点,把9看成这个结点的权。是不是就可以构造一个哈夫曼树了?然而这又有什么用呢?回顾刚才那句字符串,总共40个字节,如果传输的话,就传输40个字节。而计算机低层传输的就是0和1,那么总共传输40×8=320个二进制。
哈夫曼编码就是一种缩减每一个字符所占用的二进制位数,把重复频率高的字符用最少的二进制位表示。听到这里,是不是跟哈夫曼树联系上了?哈夫曼编码就是一种无损压缩编码。

3.2 原理

就用字符串"i like like like java do you like a java"来举例,如果不压缩,总共320个二进制位。那如何进行压缩呢?
首先统计每个字符出现的频率{32:9,97:5,100:1,101:4,117:1,118:2,105:5,121:1,106:2,107:4,108:4,111:2},这里总共12种字符,对应12种字节。我们把它看成12个结点,重复次数看成结点的权。然后对这12个结点构造出哈夫曼树,左路为0,右路为1,最后统计每一个叶子结点的路径,得到编码。
假设有一个哈夫曼树如下:
 
那么,A的编码为“1”,B的编码为“01”,C的编码为“00”。
根据这个原理,这12个结点生成的编码就为:{32=01, 97=100, 100=11000, 117=11001, 101=1110, 118=11011, 105=101, 121=11010, 106=0010, 107=1111, 108=000, 111=0011}
原来的字符串对应的二进制总共320位,经过哈夫曼编码后,变成1010100010111111110010001011111111001000101111111100100101001101110001110000011011101000111100101000101111111100110001001010011011100,共133个二进制位,压缩率为(320-133)/320=58%。
解码的时候只需要根据编码表进行解码,即可恢复原样,无损解压。

3.1 代码实现

3.1.1 构造结点类

package cn.klb.datastructures.huffman;

/**
 * @author DDKK.COM 弟弟快看,程序员编程资料站
 * @Description: 二叉树结点类
 * @Date: Create in 2023/4/10 16:06
 * @Modified By:
 */
public class Node implements Comparable<Node> {
   
     
    public int count;
    public Byte data;
    public Node left;
    public Node right;

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

    public Node(Byte data,int count) {
   
     
        this.count = count;
        this.data = data;
    }

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

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

3.1.2 实现哈夫曼编码/解码的类

package cn.klb.datastructures.huffman;

/**
 * @author DDKK.COM 弟弟快看,程序员编程资料站
 * @Description: 实现哈夫曼编码
 * @Date: Create in 2023/4/16 16:35
 * @Modified By:
 */

import java.util.*;

public class Huffman {
   
     

    // 哈夫曼编码表
    // 在 generateCodingSchedule 方法中实例化
    private Map<Byte, String> encodeSchedule = new HashMap<Byte, String>();

    public Map<Byte, String> getEncodeSchedule() {
   
     
        return encodeSchedule;
    }

    /**
     * 对哈夫曼编码后的数组进行解码,返回解码后的字节数组
     *
     * @param target
     * @return
     */
    public byte[] unzip(byte[] target) {
   
     
        StringBuilder targetStringBuilder = new StringBuilder();
        // 遍历解压前的字节数组,把每个字节对应的二进制字符串拼接到 targetStringBuilder 中
        for (int i = 0; i < target.length; i++) {
   
     
            boolean isLast = (i == target.length - 1);// 是不是最后一个字节
            // 如果是最后一个字节,那么就不需要把最后一个字节高位的0补充完整
            targetStringBuilder.append(byteToBitString(!isLast, target[i]));
        }

        // 获取解码表
        Map<String, Byte> decodeSchedule = getDecodeSchedule();

        // 存放targetStringBuilder截取后的字节
        List<Byte> bytesList = new ArrayList<Byte>();
        int count; //  遍历targetStringBuilder的所有字符的计数器
        Byte b = null; // 临时存放匹配到的字节
        boolean notMached = true;    // 是否从targetStringBuilder中扫描到了可以匹配的二进制字符串
        // 遍历targetStringBuilder所有可能长度的子字符串
        for (int i = 0; i < targetStringBuilder.length(); i += count) {
   
     
            count = 1;
            notMached = true;
            b = null;
            while (notMached) {
   
     
                // key 会从1开始递增来扫描
                String key = targetStringBuilder.substring(i, i + count);
                b = decodeSchedule.get(key);    // 看这个 key 可不可以解码
                if (b == null) {
   
       // 解码表没有对应可解码
                    count++;    // 加长截取长度,然后再看一次能不能解码
                } else {
   
     
                    notMached = false;  // 匹配到了,可以解码了
                }
            }
            bytesList.add(b);
        }

        // 把list转成byte
        byte[] source = new byte[bytesList.size()];
        for (int i = 0; i < source.length; i++) {
   
     
            source[i] = bytesList.get(i);
        }

        return source;
    }

    /**
     * 获取解码表
     *
     * @return
     */
    public Map<String, Byte> getDecodeSchedule() {
   
     
        Map<String, Byte> decodeSchedule = new HashMap<String, Byte>();
        for (Map.Entry<Byte, String> entry : encodeSchedule.entrySet()) {
   
     
            decodeSchedule.put(entry.getValue(), entry.getKey());
        }
        return decodeSchedule;
    }

    /**
     * 对传进来的源字节数组进行哈夫曼编码,返回编码后的字节数组
     *
     * @param source
     * @return
     */
    public byte[] zip(byte[] source) {
   
     
        // 1.根据源字节数组生成 nodes
        List<Node> nodes = createNodes(source);

        // 2.nodes生成哈夫曼树
        generate(nodes);

        // 3.生成哈夫曼树对应的编码表
        generateEncodeSchedule(nodes);

        // 4.对源字节数组进行编码
        byte[] target = encoding(source, encodeSchedule);

        return target;
    }

    /**
     * 根据字节数组生成结点序列 nodes,其中 其中每一个node的data表示字节,count表示字节重复的次数
     * 比如字符串为:“I love my country”
     * 则,其中一个 node为:Node{count=2,data=121} 121的 ascii 对应 y
     *
     * @param bytes
     * @return nodes
     */
    private List<Node> createNodes(byte[] bytes) {
   
     
        List<Node> nodes = new ArrayList<Node>();

        // 用于临时统计
        // Byte表示字节
        // Integer 表示这个字节重复的次数
        Map<Byte, Integer> map = new HashMap<Byte, Integer>();

        // 遍历字节数组
        for (byte b : bytes) {
   
     
            Integer count = map.get(b); // 获取字节b对应的重复次数
            if (count == null) {
   
       // 如果字节b第一次出现,则现在新加入字节b
                map.put(b, 1);
            } else {
   
         // 字节 b不是第一次出现,说明又重复了一次
                map.put(b, count + 1);
            }
        }

        // 根据统计好的 map 生成 nodes
        for (Map.Entry<Byte, Integer> entry : map.entrySet()) {
   
     
            nodes.add(new Node(entry.getKey(), entry.getValue()));
        }
        return nodes;
    }

    /**
     * 调整nodes为哈夫曼树
     *
     * @param nodes
     * @return
     */
    private void generate(List<Node> nodes) {
   
     
        // 当nodes剩下一个结点时,说明生成完毕
        while (nodes.size() > 1) {
   
     
            // 先对结点列表进行升序排列
            Collections.sort(nodes);

            // 取出id最小的前两个结点(把结点看成没有子结点的二叉树)
            Node left = nodes.get(0);
            Node right = nodes.get(1);

            // 把取出的两个结点生成新的二叉树
            Node parent = new Node(left.count + right.count);
            parent.left = left;
            parent.right = right;

            // 删除这处理完的这两个结点
            nodes.remove(left);
            nodes.remove(right);

            // 把新的二叉树添加回去
            nodes.add(parent);
        }
    }

    /**
     * 获取哈夫曼树对应的编码表
     */
    private void generateEncodeSchedule(List<Node> nodes) {
   
     
        if (nodes.size() == 1) {
   
      // size == 1 才有可能是哈夫曼树
            if (encodeSchedule.size() == 0) {
   
      // 如果编码表键值对数量为0,说明没有编码过,执行编码
                // 临时存放叶子节点的路径
                StringBuilder accumulativeTag = new StringBuilder();
                // 处理根结点的左子树
                coding(nodes.get(0).left, '0', accumulativeTag, encodeSchedule);
                // 处理根结点的右子树
                coding(nodes.get(0).right, '1', accumulativeTag, encodeSchedule);
            }
        }
    }

    /**
     * 生成编码表
     *
     * @param node            准备处理的结点
     * @param tag             如果这个结点是其父节点的左结点,则为0,反之为1
     * @param accumulativeTag 走到这个结点所经历 tag 的累积拼接
     */
    private void coding(Node node, char tag, StringBuilder accumulativeTag, Map<Byte, String> codingSchedule) {
   
     
        StringBuilder path = new StringBuilder(accumulativeTag);
        path.append(tag);
        if (node != null) {
   
        // node不为空才处理
            if (node.data == null) {
   
       // data == null 说明该结点不是叶子结点
                // 向左递归
                coding(node.left, '0', path, codingSchedule);
                // 向右递归
                coding(node.right, '1', path, codingSchedule);
            } else {
   
       // data != null,说明这个node是叶子结点,可以收尾了
                codingSchedule.put(node.data, path.toString());
            }
        }
    }

    /**
     * 根据编码表对字节数组进行编码,返回编码后的字节数组
     *
     * @param source
     * @param codingSchedule
     * @return
     */
    private byte[] encoding(byte[] source, Map<Byte, String> codingSchedule) {
   
     
        StringBuilder targetStringBuilder = new StringBuilder();
        // 对待编码字节数组进行编码,编码后的二进制拼接成字符串
        for (byte b : source) {
   
     
            targetStringBuilder.append(codingSchedule.get(b));  // 对编码后的0101这些二进制转成字符串形式,方便后面截取
        }

        // 后面要把targetStringBuilder对应的字符串形式进行截取,每8个二进制装进一个byte中
        // 如果targetStringBuilder长度为12,那么len就为 (12+7)/8=2
        int len = (targetStringBuilder.length() + 7) / 8;

        byte[] targetBytes = new byte[len];
        int index = 0;
        // 把拼接好的字符串以8位为单位进行截取,把截取到的8位看成是一个字节
        String targetString;
        for (int i = 0; i < targetStringBuilder.length(); i += 8) {
   
     
            if (targetStringBuilder.length() < i + 8) {
   
        // 不够8位
                targetString = targetStringBuilder.substring(i);    // 截取剩余的所有
            } else {
   
     
                targetString = targetStringBuilder.substring(i, i + 8); // 截取8个
            }
            // 把strByte转成一个byte,放到encodedBytes中
            // 如果targetStringBuilder不是8的倍数,最后剩下如 0101四位,调用parseInt会把它当成 0000 0101
            // parseInt("1100110", 2) returns 102,而102的补码为 01100110,前面多了一个0,所以最后一个字节在解码的时候要特别小心
            targetBytes[index++] = (byte) Integer.parseInt(targetString, 2);
        }
        return targetBytes;
    }

    /**
     * 0xff默认为整形,二进制位最低8位是1111 1111,前面24位都是0;所以和0xff进行&运算后会变为int
     * toBinaryString方法有个毛病,就是二进制如果最高位为0,转为字符串时会被省略
     * 比如:00000000 00000000 00000000 10011101,调用toBinaryString方法后获得的字符串为 10011101
     * <p>
     * 如果 b = -88,根据计算机组成原理,-88 的原码为 1101 1000,反码为 1010 0111,补码为 1010 1000
     * 计算机保存数字保存的都是补码,所以 -88 计算机保存的其实是它的补码,为 1010 1000
     * 如果要杠,说你看到的就是原码,那你看到的其实是正数的补码,正数的原码反码和补码都是一样的
     * <p>
     * b & 0xFF 使得字节类型转为int类型,加 0x100 是为了兼容正数(负数加了也没影响,因为会截取掉)
     * 比如:b = 88,那么补码就是 0101 1000(正数的原码、反码、补码都一样)
     * 执行 b & 0xFF 后变成了 00000000 00000000 00000000 01011000
     * 上面说了 toBinaryString 会把前面的0全给省略了,所以执行 toBinaryString(b & 0xFF)会得到字符串 “1011000”
     * 但我们要的是 01011000,所以 b & 0xFF 加上 0x100后,会变成 00000000 00000000 00000001 01011000
     * 执行toBinaryString方法后就得到 “101011000”,然后再截取第一位后面的所有,得到 “01011000”
     *
     * @param flag 是否要一个完整的 8位二进制字节
     * @param b
     * @return
     */
    private String byteToBitString(boolean flag, byte b) {
   
     
        if (flag) {
   
     
            return Integer.toBinaryString((b & 0xFF) + 0x100).substring(1);
        } else {
   
     
            return Integer.toBinaryString((b & 0xFF));
        }
    }
}

3.1.3 测试类

    @Test
    public void testEncode() {
   
     
        String content = "i like like like java do you like a java";
        byte[] source = content.getBytes();
        Huffman huffman = new Huffman();
        byte[] target = huffman.zip(source);
        System.out.println("编码前:" + Arrays.toString(source));
        System.out.println("编码后:" + Arrays.toString(target));

        System.out.println("编码表:"+huffman.getEncodeSchedule());
        System.out.println("解码表:"+huffman.getDecodeSchedule());

        byte[] source1 = huffman.unzip(target);
        System.out.println("解码后:"+Arrays.toString(source1));
    }

注意:代码中private byte[] encoding(byte[] source, Map<Byte, String> codingSchedule)方法存在一个bug,当最后剩下的二进制位的从左到右第一个是0时,就会出问题。因时间关系,加上和哈夫曼编码知识点无关,有空再回来处理。