Lucene 4.X 倒排索引原理與實現: (2) 倒排表的格式設計

劉超覺先發表於2014-08-29

1. 定長編碼

最容易想到的方式就是常用的普通二進位制編碼,每個數值佔用的長度相同,都佔用最大的數值所佔用的位數,如圖所示。

clip_image002[6]

 

這裡有一個文件ID列表,254,507,756,1007,如果按照二進位制定長編碼,需要按照最大值1007所佔用的位數10位進行編碼,每個數字都佔用10位。

和詞典的格式設計中順序列表方式遇到的問題一樣,首先的問題就是空間的浪費,本來254這個數值8位就能表示,非得也用上10位。另外一個問題是隨著索引文件的增多,誰也不知道最長需要多少位才夠用。

2. 差值(D-gap)編碼

看過前面前端編碼的讀者可能會想到一個類似的方法,就是我們可以儲存和前一個數值的差值,如果Term在文件中分佈比較均勻(差不多每隔幾篇就會包含某個Term),文件ID之間的差別都不是很大,這樣每個數值都會小很多,所用的位數也就節省了,如圖所示。

clip_image004[6]

 

還是上面的例子中,我們可以看到,這個Term在文件中的分佈還是比較均勻的,每隔差不多250篇文件就會出現一次這個Term,所以計算差值的結果比較理想,得到下面的列表254,253,249,251,每個都佔用8位,的確比定長編碼節省了空間。

然而Term在文件中均勻分佈的假設往往不成立,很多詞彙很可能是聚集出現的,比如奧運會,世界盃等相關的詞彙,在一段時間裡密集出現,然後會有很長時間不被提起,然後又出現,如果索引的新聞網頁是安裝抓取先後來編排文件ID的情況下,很可能出現圖所示的情況。

clip_image006[6]

 

在很早時間第10篇文件出現過這個Term,然後相關的文件沉寂了一段時間,然後在第1000篇的時候密集出現。如果使用差值編碼,得到序列10,990,21,1,雖然很多值已經被減小了,但是由於前兩篇文件的差值為990,還是需要10位進行編碼,並沒有節省空間。

3. 一元編碼(unary)

有人可能會問了,為什麼要用最大的數值所佔用的位數啊,有幾位我們們就用幾位嘛。這種思想就是變長編碼,也即每個數值所佔用的位數不同。然而說的容易做起來難,定長編碼模式下,每讀取b個bit就是一個數值,某個數值從哪裡開始,讀取到哪裡結束,都一清二楚,然而對於變長編碼來說就不是了,每個數值的長度都不同,那一連串二進位制讀出來,讀到哪裡算一個數值呢?比如上面的例子中,10和990的二進位制連起來就是10101111011110,那到底1010是一個數值,1111011110是另一個數值呢,還是1010111是一個數值,剩下的1011110算另一個數值呢?另外就是會不會產生歧義呢?比如一個數值A=0011是另一個數值B=00111的字首,那麼當二進位制中出現00111的時候,到底是一個數值A,最後一個1屬於下一個數值呢,還是整個算作數值B呢?這都是變長編碼所面臨的問題。當然還有錯了一位,多了一位,丟掉一位導致整個編碼都混亂的問題,可以通過各種校驗方法來保證,不在本文的討論範圍內,本文僅僅討論假設完全正確的前提下,如何編碼和解碼。

最簡單的變長編碼就是一元編碼,它的規則是這樣的:對於正整數x,用x-1個1和末尾一個0來表示。比如5,用11110表示。這種編碼方式對於小數值來講尚可,對於大數值來講比較恐怖,比如1000需要用999個1外加一個0來表示,這哪裡是壓縮啊,分明是有錢沒處使啊。但是不要緊,火車剛發明出來的時候還比馬車慢呢。這種編碼方式雖然初級,但是很好的解決了上面兩個問題。讀到哪裡結束呢?讀到出現0了,一個數值就結束了。會不會出現歧義呢?沒有一個數值是另一個數值的字首,當然沒有歧義了。所以一元編碼成為很多變長編碼的基礎。

4. Elias Gamma編碼

Gamma編碼的規則是這樣的:對於正整數x,首先對於clip_image008[7]進行一元編碼,然後用clip_image010[7]個bit對clip_image012[7]進行二進位制編碼。

比如對於正整數10,clip_image014[6],對於4進行一元編碼1110,計算clip_image016[6],用3個bit進行編碼010,所以最後編碼為1110010。

可以看出,Gamma編碼綜合了一元編碼和二進位制編碼,對於第一部分一元編碼的部分,可以知道何時結束並且無歧義編碼,對於二進位制編碼的部分,由於一元編碼部分指定了二進位制部分的長度,因而也可以知道何時結束並無歧義解碼。

比如讀取上述編碼,先讀取一系列1,到0結束,得到1110,是一元編碼部分,值為4,所以後面3個bit是二進位制部分,讀取下面的3個bit(即便這3個bit後面還有些0和1,都不是這個數值的編碼了),010,值為2,最後clip_image018[6],解碼成功。

Gamma編碼比單純的一元編碼好的多,對於小的數值也是很有效的,但是當數值增大的情況下,就暴露出其中一元編碼部分的弱勢,比如1000經過編碼需要19個bit。

5. Elias Delta編碼

我們在Gamma編碼的基礎上,進一步減少一元編碼的影響,形成Delta編碼,它的規則:對於正整數x,首先對於clip_image008[8]進行Gamma編碼,然後用clip_image010[8]個bit對clip_image012[8]進行二進位制編碼。

比如對於正整數10,clip_image014[7],首先對於4進行Gamma編碼,clip_image020[4],對於3進行一元編碼110,然後用2個bit對clip_image022[4]進行二進位制編碼00,所以4的Gamma編碼為11000,然後計算clip_image016[7],用3個bit進行編碼010,所以最後編碼為11000010。

Delta是Gamma編碼和二進位制編碼的整合,也是符合變長編碼要求的。

如果讀取上述編碼,先讀入一元編碼110,為數值3,接著讀取3-1=2個bit為00,為數值0,所以Gamma編碼為clip_image024[4],然後讀取三個bit位010,二進位制編碼數值為2,所以clip_image018[7],解碼成功。

儘管從數值10的編碼中,我們發現Delta比Gamma使用的bit數還多,然而當數值比較大的時候,Delta就相對比較好了。比如1000經過Delta編碼需要16個bit,要優於Gamma編碼。

6. 哈夫曼編碼

前面所說的編碼方式都有這樣一個特點,“任爾幾路來 ,我只一路去”,也即無論要壓縮的倒排表是什麼樣子的,都是一個方式進行編碼,這種方法顯然不能滿足日益增長的多樣物質文化的需要。接下來介紹的這種編碼方式,就是一種看人下菜碟的編碼方式。

前面也說到變長編碼無歧義,要求任何一個編碼都不是其他編碼的字首。學過資料結構的同學很可能會想到——哈夫曼編碼。

哈夫曼編碼是如何看人下菜碟的呢?哈夫曼編碼根據數值出現的頻率不同而採用不同長度進行編碼,出現的次數多的編碼長度短一些,出現次數少的編碼長度長一些。

這種方法從直覺上顯然是正確的,如果一個數值出現的頻率高,就表示出現的次數多,如果採用較短的bit數進行編碼,少一位就能節約不少空間,而對於出現頻率低的數值,比如就出現一次,我們就用最奢侈的方法編碼,也佔用不了多少空間。

當然這種方法也是符合資訊理論的,資訊理論中的夏農定理給出了資料壓縮的下限。也即用來表示某個數值的bit的數值的下限和數值出現的概率是有關的:clip_image026[4]。看起來很抽象,舉一個例子,如果拋硬幣正面朝上概率是0.5,則至少使用1位來表示,0表示正面朝上,1表示沒有正面朝上。當然很對實際運用中,由於對於數值出現的概率估計的沒有那麼準確,則編碼達不到這個最低的值,比如用2位表示,00表示正面朝上,11表示沒有正面朝上,那麼01和10兩種編碼就是浪費的。對於整個數值集合s,每個數值所佔用的平均bit數目,即所有clip_image028[4]的平均值clip_image030[4],稱為墒。

要對一些數值進行哈夫曼編碼,首先要通過掃描統計每個數值出現的次數,假設統計的結果如圖所示。

clip_image032[4]

 

其次,根據統計的資料構建哈夫曼樹,構建過程是這樣的,如圖:

1) 按照次數進行排序,每個文件ID構成一棵僅包含根節點的樹,根節點的值即次數。

