【Java数据结构与算法】第十二章 哈夫曼树和哈夫曼编码

第十二章 哈夫曼树和哈夫曼编码

一、哈夫曼树

给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为最优二叉树,也称为哈夫曼树(Huffman Tree)。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近

1.基本术语

WPL 最小的二叉树是赫夫曼树

  • 路径和路径长度:
    在一棵树中,从上一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为 1,则从根结点到第 L 层结点的路径长度为 L - 1
  • 结点的权及带权路径长度:
    若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积
  • 树的带权路径长度:
    树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为 WPL(Weight Path Length)

2.构建思路

假设有 n 个权值,则构造出的哈夫曼树有 n 个叶子结点。n 个权值分别设为 w1、w2、w3、…、wn

  1. 将 w1、w2、w3、…、wn 看成是有 n 棵树的森林(每棵树仅有一个结点)
  2. 在森林中选出根结点的权值最小的两棵树进行合并,作为一棵新树的左、右子树,且新树的根结点权值为其左右子树根结点的权值之和
  3. 从森林中删除选取的两棵树,并将新树加入森林
  4. 重复 2 和 3,直到森林中只剩一棵树为止,该树即为所求的哈夫曼树

以 {5,6,7,8,15} 为例

  1. 创建森林,森林包括 5 棵树,这 5 棵树的权值分别是5,6,7,8,15
  2. 在森林中,选择根结点权值最小的两棵树(5 和 6)进行合并,将它们作为一棵新树的左右子结点,新树的根结点的权值为 11,将 5 和 6 从森林删除,添加新树 11
  3. 在森林中,选择根结点权值最小的两棵树(7 和 8)进行合并,将它们作为一棵新树的左右子结点,新树的根结点的权值为 15,将 7 和 8 从森林删除,添加新树 15
  4. 在森林中,选择根结点权值最小的两棵树(11 和 15)进行合并,将它们作为一棵新树的左右子结点,新树的根结点的权值为 26,将 11 和 15 从森林删除,添加新树 26
  5. 在森林中,选择根结点权值最小的两棵树(15 和 26)进行合并,将它们作为一棵新树的左右子结点,新树的根结点的权值为 41,将 15 和 26 从森林删除,添加新树 41

此时森林中只剩一棵树 41,该树即为所求的哈夫曼树

3.代码实现

package com.sisyphus.huffmantree;

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

/**
 * @Description: 哈夫曼树$
 * @Param: $
 * @return: $
 * @Author: Sisyphus
 * @Date: 7/24$
 */
public class HuffmanTree {
    public static void main(String[] args) {
        int arr[] = {13,7,8,3,29,6,1};
        createHuffmanTree(arr);
    }

    //创建哈夫曼树的方法
    public static Node createHuffmanTree(int[] arr){
        //第一步为了操作方便
        //1.遍历 arr 数组
        //2.将 arr 的每个元素构成 Node
        //3.将 Node 放入到 ArrayList 中
        ArrayList<Node> nodes = new ArrayList<>();
        for(int value : arr){
            nodes.add(new Node(value));
        }

        while(nodes.size() > 1){
            //排序,Nodes实现了 Comparable 接口
            Collections.sort(nodes);

            System.out.println("nodes = " + nodes);

            //取出根结点权值最小的两棵二叉树
            //(1)取出权值最小的结点(二叉树)
            Node leftNode = nodes.get(0);
            //(2)取出权值第二小的结点(二叉树)
            Node rightNode = nodes.get(1);

            //(3)构建一棵新的二叉树
            Node parent = new Node(leftNode.value + rightNode.value);
            parent.left = leftNode;
            parent.right = rightNode;

            //(4)从数组中删除处理过的二叉树
            nodes.remove(leftNode);
            nodes.remove(rightNode);

            //(5)将 parent 加入 nodes
            nodes.add(parent);
        }
        System.out.println("nodes = " + nodes);
        //返回哈夫曼树的根结点
        return nodes.get(0);
    }
}

//创建结点类
//为了让 Node 对象支持排序,让 Node 实现 Comparable 接口
class Node implements Comparable<Node>{
    int value;  //结点权值
    Node left;  //指向左子结点
    Node right; //指向右子结点

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

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


    @Override
    public int compareTo(Node o) {
        //表示从小到大排序
        return this.value - o.value;
    }
}

在这里插入图片描述

三、哈夫曼编码

1.引入

