Varint數據壓縮算法

  • 二進制補碼      

先說說二進制補碼。對於有符號型整數的二進制表示而言:

1. 最高位是符號位,0代表正數,1代表負數;

2. 正數和0的反碼和補碼都是本身;

3. 對於負數,反碼就是符號位不變,其他各位依次取反;補碼就是在反碼的基礎上加1;

以int32舉例:

 
數值 源碼 反碼 補碼
15    0...0,0000,1111 共32bit   0...0,0000,1111 共32bit  0...0,0000,1111 共32bit
-11 10...0,0000 ,1011 共32bit 11...1,1111,0100 共32bit 11...1,1111,0101 共32bit

在計算機中,正數存放的本身的二進制表示,而負數存放的是它的補碼。爲什麼要存放補碼?源碼不是更好嗎?我們來分析一下。

在計算機中,加法是最常見的運算形式。一個簡單的減法運算可以轉換爲加法運算,比如15-11 = 15 + (-11)。

先看減法15-11的二進制運算: 0...0,0000,1111 - 0...0,0000 ,1011 = 0100 =十進制 4。

再看加法15 + (-11)的二進制運算: 0...0,0000,1111 + 11...1,1111,0101 = 1,0000,...,0000,0100,共33位。因爲數據類型爲int32,故高位發生了截斷,丟棄了最高位的1, 故結果爲0100 = 十進制 4。

       這麼看來,負數用補碼來表示沒什麼問題。這裏理解的關鍵是符號位參與了運算且如果超出存儲長度,高位截斷。試想一下,如果不高位截斷,則結果爲 1,0000,...,0000,0100,共33bit,高位截斷本質就是取模運算,也就是取餘。這裏的模是2^32。

舉例:

模爲12,4+8 =12,所以4和8互爲補數。

9 + 4 = (9 + 8)%12 = 5,這裏的取模就相當於高位截斷。

故有公式: a+負數b = (a + 負數b的補數),如超出長度,則高位截斷 = (a + 負數b的補數)%模。這就是補碼來代表負數的理論基礎。實際中,對於有符號數而言,符號位爲0,則該值爲正數二進制本身;符號位爲1,則這裏的值爲負數二進制的補碼。
 

  • 移位操作

移位操作分爲有符號數的移位和無符號數的移位。

有符號的左移位<<:即算術左移位,是一種帶符號的左移位運算。是將運算數的二進制碼整體左移指定位數,左移之後的右側空位用0補充;

有符號的右移位>>:即算術右移位,是一種帶符號的右移位運算。是將運算數的二進制碼整體右移指定位數,右移之後的左側空位用運算數的符號位補充,如果是正數用0補充,負數用1補充。

無符號的左移位<<:即邏輯左移位,是將運算數的二進制碼整體左移指定位數,左移之後的右側空位用0補充;

無符號的右移位>>:即邏輯右移位,是將運算數的二進制碼整體右移指定位數,右移之後的左側空位用0補充;
 

注:

(1). 如果移動的位數超過了該類型的最大位數,那麼編譯器會對移動的位數取模。如對int型移動33位,實際上只移動了33%32=1位。

(2). 當移位的運算數是byte 和short類型時,將自動把這些類型擴大爲 int 型。

(3). 在java中,無符號右移運算符爲 >>>

它的格式:value >>> num,num 指定要移位值value 移動的位數。

無符號右移的規則只記住一點:忽略了符號位擴展(即右移之後的左側空位用運算數的符號位補充,如果是正數用0補充,負數用1補充。),0補最高位。

無符號右移運算符>>> 只是對32位和64位的值有意義

(4). 算術移位和邏輯移位雖然方式是一樣的,但他們表示的移位後數的範圍是不一樣的。以8位二進制舉例,有符號數移位(算術移位)後的範圍是-128——127;而無符號數移位(邏輯移位)後的範圍是0——255。不管是哪種移位,均要考慮移位後的範圍。

以8位二進制數舉例:

-12 >>2 11110100(-12的補碼) -> 11111101 (-3的補碼)
-3 << 2 11111101 (-3的補碼) -> 11110100(-12的補碼)
  • Varint算法分析

 言歸正傳,下面進入今天的主題:varint算法分析。