2) 將具有最小值的兩棵樹合併成一棵樹,根節點的值為左右子樹根節點的值之和。

3) 將這棵新樹根據根節點的值,插入到佇列中相應的位置,保持佇列排序。

4) 重複第二步和第三步,直到合併成一棵樹為止,這棵樹就是哈夫曼樹。

clip_image034[4]

 

最終,根據最後形成的哈夫曼樹,給每個邊編號,左為0,右為1,然後從根節點到葉子節點的路徑上的字元組成的二進位制串就是編碼,如圖所示。

clip_image036[4]

 

最終形成的編碼,我們通過觀察可以發現,沒有一個文件ID的編碼是另外一個的字首,所以不存在歧義,對於二進位制序列1001110,唯一的解碼是文件ID “123”和文件ID “689”,不可能有其他的解碼方式,對於Gamma和Delta編碼,如果要儲存二進位制,則需要通過一元編碼或者Gamma編碼儲存一個長度,才能知道這個二進位制到底有多長,然而在這裡連儲存一個長度的空間都省了。

當然這樣原始的哈夫曼編碼也是有一定的缺點的:

1) 如果需要編碼的數值有N個,則哈夫曼樹的葉子節點有N個,每個都需要一個指向數值的指標,內部節點個數是N-1個,每個內部節點包含兩個指標,如將整棵哈夫曼樹儲存在記憶體中,假設數值和指標都需要佔用M個byte,則需要(N+N+2(N-1))*M=(4N-2)*M的空間,耗費還是比較大的。

2) 哈夫曼樹的形成是有一定的不穩定性的,在構造哈夫曼樹的第3步中,將一棵新樹插入到排好序的佇列中的時候,如果遇到了兩個相同的值,誰排在前面?不同的排列方法會產生不同的哈夫曼樹,最終影響最後的編碼,如圖。

clip_image038[4]

 

為了解決上面兩個問題,大牛Schwartz在論文《Generating a canonical prefix encoding》中,對哈夫曼編碼做了一定的規範(canonical),所以又稱為規範哈夫曼編碼或者正規化哈夫曼編碼。

當然哈夫曼樹還是需要建立的,但是不做儲存,僅僅用來確定每個數值所應該佔用的bit的數目,因為出現次數多的數值佔用bit少,出現次數少的數值佔用bit多,這個靈魂不能丟。但是如果佔用相同的bit,到底你是001,我是010,還是倒過來,這倒不必遵循左為0,右為1,而是指定一定的規範,來消除不穩定性,並在佔用記憶體較少的情況下也能解碼。

規範具體描述如下:

1) 所有要編碼的數值或者字元排好隊,佔用bit少的在前,佔用bit多的在後,對於相同的bit按照數值大小排序,或者按照字典順序排序。

2) 先從佔用bit最少的數值開始編碼,對於第一個數值,如果佔用i個bit,則應該是i個0。

3) 對於相同bit的其他數值,則為上一個數值加1後的二進位制編碼

4) 當佔用i個bit的數值編碼完畢,接下來開始對佔用j個bit的數值進行編碼,i < j。則j的第一個數值的編碼應該是i個bit的最後一個數值的編碼加1,然後後面再追加j-i個0

5) 充分3和4完成對所有數值的編碼。

按照這個規範,圖中的編碼應該如圖:

clip_image040[4]

 

根據這些規則,不穩定性首先得到了解決,無論同一個層次的節點排序如何,都會按照數值或字元的排序來決定編碼。

然後就是佔用記憶體的問題,如果使用正規化哈夫曼編碼,則只需要儲存下面的資料結構,如圖:

clip_image042[4]

 

當然原本數值的列表還是需要儲存的,只不過順序是安裝佔用bit從小到大,相同bit數按照數值排序的,需要N*M個byte。

另外三個陣列就比較小了,它們的下標表示佔用的bit的數目,也即最長的編碼需要多少個bit,則這三個陣列最長就那麼長,在這個例子中,最長的編碼佔用5個bit,所以,它們僅僅佔用3*5*M個byte。

第一個陣列儲存的是佔用i個bit的編碼中,起始編碼是什麼,由於相同bit數的編碼是遞增的,因而知道了起始,後面的都能夠推出來。

第二個陣列儲存的是佔用i個bit的編碼有幾個,比如5個bit的編碼有5個,所以Number[5]=5。

第三個陣列儲存的是佔用i個bit的編碼中,起始編碼在數值列表中的位置,為了解碼的時候快速找到被解碼的數值。

如果讓我們來解析二進位制序列1110110,首先讀入第一個1,首先判斷是否能構成一個1bit的編碼,Number[1]=0,佔用1個bit的編碼不存在;所以讀入第二個1,形成11,判斷是否能構成一個2bit的編碼,Number[2]=3,然後檢查FirstCode[2]=00 < 11,然而11 – 00 + 1 = 4 > Number[2],超過了2bit編碼的範圍;於是讀入第三個1,形成111,判斷是否能構成一個3bit的,Number[3]=1,然後檢查FirstCode[3]=110<111,然而111 – 110 + 1 = 2> Number[3],超過了3bit的編碼範圍;於是讀入第四個0,Number[4]=0,再讀入第五個1,判斷是否能構成一個5bit的編碼,Number[5]=4,然後檢查FirstCode[5]=11100 < 11101,11101 – 11100 + 1 = 2<4,所以是一個5bit編碼,而且是5bit編碼中的第二個,5bit編碼的第二個在位置Position[5]=5,所以此5bit編碼是數值列表中的第6項,解碼為value[6]=345。然後讀入1,不能構成1bit編碼,11不能構成2bit編碼,110,Number[3]=1,然後檢查FirstCode[3]=110=110,所以構成3bit編碼的第一個Position[3]=4,解碼為value[4]=789。

如果真能像理想中的那樣,在壓縮所有的倒排表之前,都能夠事先通過全域性的觀測來統計每個文件ID出現的概率,則能夠實現比較好的壓縮效果。

在這個例子中,我們編碼後使用的bit的數目為:

clip_image044[4]

我們再來算一下墒:

clip_image046[4]

按照夏農定理最低佔用的bit數為clip_image048[4]

可以看出哈夫曼編碼的壓縮效果相當不錯。然而在真正的搜尋引擎系統中,文件是不斷的新增的,很難事先做全域性的統計。

對於每一個倒排表進行區域性的統計和編碼是另一個選擇,然而付出的代價就是需要為每一個倒排表儲存一份上述的結構來進行解碼。很不幸上述的結構中包含了數值的列表,如果一個倒排表中數值重複率很高,比如100萬的長的倒排表只有10種數值,為100萬儲存10個數值的列表還可以接受,如果重複率不高,那麼數值列表本身就和要壓縮的倒排表差不多大了,根本起不到壓縮作用。

7. Golomb編碼

如果我們將倒排表中文件ID的概率分佈假設的簡單一些,就沒必要統計出現的所有的數值的概率。比如一個簡單的假設就是:Term在文件集集合中是獨立隨機出現的。

既然是隨機出現的,那麼就有一個概率問題,也即某個Term在某篇文件中出現的概率是多少?假設我們把整個倒排結構呈現如圖的矩陣的樣子,左面是n個Term,橫著是N篇文件,如果某個Term在某篇文件中出現,則那一位設為1,假設裡面1的個數為f,那麼概率clip_image050[4]

clip_image052[4]

 

正如在差值編碼一節中論述的那樣,我們在某個Term的倒排表裡面儲存的不是文件ID,而是文件ID的間隔的數值,我們想要做的事情就是用最少的bit數來表示這些間隔數值。

如果所有的間隔組成的集合是已知的,則可用上一節所述的哈夫曼編碼。

我們在這裡想要模擬的情況是:間隔組成的集合是不確定的,甚至是無限的,隨著新的文件的不斷到來進行不斷的編碼。

可以形象的想象成下面的情形,一個Term坐在那裡等著文件一篇篇的到來,如果文件包含自己,就掛在倒排表上。

如果文件間隔是x,則表示的情形就是,來一篇文件不包含自己,再來一篇還是不包含自己,x-1篇都過去了,終於到了第x篇,包含了自己。如果對於一篇文件是否包含自己的概率為p,則文件間隔x出現的概率就是clip_image054[4]

假設編碼當前的文件間隔x用了n個bit,這個Term接著等下一篇文件的到來,結果這次更不幸,等了x篇還包含自己,直到等到x+b篇文件才包含自己,於是要對x+b進行編碼,x+b出現的概率為clip_image056[4],顯然比x的概率要低,根據資訊理論,如果x用n個bit,則x+b要使用更多的bit,假設clip_image058[4],則最優的情況應該多用1個bit。

