为了最大程度地减少每个 Web 请求和响应传输的数据量,HTTP/2协议使用HPACK格式对标头进行编码,并使用 Hoffman 算法对其文字值进行编码。
采用逐位读取并解码的哈夫曼算法似乎在性能方面是有些问题的。而采用一次读取多位的快速霍夫曼解码则可以大幅提升解码性能。采用状态转移矩阵方式实现的哈夫曼算法首先需要创建一个状态转移矩阵,在解码的时候,根据这个状态转移矩阵一次读取n个bits并通过查找状态转移矩阵来实现快速解码。
首先,让我们通过一个非常简单的例子来如果来构建这个状态转移矩阵。假设,我们需要处理的字母表为 A、B、C、D 和 E,它的哈夫曼编码如下:
特点 | 哈夫曼表 |
---|---|
A | 00 |
乙 | 01 |
C | 100 |
D | 101 |
乙 | 11 |
我们根据上面的哈夫曼表来构建一个解码状态转义矩阵,并且这里假设解码器一次读取 2 位的霍夫曼位序列。下图是我们需要填写的表结构。
PATH | ID | SYM | LFT | 00 | 01 | 10 | 11 |
---|---|---|---|---|---|---|---|
// | 0 | - | - | - | - | - | - |
上表中的每一行代表一个状态记录。第一列 PATH 将用于注释,我们将在其中存储当前读取的位,以便我们知道哪个序列引用了哪个表行。每个字符代码的读取始终从标有 的根行开始//
。该列ID
将存储行的唯一编号。第一行标有0
。该列SYM
将存储解码出来的字符(例如A)。字段LFT
将存储有关剩余位bits数。剩余位是指到达完整位块(在我们的例子中为 2 位)还需要多个位。例如,字母 C 和 D 的剩余部分为 1,因为要达到整数位数(在我们的例子中为 2 位 * N),还需要 1 位。字母 A、B 和 E 没有剩余。其余列表示 2 位的读取块,其所有可能值范围为00
(0) 到11
(3)。
最后四列00、01、10、11分别代表在当前状态下面,碰到后续读取的00、01、10、11四种bit特位的时候,将当前状态如何转移到新的状态。
下面根据哈夫曼编码表来示例解码状态转移表的数据填充过程。如前所述,我们一次读取 2 位霍夫曼代码。让我们看看如何向表中插入第一个字母 A 的数据。
字母A用代码表示00
。由于第一列中没有//00
此代码的路径,因此我们使用新的ID
.在第一行的列00
中我们写入新建立的状态ID
=1的 。由于我们读取了字母 A 的所有位,因此我们也在该SYM
列中写入字符 A。
PATH | ID | SYM | LFT | 00 | 01 | 10 | 11 |
---|---|---|---|---|---|---|---|
// | 0 | - | - | 1 | - | - | - |
//00 | 1 | A | 0 | - | - | - | - |
然后我们对字母 B 重复此过程。字母 B 用代码 表示01
。由于此代码没有路径//01
,因此我们使用新的ID
,在第一行的列01
中我们写入新建立的行的ID
=2 。由于我们读取了字母 B 的所有位,因此我们还将字符 B 写入该SYM
列。
PATH | ID | SYM | LFT | 00 | 01 | 10 | 11 |
---|---|---|---|---|---|---|---|
// | 0 | - | - | 1 | 2 | - | - |
//00 | 1 | A | 0 | - | - | - | - |
//01 | 2 | B | 0 | - | - | - | - |
字母 C 的处理过程有些不同,因为它的位数是正好能够被2整除。首先,我们读取前 2 位位并将它们插入表中,过程与之前相同,新增一行//10 ID=3的行,并且第一行的10
列设置为3。之后,我们读取剩余的位,同时假设丢失位的所有可能变化都存在。这标有X
。由于缺少一位,我们在列中注明这一点LFT
。
PATH | ID | SYM | LFT | 00 | 01 | 10 | 11 |
---|---|---|---|---|---|---|---|
// | 0 | - | - | 1 | 2 | 3 | - |
//00 | 1 | A | 0 | - | - | - | - |
//01 | 2 | B | 0 | - | - | - | - |
//10 | 3 | - | - | 4 | 4 | - | - |
//100X | 4 | C | 1 | - | - | - | - |
&emps; 接下去继续对字母 D 和 E 重复该过程。最终的表格应如下所示:
PATH | ID | SYM | LFT | 00 | 01 | 10 | 11 |
---|---|---|---|---|---|---|---|
// | 0 | - | - | 1 | 2 | 3 | 6 |
//00 | 1 | A | 0 | - | - | - | - |
//01 | 2 | B | 0 | - | - | - | - |
//10 | 3 | - | - | 4 | 4 | 5 | 5 |
//100X | 4 | C | 1 | - | - | - | - |
//101X | 5 | D | 1 | - | - | - | - |
//11 | 6 | E | 0 | - | - | - | - |
请注意,将标有 X 的变体替换为实际可能的路径是正确的。
PATH | ID | SYM | LFT | 00 | 01 | 10 | 11 |
---|---|---|---|---|---|---|---|
// | 0 | - | - | 1 | 2 | 3 | 6 |
//00 | 1 | A | 0 | - | - | - | - |
//01 | 2 | B | 0 | - | - | - | - |
//10 | 3 | - | - | 4 | 4 | 5 | 5 |
//1000 | 4 | C | 1 | - | - | - | - |
//1001 | 4 | C | 1 | - | - | - | - |
//1010 | 5 | D | 1 | - | - | - | - |
//1011 | 5 | D | 1 | - | - | - | - |
//11 | 6 | E | 0 | - | - | - | - |
以上就把解码的状态转移矩阵建立好了,它在解码过程中起着至关重要的作用。下面利用这个状态转移矩阵来看看解码的过程。
作为示例,我们按以下顺序使用字符序列:A、D 和 B。
ADB = 0010101
解码的时候,解码器通过一次读取 2 位进行解码,初始状态是//
,即ID=0的状态。首先,我们读取序列中的前两位00
。在转移矩阵的第一行中ID=0
,我们需要检查该状态下碰到00
将转移到哪一个状态,我们看到下一个状态是1,//0
行的状态,而该行记录了SYM=A,LFT=0,表示输出字符A,因为LFT=0表示没有遗留的字符,所以状态重新回到//
。
对于接下来的 2 位,解码去重复该过程读取10
。在//
状态的10列中我们看到下一个状态ID是3,于是进入状态//10
,然后继续处理接下来的 2 位10
。查看//10
状态下面的10
列,可以看到下一个状态ID是5,于是进入状态//1010
,这个状态的SYSM
=D,代表输出字母 D。而在这里我们可以看到 列的值LFT=1
,意味着有一个剩余 1个bit。这意味着为了继续读取位,我们必须向后移动一位并在那里继续该过程。
最后我们将自己重新回到状态初始状态//
,因为前面剩余了一个bit 0
,本次只要读取一个bit即可,于是我们读取到了01
,而01
对应的是状态ID=2的状态//01
,对应于字符B,所以输出字母B,如此完成了整个解码过程。
00XXXXX => A
XX10XXX => continue
XXXX10X => D
XXXXX01 => B
通过使用之前创建的一次读取 2 位的状态转换矩阵,我们成功地将哈夫曼序列进行了解码。考虑到单个字符的最短霍夫曼代码为 5 位长,为了获得速度和所用资源之间的最佳比例,每次读取 4 位是最佳选择,利用上述介绍的过程,我们完全可以构造一个一次读取4位的状态转移矩阵。一次读取更多位意味着更快的解码,但同时也意味着需要更大的转换表从而占用更高的内存。