[数据结构与算法-16]哈夫曼树和哈夫曼编码

一、先掌握几个概念

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

1.1 什么是路径?

  在一棵树中,从一个结点到另外一个结点所通过的全部结点,成为两个结点之间的路径。
在这里插入图片描述
  好比上图这颗二叉树,从根结点A到结点H的路径,就是A、B、D、H。node

1.2 什么是路径长度?

  在一棵树中,从一个结点到另外一个结点所通过的“边”的数量,成为两个结点之间的路径长度。
在这里插入图片描述
  好比从根结点A到叶子结点H,共经历了3个边,所以路径长度为3。web

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

  树的每个结点,均可以拥有本身的权重(Weight),权重在不一样算法中起到不一样的做用。结点的带权路径长度指的是该结点的路径和权重的乘积。
在这里插入图片描述
  好比,结点G的带权路径长度为:2×8=16。算法

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

  在一棵树中,全部叶子结点(强调:是叶子节点)的带权路径长度之和,称为树的带权路径长度,英文缩写为WPL。
在这里插入图片描述
在这里插入图片描述
  好比上面这棵树的带权路径长度WPL=3×3+3×6+2×1+2×4+2×8=53。json

二、哈夫曼树

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,本质就是计算每一个数字的乘积因子。app

2.3 代码实现

  结点类:ide

package cn.klb.datastructures.tree;

/** * @Author: Konglibin * @Description: 二叉树结点类 * @Date: Create in 2020/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;
    }
}

  哈夫曼树类:svg

package cn.klb.datastructures.tree;


import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/** * @Author: Konglibin * @Description: 哈夫曼树 * @Date: Create in 2020/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.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: Konglibin * @Description: 二叉树结点类 * @Date: Create in 2020/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: Konglibin * @Description: 实现哈夫曼编码 * @Date: Create in 2020/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时,就会出问题。因时间关系,加上和哈夫曼编码知识点无关,有空再回来处理。