這樣我們就形成了一個遞推的情況,假設已知文件間隔x用了n個bit,對於clip_image060[6]來說,x+b就應該只用n+1個bit,這樣如果有了初始的文件間隔並且進行了最優的編碼,後面的都能達到最優。

於是Golomb編碼就產生了,對於引數b(當然是根據文件集合計算出的概率產生的),對於數值x的編碼分為兩部分,第一部分計算clip_image062[4],然後將q+1用一元編碼,第二部分是餘數,r=x-1-qb,由於餘數一定在0到b-1之間,則可以用clip_image064[4]或者clip_image066[4]進行無字首編碼(哈夫曼編碼)。

用上面的理論來看Golomb編碼就容易理解了,第一部分是用來保持上面的遞推性質的,一元編碼的性質可以保證,數值增加1,編碼就多用1位,遞推性質要求數值x增加b,編碼增加1位,於是有了用數值x除以b,這樣clip_image068[4]。第二部分的長度對於每個編碼都是一樣的,最多不過差一位,與數值x無關,僅僅與引數b有關,其實第二部分是用來保證初始的文件間隔是最優的,所以哈夫曼編碼進行無字首編碼。

例如x=9,b=6,則clip_image070[4],對q+1用一元編碼為10,餘數r=2,首先對於所有的餘數進行哈夫曼編碼,形成如圖的哈夫曼樹,從最小的餘數開始進行正規化哈夫曼編碼,0為00,1為01,2佔用三個bit,為01 + 1補充一位,為100,3為101,4為110,5為111。所以x=9的編碼為10100。

clip_image072[4]

 

接下來我們試圖編碼x=9+6=15,b=6,則clip_image074[4],對q+1用一元編碼為110,餘數r=2,編碼為100,最後編碼為110100,果真x增大b,編碼多了1位。

接下來要解決的問題就是如何確定b的值,按照我們們的理論推導clip_image060[7],計算起來有些麻煩,我們先來計算分母部分clip_image076[4],當p接近於0的時候,由著名的極限公式clip_image078[4],所以分母約為p,於是公式最後為clip_image080[4]

由於Golomb編碼僅僅需要另外儲存一個引數b,所以既可以基於整個文件集合的概率進行編碼,這個時候clip_image082[4],也可以應用於某一個倒排表中,對於一個倒排表進行區域性編碼,以達到更好的效果,對於某一個倒排表,term的數量n=1,f=詞頻Term Freqency,clip_image084[4],這樣不同的倒排表使用不同的引數b,達到這樣一個效果,對於詞頻高的Term,文件出現的相對緊密,用較小的b值來編碼,對於詞頻低的Term,文件出現的相對比較鬆散,用較大的b來進行編碼。

8. 插值編碼(Binary Interpolative Coding)

前面講到的Golomb編碼表現不凡,實現了較高的壓縮效果。然而一個前提條件是,假設Term在文件中出現是獨立隨機的,在倒排表中,文件ID的插值相對比較均勻的情況下,Golomb編碼表現較好。

然而Term在文件中卻往往出現的不那麼隨機,而往往是相關的話題聚集在一起的出現的。於是倒排表往往形成如下的情況,如圖.

clip_image086[4]

 

我們可以看到,從文件ID 8到文件ID 13之間,文件是相對比較聚集的。對於聚集的文件,我們可以利用這個特性實現更好的壓縮。

如果我們已知第1篇文件的ID為8,第3篇文件的ID為11,那麼第2篇文件只有兩種選擇9或者10,所以可以只用1位進行編碼。還有更好的情況,比如如果我們已知第3篇文件ID為11,第5篇文件ID為13,則第6篇文件別無選擇,只有12,可以不用編碼就會知道。

這種方法可以形象的想象成為,我們從1到20共20個坑,我們要將文件ID作為標杆插到相應的坑裡面,我們總是採用限制兩頭在中間找坑的方式,還是上面的例子,如果我們已經將第1篇文件插到第8個坑裡,已經將第3篇文件插到第11個坑裡,下面你要將第2篇文件插到他們兩個中間,只有兩個坑,所以1個bit就夠了。當然一開始一個標杆還沒有插的時候,選擇的範圍會比較的大,所以需要較多的bit來表示,當已經有很多的標杆插進去了以後,選擇的範圍會越來越小,需要的bit數也越來越小。

下面詳細敘述一下編碼的整個過程,如圖所示。

clip_image088[4]

 

最初的時候,我們要處理的是整個倒排表,長度Length為7,面對的從Low=1到High=20總共有20個坑。還是採取限制兩頭中間插入的思路,我們先找到中間數值11,然後找一坑插入它,那兩頭如何限制呢?是不是從1到20都可以插入呢?當然不是,因為數值11的左面有三個數值Left=3,一個數值一個坑的話,至少要留三個坑,數值11的右面也有三個數值Right=3,則右面也要留三個坑,所以11這根標杆只能插到從4到17共14個坑裡面,也就是說共有14中選擇,用二進位制表示的話,需要clip_image090[4]bit來儲存,我們用4位來編碼11-4=7為0111。

第一根標杆的插入將倒排表和坑都分成了兩部分,我們可以分而治之。左面一部分我們稱之<Length=3, Low=1, High=10>,因為它要處理的倒排表長度為3,而且一定是放在從1到10這10個坑裡面的。同理,右面一部分我們稱之<Length=3, Low=12, High=20>,表示另外3個數值組成的倒排表要放在從12到20這些坑裡。

先來處理<Length=3, Low=1, High=10>這一部分,如圖。

clip_image092[4]

 

同樣選取中間的數值8,然後左面需要留一個坑Left=1,右面需要留一個坑Right=1,所以8所能插入的坑從2到9共8個坑,也就是8中選擇,用二進位制表示,需要clip_image094[4]bit來儲存,於是編碼8-2=6為110。

標杆8的插入將倒排表和坑又分為兩部分,還是用上面的表示方法,左面一部分為<Length=1,Low=1,High=7>,表示只有一個值的倒排表要插入從1到7這七個坑中,右面一部分為<Length=1,Low=9,High=10>,表示只有一個值的倒排表要插入從9到10這兩個坑中。

我們來處理<Length=1,Low=1,High=7>部分,如圖。

clip_image096[4]

 

只有一個數值3,左右也不用留坑,所以可以插入從1到7任何一個坑,共7中選擇,需要3bit,編碼3-1=2為010。

對於<Length=1,Low=9,High=10>部分,如圖。

clip_image098[4]

 

只有一個數值9,可以選擇的坑從9到10兩個坑,共兩種選擇,需要1bit,編碼9-9=0為0。

再來處理<Length=3, Low=12, High=20>部分,如圖。

clip_image100[4]

 

選擇插入中間數值13,左面需要留一個坑Left=1,右面需要留一個坑Right=1,所以13可以插入在從13到19這7個坑裡,共7種選擇,需要3bit,編碼13-13=0為000。

數值13的插入將倒排表和坑分為兩部分,左面<Length=1, Low=12, High=12>,只有一個數值的倒排表要插入唯一的一個坑,右面<Length=1,Low=14,High=20>,只有一個數值的倒排表插入從14到20的坑。

對於<Length=1, Low=12, High=12>,如圖,一個數一個坑,不用佔用任何bit就可以。

clip_image102[4]

 

對於<Length=1,Low=14,High=20>,如圖,只有一個值17,放在14到20之間7個坑中,有7中選擇,需要3bit,編碼17-14=3為011。

clip_image104[4]

 

綜上所述,最終的編碼為0111 110 010 0 000 011,共17位。如果用Golomb編碼差值<3,5,1,2,1,1,4>,經計算b=2,則編碼共18位。差值編碼表現更好。

那麼解碼過程應該如何呢?初始我們知道<Length=7,Low = 1,High=20>,首先解碼的是中間的也即第3個數值,由於Left=3,Right=3,則可這個數值必定在從4到17,表示這14種選擇需要4位,因而讀取最初的4位0111為7,加上Low + Left = 4,第3個數值解碼為11。

已知第3個數值為11後,則左面應該有三個數值,而且一定是從1到10,表示為<Length=3, Low=1, High=10>,右面的也應該有三個數值,而且一定是從12到20,表示為<Length=3, low=12, high=20>。