从狭义上来讲,把人类能看懂的各种信息,转换成计算机能够识别的二进制形式,被称为编码

编码的方式可以有很多种,我们大家最熟悉的编码方式就属 ASCII 码

在ASCII码当中,把每一个字符表示成特定的8位二进制数,比如:
在这里插入图片描述

显然,ASCII码是一种等长编码,也就是任何字符的编码长度都相等

等长编码的优点很明显,因为每个字符对应的二级制编码长度相等,所以很容易设计,也很方便读写。但是计算机的存储空间以及网络传输的带宽是有限的,等长编码最大的缺点就是编码结果太长,会占用过多资源

假如一段信息当中,只有 A,B,C,D,E,F 这6个字符,如果使用不定长编码,比如:
在这里插入图片描述

如此一来,给定的信息 “ABEFCDAED”,就可以编码成二进制的 “0 00 10 11 01 1 0 10 1”,编码的总长度只有 14

但是这样的编码设计会带来歧义,A 的编码是 0,B 的编码是 00,那么二进制 000 既可能是 AB,又可能是 BA,还可能是 AAA。因此,不定长编码是不能随意设计的,如果一个字符的编码恰好是另一个字符编码的前缀,就会产生歧义

哈夫曼编码也是不定长编码,并且哈夫曼编码可以保证编码不存在二义性

2.介绍

哈夫曼编码(Huffman Coding)实现了两个重要目标:

  1. 任何一个字符编码都不是其他字符编码的前缀
  2. 信息编码的总长度最小

哈夫曼编码并非一套固定的编码,而是根据给定信息中各个字符出现的频次,动态生成最优的编码

使用需要传送的字符构造字符集C = {c1, c2, … cn},并根据字符出现的频率构建概率集W = {w1, w2, … wn}。哈夫曼编码的流程如下:

  • 将字符集 C 作为叶子结点
  • 将频率集 W 作为叶子结点的权值
  • 使用 C 和 W 构造哈夫曼树
  • 哈夫曼树的每一个结点包括左、右两个分支,二进制的每一位有 0、1 两种状态,我们可以把这两者对应起来,结点的左分支当做 0,结点的右分支当做 1

在这里插入图片描述

哈夫曼树的根结点到每一个叶子结点的路径就是一段二进制编码
在这里插入图片描述

上述过程借助哈夫曼树所生成的二进制编码,就是哈夫曼编码

需要注意,哈夫曼树根据排序方法不同,对应的哈夫曼编码也不完全一样,但是 WPL 一定是一样的

这样生成的编码有没有前缀问题带来的歧义呢?

因为每一个字符对应的都是哈夫曼树的叶子结点,从根结点到这些叶子结点的路径并没有包含关系,最终得到的二进制编码自然也不会是彼此的前缀

这样生成的编码能保证总长度最小吗?

哈夫曼树的重要特性,就是所有叶子结点的(权重 X 路径长度)之和最小

放在信息编码的场景下,叶子结点的权重对应字符出现的频次,结点的路径长度对应字符的编码长度

所有字符的(频次 X 编码长度)之和最小,自然就说明总的编码长度最小

3.代码实现哈夫曼编码综合案例

功能如下:

  • 生成字符串对应的哈夫曼编码
  • 对字符串压缩
  • 解压压缩后的字符串
  • 压缩文件
  • 解压文件
package com.sisyphus.huffmancode;

import java.io.*;
import java.util.*;

/**
 * @Description: 哈夫曼编码$
 * @Param: $
 * @return: $
 * @Author: Sisyphus
 * @Date: 7/24$
 */
public class HuffmanCode {
    public static void main(String[] args) {
        //测试压缩字符串
        String str = "The relationship between Java and JavaScript is like Zhou Yang and Zhou Yangqing.Neither of them has any similarities";
        //获取原始字符串的字节数组
        byte[] contentBytes = str.getBytes();
        System.out.println("压缩前的长度为:" + contentBytes.length);

        byte[] huffmanCodesBytes = huffmanzip(contentBytes);
        System.out.println("压缩后的结果为:" + Arrays.toString(huffmanCodesBytes));
        System.out.println("压缩后的长度为:" + huffmanCodesBytes.length);

        byte[] sourceBytes = decode(huffmanCodes, huffmanCodesBytes);
        System.out.println("原来的字符串:" + new String(sourceBytes));

        //测试压缩文件
        String srcFile = "C:\\Users\\admin\\Desktop\\src.png";
        String dstFile = "C:\\Users\\admin\\Desktop\\dst.zip";
        zipFile(srcFile,dstFile);
        File zip = new File("C:\\Users\\admin\\Desktop\\dst.zip");
        if (zip.exists()){
            System.out.println("文件压缩成功!");
        }

        //测试解压文件
        String zipFile = "C:\\Users\\admin\\Desktop\\dst.zip";
        String dstFile1 = "C:\\Users\\admin\\Desktop\\src1.png";
        unZip(zipFile,dstFile1);
        File src1 = new File("C:\\Users\\admin\\Desktop\\src1.png");
        if (src1.exists()){
            System.out.println("文件解压成功!");
        }
    }

