本篇较长较枯燥,请保持耐心看完。
前面两章介绍了一下倒排索引以及倒排索引字典的两种存储结构,分别是跳跃表和哈希表,本篇我们介绍另一种数据结构,他也被大量使用在信息检索领域,我在github
上实现的搜索引擎的词典也是用的这个数据结构,它就是B+树。
首先,我们看看什么是树,树是程序设计中一个非常基础的数据结构,记得大学时候的数据结构课,链表,栈,队列,然后就是树了,虽然那时候想必大家都被前序遍历,中序遍历,后序遍历折腾过,不过树确实是一种非常有用的数据结构。
上一篇我们说过,表2的第一列首要解决的问题就是能快速找到对应的词,然后找到对应词的倒排列表,除了跳跃表和哈希表,B+树也能满足条件,B+树是B树的变种,我们B树我们就不看了,感兴趣的大家可以直接去google一下,我们主要讲的是B+树,下图就是一个3层的B+树,我画出来可能和大家搜出来的有点出入,但是没关系,关键B+树这种数据结构的思想大家了解了就行。
假设我们有一组数字 34,40,67,5,37,12,45,24,那么,把他们存成B+树就是下图这个样子。
我们很明显看到几个特点
每个节点的大小为2
非叶子层的最后一个节点的最后一个元素为NULL
最底层的叶子节点是顺序排列的,这个例子是从小到大
上面的内节点的每一个元素都指向的下一级节点中最大的一个数相等
我尽量的把B+树说简单点,网上的资料也好,查书也好,看上去都挺复杂的,首先我们看看怎么建立这棵树,我尽量用图了,少一些文字也好理解一点,前方大量图预警。
首先,我们的数组是34,12,5,67,37,40,45,24
第一步,初始化B+树,是这样子的
这时候,啥也没有,但是占用了两个节点,标识为无的,表示这个元素无意义,标记为NULL表示无穷大
第二步,插入34这个元素,那么图变成这样子
我们看到,插入的过程是顺着指针一直走到叶子节点,发现叶子节点是空的,然后把元素插入到叶子节点的头部,然后返回上一级节点,将NULL后移,然后把第一个元素置为他的子节点的最大值,请记住这句话:置为他的子节点的最大值
第三步,接着插入第二个元素12
这个步骤复杂一点
从根节点开始遍历,发现12小于根节点的某一个元素【在这里是第1个元素】,顺着指针往下走
到达叶子节点,发现12小于叶子节点的某一个元素,说明可以放在这个叶子节点中,并且叶子节点还有一个空位置,那么直接把12按大小顺序插入到这个节点中
第四步,然后是插入5
这一步更复杂一点,产生了分裂
从根节点开始遍历,5小于34,顺着指针往下走,到达叶子节点
到达叶子节点,发现5小于叶子节点的某一个元素,说明可以放在这个叶子节点中,但是,这个节点已经满了,那么,分裂出一个新的节点,将5放到老节点中,被挤走的元素顺移到新节点中
返回上一级节点,由于第一个叶子节点的最大元素已经变成12了,所以将该节点的元素由34改成指向的叶子节点的最大元素12
由于新生成了一个节点,将NULL这个元素指向新生成的节点
第五步,接着我们插入67
这一步比较简单
从根节点开始遍历,67小于NULL,顺着指针往下走,到达叶子节点
到达叶子节点,发现67大于该节点的每一个元素,并且叶子节点有空位,直接插入即可
第六步,我们插入37,插完这个后面的我就不写了,感兴趣可以自己画一下
这一步复杂了,这一步不仅分裂了,而且分裂了两次,并且层数增加了一层
从根节点开始遍历,37小于NULL,顺着指针往下走,到达叶子节点
到达叶子节点,37小于叶子节点中的67,表示可以插入到这个节点中,但是节点满了,我们按照第四步的操作,分裂节点。
分裂完了以后,产生了一个[34,37],一个[67,无]两个节点,往上走的时候,发现上一层的节点插入了37以后也满了,继续按照第四步分裂。
分裂完了以后,发现上层没有节点了,那么就新建一个根节点当上层节点,按照分裂的步骤给根节点赋值。
按照这六步,前5个元素就插入到B+树中了,后面的步骤您可以自己走一走,B+树基本的思想就是这样子的,可能我没有按照教科书上的做法来说,但这并不影响大家的理解,我相信看完了以后虽然你脑子里没有标准的算法步骤,但应该有个大致的轮廓了,只不过需要自己再仔细想想步骤。
总的来说,B+树的插入步骤无外乎以下几个步骤
每次都要从根节点开始
比较大小,找到小于当前值的元素,顺着指针往下走,继续比较大小,一直到达叶子节点,那么这个叶子节点就是你要操作的节点了。
在叶子节点只有几种操作,一是叶子节点有空位置,那么直接插入进去,一是叶子节点满了,那么分裂一个节点出来。
不管在叶子节点进行了那种操作,最后都要顺着指针回去,如果没有分裂,那么上层就不会分裂,可能会更新上层节点元素的值,如果分裂了,那么就带着两个分裂的节点往上走,该更新值就更新值,该分裂就分裂。
如果一层一层分裂到最上层了,那么就新增一个根节点吧
查找操作和更新操作几乎一样,就是更新操作的前面两步,就不说了。
一般的更新的时候也是先查找,找到叶子节点,再更新,然后顺着指针往上走继续分裂,这个顺着往上走一般情况下首先想到的是双向指针,但是双向指针分裂的时候有点麻烦,需要把两个指针都重新指新节点,我实现的时候用了一个栈,查找叶子节点的时候把经过的节点依次压栈,到达叶子节点后,完成插入操作,往上遍历的时候依次把栈弹出来就行了,少了一个指针。
回到上一篇说的那个表2的第一列,如果是那个表的话,用这个B+树加上倒排链的话,最后的数据结构就长成这样子了(字符串的大小我随便写的,中文的顺序排列哥的脑子排不出来,你就把他们看成从小到大的顺序吧)
好了,至此,一个倒排索引就建立好了,由两部分组成,我实现的时候就是这么实现的,一个结构用B+树存储字典,另外一个就是一个顺序的文件,B+树的叶子节点存一个指向倒排文件的文件偏移量,当然,你也可以用前面的哈希表或者跳跃表,甚至还有其他类型的树,比如trie树来实现,或者你还有其他新的高效数据结构也行。
我们再来说说B+树,为什么选它?
之前我实现的时候用的是哈希表,而且大部分的搜索引擎用的都是哈希表,为什么用树呢
首先,为了节省空间,如果用哈希表的话,假如有一个字段是主键,并且是不规则的(比如cookieid),那么如果巨量的文档的话,哈希表的桶就会很大,会非常占用内存,而我调试的机器才8G内存的mac。
其次再来看看哈希表,查询的时间复杂度是O(1),看上去确实美好,如果单单是一个全文搜索引擎的话,由于key都是字符串,而且基本都是中文字符串,整个中文的词汇量才几十万,确实很好,但是如果字段不见得是中文分词的东西,还有一些其他的东西,比如各种ID,由于是个通用的搜索,所以不会给具体字段去定义专门的哈希函数,所以可能会大片产生碰撞,那复杂度就不是O(1)了,如果是一个特定场景的搜索,要规避这个问题,可以根据自己的业务需求来的,甚至可以使用完美哈希函数,而我实现的时候主要是为了更通用,所以用了B+树。
我们再看看B+树本身,如果我们每个节点可以存储100个元素,那么一个4层的B+树,可以存储1亿条数据,不管是主键字段还是其他字段都够了,而一个4层的B+树检索起来,需要遍历4个节点,每个节点用二分查找的话,是log100(2为底),大概7次吧,4层的话,最差需要查询28次,如果是3层的话,最差要21次,虽然和哈希比起来慢了这么多,但1次循环大约需要4个CPU的时钟周期吧,对于现在的服务器的计算机来说,就算21次循环+条件判断也是微秒级别的,感觉不太出来差别,何况不可能每一次都那么点背,都要查21次吧。
再有,我的索引生成的时候是按段生成的,后面会涉及到索引的多个段的合并,如果是B+树的话,字典是顺序的,你看上面那个图,叶子节点是有指针连起来的,所以合并段的时候可以使用一个多路归并就合并完了,要是哈希的话,由于不是顺序的,合并起来需要重新哈希一遍,比较麻烦。
还有,B+树这种数据结构非常适合磁盘检索,只需要把每个节点的大小设定成一个磁盘页的大小(一般是4K,至于为什么设成页的大小,和机械硬盘的结构以及预读取机制有关,感兴趣的可以自己查查,不过现在都是SSD了,这个的影响不是很大了),把指针改成磁盘的页编号,那么不用加载进内存,直接在磁盘上就能进行检索,特别适合巨量数据量的词典(比如主键),索引数据库的索引(比如Mysql的inneDB)基本上都是B+树实现的,如果大家感兴趣可以单开一篇说这个。
最后,B+树由于是顺序存储的,所以可以进行范围搜索(虽然我没有用),而哈希表只能进行全等的搜索。
最后说说我实现的这棵B+树,首先,为了更少的占用内存,我是用的磁盘的形式实现的,并且用了mmap的方式来加快读写速度,没有用双向指针,而用的栈来记录查询的路径,速度还行吧,构造一棵10万个随机字符串的树大约需要3秒,随机查询10万次大约需要150毫秒,每次1.5微秒。
当然,我实现的时候比较仓促,就是按照算法硬编码快速撸出来的,所以我这个B+树还有非常大的优化空间,首先,我的key现在是确定的,不能超过64字节,并且每个节点最多100个元素,当时为了快,确定的key和元素个数比较好编程,如果变成动态的更加节省空间,其次,没有特别的考虑连续key的情况,连续key的插入会造成空间浪费一半,还有,把速度问题交给了mmap来解决,如果内存足够,实际上启动的时候预读取非叶子节点到内存的话,查询起来会更快,不过目前基本上满足需求了,大家如果对B+树实现很感兴趣,可以看看bolt这个项目,这个是一个B+树实现的KVDB,而且是带事务的哦。
OK,这一篇就写到这了,周末要出去玩了,下周继续。。。
最后,欢迎大家扫描一下下面的微信公众号订阅,首先会在这里发出来:)