先解碼左面<Length=3, Low=1, High=10>,解碼中間的數值,也即第1個數值,由於Left=1,Right=1,則這個數值必定從2到9,表示8種選擇需要3位,因而讀出3位110,為6,加上Low+Left=2,第1個數值解碼為8。

數值8左面還有一個數值,在1到7之間,表示7種選擇需要3位,讀出3位010,為2,加上Low=1,第0個數值解碼為3。

數值8右面還有一個數值,在9到10之間,表示2種選擇需要1位,讀出1位0,為0,加上Low=9,第2個數值解碼為9。

然後解碼<Length=3, low=12, high=20>,解碼中間的數值,也即第5個數值,由於Left=1,Right=1,則這個數值必定從13到19,表示7中選擇需要3位,讀出3位000,為0,加上low=13,第5個數值解碼為13。

數值13左面還有一個數值,在12到12之間,必定是12,無需讀取,第4個數值解碼為12。

數值13右面還有一個數值,在14到20之間,表示7種選擇需要3位,讀出3位011,為3,加上low=14,則第6個數值解碼為17。

解碼完畢。

9. Variable Byte編碼

上述所有的編碼方式有一個共同點,就是需要一位一位的進行處理,稱為基於位的編碼(bitwise)。這樣一分錢一分錢的節省,的確符合我們們勤儉持家的傳統美德,也能節約不少儲存空間。

然而在計算機中,資料的儲存和計算的都是以字(Word)為單位進行的,一個字中包含的位數成為字長,比如32位,或者64位。一個字包含多個位元組(Byte),位元組成為儲存的基本單位。如果使用bitwise的編碼方法,則意味著在編碼和解碼過程中面臨者大量的位操作,從而影響速度。

對於資訊檢索系統來講,相比於儲存空間的節省,查詢速度尤為重要。所以我們將目光從省轉移到快,基於位元組編碼(Bytewise)是以一個或者多個位元組(Byte)為單位的。

最常見的基於位元組的編碼就是變長位元組編碼(Variable Byte),它的規則比較簡單,一個Byte共8個bit,其中最高位的1個bit表示一個flag,0表示這是最後一個位元組,1表示這個數還沒完,後面還跟著其他的位元組,另外7個bit是真正的數值。

如圖所示,比如編碼120,表示成二進位制是1111000沒有超過7個bit,所以用一個byte就能儲存,最高位置0。如果編碼130,表示成二進位制是10000010,已經有8個bit了,所以需要用兩個byte來儲存,第一個byte儲存第一個bit,最高位置1,接下來的一個byte儲存剩下的7個bit,最高位置0。如果數值再大一些,比如20000,則需要三個byte才能儲存。

clip_image106[4]

 

變長位元組編碼的解碼也相對簡單,每次讀一個byte,然後判斷最高位,如果是0則結束,如果是1則再讀一個byte,然後再判斷最高位,直到遇到最高位為0的,將幾個byte的資料部分拼接起來即可。

從變長位元組編碼的原理可以看出,相對於基於位的編碼,它是一次處理一個位元組的,相應付出的代價就是空間有些浪費,比如130編碼後的第一個位元組,本來就儲存一個1,還是用了7位。

變長位元組編碼作為基於位元組的編碼方式,的確比基於位的編碼方式表現出來較好的速度。在Falk Scholer的論文《Compression of Inverted Indexes For Fast Query Evaluation》中,很好的比較了這兩種型別的編碼方式。

如圖所示,圖中的簡稱的意思是Del表示Delta編碼,Gam表示Gamma編碼,Gol表示Golomb編碼,Ric表示Rice編碼,Vby表示Variable Bytes編碼,D表示文件ID,F表示Term的頻率Term Frequency,O表示Term在文件中的偏移量Offset。GolD-GamF-VbyO表示用Golomb編碼來表示文件ID,用Gamma編碼來表示Term頻率,用Vby來表示偏移量。

文中對大小兩個文件集合進行的測試,從圖中我們可以看出變長位元組編碼雖然在空間上有所浪費,然而在查詢速度上卻表現良好。

clip_image108[4]

 

10. PFORDelta編碼

變長位元組編碼的一個缺點就是雖然它是基於byte進行編碼的,但是每解碼一個byte,都要進行一次位操作。

解決這個問題的一個思路就是,將多個byte作為一組(Patch)放在一起,將Flag集中起來,作為一個Header儲存每個數值佔用幾個byte,一次判斷一組,我們稱為Signature block,如圖所示。

clip_image110[4]

 

對於Header中的8個bit,分別表示接下來8個byte的flag,前三個0表示前三個byte各編碼一個數值,接下來1表示下一個byte屬於第四個數值,然後接下來的1表示下一個byte也屬於第四個數值,接下來0表示沒有下一個byte了,因而110表示的三個byte編碼一個數值。最後10表示最後兩個byte編碼第五個數值。

細心的同學可能看出來了,Header裡面就是一元編碼呀。

那麼再改進一下,在Header裡面我們們用二進位制編碼,每兩位組成一個二進位制碼,這個二進位制數表示每一個數值的長度,長度的單位是一個byte,這樣兩位可以表示32個bit,基本可以表示所有的整數。00表示一個byte,01表示2個byte,10表示3個byte,11表示4個byte,這種方式稱為長度編碼(Length Encoding),如圖。

clip_image112[4]

 

如果數比較大,32位不夠怎麼辦?用三位,那總共8位也不夠分的啊?於是有人改變思路,Header裡面的8位並不表示長度,而是8個flag,每個flag表示是否能夠壓縮到n個byte,n是一個引數,0表示能,則壓縮為n個byte,1表示不能,則用原來的長度表示。這種方法叫做Binary Length Encoding。如同所示。

clip_image114[4]

 

這裡引數n=2,也即如果一個32位整數能壓縮為2個byte,則壓縮,否則就用全部32位表示。比如第三個數字,其實用三位就能夠表示的,但是由於不能壓縮成為2個byte,也是用完整的32位來表示的。

Binary Length Encoding已經為將數值分組打包(Patch)壓縮提供了一個很好的思路,就是將數值分為兩大部分,可以壓縮的便打包處理,太大不能壓縮的可單獨處理。這種思想成為PForDelta編碼的基礎。

然而Binary length Encoding是將能壓縮的和不能壓縮的混合起來儲存的,這其實是不利於我們批量壓縮和解壓縮的,必須一個數值一個數值的判斷。

而PForDelta做了改進,將兩部分的分開儲存。試想如果m個數值都是壓縮成b個bit的,就可以打包在一起,這樣批量讀出m*b個bit,一起解壓便可。而其他不可壓縮的,我們放在另外的地方。只要我們通過引數,控制b的大小,使得能壓縮的佔多數,不能壓縮的佔少數,批量處理完打包好的,然後再一個個料理不能打包的殘兵遊勇。可壓縮部分我們成為編碼區域(Code Section),不可壓縮的部分我們成為異常區域(Excepton Section)。

當然分開儲存有個很大的問題,就是不能保持原來數值列表的順序,而我們要壓縮的倒排表是需要保持順序的。如同所示。

clip_image116[4]

 

一個最直接的想法是,如圖(a),在原來異常數值(Exception Value)出現的位置保留一個指標,指向異常區域中這個數值的位置。然而一個很大的問題就是這個指標只能佔用b個bit,往往不夠一個指標的長度。

另外一個替代的方法是,如圖(b),我們如果知道第一個異常數值出現的位置(簡稱異常位置),並且知道異常區域的起始位置,我們可以在b個bit裡面儲存下一個異常位置的偏移量(因為偏移量為0沒有意義,所以存放0表示距離1,存放i表示距離i+1),由於編碼區域是密集儲存的,所以b個bit往往夠用。解壓縮的時候,我們先批量將整個編碼區域解壓出來,然後找到第一個異常位置,原本放在這個位置的數值應該是異常區域的第一個值,然後異常位置裡面解壓出3,說明第二個異常位置是當前位置加4,找到第二個異常位置後,原本放在這個位置的數值應該是異常區域的第二個值,以此類推。這個將異常位置串起來的連結串列我們稱為異常鏈。

然而如果很不幸,b個bit不夠用,下一個異常位置的偏移量超過了2b個bit。如圖(c),b=2,然而下一個異常位置距離為7,2位放不開,我們就需要在距離為4的位置人為插入一個異常位置,當前位置裡面寫11,異常位置裡面寫7-4-1=2,當然異常區域中也需要插入一個不存在的數值。這樣做的缺點是增加了無用的數值,降低了壓縮率,為了減少這種情況的出現,所以實踐中b不要小於4。

