JPEG 中的范式哈夫曼编码

各小节:

  • 概述
  • 自适应的哈夫曼编码
  • 范式哈夫曼编码(Canonical Huffman Code)
  • JPEG 文件格式
  • DHT 数据例子
  • Show me the code
  • 编码过程
  • 哈夫曼查找表

概述

哈夫曼(Huffman)压缩是个经典的算法,在网上可以找到很多文章。将数据拆分成一个个符号(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开始。范式霍夫曼编码通过这些原则,便可以从每个编码还原整个霍夫曼编码树。

从中总结出三个规则:

  1. 最小编码长度的第一个编码必须从0开始。
  2. 相同长度编码必须是连续的。
  3. 编码长度为 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

最前面 5 个字节跟编码没有关系,可以直接跳过。但为完整,也描述其含义。

  • 这些数据前面 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。

接下来就是关键数据了,用于保存哈夫曼编码表。

为了方便描述,我们用 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
10 bits      | 4      | 0x23 0x81 0x91 0xa1
11 bits      | 5      | 0x15 0x42 0xb1 0xc1 0xd1
12 bits      | 5      | 0x07 0x33 0x52 0xe1 0xf0
13 bits      | 0      | 
14 bits      | 3      | 0x24 0x62 0xf1
15 bits      | 0      | 
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) 就是 (c_{j-1} + 1) << 1 ,乘以 2 相当于向左移位。

因为 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             | 1100
0x11             | 1101
0x21             | 11100
0x05             | 111010
...              | ...
0xf0             | 111111111110
0x24             | 11111111111100
0x62             | 11111111111101
0xf1             | 11111111111110

有一个例外情况需要说明,13 Bits 最后一个 Symbol, 0xf0 的 Code 为 111111111110。但之后没有 14 Bits,直接跳到 15 Bits。这时向左移位就不是移 1 位,而是移 2 位。规则 3 修正为 c_{j} = (c_{j-k} + 1) << k

于是 15 Bits 的第一个符号 0x24 的 Code 为 (111111111110 + 1) << 2 = 11111111111100。

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,  0x02,  0x01,
        0x03, 0x02,  0x05,  0x02,  0x04,  0x05,  0x05,  0x00,  0x03,  0x00,
        0x00, 0x01,  0x02,  0x00,  0x03,  0x04,  0x11,  0x21,  0x05,  0x12,
        0x31, 0x13,  0x41,  0x06,  0x22,  0x32,  0x51,  0x61,  0x14,  0x71,
        0x23, 0x81,  0x91,  0xa1,  0x15,  0x42,  0xb1,  0xc1,  0xd1,  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

之后按照三个规则,重新给每个符号分配新的编码。

  1. 第一个符号的编码方式是依照符号的编码长度给予相同长度的'0'值
  2. 对接下来的符号的编码+1,保证接下来的编码大小都大于之前
  3. 如果编码较长,比特左移一位并补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 Length 为 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 位就放在较慢的顺序表中。

编辑于 2019-10-08

文章被以下专栏收录