1. 背景
當有大量數據儲存在磁盤時,如數據庫的查找,插入, 刪除等操作的實現, 如果要讀取或者寫入, 磁盤的尋道, 旋轉時間很長, 遠大於在 內存中的讀取,寫入時間.
平時用的二叉排序樹搜索元素的時間複雜度雖然是 的, 但是底數還是太小, 樹高太高.
所以就出現了 B 樹(英文爲B-Tree, 不是B減樹), 可以理解爲多叉排序樹. 一個結點可以有多個孩子, 於是增大了底數, 減小了高度, 雖然比較的次數多(關鍵字數多), 但是由於是在內存中比較, 相較於磁盤的讀取還是很快的.
2. 定義
度爲 d(degree)的 B 樹(階(order) 爲 2d) 定義如下,
每個結點中包含有 n 個關鍵字信息: 。其中:
a) 爲關鍵字,且關鍵字按順序升序排序
b) 爲指向子樹根的接點,
c) 關鍵字的數 n 滿足(由此也確定了孩子結點的個數): (根節點可以少於d-1)樹中每個結點最多含有 2d個孩子(d>=2);
除根結點和葉子結點外,其它每個結點至少有 d個孩子;
若根結點不是葉子結點,則至少有 2 個孩子(特殊情況:沒有孩子的根結點,即根結點爲葉子結點,整棵樹只有一個根節點);
所有葉子結點都出現在同一層,葉子節點沒有孩子和指向孩子的指針
性質:
如下是 度爲2的 B 樹, 每個結點可能有2,3或4 個孩子, 所以也叫 2,3,4樹, 等價於紅黑樹
3. 查找操作
可以看成二叉排序樹的擴展,二叉排序樹是二路查找,B - 樹是多路查找。
節點內進行查找的時候除了順序查找之外,還可以用二分查找來提高效率。
下面是順序查找的 python 代碼
def search(self,key,withpath=False):
nd = self.root
fathers = []
while True:
i = nd.findKey(key)
if i==len(nd): fathers.append((nd,i-1,i))
else: fathers.append((nd,i,i))
if i<len(nd) and nd[i]==key:
if withpath:return nd,i,fathers
else:return nd,i
if nd.isLeafNode():
if withpath:return None,None,None
else:return None,None
nd = nd.getChd(i)
我實現時讓 fathers 記錄查找的路徑, 方便在實現 delete 操作時使用(雖然有種 delete 方法可以不需要, 直接 from up to down with no pass by),
4. 插入操作
自頂向下地進行插入操作, 最終插入在葉子結點,
考慮到葉子結點如果有 2t-1 個 關鍵字, 則需要進行分裂,
一個有 2t-1個關鍵字 結點分裂是這樣進行的: 此結點分裂爲 兩個關鍵字爲 t-1個的結點, 分別爲 , , 然後再插入一個關鍵字到父親結點.
注意同時要將孩子指針移動正確.
所以自頂向下地查找到葉子結點, 中間遇到 2t-1個關鍵字的結點就進行分裂, 這樣如果其子結點進行分裂, 上升來的一個關鍵字可以插入到父結點而不會超過2t-1
代碼如下
def insert(self,key):
if len(self.root)== self.degree*2-1:
self.root = self.root.split(node(isLeaf=False),self.degree)
self.nodeNum +=2
nd = self.root
while True:
idx = nd.findKey(key)
if idx<len(nd) and nd[idx] == key:return
if nd.isLeafNode():
nd.insert(idx,key)
self.keyNum+=1
return
else:
chd = nd.getChd(idx)
if len(chd)== self.degree*2-1: #ensure its keys won't excess when its chd split and u
nd = chd.split(nd,self.degree)
self.nodeNum +=1
else:
nd = chd
5. 刪除操作
刪除操作是有點麻煩的, 有兩種方法[1]
- Locate and delete the item, then restructure the tree to retain its invariants, OR
- Do a single pass down the tree, but before entering (visiting) a node, restructure the tree so that once the key to be deleted is encountered, it can be deleted without triggering the need for any further restructuring
5.1. 第一種方法
有如下情況
- 刪除結點在葉子結點上
結點內的關鍵字個數大於d-1,可以直接刪除(大於關鍵字個數下限,刪除不影響 B - 樹特性)
-
結點內的關鍵字個數等於d-1(等於關鍵字個數下限,刪除後將破壞 特性),此時需觀察該節點左右兄弟結點的關鍵字個數:
a. 旋轉: 如果其左右兄弟結點中存在關鍵字個數大於d-1 的結點,則從關鍵字個數大於 d-1 的兄弟結點中借關鍵字:(這裏看了網上的很多說法, 都是在介紹關鍵字的操作,而沒有提到孩子結點. 我實現的時候想了很久纔想出來: 借關鍵字時, 比如從右兄弟借一個關鍵字(第一個), 此時即爲左旋, 將父親結點對應關鍵字移到當前結點, 再將右兄弟的移動父親結點(因爲要滿足排序性質, 類似二叉樹的選擇) 然後進行孩子操作, 將右兄弟的 插入到 當前結點的孩子指針末尾) 左兄弟類似, <mark>而且要注意到邊界條件, 比如當前結點是第0個/最後一個孩子, 則沒有 左兄弟/右兄弟</mark>)b. 合併: 如果其左右兄弟結點中不存在關鍵字個數大於 t-1 的結點,進行結點合併:將其父結點中的關鍵字拿到下一層,與該節點的左右兄弟結點的所有關鍵字合併
<mark>同樣要注意到邊界條件, 比如當前結點是第0個/最後一個孩子, 則沒有 左兄弟/右兄弟</mark> 自底向上地檢查來到這個葉子結點的路徑上的結點是否滿足關鍵字數目的要求, 只要關鍵字少於d-1,則進行旋轉(2a)或者合併(2b)操作
- 刪除結點在非葉子結點上
- 查到到該結點, 然後轉化成 上述 葉子結點中情況
- 轉化過程:
a. 找到相鄰關鍵字:即需刪除關鍵字的左子樹中的最大關鍵字或右子樹中的最小關鍵字
b. 用相鄰關鍵字來覆蓋需刪除的非葉子節點關鍵字,再刪除原相鄰關鍵字(在;葉子上,這即爲上述情況)。
python 代碼如下, delete
函數中, 查找到結點, 用 fathers::[(父節點, 關鍵字指針, 孩子指針)]
記錄路徑, 如果不是葉子結點, 就再進行查找, 並記錄結點, 轉換關鍵字.
rebalance 就是從葉子結點自底向上到根結點, 只要遇到關鍵字數少於 2d-1 的,就進行平衡操作(旋轉, 合併)
實現時要很仔細, 考慮邊界條件, 還有當是左孩子的時候操作的是父結點的 chdIdx 的前一個, 是右孩子的時候是 chdIdx 的關鍵字. 具體實現完整代碼見文末.
def delete(self,key):#to do
'''search the key, delete it , and form down to up to rebalance it '''
nd,idx ,fathers= self.search(key,withpath=True)
if nd is None : return
del nd[idx]
self.keyNum-=1
if not nd.isLeafNode():
chd = nd.getChd(idx) # find the predecessor key
while not chd.isLeafNode():
fathers.append((chd,len(chd)-1,len(chd)))
chd = chd.getChd(-1)
fathers.append((chd,len(chd)-1,len(chd)))
nd.insert(idx,chd[-1])
del chd[-1]
if len(fathers)>1:self.rebalance(fathers)
def rebalance(self,fathers):
nd,keyIdx,chdIdx = fathers.pop()
while len(nd)<self.degree-1: # rebalance tree from down to up
prt,keyIdx,chdIdx = fathers[-1]
lbro = [] if chdIdx==0 else prt.getChd(chdIdx-1)
rbro = [] if chdIdx==len(prt) else prt.getChd(chdIdx+1)
if len(lbro)<self.degree and len(rbro)<self.degree: # merge two deficient nodes
beforeNode,afterNode = None,None
if lbro ==[]:
keyIdx = chdIdx
beforeNode,afterNode = nd,rbro
else:
beforeNode,afterNode = lbro,nd
keyIdx = chdIdx-1 # important, when choosing
keys = beforeNode[:]+[prt[keyIdx]]+afterNode[:]
children = beforeNode.getChildren() + afterNode.getChildren()
isLeaf = beforeNode.isLeafNode()
prt.delChd(keyIdx+1)
del prt[keyIdx]
nd.update(keys,isLeaf,children)
prt.children[keyIdx]=nd
self.nodeNum -=1
elif len(lbro)>=self.degree: # rotate when only one sibling is deficient
keyIdx = chdIdx-1
nd.insert(0,prt[keyIdx]) # rotate keys
prt[keyIdx] = lbro[-1]
del lbro[-1]
if not nd.isLeafNode(): # if not leaf, move children
nd.insert(0,nd=lbro.getChd(-1))
lbro.delChd(-1)
else:
keyIdx = chdIdx
nd.insert(len(nd),prt[keyIdx]) # rotate keys
prt[keyIdx] = rbro[0]
del rbro[0]
if not nd.isLeafNode(): # if not leaf, move children
#note that insert(-1,ele) will make the ele be the last second one
nd.insert(len(nd),nd=rbro.getChd(0))
rbro.delChd(0)
if len(fathers)==1:
if len(self.root)==0:
self.root = nd
self.nodeNum -=1
break
nd,i,j = fathers.pop()
5.2. 第二種方法
這是算法導論[2]上的
例如
B-TREE-DELETE(T,k)
1 r ← root[T]
2 if n[r] = 1
3 then DISK_READ(c1[r])
4 DISK_READ(c2[r])
5 y ←c1[r]
6 z ←c2[r]
7 if n[y] = n[z] = t-1 ▹ Cases 2c or 3b
8 then B-TREE-MERGE-CHILD(r, 1, y, z)
9 root[T] ← y
10 FREE-NODE(r)
11 B-TREE-DELETE-NONONE(y, k)
12 else B-TREE-DELETE-NONONE (r, k)
13 else B-TREE-DELETE-NONONE (r, k)
考慮到根結點的特殊性,對根結點爲1,並且兩個子結點都是t-1的情況進行了特殊的處理:
先對兩個子結點進行合併,然後把原來的根刪除,把樹根指向合併後的子結點y。
這樣B樹的高度就減少了1。這也是B樹高度唯一會減少的情況。
除了這種情況以外,就直接調用子過程 B-TREE-DELETE-NONONE (x, k)。
B-TREE-DELETE-NONONE (x, k)
1 i ← 1
2 if leaf[x] ▹ Cases 1
3 then while i <= n[x] and k > keyi[x]
4 do i ← i + 1
5 if k = keyi[x]
6 then for j ← i+1 to n[x]
7 do keyj-1[x] ←keyj[x]
8 n[x] ← n[x] - 1
9 DISK-WRITE(x)
10 else error:”the key does not exist”
11 else while i <= n[x] and k > keyi[x]
12 do i ← i + 1
13 DISK-READ(ci[x])
14 y ←ci[x]
15 if i <= n[x]
16 then DISK-READ(ci+1[x])
17 z ←ci+1[x]
18 if k = keyi[x] ▹ Cases 2
19 then if n[y] > t-1 ▹ Cases 2a
20 then k′←B-TREE-SEARCH-PREDECESSOR(y)
21 B-TREE-DELETE-NONONE (y, k′)
22 keyi[x] ←k′
23 else if n[z] > t-1 ▹ Cases 2b
24 then k′←B-TREE-SEARCH-SUCCESSOR (z)
25 B-TREE-DELETE-NONONE (z, k′)
26 keyi[x] ←k′
27 else B-TREE-MERGE-CHILD(x, i, y, z)▹ Cases 2c
28 B-TREE-DELETE-NONONE (y, k)
29 else ▹ Cases 3
30 if i >1
31 then DISK-READ(ci-1[x])
32 p ←ci-1[x]
33 if n[y] = t-1
34 then if i>1 and n[p] >t-1 ▹ Cases 3a
35 then B-TREE-SHIFT-TO-RIGHT-CHILD(x,i,p,y)
36 else if i <= n[x] and n[z] > t-1 ▹ Cases 3a
37 then B-TREE-SHIFT-TO-LEFT-CHILD(x,i,y,z)
38 else if i>1 ▹ Cases 3b
39 then B-TREE-MERGE-CHILD(x, i, p, y)
40 y ← p
41 else B-TREE-MERGE-CHILD(x, i, y, z)▹ Cases 3b
42 B-TREE-DELETE-NONONE (y, k)
轉移到右邊的子結點
B-TREE-SHIFT-TO-RIGHT-CHILD(x,i,y,z)
1 n[z] ← n[z] +1
2 j ← n[z]
3 while j > 1
4 do keyj[z] ←keyj-1[z]
5 j ← j -1
6 key1[z] ←keyi[x]
7 keyi[x] ←keyn[y][y]
8 if not leaf[z]
9 then j ← n[z]
10 while j > 0
11 do cj+1[z] ←cj[z]
12 j ← j -1
13 c1[z] ←cn[y]+1[y]
14 n[y] ← n[y] -1
15 DISK-WRITE(y)
16 DISK-WRITE(z)
17 DISK-WRITE(x)
轉移到左邊的子結點
B-TREE-SHIFT-TO-LEFT-CHILD(x,i,y,z)
1 n[y] ← n[y] +1
2 keyn[y][y] ← keyi[x]
3 keyi[x] ←key1[z]
4 n[z] ← n[z] -1
5 j ← 1
6 while j <= n[z]
7 do keyj[z] ←keyj+1[z]
8 j ← j +1
9 if not leaf[z]
10 then cn[y]+1[y] ←c1[z]
11 j ← 1
12 while j <= n[z]+1
13 do cj[z] ←cj+1[z]
14 j ← j + 1
15 DISK-WRITE(y)
16 DISK-WRITE(z)
17 DISK-WRITE(x)
6. B+樹
B+ 樹[3]是 B- 樹的變體,與B樹不同的地方在於:
- 非葉子結點的子樹指針與關鍵字個數相同;
- 非葉子結點的子樹指針 指向關鍵字值屬於 的子樹(B- 樹是開區間);
- 爲所有葉子結點增加一個鏈指針;
- 所有關鍵字都在葉子結點出現
B+ 的搜索與 B- 樹也基本相同,區別是 B+ 樹只有達到葉子結點才命中(B- 樹可以在非葉子結點命中),其性能也等價於在關鍵字全集做一次二分查找;
下面摘自 wiki[4]
查找
查找以典型的方式進行,類似於二叉查找樹。起始於根節點,自頂向下遍歷樹,選擇其分離值在要查找值的任意一邊的子指針。在節點內部典型的使用是二分查找來確定這個位置。
插入
節點要處於違規狀態,它必須包含在可接受範圍之外數目的元素。
- 首先,查找要插入其中的節點的位置。接着把值插入這個節點中。
- 如果沒有節點處於違規狀態則處理結束。
- 如果某個節點有過多元素,則把它分裂爲兩個節點,每個都有最小數目的元素。在樹上遞歸向上繼續這個處理直到到達根節點,如果根節點被分裂,則創建一個新根節點。爲了使它工作,元素的最小和最大數目典型的必須選擇爲使最小數不小於最大數的一半。
刪除
- 首先,查找要刪除的值。接着從包含它的節點中刪除這個值。
- 如果沒有節點處於違規狀態則處理結束。
- 如果節點處於違規狀態則有兩種可能情況:
- 它的兄弟節點,就是同一個父節點的子節點,可以把一個或多個它的子節點轉移到當前節點,而把它返回爲合法狀態。如果是這樣,在更改父節點和兩個兄弟節點的分離值之後處理結束。
- 它的兄弟節點由於處在低邊界上而沒有額外的子節點。在這種情況下把兩個兄弟節點合併到一個單一的節點中,而且我們遞歸到父節點上,因爲它被刪除了一個子節點。持續這個處理直到當前節點是合法狀態或者到達根節點,在其上根節點的子節點被合併而且合併後的節點成爲新的根節點。
由於葉子結點間有指向下一個葉子的指針, 便於遍歷, 以及區間查找, 所以數據庫的以及操作系統文件系統的實現常用 B+樹,
7. B*樹
B-tree [5] 是 B+-tree 的變體,在 B+ 樹的基礎上 (所有的葉子結點中包含了全部關鍵字的信息,及指向含有這些關鍵字記錄的指針),B * 樹中非根和非葉子結點再增加指向兄弟的指針;B 樹定義了非葉子結點關鍵字個數至少爲 (2/3)*M,即塊的最低使用率爲 2/3(代替 B+ 樹的 1/2)
8. 代碼實現與測試
8.1. 測試
if __name__ =='__main__':
bt = bTree()
from random import shuffle,sample
n = 20
lst = [i for i in range(n)]
shuffle(lst)
test= sample(lst,len(lst)//4)
print(f'building b-tree with {lst}')
for i in lst:
bt.insert(i)
#print(f'inserting {i})
#print(bt)
print(bt)
print(f'serching {test}')
for i in test:
nd,idx = bt.search(i)
print(f'node: {repr(nd)}[{idx}]== {i}')
for i in test:
print(f'deleting {i}')
bt.delete(i)
print(bt)
8.2. python 實現
class node:
def __init__(self,keys=None,isLeaf = True,children=None):
if keys is None:keys=[]
if children is None: children =[]
self.keys = keys
self.isLeaf = isLeaf
self.children = []
def __getitem__(self,i):
return self.keys[i]
def __delitem__(self,i):
del self.keys[i]
def __setitem__(self,i,k):
self.keys[i] = k
def __len__(self):
return len(self.keys)
def __repr__(self):
return str(self.keys)
def __str__(self):
children = ','.join([str(nd.keys) for nd in self.children])
return f'keys: {self.keys}\nchildren: {children}\nisLeaf: {self.isLeaf}'
def getChd(self,i):
return self.children[i]
def delChd(self,i):
del self.children[i]
def setChd(self,i,chd):
self.children[i] = chd
def getChildren(self,begin=0,end=None):
if end is None:return self.children[begin:]
return self.children[begin:end]
def findKey(self,key):
for i,k in enumerate(self.keys):
if k>=key:
return i
return len(self)
def update(self,keys=None,isLeaf=None,children=None):
if keys is not None:self.keys = keys
if children is not None:self.children = children
if isLeaf is not None: self.isLeaf = isLeaf
def insert(self,i,key=None,nd=None):
if key is not None:self.keys.insert(i,key)
if not self.isLeaf and nd is not None: self.children.insert(i,nd)
def isLeafNode(self):return self.isLeaf
def split(self,prt,t):
# form new two nodes
k = self[t-1]
nd1 = node()
nd2 = node()
nd1.keys,nd2.keys = self[:t-1], self[t:] # note that t is 1 bigger than key index
nd1.isLeaf = nd2.isLeaf = self.isLeaf
if not self.isLeaf:
# note that children index is one bigger than key index, and all children included
nd1.children, nd2.children = self.children[0:t], self.children[t:]
# connect them to parent
idx = prt.findKey(k)
if prt.children !=[]: prt.children.remove(self) # remove the original node
prt.insert(idx,k,nd2)
prt.insert(idx,nd = nd1)
return prt
class bTree:
def __init__(self,degree=2):
self.root = node()
self.degree=degree
self.nodeNum = 1
self.keyNum = 0
def search(self,key,withpath=False):
nd = self.root
fathers = []
while True:
i = nd.findKey(key)
if i==len(nd): fathers.append((nd,i-1,i))
else: fathers.append((nd,i,i))
if i<len(nd) and nd[i]==key:
if withpath:return nd,i,fathers
else:return nd,i
if nd.isLeafNode():
if withpath:return None,None,None
else:return None,None
nd = nd.getChd(i)
def insert(self,key):
if len(self.root)== self.degree*2-1:
self.root = self.root.split(node(isLeaf=False),self.degree)
self.nodeNum +=2
nd = self.root
while True:
idx = nd.findKey(key)
if idx<len(nd) and nd[idx] == key:return
if nd.isLeafNode():
nd.insert(idx,key)
self.keyNum+=1
return
else:
chd = nd.getChd(idx)
if len(chd)== self.degree*2-1: #ensure its keys won't excess when its chd split and u
nd = chd.split(nd,self.degree)
self.nodeNum +=1
else:
nd = chd
def delete(self,key):#to do
'''search the key, delete it , and form down to up to rebalance it '''
nd,idx ,fathers= self.search(key,withpath=True)
if nd is None : return
del nd[idx]
self.keyNum-=1
if not nd.isLeafNode():
chd = nd.getChd(idx) # find the predecessor key
while not chd.isLeafNode():
fathers.append((chd,len(chd)-1,len(chd)))
chd = chd.getChd(-1)
fathers.append((chd,len(chd)-1,len(chd)))
nd.insert(idx,chd[-1])
del chd[-1]
if len(fathers)>1:self.rebalance(fathers)
def rebalance(self,fathers):
nd,keyIdx,chdIdx = fathers.pop()
while len(nd)<self.degree-1: # rebalance tree from down to up
prt,keyIdx,chdIdx = fathers[-1]
lbro = [] if chdIdx==0 else prt.getChd(chdIdx-1)
rbro = [] if chdIdx==len(prt) else prt.getChd(chdIdx+1)
if len(lbro)<self.degree and len(rbro)<self.degree: # merge two deficient nodes
beforeNode,afterNode = None,None
if lbro ==[]:
keyIdx = chdIdx
beforeNode,afterNode = nd,rbro
else:
beforeNode,afterNode = lbro,nd
keyIdx = chdIdx-1 # important, when choosing
keys = beforeNode[:]+[prt[keyIdx]]+afterNode[:]
children = beforeNode.getChildren() + afterNode.getChildren()
isLeaf = beforeNode.isLeafNode()
prt.delChd(keyIdx+1)
del prt[keyIdx]
nd.update(keys,isLeaf,children)
prt.children[keyIdx]=nd
self.nodeNum -=1
elif len(lbro)>=self.degree: # rotate when only one sibling is deficient
keyIdx = chdIdx-1
nd.insert(0,prt[keyIdx]) # rotate keys
prt[keyIdx] = lbro[-1]
del lbro[-1]
if not nd.isLeafNode(): # if not leaf, move children
nd.insert(0,nd=lbro.getChd(-1))
lbro.delChd(-1)
else:
keyIdx = chdIdx
nd.insert(len(nd),prt[keyIdx]) # rotate keys
prt[keyIdx] = rbro[0]
del rbro[0]
if not nd.isLeafNode(): # if not leaf, move children
#note that insert(-1,ele) will make the ele be the last second one
nd.insert(len(nd),nd=rbro.getChd(0))
rbro.delChd(0)
if len(fathers)==1:
if len(self.root)==0:
self.root = nd
self.nodeNum -=1
break
nd,i,j = fathers.pop()
def __str__(self):
head= '\n'+'-'*30+'B Tree'+'-'*30
tail= '-'*30+'the end'+'-'*30+'\n'
lst = [[head],[f'node num: {self.nodeNum}, key num: {self.keyNum}']]
cur = []
ndNum =0
ndTotal= 1
que = [self.root]
while que!=[]:
nd = que.pop(0)
cur.append(repr(nd))
ndNum+=1
que+=nd.getChildren()
if ndNum==ndTotal:
lst.append(cur)
cur = []
ndNum = 0
ndTotal =len(que)
lst.append([tail])
lst = [','.join(li) for li in lst]
return '\n'.join(lst)
def __iter__(self,nd = None):
if nd is None: nd = self.root
que = [nd]
while que !=[]:
nd = que.pop(0)
yield nd
if nd.isLeafNode():continue
for i in range(len(nd)+1):
que.append(nd.getChd(i))
9. 參考資料
-
算法導論 ↩