這就是PForDelta的基本思路。PForDelta,全稱Patched Frame Of Reference-Delta,其中壓縮後的n個bit,稱為一個Frame,Patched就是將這些Frame進行打包,Delta表示我們打包壓縮的是差值。

PForDelta是將數值分塊(Block)儲存的,一塊中可以包含百萬個數值,大小可以達到幾個Megabyte,一般的方法是,在記憶體中儲存一個m個Megabyte的快取區域(Buffer),然後不斷的讀取資料,將這個快取區域按照一定的格式填滿,便可以寫入硬碟,然後再繼續壓縮。

塊內部的格式如圖所示。

clip_image118[4]

 

塊內部分為四個部分,在圖中這四個部分是分開畫的,其實是一個部分緊接著下一個部分的。編碼區域和異常區域之間可能會有一些空隙,下面介紹中會講到為什麼。在圖中的例子裡面,我們還假設32bit足夠儲存原始數值。

第一部分是Header,裡面儲存了這個塊的大小,比如1M,大小應該是32或者64的整數倍。另外儲存了壓縮的數值所用的bit數為b。

第二部分是Entry point的陣列,有N項,每一個Entry管理128個數值。每一項32位,其中前7位表示這個Entry管理的128個數值中第一個異常位置,後25位儲存了這128個數值的異常區域的起始位置。這個陣列的存在是為了在一個塊中隨機訪問。

第三部分是編碼區域(Code Section),存放了一系列壓縮為b個bit的數值,每128個被一個entry管理,總共有128*N個數值,數值是從前向後依次排放的。在這個部分中,異常位置是以異常鏈的形式串起來的。

第四部分是異常區域(Exception Section),存放不能壓縮,以32個bit儲存原始數值的部分。這一部分的數值是從後往前排放的。由於每128個數值中異常數值的個數不是固定的,所以僅僅靠這部分不能確定哪些屬於哪個entry管理。在一個entry中,有指向起始位置的指標,然後根據編碼區域中的異常鏈,依次一個一個找異常數值。

編碼區域是從前往後增長的,異常區域是從後往前增長的,在快取塊中,當中間的空間不足的時候,就留下一段空隙。為了提高效率,我們希望解壓縮的時候是字對齊(word align)的,也即希望一次處理32個bit。假設Header是佔用32個bit,每個Entry也是32個bit,異常區域是32個bit一個數值的,然而編碼區域則不是,比如5個bit一個數值,假設一共有100個數值,則需要500個bit,不是32的整數倍,最後多餘20個bit,則需要填充12個0,然後使得編碼區域字對齊後再存放異常區域。索性我們的設計中,一個entry是管理128個數值的,所以最後一定會是32的整數倍,一定是字對齊的,不需要填充,可以保證寫入硬碟的時候,編碼區域和異常區域是緊密相鄰的。

PForDelta的字對齊和批量處理,意味著我們已經從一個bit一個bit處理的個人手工業時代,到了機械大工業時代。如圖。在硬碟上是海量的索引檔案,是由多個PForDelta塊組成的,在壓縮和解壓過程中,需要有一部分快取在記憶體中,然後其中一個塊可以進入CPU Cache,每塊的結構都是32位對齊的,對於32位機器,暫存器也是32位的。於是我們可以想象,CPU就像一個卓別林扮演的工人,來了32個bit,處理完畢,接著下一個32位,流水作業。

clip_image120[4]

 

下面我們們就通過一個例子,具體看一下PForDelta的壓縮和解壓方法。

我們假設有以下266個數值:

Input = [26, 24, 27, 24, 28, 32, 25, 29, 28, 26, 28, 31, 32, 30, 32, 26, 25, 26, 31, 27, 29, 25, 29, 27, 26, 26, 31, 26, 25, 30, 32, 28, 23, 25, 31, 31, 27, 24, 32, 30, 24, 29, 32, 26, 32, 32, 26, 30, 28, 24, 23, 28, 31, 25, 23, 32, 30, 27, 32, 27, 27, 28, 32, 25, 26, 23, 30, 31, 24, 29, 27, 23, 29, 25, 31, 29, 25, 23, 31, 32, 32, 31, 29, 25, 31, 23, 26, 27, 31, 25, 28, 26, 27, 25, 24, 24, 30, 23, 29, 30, 32, 31, 25, 24, 27, 31, 23, 31, 29, 28, 24, 26, 25, 31, 25, 26, 23, 29, 29, 27, 30, 23, 32, 26, 31, 27, 27, 29, 23, 32, 28, 28, 23, 28, 31, 25, 25, 26, 24, 30, 25, 28, 26, 28, 32, 27, 23, 31, 24, 25, 31, 27, 31, 24, 24, 24, 30, 27, 28, 23, 25, 31, 27, 24, 23, 25, 30, 23, 24, 32, 26, 31, 28, 25, 24, 24, 23, 28, 28, 28, 32, 29, 27, 27, 29, 25, 25, 32, 27, 31, 32, 28, 27, 32, 26, 23, 26, 31, 24, 32, 29, 27, 27, 25, 31, 31, 24, 23, 32, 30, 28, 29, 29, 28, 32, 26, 26, 27, 27, 29, 24, 25, 31, 27, 30, 28, 29, 27, 31, 25, 26, 26, 30, 31, 29, 30, 31, 26, 24, 29, 28, 25, 30, 24, 25, 23, 24, 32, 23, 32, 24, 27, 28, 29, 27, 31, 28, 29, 29, 32, 25, 26, 27, 29, 23, 26]

根據上面說過的原理,足夠需要三個entry來管理。

首先在索引過程中,這些數值是一個個到來的,經過初步的統計,發現數值32是最大的,並且佔到總數的10%多一點,所以我們可以將32作為異常數值。其他的數值都在0-31之間,用5個bit就可以表示,所以b=5。

下面我們就可以開始壓縮了,我們是一個entry一個entry的來壓縮的,所以128個數值為一組,程式碼如下:

//存放編碼區域壓縮前數值

int[] codes = new int[128];

//記錄每個異常數值的位置,miss[i]表示第i個異常數值的位置

int[] miss = new int[numOfExceptions];

int numOfCodes = 0;

int numOfExcepts = 0;

int numOfJump = 0;

//第一個迴圈,構造編碼區,並且統計異常數值位置

//每128個數值一組,或者不夠128則剩下的一組

while(from < input.length && numOfCodes < 128){

//統計從上次遇到異常數值開始,遇到的普通數值的個數

numOfJump = (input[from] > maxCode)?0:(numOfJump+1);

//如果兩個異常數值之間的間隔太大,則必須認為插入一個異常數值。maxJumpCode是指b=5的情況下能表示的最大間隔31。之所以判斷numOfExcepts > 0,是因為第一個異常位置用7個bit儲存在entry裡面,所以在哪裡都可以。

if(numOfJump > maxJumpCode && numOfExcepts > 0){

codes[numOfCodes] = -1;

miss[numOfExcepts] = numOfCodes;

numOfCodes++;

numOfExcepts++;

numOfJump = 0;

}

//編碼區域的構造。這個地方是最簡單的情況,就是input的數值直接進入編碼區域,這裡還可以用其他的編碼方式(比如用Golomb)進行一次編碼。

codes[numOfCodes] = input[from];

//只有遇到異常數值的時候numOfExcepts才加一

miss[numOfExcepts] = numOfCodes;

numOfExcepts += (input[from] > maxCode)?1:0;

numOfCodes++;

from++;

}

//構造完編碼區域後,可以對entry進行初始化,7位儲存第一個異常位置,25位儲存異常區域的起始位置。

int prev = miss[0];

entries[curEntry++]=prev << 25 | (curException & 0x1FFFFFF);

//第二個迴圈,構造異常鏈和異常區域

exceptionSection[curException--] = codes[prev];

for(int i=1; i < numOfExcepts; i++){

int cur = miss[i];

codes[prev] = cur - prev - 1;

prev = cur;

exceptionSection[curException--] = codes[cur];

}

codes[prev] = numOfCodes - prev - 1;

//最後將編碼區域壓縮,其中codes是壓縮前的數值,numOfCodes是數值的個數,codeSection是一個int陣列,用於存放壓縮後的數值,curCode是當前codeSection可以從哪個位置開始寫入,bwidth=5

curCode += pack(codes, numOfCodes, codeSection, curCode, bwidth);

整個過程是兩次迴圈構造未壓縮的編碼區域和異常區域,如下面的表格所示。表格中每一列中上面的數值是input,下面的數值是未壓縮編碼區域數值,其中黃色的部分便是異常位置:

