1. 簡介
在之前我們學習了紅黑樹,今天再學習一種樹——B樹。它與紅黑樹有許多類似的地方,比如都是平衡搜索樹, 但它們在功能和結構上卻有較大的差別。
從功能上看,B樹是為磁盤或其他存儲設備設計的,能夠有效的降低磁盤的I/O操作數,因此我們經常看到有許多數據庫系統使用B樹或B樹的變種作為儲存的數據結構;從結構上看,B樹的結點可以有很多孩子,從數個到數千個,這通常依賴於所使用的磁盤的單元特性。
如下圖,給出了一棵簡單的B樹。
從圖中我們可以發現,如果一個內部結點包含n個關鍵字,那么結點就有n+1個孩子。例如,根結點有1個關鍵字M
,它有2個孩子;它的左孩子包含2個關鍵字,可以看到它有3個孩子。之所以是n+1個孩子,是因為B樹的結點中的關鍵字是分割點,n個關鍵字正好分隔出n+1個子域,每個子域都對應一個孩子。
2. 輔存上的數據結構
在之前我們提到,B樹是為磁盤或其他存儲設備設計的。因此,在正式介紹B樹之前,我們有必要弄清楚為什么針對磁盤設計的數據結構有別於針對隨機訪問的主存所設計的數據結構,只有這樣才能更好理解B樹的優勢。
我們知道,磁盤比主存便宜且有更多的容量,但是它比主存要慢許多,通常會慢出4~5個數量級。為了提高磁盤的讀寫效率,操作系統在讀寫磁盤時,會一次存取多個數據而不是一個。在磁盤中,信息被分為一系列相等大小的,在柱面內連續出現的位頁面(page),每次磁盤讀或寫一個或多個完整的頁面。通常,一頁的長度可能是\(2^{11} -2^{14}\)字節。
因此,在本篇博客中,我們對運行時間的衡量主要從以下兩個方面考慮:
- 磁盤存取次數
- CPU時間
我們用讀出或寫入磁盤的信息的頁數來衡量磁盤存取的次數。注意到,磁盤存取時間並不是常量——它與當前磁道和所需磁道之間的距離以及磁盤的初始旋轉狀態有關,但是為了簡單起見,我們仍然使用讀或寫的頁數作為磁盤存取總時間的近似值。
在一個典型的B樹應用中,所需處理的數據非常大,以至於所有的數據無法一次轉入主存。B樹算法將所需頁面從磁盤復制到主存,若進行了修改,之后則會寫回磁盤。因此,B樹算法在任何時刻都只需要在主存中保存一定數量的頁面,主存的大小並不限制被處理的B樹的大小。
下面用幾行偽代碼來模擬對磁盤的操作。設x為指向一個對象的指針,我們在使用x(指向的對象)時,需要先判斷x指向的對象是否在主存中,若在則可以直接使用;否則需要將其從磁盤讀入到主存,然后才能使用。
x = a pointer to some object
DISK-READ(x) // 將x讀入主存,若x已經在主存中,則該操作相當於空操作
modify x
DISK-WRITE(x) // 將x寫回主存,若x未修改,則該操作相當於空操作
由上我們看出,一個B樹算法的運行時間主要由它所執行的DISK-READ和DISK-WRITE操作的次數決定,所以我們希望這些操作能夠讀或寫盡可能多的信息。因此,一個B樹結點通常設計的和一個完整磁盤頁一樣大,這將使得磁盤頁的大小限制B樹結點可以含有的孩子(關鍵字)的個數。
如下圖是一棵高度為2(這里計算高度時不計算根結點)的B樹,它的每個結點有1000個關鍵字,因此分支因子(孩子的個數)為1001,於是它可以儲存\(1000*(1 + 1001 + 1001 *1001)\)個關鍵字,其數量超過10億。我們如果將根結點保存在主存中,那么在查找樹中任意一個關鍵字時,至多只需要讀取2次磁盤。
3. B樹的定義
下面正式給出B樹的定義。一棵B樹\(T\)必須具備如下性質:
- 每個結點\(x\)有如下屬性:
- \(x.n\)。它表示儲存在 \(x\)中的關鍵字的個數;
- \(x.key_1,x.key_2,...,x.key_n\)。它們表示\(x\)的\(n\)個關鍵字,以非降序存放,即\(x.key_1 \leq x.key_2 \leq ... \leq x.key_n\);
- \(x.leaf\)。它是一個布爾值,如果\(x\)是葉結點,它為TRUE;否則為FALSE;
- \(x.c_1, x.c_2,...,x.c_{n+1}\)。它們是指向自己孩子的指針。如果該結點是葉節點,則沒有這些屬性。
- 關鍵字\(x.key_i\)對存儲在各子樹中的關鍵字范圍進行分割,即滿足:\(k_1 \leq x.key_1 \leq k_2 \leq x.key_2 \leq... \leq x.key_n \leq k_{n+1}\)。其中,\(k_i(i = 1, 2, ...., n+1)\)表示任意一個儲存在以\(x.c_i\)為根的子樹中的關鍵字。
- 每個葉結點具有相同的深度,即葉的高度\(h\)。
- 每個結點所包含的關鍵的個數有上下界。用一個被稱為最小度數的固定整數\(t(t \geq 2)\)來表示這些界:
- 下界:除了根結點以外的每個結點至少有 \(t-1\) 個關鍵字。因此,除了根結點外的每個內部結點至少有 \(t\) 個孩子。
- 上界:每個結點至多包含 \(2t-1\) 個關鍵字。因此,一個內部結點至多可能有\(2t\)個孩子。當一個結點恰好有\(2t-1\) 個關鍵字時,稱該結點為滿的(full)。
下面用Java實現以上定義:
import java.util.List;
/**
* B樹
*
* @param <K> B樹儲存元素的類型
*/
public class BTree<K extends Comparable<K>> {
private BNode<K> root;
private int height;
private int minDegree;
/**
* B樹的結點類
*/
public static class BNode<K extends Comparable<K>> {
private List<K> keys;
private List<BNode> children;
private int size;
private boolean leaf;
}
// setter、getter ...
}
我們抽象出代表結點的BNode
類,作為表示B樹的類BTree
的內部類;它們具有如上面定義所說的各屬性,只是在屬性名上略有不同,會意就好;並且由於B樹要求結點包含的關鍵字是按非逆序排列的,因此我們定義的泛型K
必須實現了Comparable
接口。
根據以上定義,當\(t = 2\)時的B樹是最簡單的。此時樹的每個內部結點只可能有2個、3個或4個孩子,我們稱它為2-3-4樹。顯然的,t的取值越大,B樹的高度也就越小。事實上,B樹的高度與其包含的關鍵字的個數以及它的最小度數有如下的關系:
如果\(n \geq1\),那么對於任意一棵包含\(n\)個關鍵字、高度為\(h\)、最小度樹\(t \geq 2\)的B樹\(T\)有:
證明很簡單,因為B樹\(T\)的根結點至少包含1個關鍵字,而其他的結點至少包含\(t-1\)個關鍵字,因此除根結點外的每個結點都有\(t\)個孩子,於是有:
4. B樹上的基本操作
同其它的二叉搜索樹一樣,我們主要關心B樹的 search 、 create 、 insert 和 delete 操作。首先做兩個約定:
- B樹的根結點始終在主存中,這樣我們可以直接引用根結點而不需要執行DISK-READ操作;但是若根結點被修改,我們需要對其執行DISK-WRITE操作。
- 任何被當做參數的結點在被傳遞之前,都要對它們先做一次DISK-READ操作。
4.1 Search 操作
首先考察搜索操作。它與普通的二叉搜索類似,只不過它多了幾個“叉”,需要進行多次判斷。
記B樹\(T\)的根結點(的指針)為\(root\),現在要在\(T\)中搜索關鍵字\(k\)。如果\(k\)在樹中,則返回對應結點(的指針)\(y\)和\(y.key_i = k\)的下標\(i\)組成的有序對\((y, i)\);否則返回空。
下面給出Java的實現:
private SearchResult<K> search(BNode<K> currentNode, K k) {
int i = 0;
// 此處也可采用二分查找
while (i < currentNode.size && k.compareTo(currentNode.getKeys().get(i)) > 0) {
i++;
}
if (i < currentNode.size && k.compareTo(currentNode.getKeys().get(i)) == 0) {
return new SearchResult<K>(currentNode, i);
}
if (currentNode.leaf) {
return null;
}
// DISK-READ(currentNode.getChildren()[i])
return search(currentNode.getChildren().get(i), k);
}
public static class SearchResult<K extends Comparable<K>> {
public BNode<K> bNode;
public int keyIndex;
public SearchResult(BNode<K> bNode, int keyIndex) {
this.bNode = bNode;
this.keyIndex = keyIndex;
}
}
Search 用了遞歸的操作:每層遞歸都會從左往右(從小到大)依次比較當前結點的第 i(從0起)個關鍵子與待搜索的關鍵字 k 的大小,直到第 i 個關鍵字不小於 k 。若此時第 i 個關鍵字正好等於k,則表示搜索到了,返回相關信息;否則,將以第 i 個孩子作為當前結點,按照上述過程遞歸查找。實際上,在文章開頭給出的一棵關鍵字為字母的B樹中,顏色較淺的結點即為我們在搜索關鍵字R
時,需要搜索的結點。
由此我們不難看出,上述 search 過程訪問磁盤的次數為\(O(h) = O(\log_tn)\);而每層遞歸調用中,循環操作的時間代價為\(O(t)\)(因為除根結點外,每個結點的關鍵字個數為\(t-1\)與\(2t-1\)之間)。因此,總的時間代價為\(O(th) = O(t \log_tn)\)。
4.2 Create 操作
為構造一棵B樹,我們先用create方法來創建一棵空樹(根結點為空),然后調用insert操作來添加一個新的關鍵字。這兩個過程有一個公共的過程,即allocate-node,它在\(O(1)\)時間內為一個新結點分配一個磁盤頁。
由於create操作很簡單,下面只給出偽代碼:
create(T)
x = allocate-node()
x.leaf = TRUE
x.n = 0
DISK-WRITE(x)
T.root = x
4.3 Insert 操作
在B樹上進行insert操作較為麻煩。和普通二叉搜索樹一樣,我們必須先根據關鍵字找到要插入的位置,但不是插入就結束了。因為插入新結點的操作可能會導致B樹不合法。
B樹算法采用的做法是:在插入前,先判斷結點是否是滿的,若非滿,那就直接插入;否則就將該結點一分為二,分裂為兩個結點,而中間的關鍵字插入到其父結點中。
如下圖所示,B樹的最小度數 \(t=4\),因此包含 \([P, Q, R, S, T, U, V]\) 關鍵字的結點過滿,需要分裂。其操作步驟是:將處在中間位置的關鍵字 \(S\) 提升到其父結點中,剩余關鍵字隨着結點一分為二。
特別提醒:上圖截取自《算法導論(第三版),機械工業出版社》,其中右側部分中的關鍵字W
和S
的順序弄反了!!!
需要注意的是,將中間關鍵字提升至父結點后,又可能導致父結點過滿,此時需要用同樣的方法處理父結點。此過程可能會持續發生,形成自底向上的分裂現象。
既然如此,可以采用一種更加巧妙的辦法:在逐層向下查找待插入關鍵字的位置過程中,只要遇到滿的結點,就進行分裂。這樣一來,當關鍵字提升到父結點時,就不會造成父結點過滿了。
特別地,由於根結點沒有父結點,對於過滿的根結點,需要新建一個空的根結點,原根結點中間位置的關鍵字上升到新建的空結點中。如下圖所示:
由上我們可以看出,對滿的非根結點的分裂不會使B樹的高度增加,導致B樹高度增加的唯一方式是對根結點的分裂。
下面給出分裂過程的Java代碼:
/**
* 分裂node的第i個子結點
*
* @param node 非滿的內部結點
* @param i 第i個子結點
*/
private void splitNode(BNode<K> node, int i) {
BNode<K> childNode = node.getChildAt(i);
int fullSize = childNode.getSize();
// 從滿結點childNode中截取后半部分
List<K> newNodeKeys = childNode.getKeys().subList(fullSize / 2 + 1, fullSize - 1);
List<BNode<K>> newNodeChildren = childNode.getChildren().subList((fullSize + 1) / 2, fullSize);
BNode<K> newNode = new BNode<>(newNodeKeys, newNodeChildren, childNode.leaf);
// 重新設置滿結點childNode的size,而不必截取掉后半部分
childNode.setSize(fullSize / 2);
// 將childNode的中間關鍵字插入node中
K middle = childNode.getKeyAt(fullSize / 2);
node.getKeys().add(i, middle);
// 將分裂出的結點newNodeKeys掛到node中
node.getChildren().add(i + 1, newNode);
// 更新size
node.setSize(node.getSize() + 1);
// 寫入磁盤
// DISK-WRITE(newNode)
// DISK-WRITE(childNode)
// DISK-WRITE(node)
}
代碼中的注釋基本給出的每部操作的目的,這里不再贅述。實現了分裂過程,我們接下來就可以寫insert過程了:
/**
* 插入關鍵字
*
* @param key 待插入的關鍵字
*/
public void insert(K key) {
// 判斷根結點是否是滿的
if (root.getSize() == 2 * minDegree - 1) {
// 若是滿的,則構造出一個空的結點,作為新的根結點
LinkedList<K> newRootKeys = new LinkedList<K>();
LinkedList<BNode<K>> newRootChildren = new LinkedList<BNode<K>>();
newRootChildren.add(root);
root = new BNode<K>(newRootKeys, newRootChildren, false);
splitNode(root, 0);
height++;
}
insertNonFull(root, key);
}
以上代碼中,首先判斷根結點是否滿了,若滿了,就構造出一個新的根結點,將以前的根結點掛到其下,注意此時新的根結點中還沒有關鍵字,接着調用splitNode
方法去分裂舊的根結點,這樣處理下來,就能保證根結點是非滿狀態了。以下是splitNode
過程的Java代碼:
/**
* 分裂node的第i個子結點
*
* @param node 待分裂結點的父結點(注意不是待分裂的結點)
* @param i 第i個子結點
*/
private void splitNode(BNode<K> node, int i) {
BNode<K> childNode = node.getChildAt(i);
int childKeysSize = childNode.getSize();
int childChildrenSize = childNode.getChildren().size();
// 從滿結點childNode中截取后半部分作為分裂的右結點
LinkedList<K> rightNodeKeys = new LinkedList<K>(childNode.getKeys().subList(childKeysSize / 2 + 1, childKeysSize));
LinkedList<BNode<K>> rightNodeChildren = childNode.getChildren().isEmpty() ? new LinkedList<BNode<K>>() : new LinkedList<>(childNode.getChildren().subList((childChildrenSize + 1) / 2, childChildrenSize));
BNode<K> rightNode = new BNode<>(rightNodeKeys, rightNodeChildren, childNode.leaf);
// 從滿結點childNode中截取前半部分作為分裂的左結點
LinkedList<K> leftNodeKeys = new LinkedList<K>(childNode.getKeys().subList(0, childKeysSize / 2));
LinkedList<BNode<K>> leftNodeChildren = childNode.getChildren().isEmpty() ? new LinkedList<BNode<K>>() : new LinkedList<>(childNode.getChildren().subList(0, (childKeysSize + 1) / 2));
BNode<K> leftNode = new BNode<>(leftNodeKeys, leftNodeChildren, childNode.leaf);
node.getChildren().set(i, leftNode);
// 將childNode的中間關鍵字插入node中
K middle = childNode.getKeyAt(childKeysSize / 2);
node.getKeys().add(i, middle);
// 將分裂出的結點newNodeKeys掛到node中
node.getChildren().add(i + 1, rightNode);
// 寫入磁盤
// DISK-WRITE(newNode)
// DISK-WRITE(childNode)
// DISK-WRITE(node)
}
有了上述保證,我們就可以大膽地調用insertNonFull
方法去插入關鍵字了。下面給出insertNonFull
的Java實現代碼:
/**
* 將關鍵字k插入到以node為根結點的子樹,必須保證node結點不是滿的
*
* @param node 要插入關鍵字的子樹的根結點(必須保證node結點不是滿的)
* @param key 待插入的關鍵字
*/
private void insertNonFull(BNode<K> node, K key) {
int i = node.getSize() - 1;
if (node.leaf) {
// 若node是葉結點,直接將關鍵字插入到合適的位置(因為已經保證node結點是非滿的)
while (i > -1 && key.compareTo(node.getKeyAt(i)) < 0) {
i--;
}
node.getKeys().add(i + 1, key);
// DISK-WRITE(node)
return;
}
// 若node不是葉結點,我們需要逐層下降(直到降到葉結點)的去找到key的合適位置
while (i > -1 && key.compareTo(node.getKeyAt(i)) < 0) {
i--;
}
i++;
// 判斷node的第i個子結點是否是滿的
if (node.getChildAt(i).getSize() == 2 * minDegree - 1) {
// 若是滿的,分裂
splitNode(node, i);
// 判斷應該沿分裂后的哪一路下降
if (key.compareTo(node.getKeyAt(i)) > 0) {
i++;
}
}
// 到了這一步,node.getChildAt(i)一定不是滿的,直接遞歸下降
insertNonFull(node.getChildAt(i), key);
}
正如insertNonFull
方法的名字那樣,我們在調用該方法時,必須保證其參數中node
代表的結點是非滿的,這也是為什么在insert
方法中,要保證根結點非滿的原因。
insertNonFull
方法實際上是一個遞歸操作,它不斷的迭代子樹,子樹的高度每迭代一次就減1,直至子樹就是一個葉子結點。
4.4 Delete 操作
B樹的刪除操作同樣也較簡單搜索樹復雜,因為它不僅可以刪除葉結點中的關鍵字,而且可從內部結點中刪除關鍵字。和添加結點必須保證結點中的關鍵字不能過多一樣,當從結點中刪除關鍵字后,我們還要保證結點中的關鍵字不能夠太少。因此刪除操作其實可以看做是增加操作的“逆過程”。下面給出刪除操作的算法。
該算法是一個遞歸算法,過程DELETE
接受一個結點 \(x\) 和一個關鍵字 \(k\),它實現的功能時從以 \(x\) 為根的子樹中刪除關鍵字 \(k\)。該過程必須保證無論何時, \(x\) 結點中的關鍵字個數至少為最小度數 \(t\)(這比B樹定義中要求的最小關鍵字個數 \(t - 1\) 多 1),這樣能夠使得我們可以把 \(x\) 中的 1 個關鍵字移動到子結點中,因此,我們可以采用遞歸下降的方法將關鍵字從樹中刪除,而不需要任何“向上回溯”(但有一個例外,之后會看到)。
為了以下說明的方便,我們為樹中的節點編上坐標,規定 \(T(a, b)\) 表示樹 \(T\) 中,第 \(a\) 層,從左往右數,第 \(b\) 個結點。例如:根結點的坐標是 \(T(1, 1)\);另外,需要提前說明的是以下例子中樹的最小度數 \(t = 3\)。
下面分兩大類情況討論。
一、待刪除的關鍵字 \(k\) 恰好在 \(x\) 中:
其中又分 2 小類情況:
1. 若 \(x\) 是葉節點,直接從 \(x\) 中刪除 \(k\) 即可。
這種情況比較簡單,以下面 2 張圖為例,我們即將刪除第 1 張圖中 \(T(3, 2)\) 結點中的關鍵字 \(F\) ,刪除后的 B 樹如第 2 張圖片所示:
**2. 若\(x\)是內部結點,又分以下 3 種情形討論: **
情形A: 如果結點 \(x\) 中前於 \(k\) 的子結點 \(y\) 至少包含 \(t\) 個關鍵字,則找出 \(k\) 在以 \(y\) 為根的子樹中的前驅 \(k'\),遞歸的刪除 \(k'\),並在 \(x\) 中用 \(k'\) 代替 \(k\)(注意遞歸的意思)。
文字理解起來可能比較困難,下面結合一個例子來說明:
如上面圖 (b)所示,現在想要刪除 \(T(2, 1)\) 結點中的關鍵字 \(M\)。
檢查 \(M\) 的左孩子 \(T(3, 3)\)(即前於 \(M\) 的子結點),它有 3 個關鍵字\(\{J, K, L\}\),滿足至少有 \(t\) 個關鍵字的條件。因此**將關鍵字 \(L\)(即\(M\)的前驅)刪除,並用 \(L\) 替代 \(M\) **。這樣就得到了圖 (c) 的結果。
注意,上述過程並沒有就此結束。原因是將 \(L\) 刪除后,原先\(L\) 所在結點的子結點便不合法了,多出來了一個,這時候需要將其子結點中的某個關鍵字提升到該結點中,之后又要處理子子結點……
到這時候你可能已經發現,這其實是一個遞歸的過程。
情形B: 如果前於 \(k\) 的子結點 \(y\) 中的關鍵字個數少於 \(t\) ,但后於 \(k\) 的子結點 \(z\) 中的關鍵字至少有 \(t\) 個,則找出 \(k\) 在以 \(y\) 為根的子樹的后驅 \(k'\),遞歸地刪除 \(k'\),並在 \(x\) 中用 \(k'\) 代替 \(k\)。
該情況和A
類似,這里不在贅述。
情形C: 若
A
和B
情形都不滿足,即關鍵字 \(k\) 的左右子結點 \(y,z\) 中的關鍵字的個數均小於 \(t\)(即為 \(t-1\)),則將關鍵字 \(k\) 和結點 \(z\) 中的關鍵字全部移動到結點\(y\),並刪除 \(z\) 結點。這樣問題就變為從結點 \(y\) 中刪除關鍵字 \(k\),這又回到(或總會回到)前面討論過的情形
舉例說明,現在想要刪除上面圖 (c) 中 \(T(2, 1)\) 結點中的關鍵字 \(G\)。
檢查 \(G\) 的左右孩子 \(T(3, 2), T(3, 3)\) 發現,它們包含的關鍵字均小於 \(t\),於是將 關鍵字 \(G\),以及結點 \(T(3, 3)\) 中的全部關鍵字( \(J, K\))移動到 \(T(3, 2)\) 中,這樣 \(T(3, 2)\) 中便包含 \(D, E, G, J, K\) 5 個關鍵字。現在問題就轉變為從結點 \(T(3,2)\) 中刪除關鍵字 \(G\) ,這可以采用前面討論的過程來解決。
最終得到的結果如下圖所示:
二、待刪除的關鍵字 \(k\) 不在 \(x\) 中:
在普通二叉搜索樹的遞歸刪除過程中,若當前結點不包含待刪除的關鍵字,則到下一層尋找,遞歸上述操作,直到找到待刪除關鍵字或者到達葉子結點為止。但在 B 樹的刪除算法中,為了消除類似於插入操作中遇到的“自底向上”操作現象,在向下遞歸的過程中,若發現下層結點包含的關鍵字個數為 \(t - 1\) ,在下降到該結點前,需要做如下二者之一的操作,以保證“降落”到的結點包含的關鍵字的個數總數大於 \(t - 1\) 的。
情形D: 如果 \(c_i\) 以及 \(c_i\) 的所有相鄰兄弟都只包含 \(t-1\)個結點,則將 \(x.c_i\) 與一個兄弟結點合並,並將 \(x\) 的一個關鍵字移動到新合並的結點,成為中間關鍵字。
舉例說明。在上面的圖(d) 中,我們打算刪除關鍵字 \(D\)。
從根結點(\(x\))開始向下搜尋包含關鍵字 \(D\) 的結點。顯然,接下來會選擇到結點 \(T(2, 1)\) 進行搜尋工作。但注意,此時結點 \(T(2, 1)\) 只包含 2 個關鍵字(\(C, L\)),而其所有的兄弟結點也都只包含 2 個關鍵字,因此需要將結點 \(x\) 中的一個關鍵字(只有 \(P\)),以及兄弟結點 \(T(2, 2)\) 中的全部關鍵字移動到 \(T(2, 1)\) 中,並刪除該兄弟結點和結點 \(x\)(若此時結點 \(x\) 不包含任何關鍵字)。
刪除后的情形如下圖所示:
情形E: 如果 \(c_i\) 只包含 \(t-1\) 個關鍵字,但它的一個相鄰的兄弟至少包含 \(t\) 個關鍵字,則將 \(x\) 中的某個關鍵字降至 \(c_i\),將相鄰的一個兄弟中的關鍵字提升至 \(x\),並將該兄弟相應的孩子指針也移動到 \(c_i\) 中。
例如,想要刪除上圖(e')中的關鍵字 \(B\)。在根結點(\(x\))開始向下搜尋時,發現待“降落”的下級結點 \(T(2, 1)\) 包含 2 個關鍵字,而其兄弟結點 \(T(2, 2)\) 包含 3 個關鍵字,因此,將 \(x\) 中的關鍵字 \(C\) 下降到結點 \(T(2, 1)\)中,再將結點 \(T(2, 2)\) 中的關鍵字 \(E\) 提升到剛才下降的關鍵字 \(C\) 的位置。最后還需要將關鍵字 \(E\) 的左孩子移動到 \(T(2, 1)\) 中。
刪除后的情形如下圖所示:
以上便是整個刪除操作的算法,下面給出具體的 Java 實現代碼:
/**
* 從以node為根結點的子樹中刪除key
*
* @param node 子樹的根結點(必須保證其中的關鍵字數至少為t)
* @param key 要刪除的關鍵字
* @return 是否刪除成功
*/
private boolean delete(BNode<K> node, K key) {
// node是葉結點,直接嘗試從中刪除key
if (node.isLeaf()) {
return node.getKeys().remove(key);
}
int pos = node.position(key);
if (pos == node.getSize() || node.getKeyAt(pos).compareTo(key) != 0) {
// node不包含關鍵字key
BNode<K> childNode = node.getChildAt(pos);
if (childNode.getSize() < minDegree) {
// childNode關鍵字個數小於minDegree,需要增加
BNode<K> leftSibling = null, rightSibling = null;
if (pos > 0 && (leftSibling = node.getChildAt(pos - 1)).getSize() > minDegree - 1) {
// 若childNode左兄弟中的關鍵字個數大於minDegree-1
// 首先用左兄弟中最大的關鍵字去替換node中的相應結點
K maxK = leftSibling.getKeys().removeLast();
K tempK = node.setKeyAt(pos - 1, maxK);
childNode.getKeys().addFirst(tempK);
// 移動child(若存在child)
if (!leftSibling.getChildren().isEmpty()) {
BNode<K> maxNode = leftSibling.getChildren().removeLast();
childNode.getChildren().addFirst(maxNode);
}
} else if (pos < node.getSize() && (rightSibling = node.getChildAt(pos + 1)).getSize() > minDegree - 1) {
// 同上
K minK = rightSibling.getKeys().removeFirst();
K tempK = node.setKeyAt(pos, minK);
childNode.getKeys().addLast(tempK);
// 移動child(若存在child)
if (!rightSibling.getChildren().isEmpty()) {
BNode<K> minNode = rightSibling.getChildren().removeFirst();
childNode.getChildren().addLast(minNode);
}
} else {
// childNode的左右兄弟(若存在)中的關鍵字都小於minDegree
// 合並
if (leftSibling != null) {
childNode.getKeys().addFirst(node.getKeyAt(pos - 1));
childNode.getKeys().addAll(0, leftSibling.getKeys());
childNode.getChildren().addAll(0, leftSibling.getChildren());
node.getKeys().remove(pos - 1);
node.getChildren().remove(pos - 1);
} else if (rightSibling != null) {
childNode.getKeys().addLast(node.getKeyAt(pos));
childNode.getKeys().addAll(rightSibling.getKeys());
childNode.getChildren().addAll(rightSibling.getChildren());
node.getKeys().remove(pos);
node.getChildren().remove(pos + 1);
}
if (node == root && node.getSize() == 0) {
// 根結點為空,需要刪除根結點
height--;
root = root.getChildAt(0);
}
}
}
// 此時一定能保證childNode中的關鍵字個數大於t-1
return delete(childNode, key);
}
// node包含關鍵字key
BNode<K> leftChildNode = node.getChildren().get(pos);
if (leftChildNode.getSize() > minDegree - 1) {
K maxKey = leftChildNode.getKeys().getLast();
node.getKeys().set(pos, maxKey);
return delete(leftChildNode, maxKey);
}
BNode<K> rightChildNode = node.getChildren().get(pos + 1);
if (rightChildNode.getSize() > minDegree - 1) {
K minKey = rightChildNode.getKeys().getFirst();
node.getKeys().set(pos, minKey);
return delete(rightChildNode, minKey);
}
leftChildNode.getKeys().add(node.getKeyAt(pos));
leftChildNode.getKeys().addAll(rightChildNode.getKeys());
leftChildNode.getChildren().addAll(rightChildNode.getChildren());
node.getKeys().remove(pos);
node.getChildren().remove(pos + 1);
return delete(leftChildNode, key);
}
以上代碼都是根據前面的討論寫出來的,這里也不再多做說明。
該過程盡管看起來很復雜,但根據前面的分析我們可以得出,對於一棵高度為\(h\)的B樹,它只需要\(O(h)\)次磁盤操作,所需CPU時間是\(O(th) = O(t log_tn)\)。
5. BTtreeMap
基於以上,我們可以自己實現一個Map玩玩,一下是完整的Java實現代碼:
import java.io.Serializable;
import java.util.*;
public class BTreeMap<K extends Comparable<K>, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
private Node root;
private int size;
private int height;
private int minDegree, min, max;
public BTreeMap() {
this(3);
}
public BTreeMap(int minDegree) {
if (minDegree < 0) {
throw new IllegalArgumentException("minDegree must be greater than 0!");
}
this.minDegree = minDegree;
this.min = minDegree - 1;
this.max = 2 * minDegree - 1;
this.root = new Node(true);
}
@Override
public V get(Object key) {
return search(root, (K) key); // 簡單處理,直接強轉
}
private V search(Node node, K key) {
Iterator<Node> childrenIterator = node.children.iterator();
int i = 0;
for (Entry<K, V> entry : node.keys) {
Node child = childrenIterator.hasNext() ? childrenIterator.next() : null;
int compareRes = entry.getKey().compareTo(key);
if (compareRes == 0) {
return entry.getValue();
}
if (compareRes > 0 || i == node.keysSize() - 1) {
if (compareRes > 0) {
child = childrenIterator.hasNext() ? childrenIterator.next() : null;
}
if (node.isLeaf) return null;
return search(child, key);
}
i++;
}
return null;
}
@Override
public V put(K key, V value) {
// 判斷根結點是否是滿的
if (root.isFull()) {
// 若是滿的,則構造出一個空的結點,作為新的根結點
Node newNode = new Node(false);
newNode.addChild(root);
Node oldRoot = root;
root = newNode;
splitNode(root, oldRoot, 0);
height++;
}
Entry<K, V> entry = insertNonFull(root, new Entry<K, V>(key, value));
return entry == null ? null : entry.getValue();
}
/**
* 將關鍵字k插入到以node為根結點的子樹,必須保證node結點不是滿的
*
* @param node 要插入關鍵字的子樹的根結點(必須保證node結點不是滿的)
* @param key 待插入的關鍵字
*/
private Entry<K, V> insertNonFull(Node node, Entry<K, V> key) {
int i = 0;
// 因為node.keys使用的是LinkedList,因此使用迭代器迭代效率比較高
Iterator<Node> childrenIterator = node.children.iterator();
for (Entry<K, V> entry : node.keys) {
Node child = childrenIterator.hasNext() ? childrenIterator.next() : null;
int compareRes = key.compareTo(entry);
if (compareRes == 0) {
// key相等的情況,替換
return node.keys.set(i, key); // TODO 效率不高!
}
if (compareRes < 0 || i == node.keysSize() - 1) {
if (compareRes > 0) {
i++;
child = childrenIterator.hasNext() ? childrenIterator.next() : null;
}
// 當key < entry 或者 迭代到最后一個元素,此時i指向要插入位置。
if (node.isLeaf) {
node.keys.add(i, key);
size++;
return null;
}
if (child.isFull()) {
Object[] nodeArray = splitNode(node, child, i);
Node leftNode = (Node) nodeArray[0];
Node rightNode = (Node) nodeArray[1];
child = key.compareTo(leftNode.keys.getLast()) <= 0 ? leftNode : rightNode;
}
return insertNonFull(child, key);
}
i++;
}
// node是root,且為null的情況
node.addKey(key);
size++;
return null;
}
/**
* 分裂node的第i個子結點
*
* @param pNode 被分裂結點的父結點
* @param node 被分裂結點
* @param i 被分裂結點在其父結點children中的索引
*/
private Object[] splitNode(Node pNode, Node node, int i) {
int keysSize = node.keysSize();
int ChildrenSize = node.childrenSize();
LinkedList<Entry<K, V>> leftNodeKeys = new LinkedList<Entry<K, V>>(node.keys.subList(0, keysSize / 2));
LinkedList<Node> leftNodeChildren = node.isLeaf ? new LinkedList<Node>() : new LinkedList<>(node.children.subList(0, (keysSize + 1) / 2));
Node leftNode = new Node(leftNodeKeys, leftNodeChildren, node.isLeaf);
LinkedList<Entry<K, V>> rightNodeKeys = new LinkedList<Entry<K, V>>(node.keys.subList(keysSize / 2 + 1, keysSize));
LinkedList<Node> rightNodeChildren = node.isLeaf ? new LinkedList<Node>() : new LinkedList<>(node.children.subList((ChildrenSize + 1) / 2, ChildrenSize));
Node rightNode = new Node(rightNodeKeys, rightNodeChildren, node.isLeaf);
Entry<K, V> middleKey = node.getKey(keysSize / 2);
pNode.addKey(i, middleKey);
pNode.setChild(i, leftNode);
pNode.addChild(i + 1, rightNode);
// return new Node[]{leftNode, rightNode}; TODO: new 不出來
return new Object[]{leftNode, rightNode};
}
@Override
public Set<Map.Entry<K, V>> entrySet() {
return null;
}
/**
* B樹的結點類
*/
private class Node {
private LinkedList<Entry<K, V>> keys;
private LinkedList<Node> children;
private boolean isLeaf;
private K data;
private Node(boolean isLeaf) {
this(new LinkedList<Entry<K, V>>(), new LinkedList<Node>(), isLeaf);
}
private Node(LinkedList<Entry<K, V>> keys, LinkedList<Node> children, boolean isLeaf) {
this.keys = keys;
this.children = children;
this.isLeaf = isLeaf;
}
private boolean isFull() {
return keys.size() == max;
}
/**
* 查找k,返回k在keys中的索引
*
* @param k
* @return
*/
private int indexOfKey(K k) {
return keys.indexOf(k);
}
/**
* 查找關鍵字在該結點的位置或其所在的根結點在該結點的位置
*
* @param k
* @return i
*/
private int position(Entry<K, V> k) {
int i = 0;
Iterator it = keys.iterator();
for (Entry<K, V> key : keys) {
if (key.compareTo(k) >= 0)
return i;
i++;
}
return i;
}
private boolean addKey(Entry<K, V> k) {
return keys.add(k);
}
private void addKey(int i, Entry<K, V> k) {
keys.add(i, k);
}
private boolean addChild(Node node) {
return children.add(node);
}
private void addChild(int i, Node node) {
children.add(i, node);
}
private Node setChild(int i, Node node) {
return children.set(i, node);
}
private int keysSize() {
return keys.size();
}
private int childrenSize() {
return children.size();
}
private Entry<K, V> getKey(int i) {
return keys.get(i);
}
private Entry<K, V> setKeyAt(int i, Entry<K, V> k) {
return keys.set(i, k);
}
private Node getChild(int i) {
return children.get(i);
}
@Override
public String toString() {
return keys.toString();
}
}
/**
* BEntry封裝了key與value,它將做為Node的key
*
* @param <K>
* @param <V>
*/
public static class Entry<K extends Comparable<K>, V> extends SimpleEntry<K, V> implements Comparable<Entry<K, V>> {
public Entry(K key, V value) {
super(key, value);
}
/**
* BEntry的比較其實為key的比較
*
* @param o
* @return
*/
@Override
public int compareTo(Entry<K, V> o) {
return getKey().compareTo(o.getKey());
}
}
}
由於時間關系,暫時只實現了get
和put
方法,其他方法以后有空再補上吧。