浅析哈夫曼树和哈夫曼编码

在浅析哈夫曼树之前,先来了解几个关于树的概念

1、什么是路劲

在树中,从一个点到另一个点所经过的点被称为这两个点之间的路劲。

 上图中,从跟节点到叶子节点C的路劲就是A  B C。

2、什么是路劲的长度

表示的树或中从一个点到另一个点所经过的边的数量。如下图,从A - > C的路劲就是2

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

将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积。如下图。

 节点C的带权路劲长度就是 权重(3)*路劲(2)= 6

4、什么是树的带权路劲长度。

树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL。

接下来我们来分析什么是哈夫曼树。

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

知道了哈夫曼树(树的带权路劲长度最小的树)概念,接下来我们来看下哈夫曼树的构造方法。

假设我们有6个节点,依次编号为A B C D E F ,权重依次是 3  2  1  10  5  7

 那如何构建树,使得构建的树的带权路劲最小?

1、构建森林

我们把每一个节点都当成一颗树,树只有一个节点,这样就会形成一个森林,如下入所示

 2、构建了森林之后接下来如何处理

我们定义一个辅助队列,队列里面的元素按照权重从小到大的熟悉进行排列,如下图所示(注意,队列中的元素省去了元素名,只保留了权重)

 3、择当前权值最小的两个结点,生成新的父结点

借助辅助队列,我们可以找到权值最小的结点 1 和 2 ,并根据这两个结点生成一个新的父结点,父节点的权值是这两个结点权值之和,如下图所示

 4、从队列中移除第 3 选择的两个最小结点,把新的父节点加入队列

也就是从队列中删除 1 和 2,插入 第 3 步中生成的新的父节点 ,并且仍然保持队列的升序:

 5、接着重复 第 2 步,从队列中选择 权重最小的两个节点,生成新的父节点,如下图所示。

6、接着执行 从队列中移除第 5 选择的两个最小结点,把新的父节点加入队列

如下图所示

 7、接着重复执行步骤 3 和 4 ,得到如下截图

 8、再接着 重复 执行步骤 3 和 4,得到如下截图

9、再接着重复执行步骤 3 和 4,得到如下截图

此时,我们可以看到,经过上面的几步,森林中的所有节点已经全部连接为一棵树,树的跟节点就是 28 所代表的节点,此时,我们可以称该树为一颗哈夫曼树。

经过上面的分析,我们来看下代码实现。Node节点的定义如下

package com.Ycb.hfm;

public 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;
    }
}

哈夫曼树生成相关的代码,弄懂了逻辑之后结合代码看起来比较简单,如下所示。

package com.Ycb.hfm;

import java.util.PriorityQueue;

public class HufManCodeTree {
    private Node root;

    public HufManCodeTree() {
        root = null;
    }

    /**
     * 根据传入的字节数组生成相应的哈夫曼树
     *
     * @param weights 节点列表,值既表示节点名,又可代表权重
     */
    public void createHufManTree(int[] weights) {
        if (weights == null || weights.length == 0) return;
        //1、创建森林,同时定义辅助队列,此处使用优先队列辅助
        PriorityQueue<Node> pq = new PriorityQueue<>();
        for (int i : weights) {
            pq.add(new Node(i));
        }
        //当队列中的元素大于1时,即队列中至少有两个元素时执行如下操作
        while (pq.size() > 1) {
            //取出权重最小的两个节点(同时从队列中移除)
            Node leftNode = pq.poll();
            Node rightNode = pq.poll();

            //创建父节点(权重等于两个子节点权重之和)
            Node parentNode = new Node(leftNode.weight + rightNode.weight);
            parentNode.left = leftNode;
            parentNode.right = rightNode;

            //把新生成的父节点加入到队列中,因为优先级队列会进行自动调整,所以此处不需要排序.
            // 如果是自己实现辅助队列,此处记得需要调整队列为升序
            pq.add(parentNode);
        }
        
        //当队列中的元素只剩下一个时,这个就是生成的哈夫曼树的跟节点
        root = pq.poll();
    }
}

那这种哈夫曼树到底有什么用?

