数据结构—哈夫曼树和哈夫曼编码介绍以及Java实现案例

397 阅读11分钟

「这是我参与11月更文挑战的第21天,活动详情查看:2021最后一次更文挑战」。

哈夫曼树原本是为哈夫曼编码服务的一种数据结构,又称最优二叉树,哈夫曼编码常被使用在数据的压缩和解压缩技术之中。本文详细介绍了哈夫曼树的概念,并且提供了Java实现,最后又介绍了哈夫曼编码。

如果对二叉树不是很了解的,可以先看看我的专栏 的相关文章。

1 哈夫曼树

1.1 哈夫曼树简介

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

以下图为例,先说明几个概念:

在这里插入图片描述

权:

赋予某个实体的一个量,是对实体的某个或某些属性的数值描述。在数据结构中,实体有节点(元素)和边(关系)两大类,所以对应有节点权和边权。

路径:

在一棵树中,一个结点到另一个结点之间的通路,称为路径。上图,从根结点到结点 h之间的通路就是一条路径。

节点路径长度:

在一棵树中,从一个节点到另一个节点所经过的“边”的数量,被我们称为两个结点之间的路径长度;或者说路径上的分支数目称作路径长度。上图中从根结点到结点 h 的路径长度为3。

树的路径长度:

树的路径长度就是从树根到每一结点的路径长度之和。上图的树的路径长度为1+2+3+3+2+3+1+2+2+3=22

节点的带权路径长度:

树的每一个结点,都可以拥有自己的“权重”(Weight),权重在不同的算法当中可以起到不同的作用。结点的带权路径长度,是指树的根结点到该结点的路径长度,和该结点权重的乘积。上图中h节点带权路径长度为3 * 3=9

树的带权路径长度:

树中所有叶子结点的带权路径长度之和。也被简称为WPL。上图的树的带权路径长度为3 * 2+3 * 3+3 * 4+2 * 1+3 * 5=44

1.2 哈夫曼算法

哈夫曼树的构建方法被称为哈夫曼算法,其构建步骤为:

  1. 根据给定的n个权值{w1,w2,...,wn}构成n棵二叉树的集合F={T1,T2,...,Tn},其中每棵二叉树Ti中只有一个带权为wi根结点,其左右子树均为空。
  2. 在F中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。
  3. 在F中删除这两棵树,同时将新得到的二叉树加入F中。
  4. 重复2和3步骤,直到F只含一棵树为止。这棵树便是哈夫曼树。

注意:

  1. 叶子上的权值均相同时,完全二叉树一定是最优二叉树,否则完全二叉树不一定是最优二叉树。
  2. 最优二叉树中,权越大的叶子离根越近。
  3. 最优二叉树的WPL最小,但是形态不唯一。

1.3 案例

有五个带权节点{ A5,B15,C40,D30,E10}。要求构建哈夫曼树。

先把有权值的叶子结点按照从小到大的顺序排列成一个有序序列,即:A5,E10,B15,D30,C40。

取头两个最小权值的结点作为一个新节点N1的两个子结点,取相对较小的是左孩子(实际上也可以不遵守,因为哈夫曼树是有多种形状的,但是WPL都是最小的),这里就是A为N1的左孩子,E为N1的右孩子,如下图,新结点的权值为两个叶子权值的和5+10=15。

在这里插入图片描述

将N1替换A与E,插入有序序列中,保持从小到大排列。即:N115,B15,D30,C40。

重复步骤2。将N1与B作为一个新节点N2的两个子结点。如下图,N2的权值=15+15=30。

在这里插入图片描述

将N2替换N1与B,插入有序序列中,保持从小到大排列。即:N230,D30,C40。

重复步骤2。将N2与D作为一个新节点N3的两个子结点。如下图,N3的权值=30+30=60。

在这里插入图片描述

将N3替换N2与D,插入有序序列中,保持从小到大排列。即:C40,N360。

重复步骤2。将C与N3作为一个新节点T的两个子结点,如下图,由于T即是根结点,完成哈夫曼树的构造。

此时的上图二叉树的带权路径长度WPL=40×1+30×2+15×3+10×4+5×4=205。这就是最短带权路径长度

在这里插入图片描述

根据最优二叉树形态不唯一的性质和上面的一种形状,我们还可以写出下面形状的最优二叉树:

在这里插入图片描述

2 哈夫曼树的实现

现提供哈夫曼树的Java实现,下面的类提供了两种构建方法,区别主要在一个排序使用Arrays.sort排序(注意由于是对象排序,它并非双轴快排),另一个使用了最小堆排序。它们的时间效率是不一致的。 实际使用时选取一种即可。

/**
 * 哈夫曼树简单实现
 */
