最近无事,研究了一下数据库索引。大部分索引都是采用B+tree,而B+tree又是btree的优化。就先来了解一下Btree。
作为一个索引,一般是采用Key-Value的方式来存储内容。Key表示索引的关键字,而Value表示索引内容存放的位置,假设是硬盘中的某个位置,或者说是一个数据文件的偏移量。于是这样就可以根据索引的内容来查询文件的位置。
说到这里,就会产生一个疑问。既然是key-value类型,可以有很多数据结构表示,例如hash-table,搜索复杂度在O(1),为什么要使用Btree呢,而且Btree的复杂度是O(t*log(n,t)),显然不如hash-table。
这个原因有以下几种:
1.Btree是一种外排序的数据结构,Btree的使用主要是因为一旦索引的数据量过大,无法把索引载入内存,这时候需要有一种方法来减少硬盘的读取。其实要是能把索引载完全载入内存,就算10亿个数扫一遍也用不了多久。单纯一个索引文件会有那么大么?我们来算一下,一个有10亿条数据的索引该有多大。
假设key是一个字符串,一个ascii字符占一个字节。再假设我们的字符串只支持英文大小写,那么表示10亿个数所有组合要5个字节,按32位字对齐,我们假设用8个字节来表示key。对一个字符串来说这已经相当少了,对于这么大量的数据来说基本不可能。而value值是数据条目在硬盘中的偏移,我们假设硬盘采用48位的LBA编址,一个value就是8字节。那么一条key-value的索引就是16个字节。16字节*10亿=1.5G。如果key的长度再长点,那么一个索引大概在2~3G之间。如果是64g内存的话也许不算什么,单是如果内存稍小点,还要缓存数据,那么就不太够用了。而且hash-table有一个特征,就是如果一旦负载过高,hash的冲突率升高,性能退化会极其严重。一般的hash-table,需要事先设计好能承载的容量,而btree则是可以平滑扩容的。
2.最明显的不同。由于btree是有序索引,所以btree支持范围查找。而hash是无序的,不支持偏序查找,只能查固定的某个值。这个杀伤力太大了
好了,说了这么些,来正式说一下btree吧。
Btree的原理类似二叉搜索树,搜索比当前节点大的,就搜索右子树,搜索比当前节点小的,就搜索左子树。btree的每个节点保存的数据都是一个有序数列,有序数列的分叉是子节点的指针。同时最左右两侧
都是到子节点的指针。这样,子节点的指针数量永远比父节点的数据数量大1个。
比如这样 :
2 4
/ | \
1 3 5
假如我想要找3这个数据,先在上层节点搜索,发现2和4,那么3这个数据如果有的话就一定在中间这个子节点。
由这个特征可以得出一个结论。就是右边的数据一定会大于等于左侧的数据。也就是2节点右侧的子节点的数据一定>=2,而2节点左侧的数据一定<=2。同时,<=2且最接近2的数据会在2的左子节点的最右下位置,>=2且最接近2的数据在2的右子树的最左下位置。
至于搜索效率,二叉树理想情况下搜索效率和二分搜索相当。但是极端情况二叉树会退化成一条直线,不是左边没节点,就是右边没节点,搜索的平均效率退化成O(n)。
而btree的最大特征是,有所子节点,一定会在同一个高度。无论如何都不会出现一边子树长,一边子树短的情况。
为了达到这个目的,btree采用了一个非常巧妙的方式。与传统的树非常不同,btree增加树高度的方式不是向子节点中添加新的子节点。而是从树叉处分裂。一个树叉分裂成2个高度一样的小树,一旦根节点分裂,那么树的高度就增加1。
例如高度为零的 1 2 3,一旦分裂就变成了
2
/ \
1 3
由于分裂的子树高度一样。,所以树的所有子节点都在同一层上。
怎么保证这个特性呢?
首先引用一个图论中的定义:树中子节点最少的节点的子节点数量称为这棵树的度数。(好绕口)
我们用t表示这个度数
btree中对所有非根节点的所含的数据数量有这样一个要求,最少不能少于t-1,最大不能超过2t-1。根节点可以少于t-1但不能超过2t-1。t >=2
插入的时候一旦要超过这个数量,就把树分裂。删除的时候一旦要少于这个数量,要么从兄弟节点接一个节点来充数,如果兄弟节点也不够,就把子树合并。
这样,保证了btree节点最多数据的时候数据的数是一个奇数。因为2*t-1==2*(t-1)+1,所以一个满节点可以分裂成一个至少有一个数据的根,外加2个最小的节点。
我们通过对一颗t=2的树顺序插入[0,1,2,3,4,5,6,7,8,9]这10个数来模拟这个过程。
根据定义,这颗树最少数据的节点数据数1,最大的为3
前三个数很简单 0 1 2,插入3的时候发现树的节点已经满了,于是将树分裂再插入,变成
1 继续插入4, 变成 1 插入5的时候发现234满了,于是分裂234,把3提到上一层
/ \ / \
0 23 0 234
变成
1 3 1 3 1 3 5
/ | \ / | \ / | | \
0 2 45 再插入6,变成 0 2 456 插入7 ,0 2 4 67
再插入8的的时候,发现135已经满了,二话不说直接先分裂,再插入8(注意这个过程,由于插入是由上到下检查是否该分裂,所以不会存在下层节点满,而他的父节点同时也满的情况,可以保证无论如何,分裂要提出的中位数都能添加到父节点中,并让父节点的节点数不超过2t-1)
树变成了 插入 9 ,寻找到678的时候分裂并插入。最后变成
3 3
/ \ / \
1 5 1 5 7
/ \ / \ / \ / | \
0 2 4 678 0 2 4 6 89
如论是插入和搜索都很好理解。可是删除呢?比如上面这棵树,除了删除,剩下无论直接删哪个节点都会让它不能满足btree定义的条件。另外也无法直接合并,怎么办?
btree在删除节点的时复杂一点,但是逻辑是,从上到下搜索,先看自己是不是叶子节点,如果是,数据直接删除(一开始就发现自己是叶子节点除非只有一个树根)如果不是,再看数据是不是在自己这个节点上。如果在自己身上,就检查这个节点的左右子节点哪个子节点的数据大于t-1,假设左边有多余的,按照这个递归算法,去删除左边子树的最大的节点,来顶替这个要删除的节点。如果右边有多余的,就采用右边最小的。如果左右两个子树的树根都等于t-1,那么合并之,再删除。
如果要的数据不在自己身上,就判断出会在哪个子节点上。如果这个子节点等于t-1,就看他的兄弟节点能不能借数据给他。如果不能借,就把这个子树合并。
总之,无论如何,保证这个要删除的子树的沿路永远有可以借出的数据。用来确保树不变形。
看起来很复杂,还是拿上面这棵树举例子吧。假设我想删除上面这颗树中2这个数。
先找到3,发现可能在左边,但是拿到左边节点一看,不行,左边已经只有1这一个数了,已经是t-1了,
于是找右边,发现是俩数57。决定从这个节点借出他最小的数据。问题就变成了从
5 7
/ | \
4 6 89
中删除最小的数。按照这个算法,知道最小的数在5的左侧子节点。但是拿出来一看,不行,只有4一个数,再看5右边的子节点一看,只有6也不行。干脆合并。这棵子树变成了
7
/ \
456 89
终于满足条件了,找到456一看,还是叶子节点,直接把4删除。这个4用来借给左边的节点。
怎么借?看下图。
3 4
/ \ / \
1 7 +4 ---------> 1 7
/ \ / \ / \ / \
0 2 56 89 0 23 56 89
算法就是用借来的数顶替自己,把自己插到子节点右下角的位置。
借完以后,发现左边一个1,右边一个7,不行,还得合并,变成了
1 4 7
/ | | \
0 23 56 89
再看,发现2可能在1和4中间的子节点。拿到一看,满足条件,再一看,还是叶子节点。2直接删除,最后这颗树变成了这样:
1 4 7
/ | | \
0 3 56 89
嗯,还是一棵完好的Btree。
写了点代码来表现这个过程:
#!/usr/bin/env python from random import randint,choice from bisect import bisect_left from collections import deque class InitError(Exception): pass class ParaError(Exception): pass class KeyValue(object): __slots__=('key','value') def __init__(self,key,value): self.key=key self.value=value def __str__(self): return str((self.key,self.value)) def __cmp__(self,key): if self.key>key: return 1 elif self.key==key: return 0 else: return -1 class BtreeNode(object): def __init__(self,t,parent=None): if not isinstance(t,int): raise InitError,'degree of Btree must be int type' if t<2: raise InitError,'degree of Btree must be equal or greater then 2' else: self.vlist=[] self.clist=[] self.parent=parent self.__degree=t @property def degree(self): return self.__degree def isleaf(self): return len(self.clist)==0 def traversal(self): result=[] def get_value(n): if n.clist==[]: result.extend(n.vlist) else: for i,v in enumerate(n.vlist): get_value(n.clist[i]) result.append(v) get_value(n.clist[-1]) get_value(self) return result def show(self): q=deque() h=0 q.append([self,h]) while True: try: w,hei=q.popleft() except IndexError: return else: print [v.key for v in w.vlist],'the height is',hei if w.clist==[]: continue else: if hei==h: h+=1 q.extend([[v,h] for v in w.clist]) def getmax(self): n=self while not n.isleaf(): n=n.clist[-1] return (n.vlist[-1],n) def getmin(self): n=self while not n.isleaf(): n=n.clist[0] return (n.vlist[0],n) class IndexFile(object): def __init__(fname,cellsize): f=open(fname,'wb') f.close() self.name=fname self.cellsize=cellsize def write_obj(obj,pos): pass def read_obj(obj,pos): pass class Btree(object): def __init__(self,t): self.__degree=t self.__root=BtreeNode(t) @property def degree(self): return self.__degree def traversal(self): """ use dfs to search a btree's node """ return self.__root.traversal() def show(self): """ use bfs to show a btree's node and its height """ return self.__root.show() def search(self,mi=None,ma=None): """ search key-value pair within range mi<=key<=ma. if mi or ma is not specified,the searching range is key>=mi or key <=ma """ result=[] node=self.__root if mi is None and ma is None: raise ParaError,'you need setup searching range' elif mi is not None and ma is not None and mi>ma: raise ParaError,'upper bound must be greater or equal than lower bound' def search_node(n): if mi is None: if not n.isleaf(): for i,v in enumerate(n.vlist): if v<=ma: result.extend(n.clist[i].traversal()) result.append(v) else: search_node(n.clist[i]) return search_node(n.clist[-1]) else: for v in n.vlist: if v<=ma: result.append(v) else: break elif ma is None: if not n.isleaf(): for i,v in enumerate(n.vlist): if v<mi: continue else: search_node(n.clist[i]) while i<len(n.vlist): result.append(n.vlist[i]) result.extend(n.clist[i+1].traversal()) i+=1 break if v.key<mi: search_node(n.clist[-1]) else: for v in n.vlist: if v>=mi: result.append(v) else: if not n.isleaf(): for i,v in enumerate(n.vlist): if v<mi: continue elif mi<=v<=ma: search_node(n.clist[i]) result.append(v) elif v>ma: search_node(n.clist[i]) if v<=ma: search_node(n.clist[-1]) else: for v in n.vlist: if mi<=v<=ma: result.append(v) elif v>ma: break search_node(node) return result def insert(self,key_value): node=self.__root full=self.degree*2-1 mid=full/2+1 def split(n): new_node=BtreeNode(self.degree,parent=n.parent) new_node.vlist=n.vlist[mid:] new_node.clist=n.clist[mid:] for c in new_node.clist: c.parent=new_node if n.parent is None: newroot=BtreeNode(self.degree) newroot.vlist=[n.vlist[mid-1]] newroot.clist=[n,new_node] n.parent=new_node.parent=newroot self.__root=newroot else: i=n.parent.clist.index(n) n.parent.vlist.insert(i,n.vlist[mid-1]) n.parent.clist.insert(i+1,new_node) n.vlist=n.vlist[:mid-1] n.clist=n.clist[:mid] return n.parent def insert_node(n): if len(n.vlist)==full: insert_node(split(n)) else: if n.vlist==[]: n.vlist.append(key_value) else: if n.isleaf(): p=bisect_left(n.vlist,key_value) #locate insert point in ordered list vlist n.vlist.insert(p,key_value) else: p=bisect_left(n.vlist,key_value) insert_node(n.clist[p]) insert_node(node) def delete(self,key_value): node=self.__root mini=self.degree-1 def merge(n,i): n.clist[i].vlist=n.clist[i].vlist+[n.vlist[i]]+n.clist[i+1].vlist n.clist[i].clist=n.clist[i].clist+n.clist[i+1].clist n.clist.remove(n.clist[i+1]) n.vlist.remove(n.vlist[i]) if n.vlist==[]: n.clist[0].parent=None self.__root=n.clist[0] del n return self.__root else: return n def tran_l2r(n,i): left_max,left_node=n.clist[i].getmax() right_min,right_node=n.clist[i+1].getmin() right_node.vlist.insert(0,n.vlist[i]) del_node(n.clist[i],left_max) n.vlist[i]=left_max def tran_r2l(n,i): left_max,left_node=n.clist[i].getmax() right_min,right_node=n.clist[i+1].getmin() left_node.vlist.append(n.vlist[i]) del_node(n.clist[i+1],right_min) n.vlist[i]=right_min def del_node(n,kv): p=bisect_left(n.vlist,kv) if not n.isleaf(): if p==len(n.vlist): if len(n.clist[-1])>mini: del_node(n.clise[p],kv) elif len(n.clist[p-1])>mini: tran_l2r(n,p-1) del_node(n.clist[-1],kv) else: del_node(merge(n,p-1),kv) else: if n.vlist[p]==kv: left_max,left_node=n.clist[i].getmax() if len(n.clist[p].vlist)>mini: del_node(n.clist[p],left_max) n.vlist[p]=left_max else: right_min,right_node=n.clist[i+1].getmin() if len(n.clist[p+1].vlist)>mini: del_node(n.clist[p+1],right_min) n.vlist[p]=right_min else: del_node(merge(n,p),kv) else: if len(n.clist[p].vlist)>mini: del_node(n.clist[p],kv) elif len(n.clist[p+1].vlist)>mini: tran_r2l(n,p) del_node(n.clist[p],kv) else: del_node(merge(n,p),kv) else: try: pp=n.vlist[p] except IndexError: return -1 else: if pp!=kv: return -1 else: n.vlist.remove(kv) return 0 del_node(node,key_value) def test(): mini=50 maxi=200 testlist=[] for i in range(1,1001): key=randint(1,1000) #key=i value=choice('abcdefg') testlist.append(KeyValue(key,value)) mybtree=Btree(5) for x in testlist: mybtree.insert(x) print 'my btree is:\n' mybtree.show() #mybtree.delete(testlist[0]) #print '\n the newtree is:\n' #mybtree.show() print '\nnow we are searching item between %d and %d\n\n'%(mini,maxi) print [v.key for v in mybtree.search(mini,maxi)] #for x in mybtree.traversal(): # print x if __name__=='__main__': test()
注意的是以上代码只是在内存上模拟的btree的插入删除算法。而真实的btree的每个节点都是要放到硬盘上的。也就是每个btree 中children_list里存放的是该节点在硬盘上的地址,或者是文件偏移。本来也想做个来着,可是python这种动态数据类型的想要把数据格式化存放到硬盘最好的方式是把btree-node 变成ctype,真的还不如用c做。如果用c,那么为了表现key-value中key的多种类型,还要借助c++模板,太过麻烦了。好多年没碰过c++了,再说上学那会也根本没好好学,遂作罢。
另外,最后再看一下,btree的性能。
btree的性能分成两部分,一是节点载入的次数,也就是读取硬盘的次数,这个肯定是最为主要的性能影响部分。二是计算这个算法所要花费的cpu时间。跟硬盘读取速度比可以说忽略不计了。内存和硬盘速度要差6个数量级,这就是一百万倍的差距,弥补这个差距太难了。
再搜索时候,最差情况是搜索到叶子节点,硬盘的读取次数取决于树的高度。
假设只有一个树根的时候,高度为0。我再假设树的度数为t。树最高的时候,每个节点的数最少,那么为t-1
树根只有1个树,第一层有2* (t-1)。第二层每个t-1,又有t个子树,就是2t个(t-1)。假设高度为h,那么h层就有2*t的h方个。
假设节点的总数为n。那么n=1+2*(t-1)**1+...2*(t-1)**h。
一个初中生一眼就看出这是一个等比数列求和问题。等式两边同时乘t-1再一减。得到n=2*t**h-1.
得到树的最大高度是以t为底(n+1)/2的对数。
我们就算取t=2,就是树最高的情况下,也能有log(n)的性能。10亿个数假设树的度数为500,那么树的高度只有3.2层!最多只需要读4次硬盘就能找到数据。
(ps,层数中没有计算根节点,是每次操作都会涉及根节点,因此根节点一定是被cache到内存中的)
我们再来思考一下,btree的度数是如何决定的。
btree的一个节点应该是最适合硬盘读取的量。硬盘计算的单位是扇区512字节,但操作系统是按照簇来算的,我们假设是标准的4k。一个4k能容纳多少度数的一个btree节点取决于btree索引的key和value的大小,假设地址8字节,key-value 24字节,其他忽略不计。也就是(2t-1)*24+2t*8=4*1024 得到t=64。10亿个数最多读5次数据。如果我再把节点的容量变大,那么3次以内可以保证读到数据。
读取看完了,再来看插入和删除。
每次插入节点至少要搜索h次磁盘并至少写入一次。也就是插入性能和没有索引比至少低一倍。如果树的沿路都要分裂,每次分裂还要写回磁盘3次,那么3层b数每次写操作最多多出3次读操作和10来次写操作。在t很大的情况下,分裂的索引读取和写回的操作是少不了的。
删除就更不用说了,性能影响会更加严重。
当然,删除是可以优化的,就是标记起来暂时不删除。
尽管如此,读操作上获得的千万倍的速度提升是要远远超过这些负面影响。
最后,关于btree还有几点。
1.key-value中有相同的key增加了搜索的算法复杂度。因为即使找到key以后,还需要继续搜索。但是这个问题对于实际情况没什么影响。因为对于一颗度数很大,层数很少的btree来讲,绝大多数的数据都存在在叶子节点上。相同key分布在中间节点的概率本就不大,还要分布在中间节点的边缘,这个概率更加小。
2.删除btree节点远比想象中要复杂。这是因为删除一个数据之前,肯定伴随一次查找,有可能满足这个查找的节点很多,但只删除一个。这种情况不能根据key来删,只能根据key-value来删。这个查找的过程,明显是可以用来优化删除方法的。我的程序中并没有考虑这个问题。
3. 这个btree索引只能支持前缀匹配搜索,按照正常的字典序来计算搜索。但是对中缀和后缀搜索无能为力。
直白点说就是只支持like 'a%',不支持like '%a%'和like '%a'。
4.由于节点插入的和存放无序性,读一段连续数据会变成完全随机读取。完全遍历的方法也不是很优雅。
5.btree-node的数据结构上是可以不含指向父节点的指针的,加上这个,能方便一点
先写这么些,累死了。不对的地方,还希望看到的人及时指正