Lucene 4.X 倒排索引原理与实现: (2) 倒排表的格式设计

1. 定长编码

最容易想到的方式就是经常使用的普通二进制编码,每一个数值占用的长度相同,都占用最大的数值所占用的位数,如图所示。前端

clip_image002[6]

这里有一个文档ID列表,254,507,756,1007,若是按照二进制定长编码,须要按照最大值1007所占用的位数10位进行编码,每一个数字都占用10位。java

和词典的格式设计中顺序列表方式遇到的问题同样,首先的问题就是空间的浪费,原本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位进行编码,并无节省空间。this

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]进行二进制编码。lua

好比对于正整数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个数值成为一组。

咱们姑且称这种方式为跳跃表规则。

相关文章
相关标签/搜索