哈夫曼编码是一种高效的编码方式,我们知道,计算机不像人类一样,可以认识很多复杂的文字,复杂的图像,计算机唯一认识的就是0(低电平)和1(高电平),因此,我们在计算机上看到的一切文字或者图片、音频等等,都是以二进制的形式进行存储和传输的。

接下来我们来讲几种编码形式

定长编码

固定长度编码一种二进制信息的信道编码。这种编码是一次变换的输入信息位数固定不变。简称“定长编码”。这种编码的编译码电路比较简单,

编码是信息从一种形式或格式转换为另一种形式的过程也称为计算机编程语言的代码简称编码。用预先规定的方法将文字、数字或其它对象编成数码,或将信息、数据转换成规定的电脉冲信号。编码在电子计算机、电视、遥控和通讯等方面广泛使用。编码是信息从一种形式或格式转换为另一种形式的过程。解码,是编码的逆过程。(来源百度百科

如将字符串 ”i like like like java do you like a java“ 转换成二进制,统计得出转换后的二进制长度达到了320。如下图(网上有转换工具,也可以自己写代码

可以看到,根据定长编码,转换成的二进制字符串的长度达到了320位。定长编码的优缺点如下:

优点:设计和实现都比较简单,包括后续的解码过程也比较简单

缺点:计算机的存储空间和网络带宽是有限的,而且在很多软件中我们发送文件时也有文件大小的限制,普遍的做法就是弄一个压缩软件压缩下。。

变长编码,所谓的变长编码就是不用固定的位表示编码,可以用变长编码来对字符串进行编码。如二进制的 1 位可以表示两种状态(0和1),二进制的 2 位可以表示四种状态(即00、01、10 和 11),对于字符串 "i like like like java do you like a java",统计出字符串中涉及到的字符及其出现的次数

 我们可以看到涉及到字符 ' ','a','d','e','u','v','i'等等。那其实我们可以用 0 来表示空格,用 1 来表示 a,用 01 来表示 i,用0101等等来表示 d ,根据这样就可以对字符串进行编码了

但是现在解码时会遇到一个问题,当我们拿到,二进制串010011时,我们该怎么解码为对应的字符串?

当拿到第一个0时我们可以确定为空,第二个0也可以确认为空,但是拿到第三个0时我们就可以确定为空?但是继续往后拿到 0 1 我们可以解码为 i 也可以解码为 ‘ a’,所以这就存在二义性,经过这种不定长编码编码的串,无法唯一的解码。根本的原因是,字符 ‘ ’ (0)的编码是字符 i (01) 和 字符 d (0101)的编码的前缀,字符 i (01)是 字符 d (0101) 的前缀,对前缀编码的理解可以参照百度百科前缀编码。可以简单的理解为 字符的编码都不能是其他字符编码的前缀,符合该条件的编码称为前缀编码。

哈夫曼编码

这种编码实现两个重要的目标

1.任何一个字符编码,都不是其他字符编码的前缀。

2.信息编码的总长度最小

先以每个字符出现的次数作为权重,按照前面的哈夫曼树的生成方法生成哈夫曼树。生成了哈夫曼树之后,从根节点开始,向左边标记为0,向右,边标记为1,一直到叶子节点,这样就可以得到每个字符的哈夫曼编码了。如下图:

接下来一步步分析实现

先改造下节点的定义,如下所示

public class Node implements Comparable<Node> {
    //节点实际存储的数据
    Byte data;
    Node left;
    Node right;
    //权重,即节点出现的次数
    int weight;
    //节点的哈夫曼编码,主要是方便后续处理
    String hufCode;

    public Node(Byte data, Node left, Node right, int weight) {
        this.data = data;
        this.left = left;
        this.right = right;
        this.weight = weight;
        this.hufCode = "";
    }

    /**
     * 前序遍历
     */
    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{" +
                "data=" + data +
                ", weight=" + weight +
                ", hufCode='" + hufCode + '\'' +
                '}';
    }


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

定义生成哈夫曼树的方法:

生成哈夫曼树的方法比较简单,大概是以下几步

1、统计每个字节出现的次数
2、创建森林(即每个节点代表一棵树),同时创建辅助队列(此处是有优先级队列)
3、取两个权重最小的节点,创建父节点
4、移除两个权重最小的节点,加入新创建的父节点。
5、重复 步骤3 和 步骤4 直到队列中只存在一个节点,存在的这个节点就是哈夫曼树的跟。

private Node createTree(byte[] bytes) {
        //按照每个字节出现的次数进行分类统计
        Map<Byte, Integer> map = new HashMap<>();
        for (Byte b : bytes) {
            Integer count = map.get(b);
            if (count == null) {
                map.put(b, 1);
            } else {
                map.put(b, count + 1);
            }
        }

        //使用优先级队列辅助实现
        PriorityQueue<Node> priorityQueue = new PriorityQueue<>();
        for (Map.Entry<Byte, Integer> entry : map.entrySet()) {
            priorityQueue.add(new Node(entry.getKey(), null, null, entry.getValue()));
        }
        while (priorityQueue.size() > 1) {
            Node leftNode = priorityQueue.poll();
            Node rightNode = priorityQueue.poll();

            //构造parentNode节点
            Node parentNode = new Node(null, leftNode, rightNode, leftNode.weight + rightNode.weight);
            priorityQueue.add(parentNode);
        }

        //即获取到最终的节点
        return priorityQueue.poll();
    }

根据哈夫曼树,生成哈夫曼编码

从上面哈夫曼树的生成逻辑我们可以知道两个结论:

1、传入的字节都存储在叶子节点上
2、生成parentNode时data都设置为空,所以非叶子节点的data都是null

所以生成叶子节点的哈夫曼编码也比较简单,从跟节点开始,向左为编码+'0',向右编码+'1'

如以下树,当前节点的哈夫曼编码是 '0',则left对应的哈夫曼编码就是 '0' + '0' 即为'00',right对应的哈夫曼编码就是'0' + '1' 即为 '01'。

 经过上面的分析,代码如下。

 /**
     * 得到字节的哈夫曼编码
     *
     * @param root 根节点
     */
    private Map<Byte, String> getByteHufManCode(Node root) {
        Map<Byte, String> hufCodesMap = new HashMap<>();
        if (root == null) return hufCodesMap;

        //定义一个队列辅助遍历
        Queue<Node> queue = new LinkedList<>();
        queue.add(root);
        while (!queue.isEmpty()) {
            Node node = queue.poll();
            //构造哈夫曼树时,根据原始节点向上构建父节点时,设置的data是null
            if (node.data != null || (node.left == null && node.right == null)) {
                hufCodesMap.put(node.data, node.hufCode);
                continue;
            }
            Node leftNode = node.left;
            Node rightNode = node.right;
            if (leftNode != null) {
                leftNode.hufCode = node.hufCode + "0";
                queue.add(leftNode);
            }
            if (rightNode != null) {
                rightNode.hufCode = node.hufCode + "1";
                queue.add(rightNode);
            }
        }
        return hufCodesMap;
    }

其实到这里哈夫曼编码相关的就已经完了

接下来我们来看一个实际的问题,看一下使用上面的哈夫曼编码对如下字符串进行编码
i like like like java do you like a java,编码之前我们通过文本文件看下大小,占40字节,即320位

我们通过生成的哈夫曼编码对字符串进行编码,生成哈夫曼编码串

1、其实生成的方法也比较简单,把字符串转换为字节数组
2、遍历字节数组,根据前面生成的编码哈夫曼编码映射依次生成就可以了
3、但是要记住,生成哈夫曼编码的字节数组和待生产哈夫曼编码串的字节数组必须是同一个

通过上面分析,代码如下,代码比较简单:

 /**
     * 根据传入的哈夫曼编码和字节数组 对字节数组进行编码
     *
     * @param hufManCodeMap
     * @param bytes
     * @return
     */
    public String encode(Map<Byte, String> hufManCodeMap, byte[] bytes) {
        StringBuilder stringBuilder = new StringBuilder();
        for (Byte b : bytes) {
            stringBuilder.append(hufManCodeMap.get(b));
        }
        return stringBuilder.toString();
    }

生成的结果如下:

从生成的结果中我们可以看出,如果我们单纯把这一串作为生成的串进行传输,不但没变小,反而变大了,生成之前 字符串"i like like like java do you like a java"占40个字节,如果把生成的串进行原模原样的传输,则生成之后的串占133字节,我们看出,生成的串全是由0 和 1组成的编码序列,那我们可以理解为占用133位,这样对比40*8 = 320 位来说,其实压缩效果还是挺明显的,所以我们还需要把生成的序列生成字节数组(按照8位作为一个字节进行分割,后面不足8位的进行特殊处理)进行传输。

经过上面的分析,接下来我们要把生成的串

1000100110011101111010011001110111101001100111011110110111110000111001000001010011011010100000101001100111011110111001101111100001110

按照8位作为一个字节生成字节数组。进行传输

其实生成的逻辑也比较简单,按照8位进行分割。

1、如果刚好是8的倍数,则不需要进行任何处理,直接生成对应的字节数组
2、如果不是8的倍数,则最后余下的即为需要进行特殊处理

刚好是8的倍数,如下序列

01100111 01111011
则直接按照每8位进行分割,计算即可,如上面串,按照8位进行分割,分割为如下两段

01100111 和  01111011 ,对应的10进制分别是 103 和 123,根据ascii查找,103其实对应的就是字符 g,123 对应的就是 {

长度不是8的倍数,如下序列

01100111 011
按照8位进行分割时会分隔为如下两串,01100111  和 011
对应的10进制分别是 103 和 3,其实这个过程都没有问题,但是当我们进行网络传输,需要解析解码时就会存在异常了,对于第一个字节,因为是满8位的,没得问题,但是对于第二个字节,就会存在问题,解码时就会变为00000011,前面多了5个零,所以我们在传递的数据中也需要记录对于最后一个字节我们补了多少个零,解码时也对应去除掉前面的零。其实经过上面的分析,逻辑也比较简单,就是简单的字符串分割逻辑

分析如下图

 经过上面的分析,其实代码就比较简单了,就是一些简单的字符串分割,代码如下:

public byte[] getHufManCodeBytes(Map<Byte, String> byteStringMap, byte[] bytes) {
        //根据哈夫曼编码把字节数组转换为哈夫曼编码串
        String encodeString = encode(byteStringMap, bytes);

        //按照8位为一段进行分割处理
        byte[] encodeBytes = getHuffmanCodeString(encodeString);

        return encodeBytes;
    }

//encode的代码见上面。

private byte[] getHuffmanCodeString(String encodeString) {
        //返回的字节数组的长度
        int len = 0;
        //哈夫曼编码串的长度
        int strLength = encodeString.length();
        //需要补0的数量
        int zeroCount = 0;
        //如果长度刚好是8的倍数,则直接分割处理就好
        if (strLength % 8 == 0) {
            // +1 的目的是为了记录 补 的 0  的个数
            len = strLength / 8 + 1;

            //不需要补零
            zeroCount = 0;
        } else {
            //如果长度刚好不是8的倍数,则需要补0
            //此处加2是因为,如长度11/8 = 1 ,但是需要两个字节来存,所以加1,另外的 +1 是需要用来记录补0的数量
            len = strLength / 8 + 2;

            //zeroCount的计算步骤,如对于 01111011 011串来说,需要补0的数量就是5,因为 011 需要补位00000 011,但是这个5该怎么计算?
            //其实很简单 strLength % 8 表示 分割的最后一段有多少位,如 01111011 011.length() = 11 ,则 11 % 8 ==3,则需要补的位数就是8-3 = 5
            zeroCount = 8 - (strLength % 8);
        }

        //创建返回的字节数组,这下面就比较简单,依次分割,计算对于的字节值,存储到数组中即可
        byte[] huffmanCodeBytes = new byte[len];
        //补的0的数量存储在最后一个字节
        huffmanCodeBytes[len - 1] = (byte) zeroCount;
        int index = 0;
        for (int i = 0; i < strLength; i += 8) {
            String strBytes;
            if (i + 8 < strLength) {
                strBytes = encodeString.substring(i, i + 8);
            } else {
                strBytes = encodeString.substring(i);
            }
            huffmanCodeBytes[index] = (byte) Integer.parseInt(strBytes, 2);
            index++;
        }
        return huffmanCodeBytes;
    }

那如何解码,对于解码,其实也是很简单的过程,代码中有详细的注释,需要关注方法byteToBinaryString对补零的处理逻辑,和方法decode根据编码串解码的逻辑,其他都没什么问你题

public byte[] decode(Map<Byte, String> hufCodes, byte[] bytes) {
        StringBuilder stringBuilder = new StringBuilder();
        //编码时需要补0的数量是存储在最后一个坑,所以这里直接可以拿到
        int zeroCount = bytes[bytes.length - 1];
        for (int i = 0; i < bytes.length - 1; i++) {
            boolean lastByte = (i == bytes.length - 2);

            //解码为哈夫曼编码串
            stringBuilder.append(byteToBinaryString(bytes[i], lastByte, zeroCount));
        }

        //我们原始传递进来的字典,key是字节,value是其对应的哈夫曼编码
        //但是现在我们拿到的是哈夫曼编码串,需要拆分,然后拿到对应的字节,所以这里需要借助一个中间字典把原始的字典转换下
        Map<String, Byte> decodeHuffmanCodes = new HashMap<>();
        for (Map.Entry<Byte, String> entry : hufCodes.entrySet()) {
            decodeHuffmanCodes.put(entry.getValue(), entry.getKey());
        }

        //其实这个拆分也比较简单,此处需要记住,哈夫曼编码是前缀编码
        //举一个简单的列子,加入存在如下对应关系
        //哈夫曼编码 110 对应 A,编码 0 对应字符 B
        //  --当我们拿到串0 110 110,此处借助双指针,拿到0时因为字典中有0,解析出B,
        //  --继续,当我们拿到 1 时,因为没有1,所以快指针继续往后,拿到 1,目前key变为 11,11还是不在字典中,则快指针继续移动,拿到 110,此时key变为110,因为存在A,所以串变为 BA 
        //  --再继续,最终拿到 BAA ,算比较简单的步骤了
        List<Byte> list = new ArrayList<>();
        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 < list.size(); i++) {
            decodeResult[i] = list.get(i);
        }
        return decodeResult;
    }

 private String byteToBinaryString(byte b, boolean lastByte, int zeroCount) {

        //与 256 按位或,不足8位的补高位,够8位则不会变, 256 表示 100000000,与256按位或的目的其实就是为了不足8位的给补足,足8位的,
        // 低8位不会变化
        int temp = b | 256;

        //不需要补零
        String binaryString = Integer.toBinaryString(temp);

        //只需要低8位
        String str8BitString = binaryString.substring(binaryString.length() - 8);
        //不是最后一个字节或者不需要补0,则不需要进行任何处理
        if (!lastByte || zeroCount == 0) {
            return str8BitString;
        } else {
            //zeroCount表示补0的数量,那剩下的部分其实就是真正需要的值,
            // 如 011,补了5个零凑足了8位,补了5个零,我们拿到00000 011 时,其实,需要的就是后三位
            return str8BitString.substring(zeroCount);
        }
    }

最终的调用如下:

public class Test1 {
    public static void main(String[] args) {
        System.out.println("====原始字符串====");
        String s = "因为解压时需通过二进制字符串获取哈夫曼编码对应的字节,所以需将哈夫曼编码映射关系倒1212过来";
        System.out.println(s);

        HufManTree hufManTree = new HufManTree();
        Map<Byte, String> hufCodeMap = hufManTree.getByteHufManCode(s.getBytes());
        byte[] bytes = hufManTree.getHufManCodeBytes(hufCodeMap, s.getBytes());
        System.out.println("====哈夫曼压缩后====");
        System.out.println(new String(bytes));
        System.out.println("====解码后的字符串====");
        System.out.println(new String(hufManTree.decode(hufCodeMap, bytes)));
    }
}

得到的结果如下:

巨人的肩膀:https://www.cnblogs.com/gaofei200/p/13858957.html

仅个人记录用途。

 

 

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值