Entry 1的未壓縮編碼區域

image

image

Entry 2的未壓縮編碼區域,其中第214個異常位置和第248個異常位置中間隔了33個位置,無法用5個bit表示,於是在第216個位置人為插入一個異常位置,就是紅色的部分。

image

image

Entry 3的未壓縮編碼區域,本來input中只有266個數值,這裡又新增兩個0數值(綠色的部分)是為什麼呢?因為每個數值壓縮後將佔用5個bit,如果只有11個數值的話共55位,而要求字對齊的話,需要64位,因而需要人為新增9個0.

image

下面應該對編碼區域進行壓縮了,在大多數的實現中,壓縮程式碼多少有些晦澀難懂。一般來說,會對每一種b有一個程式碼實現,在這裡我們舉例列出的是b=5的程式碼實現。

整個過程我們可以想象成codeSection是一條條32位大小的袋子,而codes是一系列待壓縮的32位的物品,其中貨真價實的就5位,其他都是水分(都是0),下面要做的事情就是把待壓縮的物品一件件拿出來,把有水分擠掉,然後往袋子裡面裝。

裝的時候就面臨一個問題,32不是5的整數倍,放6個還空著2位,放7個不夠空間,這迴圈怎麼寫啊?所以只能以最小公倍數32*5=160位為一個處理批次,放在一個迴圈裡面,也即每個迴圈處理5個袋子,32個物品,使得32個物品正好能放在5個袋子裡面。

//bwidth=5

private static int pack(int[] codes, int numOfCodes, int[] codeSection,

int curCode, int bWidth) {

int cur = 0;

// suppose bwidth = 5

// bwidth不一定能被32的整除,所以每32個一組,保證處理完以後,32*bwidth個bit,一定是字對齊的。

while (cur < numOfCodes) {

codeSection[curCode + 0] = 0;

codeSection[curCode + 1] = 0;

codeSection[curCode + 2] = 0;

codeSection[curCode + 3] = 0;

codeSection[curCode + 4] = 0;

//curCode + 0是第一個袋子,先放codes裡面從cur+0到cur+5六個物品後,還空著2位,於是把第七個物品前2位截出來,放進去。0x18二進位制11000,作用就是最後5位保留前兩位,然後右移3位,就把前2位放到了袋子的最後2位。

codeSection[curCode + 0] |= codes[cur + 0] << (32 - 5);

codeSection[curCode + 0] |= codes[cur + 1] << (32 - 10);

codeSection[curCode + 0] |= codes[cur + 2] << (32 - 15);

codeSection[curCode + 0] |= codes[cur + 3] << (32 - 20);

codeSection[curCode + 0] |= codes[cur + 4] << (32 - 25);

codeSection[curCode + 0] |= codes[cur + 5] << (32 - 30);

codeSection[curCode + 0] |= (codes[cur + 6] & 0x18) >> 3;

//curCode+1是第二個袋子。剛才第七個物品前2位被截了放在第一個袋子裡,那麼首先剩下的3位放在第二個袋子的開頭,0x07就是00111,也就是擷取後三位。然後再放5個物品,還空著4位,於是第十三個物品擷取前四位(0x1E二進位制11110)。

codeSection[curCode + 1] |= (codes[cur + 6] & 0x07) << (32 - 3);

codeSection[curCode + 1] |= codes[cur + 7] << (32 - 3 - 5);

codeSection[curCode + 1] |= codes[cur + 8] << (32 - 3 - 10);

codeSection[curCode + 1] |= codes[cur + 9] << (32 - 3 - 15);

codeSection[curCode + 1] |= codes[cur + 10] << (32 - 3 - 20);

codeSection[curCode + 1] |= codes[cur + 11] << (32 - 3 - 25);

codeSection[curCode + 1] |= (codes[cur + 12] & 0x1E) >> 1;

//curCode + 2第三個袋子。先放第十三個物品剩下的1位(0x01二進位制00001),然後再放入6個物品,最後空著1位。將第二十個物品的第1位擷取出來(0x10二進位制10000)放入。

codeSection[curCode + 2] |= (codes[cur + 12] & 0x01) << (32 - 1);

codeSection[curCode + 2] |= codes[cur + 13] << (32 - 1 - 5);

codeSection[curCode + 2] |= codes[cur + 14] << (32 - 1 - 10);

codeSection[curCode + 2] |= codes[cur + 15] << (32 - 1 - 15);

codeSection[curCode + 2] |= codes[cur + 16] << (32 - 1 - 20);

codeSection[curCode + 2] |= codes[cur + 17] << (32 - 1 - 25);

codeSection[curCode + 2] |= codes[cur + 18] << (32 - 1 - 30);

codeSection[curCode + 2] |= (codes[cur + 19] & 0x10) >> 4;

//curCode + 3第四個袋子。先放第二十個物品剩下的4位(0x0F二進位制位01111)。然後放5個物品,最後還空著3位。將第二十六個物品擷取3位放入(0x1C二進位制11100)。

codeSection[curCode + 3] |= (codes[cur + 19] & 0x0F) << (32 - 4);

codeSection[curCode + 3] |= codes[cur + 20] << (32 - 4 - 5);

codeSection[curCode + 3] |= codes[cur + 21] << (32 - 4 - 10);

codeSection[curCode + 3] |= codes[cur + 22] << (32 - 4 - 15);

codeSection[curCode + 3] |= codes[cur + 23] << (32 - 4 - 20);

codeSection[curCode + 3] |= codes[cur + 24] << (32 - 4 - 25);

codeSection[curCode + 3] |= (codes[cur + 25] & 0x1C) >> 2;

//curCode + 4第五個袋子。先放第二十六個物品剩下的2位。最後這個袋子還剩30位,正好放下6個物品。

codeSection[curCode + 4] |= (codes[cur + 25] & 0x03) << (32 - 2);

codeSection[curCode + 4] |= codes[cur + 26] << (32 - 2 - 5);

codeSection[curCode + 4] |= codes[cur + 27] << (32 - 2 - 10);

codeSection[curCode + 4] |= codes[cur + 28] << (32 - 2 - 15);

codeSection[curCode + 4] |= codes[cur + 29] << (32 - 2 - 20);

codeSection[curCode + 4] |= codes[cur + 30] << (32 - 2 - 25);

codeSection[curCode + 4] |= codes[cur + 31] << (32 - 2 - 30);

//處理下一組

cur += 32;

curCode += 5;

}

int numOfWords = (int) Math.ceil((double) (numOfCodes * 5) / 32.0);

return numOfWords;

}

經過壓縮後,整個block的格式如下,整個block被組織成int陣列,[]裡面的數值為下標:

Header:這部分只佔用32位,在這裡包含四部分,第一部分5位,表示b=5。第二部分表示Entry部分的長度,佔用了3個32位,也即有三個Entry。第三部分表示編碼區域的長度,佔用了42個

image

image

Entry列表:包含3個entry,每個entry佔用32位,前7位表示第一個異常位置,後25位表示這個entry在異常區域中的起始位置。

image

編碼區域。總共佔用42個int,每5個int可以存放32個壓縮後的編碼(每個編碼佔用5位)。第三個entry共佔用兩個int,儲存了11個數值佔用55位,另外人為補充9個0.

image

異常區域。在塊中,異常區域是從後向前延伸的。其中從74到60的紅色部分屬於Entry 1,從59到50的黃色部分屬於Entry 2,綠色部分屬於Entry 3。

image

當需要讀取這個快的時候,便需要對這個塊進行解碼。

首先通過解析Header來得到全域性資訊。

接下來需要讀取Entry列表,來解析每個Entry中的資訊。

然後對於每個Entry進行解碼,程式碼如下:

//解析entry,得到全域性資訊

int entrycode = entries[i];

int firstExceptPosition = entrycode >> 25;

int curException = entrycode & 0x1FFFFFF;

//進行解壓縮,將編碼區域5位一個數值解壓為一個int陣列。Codes就是解壓後的陣列,tempCodeSection指向編碼區域這個Entry的起始位置,numOfCodes是需要解壓的數值的個數,bwidth=5.

Unpack(codes, numOfCodes, tempCodeSection, bwidth);

//第一個迴圈將異常數值還原

int cur = firstExceptPosition;

while (cur < numOfCodes && curException >= lastException) {

int jump = codes[cur];

codes[cur] = block[curException--];

cur = cur + jump + 1;

}

//第二個迴圈輸出結果並且跳過人為新增的異常數值

