Loading...
墨滴

jrh

2021/08/29  阅读:52  主题:Obsidian

哈夫曼编码与文件压缩

一:哈夫曼树与哈夫曼编码

大家知道,文件压缩的原理么?

假如我们有一个文件,文件当中仅有 A、B、C、D、E 五种字符,这五种字符出现的频率分别为 5次、4次、3次、2次、1次。

我们知道每个英文字母均占用一个字节,即 8 个比特,所以,我们可以认为原文件的大小就是15个字节,即:120 个比特。

现在,我们要设计一种能够压缩这个文件的算法,你会怎么做?

固定长度编码

如果这个文件中仅有 A、B、C、D、E 这五种字符,我们可以让 A、B、C、D、E 按照二进制数分别表示为 000001010011100。原本一个字母占用 8 个比特位,现在我们仅仅使用 3 个比特位就可以表示一个字母了。

首先,创建一个字典:

K V
A 000
B 001
C 010
D 011
E 100

读取原文件,每读一个字符,就在字典中转换为二进制的映射并写入到压缩文件里。

经过这样的转换后,压缩文件的大小为:45 个比特。

对压缩文件进行解压还原的方式也非常简单,我们只需要创建一个和解压文件中使用的字典相反的字典:

K V
000 A
001 B
010 C
011 D
100 E

然后读取压缩后的文件,每次读 3 个比特位,就在字典中找到相应的字符,写入到新的文件中。这样就达成了解压还原文件的目的。

我们对原文件定义了一种“编码格式”,通过这种编码来达成压缩与解压的目的。这种编码方式叫做固定长度编码(Fixed Length Code),简称:FLC;而这种压缩算法叫做固定位长算法(Fixed Bit Length Packing)。

固定位长算法是比较常见的压缩算法之一。

哈夫曼树与哈夫曼编码

哈夫曼编码(Huffman Coding),是一种用于无损数据压缩的熵编码,由美国计算机科学家 David Albert Huffman 在 1952 年发明。

1951年,哈夫曼在 MIT 攻读博士学位,导师 Robert Fano 给出的学期报告题目是:查找最有效的二进制编码。由于无法证明哪个已有编码是最有效的,哈夫曼放弃了对已有编码的研究,转向新的探索,最终发现了基于有序频率二叉树(哈夫曼树)编码的想法,并很快证明了这个方法是最有效的。

相对于固定长度编码来说,哈夫曼编码是一种可变长度编码(Variable Length Code),简称 VLC。其流程是对文件中出现的字符的权值进行统计,出现概率高的字母使用较短的编码,反之出现概率低的字母使用较长的编码,这样使得编码之后的字符串的平均长度,期望值降低,从而达到无损压缩数据的目的。

我们依旧以文件当中仅有 A、B、C、D、E 五种字符,五种字符出现的频率分别为 5次、4次、3次、2次、1次来举例:

首先,我们需要统计文件中每种字符的权值(出现的频率),并且将这些字符添加到一个按权值由小到大的顺序维护的优先队列中:

然后,我们取出权值最小的两个元素作为左右子树,并将这两个元素权值的和作为根节点构造出一棵新树:

接着,将树的根节点入队:

重复上述操作,直至队列为空,这样,一棵哈夫曼树(Huffman Tree)就构建完成了。

我们将这棵哈夫曼树所有节点的“左边路径”标记为 0;所有的“右边路径”标记为 1:

这样,我们就得到了每个字符对应的编码的字典(从根节点到字符的路径就是该字符对应的编码表示方式)。各个字符所对应的编码依次表示为:

K V
A 11
B 10
C 00
D 011
E 010

通过哈夫曼编码得到的压缩文件的大小是多少呢?33 个比特。哈夫曼编码压缩要比固定长度编码压缩更加节省空间!

二:构建哈夫曼树

在上文中,我已经介绍了如何构建一棵哈夫曼树,接下来让我们看一下具体的代码:

首先是哈夫曼树节点的定义,哈夫曼树的节点是带有权值的,这里面大家可以认为,一个节点的权值就是该节点所对应的值在文件中出现的次数。我们要将节点进行入队出队操作,既而,哈夫曼的节点必须是可比较的,比较的策略就是根据节点的权值大小。

节点的 Java 代码

public class Node<Eimplements Comparable<Node<E>> {
    public E e;
    public double weight;
    public Node<E> left;
    public Node<E> right;

    public Node(E e, double weight, Node<E> left, Node<E> right) {
        this.e = e;
        this.weight = weight;
        this.left = left;
        this.right = right;
    }

    public Node(E e, Node<E> left, Node<E> right) {
        this(e, 0.0, left, right);
    }

    public Node(E e, double weight) {
        this(e, weight, nullnull);
    }

    public Node() {
        this(null0.0);
    }

    @Override
    public String toString() {
        return "Node [e = " + e + ",weight = " + weight + "]";
    }


    @Override
    public int compareTo(Node node) {
        return Double.compare(this.weight, node.weight);
    }
}

节点定义完毕后,哈夫曼树的代码其实就非常简单了,我们的 HuffmanTree 这个类只需要一个方法 createhuffmanTree,这个过程我在上文已经介绍了,代码也非常简单:

HuffmanTree 的 Java 代码

import java.util.*;

public class HuffmanTree {
    /**
     * @param list 传入所有节点的 list
     * @return 返回哈夫曼树的根节点
     */

    public static Node createHuffmanTree(List<Node> list) {
        PriorityQueue<Node> queue = new PriorityQueue<>();
        for (int i = 0; i < list.size(); i++) {
            queue.offer(list.get(i));
        }
        return createHuffmanTree(queue);
    }

    /**
     * @param queue 按照节点的权值(node.weight) 由小到大维护的优先队列
     * @return 返回哈夫曼树的根节点
     */

    public static Node createHuffmanTree(PriorityQueue<Node> queue) {
        while (queue.size() > 1) {
            Node left = queue.poll();
            Node right = queue.poll();
            Node parent = new Node(null, left.weight + right.weight, left, right);
            queue.offer(parent);
        }
        return queue.poll();
    }

三:文件压缩与解压的实现

代码仓:http://github.com/jinrunheng/file-compress

文件压缩

我实现的文件压缩与解压的 demo 仅针对英文文本有效。

文件压缩流程:

  1. 统计文件中所有字符出现的频次
  2. 将字符作为节点的值,字符出现的频次作为节点的权,构建哈夫曼树
  3. 将哈夫曼树的每个节点的字符与对应的 01 编码作为映射存储到字典中(HashMap)
  4. 再次读取文件,将每个字符转化为 01 编码的字符串格式
  5. 将这个文件对应的 01 编码字符串转换为真正的比特并写入到文件中,完成压缩

解压流程:

  1. 读取压缩文件,将压缩文件转换为 01 的字符串形式
  2. 将哈夫曼树的每个节点对应的 01 编码与字符值作为映射存储到字典中(HashMap)
  3. 对压缩文件转换成的字符串进行遍历;使用两个指针 i,j,第一个指针 i 指向起始点,第二个指针 j 以 i 所在的位置开始从左向右逐一扫描。如果 i~j 对应的字符串在字典中有匹配的 key,那么就在字典中取出对应的 value 写入到还原的文件中,并更新指针 i 的位置;无匹配的 key 则继续移动 j 指针直至匹配。当 i 移动到字符串最后的位置时,结束遍历,解压完成。

我对《Harry Potter and the Sorcerer's Stone》 这个英文原著进行了文件压缩与解压的测试:

  • 原文本的大小为:450KB
  • 压缩文件 test.huffman 的大小为 260KB
  • 对压缩文件进行解压还原,成功还原了原文本

具体的代码大家可以点击上面给出的链接进行查看。

四:哈夫曼编码的最优性证明

!! 以下内容结合了《Algorithms Fourth Edition》中的内容

哈夫曼树又叫做“最优二叉树”。

这里面“最优”的含义是:当使用 n 个节点(每个节点都有自己的权值)构建一棵树时,如果构建的这棵树带权路径长度(WPL)最小,那么这棵树就是最优二叉树。

WPL 是树的带权路径长度,其含义为树中所有叶子节点的带权路径长度之和。

譬如上图所示的哈夫曼树,其 WPL 值为:

7 * 1 + 5 * 2 + 2 * 3 + 4 * 3 = 35

那么,我们如何证明在给定一个含有 r 个符号的集合和它们的频率,使用哈夫曼树构造的哈夫曼编码的 WPL 是最小的呢?

证明:

数学归纳法。假设,哈夫曼编码对于任意规模小于 r 的符号集合都是最优的。设 是用哈夫曼算法计算并编码符号集合相应的频率 所得到的输出,并用 表示输出的总长度(WPL)。

假设 是最先被选中的两个符号,那么算法接下来将计算 替代后的 r-1 个符号的集合的编码以输出 ,其中 表示深度为 的某个叶子结点中的新符号。可以注意到:

现在,假设 有一棵最优的高度为 的单词查找树 。注意, 的深度必然都是 (否则将它们和深度为 的节点交换就可以得到一棵 WPL 更小的单词查找树)。另外,通过将 的兄弟节点交换可以假设 是兄弟节点。现在,考虑将它们的父节点替换为 所得到的树 。注意(用同样的方法可以得到)

根据数学归纳法, 是最优的,即 。因此有:

因为 是最优的,等号必然成立,因此 也是最优的。

所以,我们证明了哈夫曼编码是最优的。

五:总结

本文介绍了哈夫曼树与哈夫曼编码,并且使用了一个 demo 验证了基于哈夫曼编码的压缩算法是有效的。在文章的最后,我们验证了哈夫曼编码是最优编码,其实哈夫曼编码的思想是一种典型的贪心策略(greedy strategy)。在后面,我会向大家介绍贪心算法,敬请期待。

好啦,至此为止,哈夫曼编码与文件压缩这一篇文章我就介绍完毕了~欢迎大家关注我的公众号【kim_talk】,在这里希望你可以收获更多的知识,我们下一期再见!

jrh

2021/08/29  阅读:52  主题:Obsidian

作者介绍

jrh