哈夫曼压缩是个经典的算法,在网上可以找到很多文章。将数据拆分成一个个符号(Symbol, 符号可以是字符或者字节),统计每个符号出现的频率,根据频率构建出二叉树。之后根据二叉树,为每个符号的具体数值分配二进制位编码,频率越高,编码的二进制位越短,从而实现压缩。
要想正确解码,除了要保存编码后的二进制位,还需要保存编码树信息。这样解码器才能知道原始值和编码值的对应关系。
注: 此文“编码树”一词,不一定真的是二叉树,其实是指“原始值和编码值的对应关系”。只是“编码树”一词简短些,没有那样啰嗦,但其实不够精确。
可以有很多方法保存编码信息。比如依次保存(原始值,频率值),就可以还原出二叉树,也就还原出编码信息。或者依次保存 (原始值,编码值),或者将二叉树序列化。但这些方法不够高效。
假如保存编码树本身就占太多数据,对于小量数据的压缩,压缩后总数据量反而会更大,就得不偿失了。
要真正应用哈夫曼压缩,其中一个关键的问题,就是如何用尽量少的数据保存编码树信息。但很多讲述哈夫曼编码的文章都忽略了这个问题。
自适应的哈夫曼编码
编码信息最节省数据的保存方式,就是根本不保存。
一种方式,是编码器和解码器,使用事先约定好的编码树。编码树信息直接嵌在编码器和解码器的源代码当中,这样压缩数据中就不需要再保存。比如英文文本,可以根据历史文献,事先统计出各字母的频率。以后就用这个约定好的频率表压缩和解压英文小说。但这种方式只能针对特定的数据,并不具有通用性。
另一种方式,是使用自适应的哈夫曼编码。自适应编码的大致流程如下:
init_model()
do {
symbol = get_symbol(input)
code = encode(symbol)
write_code(code, ouput)
update_model(symbol)
} while(!isEnded(symbol));首先初始化模型,之后获取符号,编码符号后再动态更新模型。自适应解码的大致流程类似:
init_model()
do {
code = get_code(input)
symbol = decode(code)
write_symbol(symbol, output)
update_model(symbol)
} while (!isEnded(code))编码器和解码器,在编码解码过程中,动态维护更新模型,也就就不需要保存模型。
对于自适应的哈夫曼编码,这个模型就是哈夫曼编解码所需的频率二叉树。使用自适应哈夫曼编码,不需要保存编码树信息,但因为需要经常更新编码树,会导致时间开销增大。
保存哈夫曼编码信息,最常用的是使用范式哈夫曼编码。
范式哈夫曼编码(Canonical Huffman Code)
范式哈夫曼编码最早由 Schwartz(1964) 提出,Canonical 这个词是规范化,遵守一定标准的意思。范式哈夫曼编码,是哈夫曼编码的一个子集。其基本思想,是对哈夫曼编码施加某些强制约定,让其遵守一些规则,之后通过很少的数据便能重构出哈夫曼编码树。
为了完整,摘抄自维基百科范氏霍夫曼編碼
而范式霍夫曼编码修正了这些缺点,借由一些原则以达成利用较少的数据便能还原霍夫曼编码的功能。范式霍夫曼编码要求相同长度编码必须是连续的,例如:长度为4的编码0001,其他相同长度的编码必须为0010、0011、0100...等等。为了尽可能降低存储空间,编码长度为 j 的第一个符号可以从编码长度为 j - 1 的最后一个符号所得知,即 c(j) = 2 * [c(j - 1) + 1],例如:从长度为3的最后一个编码100,可推知长度为4的第一个编码为1010。最后,最小编码长度的第一个编码必须从0开始。范式霍夫曼编码通过这些原则,便可以从每个编码还原整个霍夫曼编码树。
从中总结出三个规则,
- 最小编码长度的第一个编码必须从0开始。
- 相同长度编码必须是连续的。
- 编码长度为 j 的第一个符号可以从编码长度为 j - 1 的最后一个符号所得知,即 c(j) = 2 * [c(j - 1) + 1]。
上面这些话,我每个字都看得懂,合起来却根本不知道它在说什么。
当不明白理论究竟在说什么时,一个好方法是去分析具体的例子。JPEG 就用到了范式哈夫曼编码,我们绕远一点路,先去分析 JPEG 的哈夫曼编码,再回头弄懂这个算法。
JPEG 文件格式
JPEG 文件格式,可以拆分成一个个分区。
SOI(Start of Image, 0xFF + 0xD8)
section 0
section 1
section 2
section 3
....
section N
EOI(End of Image, 0xFF + 0xD9)每个分区基本格式为:
0xFF + Tag(标记)
data length(数据长度)
data (具体数据)当 Tag 为 0xC4, 表示 DHT(Difine Huffman Table),用于保存哈夫曼编码表,就是上面说的范式哈夫曼编码。
DHT 数据例子
我摘抄了一段 DHT 数据,共有 57 个字节,十六进制如下。
ff c4 0 37 11 0 2 2 2 1
3 2 5 2 4 5 5 0 3 0
0 1 2 0 3 4 11 21 5 12
31 13 41 6 22 32 51 61 14 71
23 81 91 a1 15 42 b1 c1 d1 7
33 52 e1 f0 24 62 f1- 这些数据前面 2 个字节 (ff c4) 表示这段数据是 DHT, DHT 的 Tag 为 0xc4。
- 接下来 2 个字节(0 37) 表示数据长度,十六进制的 0x37 就是十进制的 55。总长度 57 减去前面 2 个字节的 Tag,就等于长度 55。
- 接下来的 1 个字节(11),高 4 位为 1,表示 AC(交流)哈夫曼表。低 4 位表示哈夫曼表的 ID,这里 ID 为 1。
最前面 5 个字节跟编码没有关系,再接下来就是关键数据了,用于保存哈夫曼编码表。
为了方便描述,我们用 Symbol(符号)这个词表示编码前的原始值,用 Code(编码)表示哈夫曼编码后的二进制数据。哈夫曼编码后是个二进制位串,我们用 Code Length 来表示二进制位数。下面的这批数据。
0 2 2 2 1
3 2 5 2 4 5 5 0 3 0
0 1 2 0 3 4 11 21 5 12
31 13 41 6 22 32 51 61 14 71
23 81 91 a1 15 42 b1 c1 d1 7
33 52 e1 f0 24 62 f1其实描述了这个表格。
Code length | Number | Symbol
-------------+--------+----------
1 bit | 0 |
2 bits | 2 | 0x01 0x02
3 bits | 2 | 0x00 0x03
4 bits | 2 | 0x04 0x11
5 bits | 1 | 0x21
6 bits | 3 | 0x05 0x12 0x31
7 bits | 2 | 0x13 0x41
8 bits | 5 | 0x06 0x22 0x32 0x51 0x61
9 bits | 2 | 0x14 0x71
11 bits | 4 | 0x23 0x81 0x91 0xa1
12 bits | 5 | 0x15 0x42 0xb1 0xc1 0xd1
13 bits | 5 | 0x07 0x33 0x52 0xe1 0xf0
14 bits | 0 |
15 bits | 3 | 0x24 0x62 0xf1
16 bits | 0 |DHT 数据前 16 个数字描述 Code Length 的个数(Number),编码后不可能是 0 Bit, 就从 1 Bit 开始数。后面是具体的 Symbol 值,根据个数,依次填入表格项中。
这个表格值保存了 Code 的位数,是在说,
- 0x01 0x02,编码后的 Code 有 2 位。
- 0x00 0x03,编码后的 Code 有 3 位。
- 0x04 0x11,编码后的 Code 有 4 位。
但是就算我们知道了 Code 的位数,但还不知道 Code 本身啊?
上文说过,“范式哈夫曼编码,其基本思想是,是对哈夫曼编码施加某些强制约定,让其遵守一些规则,之后通过很少的数据便能重构出哈夫曼编码树”。现在轮到范式哈夫曼编码的规则出场了。
规则 1
- 最小编码长度的第一个编码必须从 0 开始。
上表中,最短编码长度为 2 Bits,从 0 开始。于是第 1 个 Symbol(0x01)编码就为 00。
Symbol(十六进制) | Code(二进制)
-----------------+------
0x01 | 00规则 2
- 相同长度编码必须是连续的。
第 2 个 Symbol(0x02) 编码长度也是 2 Bits。要连续,就是 00 + 1 = 01。于是
Symbol(十六进制) | Code(二进制)
-----------------+------
0x01 | 00
0x02 | 01规则 3
- 编码长度为 j 的第一个符号可以从编码长度为 j - 1 的最后一个符号所得知,即 c(j) = 2 * [c(j - 1) + 1]。
这里的 c(j) = 2 * [c(j - 1) + 1] 看起来复杂。其实就是 (code + 1) << 1;
因为 2 bits 最后的 Code = 01,因此 3 Bits 第一个 (01 + 1) << 1,就为 (10 << 1) = 100。注意上面计算是二进制。因此
Symbol(十六进制) | Code(二进制)
-----------------+------
0x01 | 00
0x02 | 01
0x00 | 100依次类推,根据范式哈夫曼编码的规则,还原出 Symbol 和 Code 的对应表。
Symbol(十六进制) | Code(二进制)
-----------------+------
0x01 | 00
0x02 | 01
0x00 | 100
0x03 | 101
0x04 | 11000
0x11 | 110010
0x21 | 110011
0x00 | 110100
0x12 | 1101010
... | ...Show me the code
上面描述似乎很复杂,实际的代码很简单,核心代码就几行。下面代码从 JPEG 的 DHT 中打印出 Symbol 和 Code 的对应关系,就是上面的表格。
#include <stdio.h>
static const char *to_binary_str(int code, int n_bits, char buf[64]) {
int mask = 1 << (n_bits - 1);
for (int i = 0; i < n_bits; i++) {
if (code & mask) {
buf[i] = '1';
} else {
buf[i] = '0';
}
mask >>= 1;
}
buf[n_bits] = 0;
return buf;
}
int main(int argc, const char *argv[]) {
// clang-format off
const int DHT[] = {
0xff, 0xc4, 0x00, 0x37, 0x11, 0x00, 0x02, 0x02, 0x0, 0x01,
0x03, 0x02, 0x05, 0x02, 0x04, 0x05, 0x05, 0x00, 0x0, 0x00,
0x00, 0x01, 0x02, 0x00, 0x03, 0x04, 0x11, 0x21, 0x0, 0x12,
0x31, 0x13, 0x41, 0x06, 0x22, 0x32, 0x51, 0x61, 0x1, 0x71,
0x23, 0x81, 0x91, 0xa1, 0x15, 0x42, 0xb1, 0xc1, 0xd, 0x07,
0x33, 0x52, 0xe1, 0xf0, 0x24, 0x62, 0xf1,
};
// clang-format on
const int *numbers = DHT + 5;
const int *symbols = numbers + 16;
char buf[64];
int code = 0;
for (int i = 0; i < 16; i++) {
int num = numbers[i];
int n_bits = i + 1;
for (int j = 0; j < num; j++) {
int symbol = *symbols;
printf("0x%0.2x | %s\n", symbol, to_binary_str(code, n_bits, buf));
code++;
symbols++;
}
code <<= 1;
}
return 0;
}编码过程
上面描述了范式哈夫曼的解码过程,用很少量的数据就还原出 Symbol 和 Code 的对应关系。编码过程也很简单。首先按照经典的哈夫曼编码,得到一个对应关系,这个例子见范氏霍夫曼編碼
F:000
O:001
R:100
G:101
E:01
T:11再按照编码长度排序,这里也按照字母排序了,其实只要按照长度排序就行了
E:01
T:11
F:000
G:101
O:001
R:100之后按照三个规则,重新给每个符号分配新的编码。
- 第一个符号的编码方式是依照符号的编码长度给予相同长度的'0'值
- 对接下来的符号的编码+1,保证接下来的编码大小都大于之前
- 如果编码较长,比特左移一位并补0
E:01 → 00 按照1.
T:11 → 01 依照2.
F:000 → 100 依照2.&3.
G:101 → 101 依照2.
O:001 → 110 依照2.
R:100 → 111 依照2.经过给符号重新编码后,就规范化了。之后只需要保存 Symbol 的编码长度, 不用保存 Code 本身。就大大节省了数据量。
哈夫曼编码的关键在于,频率越高,编码的二进制位越短,而具体的编码到底是多少,其实是没有所谓的。范式哈夫曼编码,经过规范化后,具体的 Code 跟原来不同了,但它的长度保持一致,压缩效率跟原来一样。但却可大大节省保存编码树本身的数据量。
哈夫曼编码后,二进制位数据是连续的,中间没有分隔符。需要保证各个字符的编码是不会冲突的,也就是说,不会存在某一个编码是另一个编码的前缀。
范式哈夫曼编码规则 1,从 0 开始,保证有个起点。2、3 是递增规则,描述了如何从前面的 code 得到后面的 code。有起点,有递增规则,就可不断地生成新的编码(联想到数学归纳法)。而 2、3 的递增规则,也保证了规范后的编码不会冲突,不会存在某一个编码是另一个编码的前缀。
哈夫曼查找表
哈夫曼解码,还有一个细节可以讨论。
得到了 symbol 和 code 的对照表,可以重新构造二叉树,每次读取一位来遍历二叉树。每次到达叶节点就解码出一个 Symbol。但这种方式需要重新构造二叉树,并且每次只能解码一位。
其实不一定非要重新构造二叉树,也可以将对照表依次存储起来。比如
Symbol | Code | Bit Length
-------+--------+-----------
0x01 | 00 | 2
0x02 | 01 | 2
0x00 | 100 | 3
0x03 | 101 | 3
0x04 | 11000 | 5
0x11 | 110010 | 6
0x21 | 110011 | 6
... | ... | ...解码的时候,依次尝试表格中每一项。但这样循环遍历,对应表格越大,就会越慢。
通常哈夫曼快速解码时,会使用查找表 (Lookup table)。
哈夫曼编码有个特点,某一个编码不可能是另一个编码的前缀。假如某一个 Symbol 的 Code 位 01,就不可能出现另一个 Symbol 的 Code 为 010。因为二进制位数据是连续的,如果同时出现 Code 为 01 和 010,就会导致解码混乱。哈夫曼编码不可能出现这种情况。
利用哈夫曼编码的这个特性,我们可以构建出查找表。比如下面的对照表。
Symbol | Code | Bit Length
-------+------+-----------
A | 00 | 2
B | 01 | 2
C | 100 | 3
D | 101 | 3根据对照表,字符串 "ADBCD" 的编码为
0010101100101解码是要从 Code 找到 Symbol, 这里最大的 Bit Length 为 3,我们去构造出 3 位的查找表。3 位的表格有 2 ^ 3 = 8 项,如下。
Code | Symbol | Bit Length
-----+--------+-----------
000 | A | 2
001 | A | 2
010 | B | 2
011 | B | 2
100 | C | 3
101 | D | 3
110 | 0xFF | 0
111 | 0xFF | 0注意查找表中,Code 为 000 和 001 都直接对应 Symbol A。这是因为 A 的原始 Code 为两位的 00,不可能出现其他前缀相同的编码,也就可以将 000 和 001 都分配给 A。其中 B 的情况类似。而 110 和 111 没有对应的编码, Bit Length 就为 0,表示找不到的情况。
另外要注意到,查找表中,Code 是按顺序来排列的。实际程序中 Code 就相当于数组的索引,可以直接定位,因此 Code 也不需要真正保存。
有了查找表,我们来解码 0010101100101。
- 读取 3 位为 001, 使用 001 作为索引,定位到查找表的选项,知道真正的 Bit Lenght 为 2。于是解码出 'A', 解码器前进 2 位,剩余
10101100101。 - 读取 3 位为 101,使用 101 作为索引,定位到查找表的选项。于是解码出 'D', 解码器前进 3 位,剩余
01100101。 - 读取 3 位为 011,解码出 'B', 解码器前进 2 位,剩余
100101。 - 读取 3 位为 100,解码出 ‘C', 解码器前进 3 位,剩余
101。 - 读取 3 位为 101,解码出 'D', 解码器前进 3 位,解码完成。
构造查找表之后,每次都可以直接读取 3 位,直接定位快速解码。再根据 Bit Length 前进真实的位数。最终正确解码出 'ADBCD'。
这是空间换时间的策略,3 位查找表共有 2^3 = 8 项,8 位就有 2^8 = 256 项。
真实的解码器中,通常会限制一个最大值,比如我限制最大值 8 位。8 位就有 2^8 = 256 项。少于 8 位的 Code 会在查找表中,多于 8 位就放在较慢的顺序表中。