對於存儲IO和網絡IO而言,數據越小,越能減少IO開銷和節省網絡帶寬。對於常規的數據存儲,固定類型的數據所佔的內存大小是確定的。比如:byte佔8bit,int32佔32bit,float佔32bit等等。也就是說,不論數值大小如何,內存都要開闢固定長度的內存區用來存放數據。絕大多數情況下這是浪費的。比如無符號型0~255,有符號型-128~127,本來8bit就可以搞定,而int32都分配了32bit的長度,這意味着前面的3字節長度並沒有利用,是浪費的。Varint存儲算法就是針對這種情況的改良。

對於Varint,每8bit的數據中,首位叫作most signficant bit,簡稱爲msb,它不是數據位,代表特殊的含義:

0:代表該字節就是數據的結尾;1:代表該字節的下一字節依然是數據的一部分。

後面的7bit用來表示數據域。

舉例說明。

1的二進制爲:0000 0001,用varint編碼爲:0000 0001,即0x01,顯然一個字節就表示出了大小。節省了3字節的空間。

 666的二進制爲:1010011010,用varint編碼爲:10011010, 0000 0101。佔兩字節大小。這裏解釋一下,varint算法採用的是逆序存儲。即二進制數據從後向前開始,每7位爲一個單元,開頭加上msb,按從前向後排列。從後往前的第一個7bit爲0011010,一個字節顯然存不下666,所以msb爲1,所以第一個字節爲10011010;剩下的3bit爲101,不足7bit補0,加上msb爲1,故第二字節爲0000 0101。

需要說明的是,這裏的數據域存放的都是原數據的補碼。文章的開頭提過,0和正數的補碼爲本身;負數的補碼爲符號位不變,其他位分別取反,最後的結果加1。

再來看下解碼過程。

對於1001 1010, 0000 0101。首先讀取首字節10011010,再讀取msb,這裏爲1,可知接下來的1字節依然需要讀取;剩餘的數據域爲0011010;再讀取接下來的1字節:0000 0101, msb爲0,故該字節即爲最後一字節。數據域爲000 0101;逆序還原最終數據,得到:0000 10 1001 1010,轉換爲十進制得到666。

總結一下,Varints 編碼規則主要有三點:

  1. 在每個字節開頭的 1bit 設置了 msb(most significant bit ),標識是否需要繼續讀取下一個字節;剩餘的7bit爲數據域;
  2. 數據域存儲數字對應的二進制補碼;
  3. 數據域按逆序排列,低位排在前面;

到目前爲止,我們看到的都是Varint編碼的優點:用盡可能少的字節表示數據。顯然對於一般的小數值而言這是巨大的優勢。下面我們分析一下這種編碼的缺點。

(1) 不適合存儲大數值

       一方面,對於int32而言,有32個bit表示數據域。Varint每字節都有一個msb,即意味着32bit中只有28bit來表示數據。對於小於2^28的數據,Varint最多需要4字節來編碼。但對於2^28~ (2^32-1)範圍內的數據,Varint編碼4字節顯然存不下,還需要額外的1字節,共5字節。所以,對於2^28~ (2^32-1)範圍的數據,Varint編碼性能是降低的,開銷還大。但絕大多數情況下,我們用到的值大概率的都是小於2^28的,所以Varint編碼還是很有優勢的。

(2) 不適合存儲負數

       另一方面,對於負數。我們知道存儲的是補碼。-1的補碼爲1111...,1111,共32bit。Varint要存放-1需要多少字節呢?乍一看一共32個1,所以需要5字節。

而在protocol buffer中,Varint編碼需要10字節來存放負數。這是因爲爲了兼容,將 int32 擴展成 int64 的八個字節。所以-1的補碼爲1111,..., 1111,共64bit。加上10個msb,一共10字節。(64bit的數據域爲:9個7bit + 1bit,故共10個msb。 )

       顯然Varint編碼對於存放int32、int64的負數是低效的,開銷更浪費。爲解決這個問題, ZigZag 編碼被推出以解決負數編碼效率低的問題。ZigZag 的原理和概念簡單易懂,用話概括介紹 ZigZag 編碼:有符號整數映射到無符號整數,然後再使用 Varints 編碼。既然Varint編碼負數效率低下,那麼將負數"轉換、映射成"正數,針對正數進行Varint編碼並傳輸,對方收到數據後,再進行"轉換、映射"解碼成對應的負數即可。