for (int j = 0; j < codes.length; j++) {

if (codes[j] > 0) {

output[curOutput++] = codes[j];

}

}

對編碼區域的解壓方式也正好是壓縮方式的逆向過程。是從袋子裡面將物品拿出來的時候了。

private static void Unpack(int[] codes, int numberOfCodes, int[] codeSection,

int bwidth) {

int cur = 0;

int curCode = 0;

while (cur < numberOfCodes) {

//從第一個袋子中,拿出6個物品,還剩2位。

codes[cur + 0] = (codeSection[curCode + 0] >> (32 - 5)) & 0x1F;

codes[cur + 1] = (codeSection[curCode + 0] >> (32 - 10)) & 0x1F;

codes[cur + 2] = (codeSection[curCode + 0] >> (32 - 15)) & 0x1F;

codes[cur + 3] = (codeSection[curCode + 0] >> (32 - 20)) & 0x1F;

codes[cur + 4] = (codeSection[curCode + 0] >> (32 - 25)) & 0x1F;

codes[cur + 5] = (codeSection[curCode + 0] >> (32 - 30)) & 0x1F;

codes[cur + 6] = (codeSection[curCode + 0] << 3) & 0x18;

//第一個袋子中的2位和第二個袋子中的前3位組成第7個物品。然後接著從第二個袋子中拿出5個物品,剩下4位。

codes[cur + 6] |= (codeSection[curCode + 1] >> (32 - 3)) & 0x07;

codes[cur + 7] = (codeSection[curCode + 1] >> (32 - 3 - 5)) & 0x1F;

codes[cur + 8] = (codeSection[curCode + 1] >> (32 - 3 - 10)) & 0x1F;

codes[cur + 9] = (codeSection[curCode + 1] >> (32 - 3 - 15)) & 0x1F;

codes[cur + 10] = (codeSection[curCode + 1] >> (32 - 3 - 20)) & 0x1F;

codes[cur + 11] = (codeSection[curCode + 1] >> (32 - 3 - 25)) & 0x1F;

codes[cur + 12] = (codeSection[curCode + 1] << 1) & 0x1E;

//第二個袋子的最後4位和第三個袋子的前1位組成一個物品,然後在第三個袋子裡面拿出6個物品,剩下1位。

codes[cur + 12] |= (codeSection[curCode + 2] >> (32 - 1)) & 0x01;

codes[cur + 13] = (codeSection[curCode + 2] >> (32 - 1 - 5)) & 0x1F;

codes[cur + 14] = (codeSection[curCode + 2] >> (32 - 1 - 10)) & 0x1F;

codes[cur + 15] = (codeSection[curCode + 2] >> (32 - 1 - 15)) & 0x1F;

codes[cur + 16] = (codeSection[curCode + 2] >> (32 - 1 - 20)) & 0x1F;

codes[cur + 17] = (codeSection[curCode + 2] >> (32 - 1 - 25)) & 0x1F;

codes[cur + 18] = (codeSection[curCode + 2] >> (32 - 1 - 30)) & 0x1F;

codes[cur + 19] = (codeSection[curCode + 2] << 4) & 0x10;

//第三個袋子的最後1位和第四個袋子的前4位組成一個物品,然後從第四個袋子中拿出5個物品,剩下3位。

codes[cur + 19] |= (codeSection[curCode + 3] >> (32 - 4)) & 0x0F;

codes[cur + 20] = (codeSection[curCode + 3] >> (32 - 4 - 5)) & 0x1F;

codes[cur + 21] = (codeSection[curCode + 3] >> (32 - 4 - 10)) & 0x1F;

codes[cur + 22] = (codeSection[curCode + 3] >> (32 - 4 - 15)) & 0x1F;

codes[cur + 23] = (codeSection[curCode + 3] >> (32 - 4 - 20)) & 0x1F;

codes[cur + 24] = (codeSection[curCode + 3] >> (32 - 4 - 25)) & 0x1F;

codes[cur + 25] = (codeSection[curCode + 3] << 2) & 0x1C;

//第四個袋子剩下的3位和第五個袋子的前2位組成一個物品,然後第五個袋子取出6個物品。

codes[cur + 25] |= (codeSection[curCode + 4] >> (32 - 2)) & 0x03;

codes[cur + 26] = (codeSection[curCode + 4] >> (32 - 2 - 5)) & 0x1F;

codes[cur + 27] = (codeSection[curCode + 4] >> (32 - 2 - 10)) & 0x1F;

codes[cur + 28] = (codeSection[curCode + 4] >> (32 - 2 - 15)) & 0x1F;

codes[cur + 29] = (codeSection[curCode + 4] >> (32 - 2 - 20)) & 0x1F;

codes[cur + 30] = (codeSection[curCode + 4] >> (32 - 2 - 25)) & 0x1F;

codes[cur + 31] = (codeSection[curCode + 4] >> (32 - 2 - 30)) & 0x1F;

cur += 32;

curCode += 5;

}

}

11. Simple Family

另一種多個數值打包在一起,並且是字對齊的編碼方式,就是下面我們要介紹的Simple家族。

對於32位機器,一個字是32個bit,我們希望這32個bit裡面可以放多個數值。比如1位的放32個,2位的放16個,3位放10個,不過浪費2位,4位放8個,5位放6個,6位放5個,7位和8位都是4個算一樣,9位,10位都是放3個,11位,12位一直到16位都是放2個,32位放1個,共10種方法。那麼來了32位,我們怎麼區分裡面是哪種方法呢?在放置真正的資料之前,需要放置一個選擇器(selector),來表示我們是怎麼儲存的,10種方法4位就夠了。

如果這4位另外儲存,就不是字對齊的了,所以還是將這4位放在32位裡面,這樣資料位就剩了28位了。那這28位怎麼安排呢?1位的28個,2位的14個,3位的9個,4位的7個,5位的5個,6位和7位的4個,8位和9位的3個,10位到14位的都是2個,15位到28位的都是1個,共9種。如果同樣儲存2個,當然選最長的位數14,所以形成以下的表格:

image

由於一共9種情況,所以這種編碼方式稱為Simple-9。

4位selector來表示9,太浪費了,浪費了差不多一半的編碼(24=16),如果少一種情況,8種的話,就少一位做selector,多一位儲存數值了,比如去掉selector=e這種情況,所有5位的都儲存成7位,這樣儲存資料就是29位了,很遺憾,29是個質數,除了1和本身不能被任何數整除,多出的一位也往往浪費掉。

於是我們再進一步,用30位來儲存數值,這樣會有10種情況,而且浪費的情況也減少了。如下面的表格所示:

image

 

雖然看起來30比28要好一些,然而剩下的兩位如何表示10種情況呢?我們假設文件編號之間的差值不會劇烈抖動,一會兒大一會兒小,而是維持在一個穩定的水平,就算髮生突然的變化,變化完畢後也能保持較穩定的水平。所以我們可以採取這樣的策略,首先對於第一個32位,假設selector是某一個數r,比如r=f,6位儲存一個數值,那麼下一個32位開始的兩位表示四種情況:

1) 如果用的位數少,比如3位就夠,則selector=r-1=e,也即用5位儲存

2) 如果用的位數不變,還是用6位儲存,則selector=r

3) 如果用的位數多 ,selector=r+1=g,也即用7位儲存

4) 如果r+1=7位放不下,比如需要10位,說明了變化比較突然,於是r取最大值j,用30位來儲存。

一旦出現了突然的較大變化,則會使用最大的selector j,然後隨著突然變化後的慢慢平滑,selector還會降到一個文件的值。當然,如果事先經過統計,發現最大的文件間隔也不過需要15位,則對於第四種情況,可以使得r=i。

這種用相對的方式來表示10種情況,成為Relative-10。

既然selector只需要2位,那麼上面的表格中selector=d和selector=g的沒有用的兩位,不是也可以成為下一個32位的selector麼?這樣下一個整個32位都可以來儲存資料了。

另外上面表格中每個數值的編碼長度一欄為什麼會從7跳到10呢?是因為8位,9位,10位都只能儲存3個,當然用最大的10位了。然而如果沒有用的2位可以留給下一個32位做selector,那就有必要做區分了,比如一個值只需要9位,我們們就用9位,三九二十七,剩下的三位中的兩位作為下一個32位的selector。另外14位和15位也是這個情況,如果兩個14位,就可以剩下兩位作為下一個的selector,如果是兩個15位就沒有給下一個剩下什麼。

這樣selector由10種情況變成了12種情況,如圖下面的表格所示:

