哈夫曼编码与文件压缩

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

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

假如我们有一个文件,文件当中仅有 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次来举例:

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

image

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

image

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

image

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

image

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

image

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

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

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

二:构建哈夫曼树

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

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

节点的 Java 代码

public class Node<E> implements 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, null, null);
    }

    public Node() {
        this(null, 0.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 是树的带权路径长度,其含义为树中所有叶子节点的带权路径长度之和。

image

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

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

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

证明:

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

假设 (s_i,f_i) (s_j,f_j) 是最先被选中的两个符号,那么算法接下来将计算 (s_i,f_i)(s_j,f_j)(s^*,f_{i+j}) 替代后的 r-1 个符号的集合的编码以输出 T_H^*,其中 s^* 表示深度为 d 的某个叶子结点中的新符号。可以注意到:

W(T_H) = W(T_H^*) - d(f_i + f_j) + (d+1)(f_i + f_j) = W(T_H^*) + (f_i + f_j)

现在,假设(s_1,f_1),...(s_r,f_r) 有一棵最优的高度为 h 的单词查找树 T。注意,(s_i,f_i)(s_j,f_j)的深度必然都是 h(否则将它们和深度为 h 的节点交换就可以得到一棵 WPL 更小的单词查找树)。另外,通过将 (s_j,f_j)(s_i,f_i) 的兄弟节点交换可以假设 (s_i,f_i)(s_j,f_j) 是兄弟节点。现在,考虑将它们的父节点替换为(s^*,f_{i+j}) 所得到的树 T^* 。注意(用同样的方法可以得到)W(T) = W(T^*) + (f_i + f_j)

根据数学归纳法,T_H* 是最优的,即 W(T_H^*) <= W(T^*)。因此有:

W(T_H) = W(T_H^*) + (f_i + f_j) <= W(T^*) + (f_i + f_j) = W(T)

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

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

五:总结

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,997评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,603评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,359评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,309评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,346评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,258评论 1 300
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,122评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,970评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,403评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,596评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,769评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,464评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,075评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,705评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,848评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,831评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,678评论 2 354

推荐阅读更多精彩内容