public class HuffmanTree<E> {
    /**
     * 外部保存根节点的引用
     */
    private BinaryTreeNode<E> root;

    /**
     * 内部节点
     *
     * @param <E> 节点数据类型
     */
    public static class BinaryTreeNode<E> {
        //节点数据
        E data;
        //节点权重
        String weight;
        //左子结点
        BinaryTreeNode<E> left;
        //右子节点
        BinaryTreeNode<E> right;

        public BinaryTreeNode(E data, String weight) {
            this.data = data;
            this.weight = weight;
        }

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


    /**
     * 根据指定的普通node节点集合构建哈夫曼树
     *
     * @param binaryTreeNodes node节点集合,采用普通list集合
     * @param <E>         节点数据类型
     * @return 哈夫曼树
     */
    public static <E> HuffmanTree<E> build(List<BinaryTreeNode<E>> binaryTreeNodes) {
        //比较器
        Comparator<BinaryTreeNode<E>> comparator = (o1, o2) -> new BigDecimal(o1.weight).subtract(new BigDecimal(o2.weight)).intValue();
        while (binaryTreeNodes.size() > 1) {
            //手动对集合的节点按照权值大小从大到小进行排序
            binaryTreeNodes.sort(comparator);
            //移除并获取权值最小的两个节点
            BinaryTreeNode<E> left = binaryTreeNodes.remove(0);
            BinaryTreeNode<E> right = binaryTreeNodes.remove(0);
            //生成新节点,新节点的权值为两个子节点的权值之和
            BinaryTreeNode<E> parent = new BinaryTreeNode<>(null, new BigDecimal(left.weight).add(new BigDecimal(right.weight)).toString());
            //让新节点作为两个权值最小节点的父节点
            parent.left = left;
            parent.right = right;
            //将新节点加入到集合中
            binaryTreeNodes.add(parent);
        }
        //采用循环不断地执行上面的步骤,直到list集合中只剩下一个节点,最后剩下的这个节点就是哈夫曼树的根节点
        HuffmanTree<E> huffmanTree = new HuffmanTree<>();
        huffmanTree.root = binaryTreeNodes.remove(0);
        return huffmanTree;
    }

    /**
     * 根据指定的最小堆构建哈夫曼树
     *
     * @param binaryTreeNodes node节点集合,采用最小堆
     * @param <E>         节点数据类型
     * @return 哈夫曼树
     */
    public static <E> HuffmanTree<E> build(PriorityQueue<BinaryTreeNode<E>> binaryTreeNodes) {
        while (binaryTreeNodes.size() > 1) {
            //移除并获取权值最小的两个节点
            BinaryTreeNode<E> left = binaryTreeNodes.poll();
            BinaryTreeNode<E> right = binaryTreeNodes.poll();
            //生成新节点,新节点的权值为两个子节点的权值之和
            BinaryTreeNode<E> parent = new BinaryTreeNode<>(null, new BigDecimal(left.weight).add(new BigDecimal(right.weight)).toString());
            //让新节点作为两个权值最小节点的父节点
            parent.left = left;
            parent.right = right;
            //将新节点加入到最小堆中,自动排序
            binaryTreeNodes.add(parent);
        }
        //采用循环不断地执行上面的步骤,直到list集合中只剩下一个节点,最后剩下的这个节点就是哈夫曼树的根节点
        HuffmanTree<E> huffmanTree = new HuffmanTree<>();
        huffmanTree.root = binaryTreeNodes.poll();
        return huffmanTree;
    }

    /**
     * 获取根节点
     *
     * @return 根节点或者null-表示空树
     */
    public BinaryTreeNode<E> getRoot() {
        return root;
    }

}

2.1 测试

public class HuffmanTreeTest {

    /*采用用普通集合和最小堆都行,最大的区别是它们的采用不同的排序算法,效率是不一致的*/

    /**
     * 采用普通list作为临时存储节点数据的集合,因此我们需要手动排序
     */
    @Test
    public void test1() {
        //采用普通list作为临时存储节点数据的集合,因此我们需要手动排序
        List<BinaryTreeNode<String>> binaryTreeNodes = new ArrayList<>();
        // A5,B15,C40,D30,E10
        binaryTreeNodes.add(new BinaryTreeNode<>("A", "5"));
        binaryTreeNodes.add(new BinaryTreeNode<>("B", "15"));
        binaryTreeNodes.add(new BinaryTreeNode<>("C", "40"));
        binaryTreeNodes.add(new BinaryTreeNode<>("D", "30"));
        binaryTreeNodes.add(new BinaryTreeNode<>("E", "10"));
        HuffmanTree<String> huffmanTree = HuffmanTree.build(binaryTreeNodes);
        BinaryTreeNode<String> root = huffmanTree.getRoot();
        System.out.println(root);
    }

