Post

文件压缩算法:霍夫曼编码

背景介绍

在介绍这个算法之前,我们先来说说文件压缩与编码到底是怎么一回事。

我们知道计算机是基于二进制的,也就是说任何信息到最后其实都变成了类似 0101101010101... 这样的序列。文件中存储的也是这样的序列。但你会说,不对呀,为什么我打开文件看到的是具体的文字和内容。这就要说到编码了,通过编码,我们使得这样的序列变得有意义了。

比如最常见的 ASCII 编码,就是对一些英文字符以及常用的符号进行编码,比如 a 这个字符,它的二进制表示是 01100001。也就是一个文件,这里面的内容最后都会通过编码转化成二进制序列在计算机上存储。

那么这就有个问题,文件在硬盘上存储是需要硬盘空间的,将文件通过网络传输是需要网络带宽的,把文件加载到内存中也是需要内存空间的。直接将一个大的文件进行网络传输,或是直接存储,都需要耗费大量的空间和时间,那么我们可否通过一定的方式将文件进行压缩来节省资源。当然可以,这就是文件压缩算法做的事情。

编码树

文件压缩要做的事情就是尽量缩小二进制流的长度,但要保证文件信息不失真。为了方便起见,这里我们假设一个文件中只有 aeist,空格,换行这几种编码,它们具体编码,出现的频率,如下:

CharacterCodeFrequencyTotal Bits
a0001030
e0011545
i0101236
s01139
t100412
space1011339
newline11013
Total  174

这个文件总共是有 174 个 Bits,如果是不压缩直接传输,那么我们需要传输 174 个 Bits。因为编码里面不是 0 就是 1,我们可以基于此构建一个编码树:

这其实就是一个普通的二叉树,根节点表示的信息为空,往左表示新增一个 0, 往右表示新增一个 1,我们的编码对应的字符均在叶子节点上。

这树跟字典树(Trie)很相似,只不过字典树上的字符变成了 0 或 1。如果想要对文件进行压缩,那么我们势必要在编码上面做文章,那么最终与之相对应的编码树也会改变。

那么这里有哪些地方可以优化呢?首先观察到的一点是,叶子节点的深度决定了该字符对应的长度,所以,对于那些出现频率特别高的字符,我们需要降低其叶子节点的深度

比如下面这颗编码树:

上面这颗编码树所对应的表格如下:

CharacterCodeFrequencyTotal Bits
a0011030
e011530
i101224
s000000315
t00001416
space111326
newline0000115
Total  146

可以看到基于这组编码后的文件总共只需要传输 146 Bits 的信息,相比于之前的 174 有所提高。

可能很多人会有一个疑问就是,为什么每个字符的编码长度可以不一样?这里需要清楚的是,这里的编码的目的是为了节省空间,它并不是像 ASCII 编码那样需要考虑所有的情况,所有的字符。这里我们仅仅需要考虑已经出现过的字符就好了。

另外,只要我们能够保证所有字符的编码都在叶子节点上,那么最后编码的信息就是唯一确定的。这里面的原理就是,所有字符都没有重叠,一个字符的编码永远不可能是另外一个字符的编码前缀,这样的编码方式也被称为前缀编码(prefix code)。

霍夫曼编码算法就是为了找到最优的编码树,使得总的传输 Bit 最小。

霍夫曼编码

通过观察,我们的一个直观感受是,频率小的字符需要尽可能地安放在深度深的叶子节点,频率大的字符需要尽可能地安放在深度浅的叶子节点。霍夫曼编码是通过树节点的合并来达成的,它选择先合并频率小的节点,最后再合并频率大的节点,这样能保证所有的字符节点都是叶子节点,而且可以保证最后的 Bit 数是最优的。

我们还是以之前的例子来进行说明。

一开始我们把所有的字符都当成是一个个树节点,树节点对应的频率在左上角:

我们首先将字符 snl 进行合并,并生成一个新的根节点,这样需要合并的树就少了一个:

以此类推,我们可以将其他节点依照树的频率,依次合并:





到最后我们就可以得到一个最终的编码树,每个字符所对应的编码也清楚了。

霍夫曼编码的时间复杂度其实是和字符的种类数相关的。如果使用堆的话,操作复杂度就是 O(ClogC),其中 C 表示的是字符的种类数。但这仅仅是霍夫曼算法的时间复杂度。在实际应用中,我们是需要遍历一边整个文件来获取每个字符出现的频率,还有就是在我们得到具体字符的对应的霍夫曼编码后,我们还需要对整个文件进行重新编码,这个时间复杂度是 O(N) 其中 N 是文件的字符总数。

具体实现

最后,给出霍夫曼算法的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class HuffmanNode {
  int freq;
  char c;

  HuffmanNode left;
  HuffmanNode right;
}

class MyComparator implements Comparator<HuffmanNode> {
  public int compare(HuffmanNode x, HuffmanNode y) {
    return x.freq - y.freq;
  }
}

class Huffman {
  public HuffmanNode calculate(HuffmanNode[] chars) {
    if (chars.length < 1) {
      return null;
    }

    PriorityQueue<HuffmanNode> pq = new PriorityQueue<>(
      new MyComparator()
    );

    for (HuffmanNode n : chars) {
      pq.add(n);
    }

    while(pq.size() > 1) {
      HuffmanNode x = pq.poll();
      HuffmanNode y = pq.poll();

      HuffmanNode newTree = new HuffmanNode();
      newTree.freq = x.freq + y.freq;
      newTree.left = x;
      newTree.right = y;
      pq.add(newTree);
    }

    return pq.poll();
  }
}
This post is licensed under CC BY 4.0 by the author.