如上圖所示,0的ZigZag編碼爲0;-1的ZigZag編碼爲1;1的ZigZag編碼爲2;-2的ZigZag編碼爲3 ...。比如int32 a = -1,經過ZigZag編碼得到1,對1進行Varint編碼得到0000 0001,並存儲傳輸。接收方收到數據0000 0001後進行Varint解碼並得到1,將通過ZigZag解碼得到-1。

需要注意的是:這裏的“編碼、映射”並非簡單存儲映射表,是以移位操作實現的。另外可知,在不溢出的前提下,ZigZag編碼正數得到的是原數的兩倍。

下面我們看下ZigZag的代碼實現:

//int to uint的轉換:
private static uint Zig(int value){
    return (uint)((value << 1) ^ (value >> 31));
}

//uint to int的轉換:
private static int Zag(uint ziggedValue){
    int value = (int)ziggedValue;
    return (-(value & 0x01)) ^ ((value >> 1) & ~( 1<< 31));
}

 

前面提到Varint編碼int32的正數最多需要5字節。下面是對一個uint進行Varint編碼:

private static byte[] WriteUint32ToVarint(uint value){
     byte[] data = new byte[5];
     int count = 0;
     do{
           data[count] = (byte)((value & 0x7F) | 0x80);
           count++;
     } while ((value >>= 7) != 0);
     data[count - 1] &= 0x7F;
     return data;
}

分析下代碼:

    data[count] = (byte)((value & 0x7F) | 0x80);   // value & 0x7F得到頭7位的數值; | 0x80是表明後面的byte也是數字的一部分。

    while ((value >>= 7) != 0)    右移7位如果不爲零,則繼續如上操作。

    data[count - 1] &= 0x7F  因爲最後一個字節就是編碼的結束,故把最高位設置成0。

 

接下來就是一個uint的解碼過程 :

private static uint ReadUInt32FromVarint(byte[] data){
            uint value = data[0];
            if ((value & 0x80) == 0) return value;
            value &= 0x7F;
            uint chunk = data[1];
            value |= (chunk & 0x7F) << 7;
            if ((chunk & 0x80) == 0) return value;
            chunk = data[2];
            value |= (chunk & 0x7F) << 14;
            if ((chunk & 0x80) == 0) return value;
            chunk = data[3];
            value |= (chunk & 0x7F) << 21;
            if ((chunk & 0x80) == 0) return value;
            chunk = data[4];
            value |= chunk << 28;
            if ((chunk & 0xF0) == 0) return value;
            throw new OverflowException("ReadUInt32Variant Error!");
}

    (value & 0x80) == 0 表示最高位msb爲0,說明後面數據已經不是該數據部分了,故停止解碼並返回。

    (chunk & 0xF0) == 0 因data[4]只有低4位,故chunk只有低4位,如果不是則表明這個byte不是數值存儲的一部分。

   

測試一下看下編碼效果

byte[] data = WriteUint32ToVarint(Zig(0));
System.out.println(data.length);
data = WriteUint32ToVarint(Zig(666));
System.out.println(data.length);
data = WriteUint32ToVarint(Zig(10000));
System.out.println(data.length);
data = WriteUint32ToVarint(Zig(-100000));
System.out.println(data.length);

分別是1,2,3,3。分析一下:

0的ZigZag編碼還是0,對應的二進制爲0000 0001,故對應Varint編碼還是0000 0001,1字節;

666的ZigZag編碼是1332,對應的二進制爲10100110100,故對應Varint編碼還是1#0110100,0#000 1010,2字節;

10000的ZigZag編碼是20000,對應的二進制爲10011100 0100000,故對應Varint編碼還是1#0100000,1#0011100 0#000 0001,3字節;

-100000的ZigZag編碼對應的二進制爲1100 0011010 0111111,故對應Varint編碼還是1#0111111,1#0011010 0#000 1100,3字節;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章