image

image

 

如果上一個32位剩下2位作為selector,則當前的32位則可以全部用於儲存資料。當然也會有剩餘,可留給後來人。那麼32位全部用來儲存資料的情況下,selector應該如下面表格所示:

image

 

這樣每個32位都分為兩種情況,一種是上一個留下了2位做selector,本身32位都儲存資料,另一種是上一個沒有留下什麼,本身2位做selector,30位做資料。

這種上一個32位為下一個留下遺產,共12種情況的,成為Carryover-12編碼。

下面舉一個具體的例子,比如我們想編碼如下的輸入:

int[] input = {5, 30, 120, 60, 140, 160, 120, 240, 300, 200, 500, 800, 300, 900};

首先定義上面的兩個編碼表:

class Item {

public Item(int lengthOfCode, int numOfCodes, boolean leftForNext) {

this.lengthOfCode = lengthOfCode;

this.numOfCodes = numOfCodes;

this.leftForNext = leftForNext;

}

int lengthOfCode;

int numOfCodes;

boolean leftForNext;

}

static Item[] noPreviousSelector = new Item [12];

static {

noPreviousSelector[0] = new Item(1, 30, false);

noPreviousSelector[1] = new Item(2, 15, false);

noPreviousSelector[2] = new Item(3, 10, false);

noPreviousSelector[3] = new Item(4, 7, true);

noPreviousSelector[4] = new Item(5, 6, false);

noPreviousSelector[5] = new Item(6, 5, false);

noPreviousSelector[6] = new Item(7, 4, true);

noPreviousSelector[7] = new Item(9, 3, true);

noPreviousSelector[8] = new Item(10, 3, false);

noPreviousSelector[9] = new Item(14, 2, true);

noPreviousSelector[10] = new Item(15, 2, false);

noPreviousSelector[11] = new Item(28, 1, true);

}

static Item[] hasPreviousSelector = new Item [12];

static {

hasPreviousSelector[0] = new Item(1, 32, false);

hasPreviousSelector[1] = new Item(2, 16, false);

hasPreviousSelector[2] = new Item(3, 10, true);

hasPreviousSelector[3] = new Item(4, 8, false);

hasPreviousSelector[4] = new Item(5, 6, true);

hasPreviousSelector[5] = new Item(6, 5, true);

hasPreviousSelector[6] = new Item(7, 4, true);

hasPreviousSelector[7] = new Item(8, 4, false);

hasPreviousSelector[8] = new Item(10, 3, true);

hasPreviousSelector[9] = new Item(15, 2, true);

hasPreviousSelector[10] = new Item(16, 2, false);

hasPreviousSelector[11] = new Item(28, 1, true);

}

形成的編碼格式如圖

clip_image122[4]

 

假設約定的起始selector為6,也即表3-10的g行。對於第一個32位,是不會有前面遺傳下來的selector的,所以前兩位表示selector=1,也即就用原始值6,第g行,接下來應該是4個7位的數值,最後剩餘兩位作為下一個32位的selector。Selector=2,所以用6+1=7,也即表3-11的h行,下面的整個32位都是數值,儲存了4個8位的,沒有遺留下什麼。接下來的32位中,頭兩位是selector=1,還是第7行,也即第h行,只不過是表3-10中的,所以接下來應該是3個9位,遺留下最後兩位selector=2,也即是7+1=8行,也即表3-11的第i行,接下來應該是3個10位的。

整個解碼的過程如下:

//block是編碼好的塊,defaultSelector是預設的selector

private static int[] decompress(int[] block, int defaultSelector, int numOfCodes) {

//當前處理到編碼塊的哪一個int

int curInBlock = 0;

//解碼結果

int[] output = new int[numOfCodes];

int curInOutput = 0;

//前一個selector,用於根據相對值計算當前selector的值

int prevSelector = defaultSelector;

int curSelector = 0;

//最初的編碼表用當然是沒有遺留的

Item[] curSelectorTable = noPreviousSelector;

//尚未處理的bit數

int bitsRemaining = 0;

//當前int中每個編碼的bit數

int bitsPerCode = curSelectorTable[curSelector].lengthOfCode;

//當前要解碼的32位編碼

int cur = 0;

//一個迴圈,當curInBlock > block.length的時候,編碼塊已經處理完畢,但是還需要等待最後一個32位處理完畢,當bitsRemaining大於等於bitsPerCode的時候,說明還有沒處理完的編碼

while (curInBlock < block.length || bitsRemaining >= bitsPerCode) {

//當bitsRemaining不足一個編碼的時候,說明當前的32位處理完畢,應該讀入下一個32位了。

if(bitsRemaining < bitsPerCode){

//當bitsRemaining小於2,說明當前32位剩下的不足2位,不夠給下一個做selector的,因而下一個selector在下一個32位的頭兩位。

if(bitsRemaining < 2){

//取下一個32位

cur = block[curInBlock++];

//前兩位為selector

int selector = (cur >> 30) & 0x03;

//根據selector的相對值計算當前的selector

if(selector == 0){

curSelector = prevSelector - 1;

} else if (selector == 1){

curSelector = prevSelector;

} else if (selector == 2) {

curSelector = prevSelector + 1;

} else {

curSelector = curSelectorTable.length - 1;

}

prevSelector = curSelector;

//當前32位中資料僅僅佔30位

bitsRemaining = 30;

//使用編碼表3-10

curSelectorTable = noPreviousSelector;

}

//如果bitRemaining大於等於2,足夠給下一個做selector,則解析最後兩位為selector。

else {

int selector = cur & 0x03;

if(selector == 0){

curSelector = prevSelector - 1;

} else if (selector == 1){

curSelector = prevSelector;

} else if (selector == 2) {

curSelector = prevSelector + 1;

} else {

curSelector = curSelectorTable.length - 1;

}

prevSelector = curSelector;

//取下一個32位,全部用於儲存數值

cur = block[curInBlock++];

bitsRemaining = 32;

//使用編碼表3-11

curSelectorTable = hasPreviousSelector;

}

bitsPerCode = curSelectorTable[curSelector].lengthOfCode;

}

//在bitRemaing中擷取一個編碼,解碼到輸出,更新bitsRemaining

int mask = (1 << bitsPerCode) - 1;

output[curInOutput++] = (cur >> (bitsRemaining - bitsPerCode)) & mask;

bitsRemaining = bitsRemaining - bitsPerCode;

}

return output;

}

12. 跳躍表

上面說了很多的編碼方式,能夠讓倒排表又小又快的儲存和解碼。

但是對於倒排表的訪問除了順序讀取,還有隨機訪問的問題,比如我想得到第31篇文件的ID。

第一點,差值編碼使得每個文件ID都很小,是個不錯的選擇。第二點,上面所述的很多編碼方式都是變長的,一個挨著一個儲存的。這兩點使得隨機訪問成為一個問題,首先因為第二點我們根本不知道第30篇文件在檔案中的什麼位置,其次就算是找到了,因為第二點,找到的也是差值,需要知道上一個才能知道自己,然而上一個也是差值,難不成每訪問一篇文件整個倒排表都解壓縮一遍?

看過前面前端編碼的同學可能想起了,差值和字首這不差不多的概念麼?弄幾個排頭兵不就行啦。

如圖,上面的倒排表被用差值編碼表示成了下面一行的數值。我們將倒排表分成組,比如3個數值一組,每組有個排頭兵,排頭兵不是差值編碼的,這樣如果你找第31篇文件,那它肯定在第10組裡面的第二個,只需要解壓一個組的就可以了。

clip_image124[4]

 

有了跳躍表,根據文件ID查詢位置也容易了,比如要在文件57,在排頭兵中可以直接跳過17,34,45,肯定在52的組裡,發現52+5=57,一進組就找到了。

當然排頭兵如果比較大,也可以用差值編碼,是基於前一個排頭兵的差值,由於排頭兵比較少,反正查詢的時候要一個個看,都解壓了也沒問題。

如果連結串列比較長,導致排頭兵比較多,沒問題還可以有多級跳躍表,排頭兵還有排頭兵,排長上面是連長。這樣查詢的時候,先查詢連長,找到所在的連再查詢排長,然後再進組查詢。

上面提到的PForDelta的編碼方式可以和跳躍表進行整合,由於PForDelta的編碼區域是定長的,基本可以滿足隨機訪問,然而對於差值問題,可以再entry中新增排頭兵的值的資訊,使得128個數值成為一組。

我們姑且稱這種方式為跳躍表規則。

相關文章