    /**
     * 采用 最小堆--priorityQueue 作为临时存储节点数据的集合,priorityQueue的性质就是对集合的元素进行自动排序,因此我们不必手动排序
     */
    @Test
    public void test2() {
        //采用最小堆--priorityQueue.作为临时存储节点数据的集合,priorityQueue的性质就是对集合的元素进行自动排序,我们只需要指定排序规则
        PriorityQueue<BinaryTreeNode<String>> priorityQueueBinaryTreeNodes = new PriorityQueue<>((o1, o2) -> new BigDecimal(o1.weight).subtract(new BigDecimal(o2.weight)).intValue());
        priorityQueueBinaryTreeNodes.add(new BinaryTreeNode<>("A", "5"));
        priorityQueueBinaryTreeNodes.add(new BinaryTreeNode<>("B", "15"));
        priorityQueueBinaryTreeNodes.add(new BinaryTreeNode<>("C", "40"));
        priorityQueueBinaryTreeNodes.add(new BinaryTreeNode<>("D", "30"));
        priorityQueueBinaryTreeNodes.add(new BinaryTreeNode<>("E", "10"));
        HuffmanTree<String> huffmanTree = HuffmanTree.build(priorityQueueBinaryTreeNodes);
        BinaryTreeNode<String> root = huffmanTree.getRoot();
        System.out.println(root);
    }
}

3 哈夫曼编码

哈夫曼树的出现主要是为了哈夫曼编码服务的,哈夫曼编码有着非常广泛的应用,哈夫曼编码常被使用在数据的压缩和解压缩技术之中。

哈夫曼编码的基本思想是以字符的使用频率作为权,构造一棵哈夫曼树,然后利用哈夫曼树对字符进行编码。这棵哈夫曼树,是将所要编码的字符作为叶子结点,该字符在文件中的使用频率作为叶子结点的权,以自底向上的方式,通过n-1次合并运算后构造出一棵树,权值越大的叶子离根越近。

比如我们有一段文字内容为“BADCADFEED”要网络传输给别人,显然用二进制的数字(0和1)来表示是很自然的想法。我们现在这段文字只有六个字母ABCDEF,那么我们可以用相应的二进制数据表示。

在这里插入图片描述

这样真正传输的数据就是编码后的“001000011010000011101100100011”,对方接收时可以按照3位一分来译码。如果一篇文章很长,这样的二进制串也将非常的可怕。而且事实上,不管是英文、中文或是其他语言,字母或汉字的出现频率是不相同的,比如英语中的几个元音字母“ae i o u”,中文中的“的 了 有 在”等汉字都是频率极高。

假设六个字母的频率为A 27,B 8,C 15,D15,E 30,F 5,合起来正好是100%。那就意味着,我们完全可以重新按照哈夫曼树来规划它们。下左图为构造哈夫曼树的过程的权值显示。右图为将权值左分支改为0,右分支改为1后的哈夫曼树。

在这里插入图片描述

此时,我们对这六个字母用其从树根到叶子所经过路径的0或1来编码,可以得到下表所示这样的定义。

在这里插入图片描述

我们将文字内容为“BADCADFEED”再次编码,对比可以看到结果串变小了。

  1. 原编码二进制串:001000011010000011101100100011(共30个字符)
  2. 新编码二进制串:1001010010101001000111100(共25个字符)

也就是说,我们的数据被压缩了,节约了大约17%的存储或传输成本。随着字符的增加和多字符权重的不同,这种压缩会更加显出其优势。

当我们接收到1001010010101001000111100这样压缩过的新编码时,我们应该如何把它解码出来呢?

编码中非0即1,哈夫曼编码是可变字长编码(VLC)的一种。但是如果长短不等的话其实是很容易混淆的,所以若要设计长短不等的编码,则必须是任一字符的编码都不是另一个字符的编码的前缀,这种编码又称做前缀编码。

按照上表设计的编码就不存在容易与1001、1000混淆的“10”和“100”编码。实际上哈夫曼编码就是一种前缀编码。

但是在解码时,还是要用到哈夫曼树,即发送方和接收方必须要约定好同样的哈夫曼编码规则。

当我们接收到1001010010101001000111100时,由约定好的哈夫曼树可知,1001得到第一个字母是B,接下来01意味着第二个字符是A,如下图所示,其余的也相应的可以得到,从而成功解码。

在这里插入图片描述

一般地,设需要编码的字符集为{d1,d2,...,dn},各个字符在电文中出现的次数或频率集合为{w1,w2,...,wn},以d1,d2,...,dn作为叶子结点,以w1,w2,...,wn作为相应叶子结点的权值来构造一棵哈夫曼树。规定哈夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,这就是哈夫曼编码。

相关文章:

  1. 《算法》
  2. 《大话数据结构》

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!