上一篇從信息論的角度揭露了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,簡單明瞭。
掃描終止信號的2種模式:前綴VS休止符
掃描儀(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》第四季