    //编写一个方法,完成对压缩文件的解压
    /**
     *
     * @param zipFile   准备解压的文件
     * @param dstFile   将文件解压到哪个路径
     */
    public static void unZip(String zipFile,String dstFile){
        //定义文件输入流
        InputStream is = null;
        //定义一个对象输入流
        ObjectInputStream ois = null;
        //定义文件输出流
        OutputStream os = null;
        try {
            //创建文件输入流
            is = new FileInputStream(zipFile);
            //创建一个和 is 关联的对象输入流
            ois = new ObjectInputStream(is);
            //读取 byte 数组 huffmanBytes
            byte[] huffmanBytes = (byte[])ois.readObject();
            //读取哈夫曼编码表
            Map<Byte,String> huffmanCodes = (Map<Byte, String>)ois.readObject();

            //解码
            byte[] bytes = decode(huffmanCodes,huffmanBytes);
            //将 bytes 数组写入到目标文件
            os = new FileOutputStream(dstFile);
            //写数据到 dstFile
            os.write(bytes);
        } catch (IOException | ClassNotFoundException e) {
            e.printStackTrace();
        }finally {
            try {
                os.close();
                ois.close();
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    //编写方法,将一个文件进行压缩
    /**
     *
     * @param srcFile   你传入的希望压缩的文件的全路径
     * @param dstFile   我们压缩后将压缩文件放到哪个目录
     */
    public static void zipFile(String srcFile,String dstFile){
        //创建输出流
        OutputStream os = null;
        ObjectOutputStream oos = null;
        //创建文件的输入流
        FileInputStream is = null;
        try {
            is = new FileInputStream(srcFile);
            //创建一个和源文件大小一样的 byte[]
            byte[] b = new byte[is.available()];
            //读取文件
            is.read(b);
            //直接堆源文件压缩
            byte[] huffmanBytes = huffmanzip(b);
            //创建文件的输出流,存放压缩文件
            os = new FileOutputStream(dstFile);
            //创建一个和文件输出流关联的 ObjectOutputStream
            oos = new ObjectOutputStream(os);
            //把哈夫曼编码后的字节数组写入压缩文件
            oos.writeObject(huffmanBytes);//先把
            //这里我们以对象流的方式写入哈夫曼编码,是为了以后我们解压的时候恢复源文件使用
            oos.writeObject(huffmanCodes);
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            try {
                oos.close();
                os.close();
                is.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    //完成数据的解压
    //思路
    //1.先转成哈夫曼编码对应的二进制字符串
    //2.对照哈夫曼编码转换为字符串

    /**
     * 将一个 byte 转成一个二进制的字符串
     * @param flag  如果是 true 则需要补高位,如果是 false 则不补
     * @param b     传入的 byte
     * @return      是该 b 对应的二进制的字符串,(注意是按补码返回的)
     */
    private static String byteToBitString(boolean flag,byte b){
        //使用变量保存 b
        int temp = b;   //将 b 转成 int
        //如果是正数,我们还存在补高位的问题
        if (flag) {
            temp |= 256;    //按位或 256(1 0000 0000) | 1(0000 0001) => 1 0000 0001
        }
        String str = Integer.toBinaryString(temp);  //返回的是 temp 对应的二进制的补码
        if (flag) {
            return str.substring(str.length() - 8);
        }else{
            return str;
        }
    }

    //编写一个方法,完成对压缩数据的解码
    /**
     *
     * @param huffmanCodes  哈夫曼编码 map
     * @param huffmanBytes  哈夫曼编码得到的字节数组
     * @return              就是原来的字符串对应的数组
     */
    private static byte[] decode(Map<Byte,String> huffmanCodes,byte[] huffmanBytes){
        //1.先得到 huffmanBytes 对应的二进制的字符串
        StringBuilder stringBuilder = new StringBuilder();
        //将 byte 数组转成二进制的字符串
        for (int i = 0; i < huffmanBytes.length; i++) {
            byte b = huffmanBytes[i];
            //判断是不是最后一个字节
            boolean flag = (i == huffmanBytes.length - 1);
            stringBuilder.append(byteToBitString(!flag,b));
        }
        //把字符串按照指定的哈夫曼编码进行解码
        //把哈夫曼编码进行调换,因为需要反向查询
        Map<String,Byte> map = new HashMap<>();
        for (Map.Entry<Byte,String> entry : huffmanCodes.entrySet()) {
            map.put(entry.getValue(),entry.getKey());
        }
        //创建一个集合,存放 byte
        ArrayList<Byte> list = new ArrayList<>();
        //i 可以理解成就是索引,扫描 stringBuilder
        for (int i = 0; i < stringBuilder.length();) {
            int count = 1;  //小的计数器
            boolean flag = true;
            Byte b = null;

            while (flag) {
                //递增地取出字节数组中的 ’1‘ 或者 ’0‘
                String key = stringBuilder.substring(i,i+count);//i 不动,让 count 移动,指定匹配到一个字符
                b = map.get(key);
                if (b == null){ //说明没有匹配到
                    count++;
                }else{
                    //匹配到了
                    flag = false;
                }
            }
            list.add(b);
            i += count; //i 直接移动到 count,左闭右开
        }
        //for 循环结束后 list 就存放了所有字符
        //把 list 中的数据放入到 byte[] 并返回
        byte b[] = new byte[list.size()];
        for (int i = 0; i < b.length; i++) {
            b[i] = list.get(i);
        }
        return b;
    }

    //使用一个方法,将所有的方法封装起来,便于我们调用
    /**
     *
     * @param bytes 原始的字符串对应的字节数组
     * @return      经过哈夫曼编码处理后的字节数组(压缩后的数组)
     */
    private static byte[] huffmanzip(byte[] bytes){
        List<Node> nodes = getNodes(bytes);
        //创建哈夫曼树
        Node huffmanTreeRoot = createHuffmanTree(nodes);
        //根据哈夫曼树创建对应的哈夫曼编码
        Map<Byte, String> huffmanCodes = getCodes(huffmanTreeRoot);
        //根据生成的哈夫曼编码亚索,得到压缩后的哈夫曼编码字节数组
        byte[] huffmanCodeBytes = zip(bytes,huffmanCodes);

        return huffmanCodeBytes;
    }

    //编写一个方法,将字符串对应的 byte[] 数组,通过生成的哈夫曼编码表,返回一个哈夫曼编码压缩后的 byte[]
    /**
     *
     * @param bytes         原始的字符串对应的 byte[]
     * @param huffmanCodes  生成的哈夫曼编码 map
     * @return              返回哈夫曼编码处理后的 byte[],即 8 位对应一个 byte,存放在 bute[] 数组中,需要注意的是 byte 存放的是二进制数的补码
     */
    private static byte[] zip(byte[] bytes,Map<Byte,String> huffmanCodes){
        //1.利用 huffmanCodes 将 bytes 转成哈夫曼编码对应的字符串
        StringBuilder stringBuilder = new StringBuilder();
        //遍历 bytes 数组
        for (byte b : bytes){
            stringBuilder.append(huffmanCodes.get(b));
        }

        //统计返回 bytep[] huffmanCodeBytes 长度
        //一句话搞定 int len = (stringBuilder.length() + 7) / 8;
        int len;
        if (stringBuilder.length() % 8 == 0){
            len = stringBuilder.length() / 8;
        }else{
            len = stringBuilder.length() / 8 + 1;
        }
        //创建存储压缩后的 byte 数组
        byte[] huffmanCodeBytes = new byte[len];
        int index = 0;//记录是第几个 bute
        for (int i = 0; i < stringBuilder.length(); i += 8) {   //因为是每 8 位对应一个 byte,所以步长 +8
            String strByte;
            if (i + 8 >stringBuilder.length()){ //不够 8 位
                strByte = stringBuilder.substring(i);
            }else {
                strByte = stringBuilder.substring(i,i + 8);
            }

            //将 strByte 转成一个 byte,放入到 huffmanCodeBytes
            huffmanCodeBytes[index] = (byte) Integer.parseInt(strByte,2);
            index++;
        }
        return huffmanCodeBytes;
    }

    //生成哈夫曼树对应的哈夫曼编码
    //思路:
    //1.将哈夫曼编码表存放在 Map<Byte,String>
    static Map<Byte,String> huffmanCodes = new HashMap<>();
    //2.在生成哈夫曼编码表时,需要去拼接路径,定义一个StringBuilder 存储某个叶子结点的路径
    static StringBuilder stringBuilder = new StringBuilder();

    //为了调用方便,我们重载 getCodes
    private static Map<Byte,String> getCodes(Node root){
        if (root == null){
            return null;
        }
        //处理 root 的左子树
        getCodes(root.left,"0",stringBuilder);
        //处理 root 的右子树
        getCodes(root.right,"1",stringBuilder);
        return huffmanCodes;
    }


    /**
     * 得到传入的 node 结点的所有叶子结点的哈夫曼编码,并放入到 huffmanCodes 集合
     * @param node  传入结点
     * @param code  路径:左子结点 0,右子结点 1
     * @param stringBuilder 用于拼接路径
     */
    private static void getCodes(Node node, String code, StringBuilder stringBuilder){
        StringBuilder stringBuilder2 = new StringBuilder(stringBuilder);
        //将 code 加入到 stringBuilder2
        stringBuilder2.append(code);
        if (node != null){  //如果 node === null 不处理
            //判断当前 node 是叶子结点还是非叶子节点
            if (node.data == null){ //非叶子节点
                //递归处理
                //向左递归
                getCodes(node.left,"0",stringBuilder2);
                //向右递归
                getCodes(node.right,"1",stringBuilder2);
            }else{  //说明是一个叶子结点
                //就表示找到某个叶子节点了
                huffmanCodes.put(node.data,stringBuilder2.toString());
            }
        }
    }

    //前序遍历的方法
    private static void preOrder(Node root){
        if (root != null){
            root.preOrder();
        }else{
            System.out.println("哈夫曼树为空,无法遍历");
        }
    }

    /**
     *
     * @param bytes 接收字节数组
     * @return      返回的是 List 形式
     */
    private static List<Node> getNodes(byte[] bytes){
        //1.创建一个 ArrayList
        ArrayList<Node> nodes = new ArrayList<Node>();

        //遍历 bytes,统计每一个 byte 出现的次数 -> map[key,value]
        HashMap<Byte,Integer> counts = new HashMap<>();
        for (byte b : bytes) {
            Integer count = counts.get(b);
            if (count == null){ ///Map 还没有这个字符数据,第一次加入
                counts.put(b,1);
            }else{
                counts.put(b,count + 1);
            }
        }
        
        //把每一个键值对转成一个 Node 对象,并加入到 nodes 集合
        //遍历 map
        for (Map.Entry<Byte, Integer> entry : counts.entrySet()) {
            nodes.add(new Node(entry.getKey(),entry.getValue()));
        }

        return nodes;
    }

    //通过 List 创建对应的哈夫曼树
    private static Node createHuffmanTree(List<Node> nodes){
        while(nodes.size() > 1){
            //排序,从小到大
            Collections.sort(nodes);
            //取出第一棵最小的二叉树
            Node leftNode = nodes.get(0);
            //取出第二棵最小的二叉树
            Node rightNode = nodes.get(1);
            //创建一棵新的二叉树,它的根结点没有 data,只有权值
            Node parent = new Node(null,leftNode.weight + rightNode.weight);
            parent.left = leftNode;
            parent.right = rightNode;

            //将已经处理的两棵二叉树从 nodes 删除
            nodes.remove(leftNode);
            nodes.remove(rightNode);

            //将新的二叉树加入到 nodes
            nodes.add(parent);
        }
        //最后的结点就是哈夫曼树的根结点
        return nodes.get(0);
    }
}

//创建 Node
class Node implements Comparable<Node>{
    Byte data;  //存放数据(字符)本身,比如 'a' => 97
    int weight; //权值,表示字符出现的次数
    Node left;
    Node right;

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

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

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

    //前序遍历
    public void preOrder(){
        System.out.println(this);
        if (this.left != null){
            this.left.preOrder();
        }
        if (this.right != null){
            this.right.preOrder();
        }
    }
}

在这里插入图片描述

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

313YPHU3

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值