【資料結構與演算法】B tree 即相關操作 深入解讀
B tree是從其他搜尋樹結構中延伸出來的一種用於外存裝置比如disk的一種資料結構。先拋開B tree與disk的關係,單純地瞭解下資料結構。
1.定義
粗略地來講,B tree定義主要包含以下幾個方面:
(1)每一個節點的結構,節點包含關鍵字key,指標pointer,key數目n和葉節點標誌bool。
(2)順序問題,每一個節點的key以非降序方式排列。而且,如果ci是ki和ki+1兩個關鍵字之間的孩子節點,那麼所有以ci為root的子樹中的key都滿足,k1<=k<=ki+1。整個子樹的關鍵字的範圍都被父節點卡死了。
(3)數目問題,定義一個引數t,那麼對於每一個節點,至多含有2t-1個關鍵字或者2t個子節點,除根節點以外的節點,至少含有t-1個關鍵字或者t個子節點,根節點至少含有1個關鍵字。
(4)平衡性,所有的葉子節點都必須位於同一層。
下面是《演算法導論》一書中的權威定義:
當然,B tree的定義有許多變種,可能會稍有差異,比如有些地方key是以嚴格增序排列的,比如下面會上一個資料庫系統裡面的B tree的定義,這是因為在資料庫中B tree通常建立在主鍵上,是不允許有重複值的,因此定義稍微不同。再比如,B tree的引數t有的地方叫做order,order含義不同,指的是最多的子節點數目,不過換湯不換藥,把最基本最核心的該清楚就可以了。下面是《資料庫系統基礎 6th》一書中給出的定義:
至此,B tree的定義搞清楚了。
2.B tree的優勢
之前,已經有了一些搜尋樹結構可以利用了,為什麼disk儲存結構不適用那些呢?比如二叉搜尋樹,2-3樹,AVL樹或者紅黑樹等等。部分原因在於B tree具有下面的性質:
(1)平衡性,《演算法導論》一書中證明了平衡搜尋樹的查詢,插入和刪除的worst case時間可以被garantee到Ologn,那麼自然我們要追求一棵平衡樹。
(2)飽滿,B tree對每一個節點的都做了限制,比如其中一條要求最少含有t-1個key,這個保證了每一個節點都是至少半滿,因此空間利用率高,樹的高度會降低,效能也會提高。而且B tree的引數t也就是分支數一般很大,也讓樹的高度不會太大。
因此,個人感覺以上兩點決定了B tree的優勢所在,當然代價就是在B tree上的操作也會複雜一些。
3.B tree的高度
結論:假設高度為h,總結點數目為n,那麼h=O(f(n,t)),h有一個上界。
證明:因為這裡找的是h的上界,所以我們考慮最壞情況,每一個節點都儘可能少存,也就是說每一個節點都按含有t個子節點來算,那麼高度能取最大值。考慮每一層的節點數:第一層,1;第二層:2;第三層:2t;第四層:2t^2。。。
因此總數是1+2+2t+....+2t^(h-2) = O(t^h),所以粗略得到h=Ologt n。(以t為底n的對數)
即,樹高一定以t為base的節點數的對數。這個結論可以說明(1)相比其他樹,B tree之所以飽滿是因為高度是以t為底的對數,在相同數目節點前提下,就比以2為底的對數的高度小(比如二叉樹)。另外,高度上界的確定使得B tree其餘操作的上屆也確定,是一個在worst case情況下的保證,這是平衡性保證的。其餘非平衡的比如二叉樹,極限情況下可能退化為連結串列,操作是On的。
4.搜尋操作:
Search(T, k),過程大致是先看t節點是否有一個ki=k,如果有返回(t,i),否則如果t是leaf,那麼返回null,否則找到一個i使得ki-1<k<ki,進行一次disck讀取操作,讀取ci,遞迴呼叫search(ci, k)即可。當然是用二分查詢效率更高。
下面是《演算法導論》中的原始碼:
5.分割操作:
如果一個節點是滿的即2t-1,那麼必須先分割才能插入。思路是把kt移至上一層,然後節點分為兩部分,一部分含有k1至kt-1,另一部分含有kt+1至k2t-1。kt移至上一層節點後,其左右指標分別指向這兩個節點。如果上層也是full那麼就要一直遞迴下去直到root,如果root也full,那麼root分裂為兩個,new一個新的root指向這兩部分,這時候樹的高度增加1。補充一點,按照這裡的定義方式,full的情況對應的key是奇數,那麼一定存在中間元素可以上移,但是有些地方定義的full允許有偶數,這時應該是哪個key上移?這時中間會存在兩個key,可以取左邊也可以取右邊,只要保證一致性就可以。
另外,這裡的split是一個一般意義上的split,但是如果我們有一些前提或者假設,那麼情況會簡單一些,如果我們假設要分裂的節點的父節點一定不是full,那麼我們一次操作就可以完成,不需要遞迴,這個假設是有意義的,因為在後續的insert操作中,我們就是利用了這種split。那麼如何保證?因為我們這裡的split只是作為insert的一個呼叫函式來處理的,也就是隻用在insert中,而不是當做一個外界呼叫的介面來定義的。所以考慮insert,總是從root向下查詢插入位置,這時,如果發現當前節點full,就事先做一個split,那麼後續的節點的父節點就總是非full的。這樣演算法實現比較簡單。
下面是《演算法導論》中的在父節點非full情況下的split:
上述程式碼的內容:函式split(x,i)指分裂x節點的第i個子節點即ci。5-9行是設定新的節點的key和子節點,10行是修改y的數目,減半。11-17是把x的指標和key後移,然後放入y節點的原本的中間的key,同時讓x指向z。
6.插入操作:
插入操作很體現B tree的特徵,B tree和二叉搜尋樹不同,二叉樹是每一次新增一個子節點。但是B tree為了保證平衡性,每一次都是在leaf上插入,這樣保證了樹是向上增長的,因為分裂。這個特徵是B tree的關鍵。那麼如何做insert?思路大致是根節點可能是滿的,如果是,先分裂,root現在不是full,可以進行在非full節點插入操作,如何實現在非ful節點插入?先檢測是否為leaf,如果是,那麼插入適當位置返回。否則找到合適子節點,可能full,如果full,先分裂,再遞迴呼叫非full插入函式。這樣做的好處就是保證了上一層的節點是非full得。這樣一共有兩個函式,一個是inert(),一個是insert_nonfull()。insert只調用一次,處理了root滿的情況,然後就是呼叫insert_nonfull。下面是《演算法導論》程式碼:
上述程式碼先檢測root是否滿,若是,則新建一個root,讓新root指向舊root,對新root呼叫split。然後root為非full,再呼叫non_full()。
return case是當前已經是leaf,那麼直接插入即可。否則找到適當的子節點ci,從記憶體讀取一次,然後如果是滿,先split,然後遞迴呼叫insert_non_full()在分割後的節點。否則直接對ci呼叫insert_non_full()。
7.刪除操作:
當呼叫delete(T,k)時,該函式的執行情況大致是
(1)如果t內包含k,且是leaf,那麼直接刪除。如果包含k不是葉節點,那麼如果k兩邊的子節點有一個是至少含有t個key,那麼假設是左邊的子節點y,就把y的最右邊的key1移到當前節點,遞迴刪除y中的key1。如果是右子樹同理。如果包含k且k兩邊的子樹關鍵字個數都是小於t,那麼就合併左右子樹並且把k下放為中間key,遞迴刪除即可。
(注意這裡對應的是第一次進入函式的執行情況,也就是第一個考察的節點就包含關鍵字那麼就做上述處理。有人可能會問如果t包含k且是leaf而且個數小於t,那麼刪除以後就會違反B tree性質?因為這裡是第一個節點,如果它同時也是葉子,那麼說明這棵樹只有一個節點,那麼規則應該變為至少含有一個key。如果真的只含有一個key那麼刪完就是空樹,也是可以的。)
(2)如果t內沒有k,那麼一定存在在為ci為跟的子樹中,如果ci個數為t - 1,那麼如果至少有一個相鄰的兄弟節點至少含有t,就將t的一個key下放至ci,將其兄弟的key上移至t。否則如果兄弟也是t-1,那麼選擇其中一個和ci合併。同時將t的一個key下移成為中間的key。(如果t是根,可能高度會降)
(這裡的思路是如果第一次找不到,那麼在下降至下一個節點時,先保證含有至少t個關鍵字,做預處理。)
8.B tree與disk的一些關係:
首先B tree的引數t與disk有關,我們當然希望t越大越好,但是對於一個節點,設計時要讓其正好位於一個disk的block上,block是IO的基本單位,這樣我們在一次IO就可以讀取到某一個節點的全部資訊,IO操作是代價比較高的,因此我們絕不希望一個節點佔據兩個或者多個block,從而導致多次IO。那麼假設一個節點的key佔據K大小,指標佔據P大小,那麼有Block size >= (2t-1)*P + 2t*K,這樣就可以計算出引數t的大小。但是有個問題,如果t很大,那麼做搜尋的代價也會增加。不過在記憶體做搜尋與disk的IO比起來根本不算什麼。在與disk打交道的領域,IO次數永遠是最重要的因素。
最後B tree是存放在disk上的,我們通過讀取B tree來的值來得到我們要找的資料的block在哪裡。如果沒有B tree,我們只能遍歷disk,這個IO會爆炸,有了B tree,我們只需要Oh就可以得到,這就是B tree的意義所在。
9.一些討論:
這裡是單純地討論了B tree的結構,是一種理論上的結構,如果說應用,比如DBMS中,那麼其實結構會稍微有變化,比如在DBMS中,每一個key還會有一個data指標,指向該關鍵字的block。
另外,B tree的操作也會有不同的版本,這裡給出的是《演算法導論》以及我上大資料課程上的版本,個人也覺得是一個很清晰的版本。當然也有另外的,比如《資料庫系統基礎 6th》一書中,其insert和delete都是做預處理的。而是使用回溯或者遞迴。舉個例子,insert,他是找到相應的leaf,如果大於2t-1,就先分裂再插入,再遞迴處理父節點。這種實現需要記錄遍歷路徑,可以使用stack完成。