上一篇从信息论的角度揭露了IEEE浮点数的设计缺陷,目的是提出一套可以替代IEEE浮点数的编码方案:精度反转算法。但首先要了解该算法的基础:VLQ编码。
Base127 VLQ:可变长的物理量
VLQ指variable length quantity,即可变长度的量,这个量可以是任何信息的数量。不得不说大厂取名字很有讲究,一般都喜欢绕过名词本身用途,引用更抽象的意思,比如PWA:progressive web application,渐进式web应用,看上去很高大上其实就是一套可以本地安装web应用的api。
VLQ也是如此,本来发明VLQ只是用来编码整数,希望让绝对值更小的整数占据更小的空间,后来发现任何物理量都可以通过VLQ来编码,由此得名。
这种命名习惯据说是有科学依据的,所谓的“行业壁垒”,指的是内行人总是会不自觉地提高外行人的学习门槛,虽然个体都是无意识的利己行为,群体的行为结果就是行业壁垒的提高。本来很简单的名词都会被“神秘”化。
VLQ是基于7bit组的变长编码,本身很简单,就是利用每个字节的首个bit来暗示是否有后续字节。
0XXXXXXX
1XXXXXXX 0XXXXXXX
1XXXXXXX 1XXXXXXX 0XXXXXXX
......
这样做的好处在于,不需要将对象的长度写在前缀中,而每个末字节的“0”代表“休止符”。这是信息论又一个重要概念。
例如,十进制自然数106,903转换成VLQ字节串的示意图如上,106903 = 6*2^14 + 67*2^7 + 23,简单明了。
扫描仪(decoder)在一条序列化数据上从左至右扫描的时候,当扫描到某一个“子元素/对象/字符/值”身上时,何时结束是一个关键点,通常有2种方式来暗示何时停止。
前缀式:将子元素的长度存在前缀中。
休止符式:通过末端的一个“休止符”来提示扫描仪,它可以是一个终止字符也可以是一个终止字节。
前一种将长度写在前缀中的方式在二进制的协议格式中非常常见,比如众多IP子协议和二进制序列化格式;后一种通过“休止符”来终止的方式则常见于海量的文本格式以及古老的文本型通讯协议,连DNA的解码都是通过“终止子”来分隔肽链。
休止符相对于前缀的好处在于柔韧性,不用为长度上限发愁,比如字符串的EOF终止字符:只要扫描仪没碰到EOF,就会一直扫描下去。很显然,我们VLQ属于“休止符式”。
VLQ偏移自然数(冗余消消消)
但还不够,上一篇提到的编码的2个基本原则:“无歧义”、“无冗余”。如果用VLQ来表示一个自然数的话会出现这样的情况:用1个字节能表示的数(0~127)用2个字节同样能表示(0~16383)。n字节的VLQ能兼容n-1字节VLQ。我们是拒绝这样的兼容的,如果单字节VLQ表示0~127的自然数,双字节从一开始干脆从128开始计数。
多字节VLQ自然数的实际值等于它的面值加上一个偏移值,这个偏移值等于上一级字节数的最大值加一,也就是本级的最小值。
偏移的原因在于,自然状态下不同的实数长度共享了一部分实数空间,比如3字节的实数包含了2字节的全部空间,例如 00 00 01 和 00 01都是1.。
所以每一种长度的实数的实际值要加上之前所有更短长度的空间总和。例如 00 01代表1,则 00 00 01代表257(255+2)。
不同字节数的VLQ整数和对应的实际值具有如下关系:
字节数 |
整数空间 |
min |
max |
---|---|---|---|
1 | 2^7 | 0 | -1+2^7 |
2 | 2^14 | 2^7 | -1+2^7+2^14 |
3 | 2^21 | 2^7+2^14 | -1+2^7+2^14+2^21 |
n | 2^7n | 2^7+2^14+...+2^7(n-1) | -1+2^7+2^14+...+2^7n |
其中,每个min等于上一行的max+1。
min代表此整数空间中若干个7bit组“全0”的意义,max代表此整数空间中“全1”的意义。
VLQ的二进制面值和实际值的映射关系:
VLQ |
自然数 |
0000 0000 |
0 |
...... |
|
0111 1111 |
127 |
1000 0000 0000 0000 |
128 |
...... |
|
1111 1111 0111 1111 | 16511 |
1000 0000 1000 0000 0000 0000 | 16512 |
...... |
有了一一映射(bijective),即使随便拿来一串字节,都能解析成一个唯一的自然数,从空间效率上不仅实现了变长,又没有浪费一丝空间。这就是“精度反转算法”的基础:VLQ偏移自然数,简称VLQ自然数。VLQ与自然数的相互转换函数也应运而生。
注意,VLQ偏移自然数并不是我的原创(本来以为是我独创的,但寻思着我也没那么聪明,我能想到别人也能想到),后来搜索过后才发现Git早已实现了这套算法,还给他起了个专门的名字:双射计数法(bijective numeration)双射就是一一映射的意思。
双射VLQ的代码实现
const r7 = 2 ** 7;
const r14 = 2 ** 14;
const r21 = 2 ** 21;
const r28 = 2 ** 28;
const r35 = 2 ** 35;
const r42 = 2 ** 42;
const R7 = r7;
const R14 = r14 + r7;
const R21 = r21 + r14 + r7;
const R28 = r28 + r21 + r14 + r7;
const R35 = r35 + r28 + r21 + r14 + r7;
const R42 = r42 + r35 + r28 + r21 + r14 + r7;
const r = [1, r7, r14, r21, r28, r35, r42];
const R = [0, R7, R14, R21, R28, R35, R42];
上面预先计算了一些常量,以空间换时间,供下面使用。
自然数转换成VLQ字节串的函数:
function nature2vlq(number) {
let tobeUint8Array;
R.find((RR, index) => {
if (number < RR) {
const faceValue = number - R[index - 1];
tobeUint8Array = Array.from({ length: index })
.map((x, i) => (faceValue / r[i]) % r7 | (i ? r7 : 0))
.reverse();
return true;
} else return false;
});
return new Uint8Array(tobeUint8Array);
}
VLQ字节串转换成自然数的函数:
function vlq2nature(vlq) {
return (
vlq
.map((x) => x & (r7 - 1))
.reduce((sum, next, i) => {
sum += next * r[vlq.length - i - 1];
return sum;
}, 0) + R[vlq.length - 1]
);
}
有了VLQ偏移自然数,变长整数编码也水到渠成,只要结合任意一种传统整数编码比如2的补码或者zigzag,以之为自然数再映射到VLQ即可。
配图:《Rick & Morty》第四季