原文地址: http://blog.51cto.com/thuhak/1261783
最近无事,研究了一下数据库索引。大部分索引都是采用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。
写了点代码来表现这个过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
|
#!/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的数据结构上是可以不含指向父节点的指针的,加上这个,能方便一点
先写这么些,累死了。不对的地方,还希望看到的人及时指正