B樹——算法導論(25)

B樹

1. 簡介

在之前我們學習了紅黑樹,今天再學習一種樹——B樹。它與紅黑樹有許多類似的地方,比如都是平衡搜索樹,但它們在功能和結構上卻有較大的差別。

從功能上看,B樹是爲磁盤或其他存儲設備設計的,能夠有效的降低磁盤的I/O操作數,因此我們經常看到有許多數據庫系統使用B樹或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}字節\)

因此,在本篇博客中,我們對運行時間的衡量主要從以下兩個方面考慮:

  1. 磁盤存取次數
  2. 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-READDISK-WRITE操作的次數決定,所以我們希望這些操作能夠讀或寫儘可能多的信息。因此,一個B樹結點通常和一個完整磁盤頁一樣大,並且磁盤頁的大小限制了一個B樹結點可以含有的孩子的個數。

如下圖是一棵高度爲2(這裏計算高度時不計算根結點)的B樹,它的每個結點有1000個關鍵字,因此分支因子(孩子的個數)爲1001,於是它可以儲存\(1000 × (1 + 1001 + 1001 ×1001)\)個關鍵字,其數量超過10億。我們如果將根結點保存在主存中,那麼在查找樹中任意一個關鍵字時,至多只需要讀取2次磁盤。

關鍵字超過10億的B樹

3. B樹的定義

下面正式給出B樹的定義。一棵B樹\(T\)必須具備如下性質:

  1. 每個結點\(x\)有如下屬性:
    1. \(x.n\)。它表示儲存在 \(x\)中的關鍵字的個數;
    2. \(x.key_1,x.key_2,...,x.key_n\)。它們表示\(x\)\(n\)個關鍵字,以非降序存放,即\(x.key_1 \leq x.key_2 \leq ... \leq x.key_n\)
    3. \(x.leaf\)。它是一個布爾值,如果\(x\)是葉結點,它爲TRUE;否則爲FALSE;
    4. \(x.c_1, x.c_2,...,x.c_{n+1}\)。它們是指向自己孩子的指針。如果該結點是葉節點,則沒有這些屬性。
  2. 關鍵字\(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\)爲根的子樹中的關鍵字。
  3. 每個葉結點具有相同的深度,即葉的高度\(h\)
  4. 每個結點所包含的關鍵的個數有上下界。用一個被稱爲最小度數的固定整數\(t(t \geq 2)\)來表示這些界:
    1. 下界:除了根結點以外的每個結點必須至少有\(t-1\)個關鍵字。因此,除了根結點外的每個內部結點至少有\(t\)個孩子。
    2. 上界:每個結點至多包含\(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樹的結點類
     *
     * @param <K>
     */
    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\)有:
\[ h \leq \log_t \frac{n+1}{2} \]

證明很簡單,因爲B樹\(T\)的根結點至少包含1個關鍵字,而其他的結點至少包含\(t-1\)個關鍵字,因此除根結點外的每個結點都有\(t\)個孩子,於是有:
\[ n \geq 1 + (t - 1)\sum_{i=1}^h2t^{i-1} = 1 + 2(t - 1)(\frac{t^h - 1}{t-1}) = 2t^h - 1 \]

4. B樹上的基本操作

同其它的二叉搜索樹一樣,我們主要關心B樹的searchcreateinsertdelete操作。首先做兩個約定:

  1. B樹的根結點始終在主存中,這樣我們可以直接引用根結點而不需要執行DISK-READ操作;但是若根結點被修改,我們需要對其執行DISK-WRITE操作。
  2. 任何被當做參數的結點在被傳遞之前,都要對它們先做一次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時,若剛好等於k,則表示搜索到了,直接返回;若未找到,則會將第i個孩子作爲當前結點,遞歸繼續查找,即繼續到以第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樹結點的分裂

注意:上圖截取自《算法導論(第三版),機械工業出版社》,其中右側部分中的關鍵字WS的順序弄反了。

結合上圖,滿結點的分裂過程應該很容易理解。在圖中,B樹的最小度數\(t=4\),因此左側中包含關鍵字\([P, Q, R, S, T, U, V]\)的結點是滿的。於是我們先將處在中間位置的關鍵字\(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方法去插入關鍵字了。下面給出insertNonFullJava實現代碼:

/**
 * 將關鍵字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樹要求的最小關鍵字個數多1個以上,這樣能夠使得我們可以把\(x\)中的一個關鍵字移動到子結點中,因此,我們可以採用遞歸下降的方法將關鍵字從樹中刪除,而不需要任何“向上回溯”(有一個例外,之後會看到)。

在給出算法之前,我們做如下約定:

  1. 我們用\(T(a, b)\)來表示樹\(T\)中,高度爲\(a\),從左往右第\(b\)個結點(規定根結點的高度爲0;同一高度的結點中,最左側的結點是第0個結點。因此根結點可用\(T(0, 0)\)來表示);
  2. 以下給出的例子中的樹的最小度數\(t = 3\)

下面分兩大類情況討論:

一、待刪除的關鍵字\(k\)\(x\)中:

  1. \(x\)是葉節點,直接從\(x\)中刪除\(k\)

這種情況比較簡單,如下圖:

圖a
圖b

對照圖a,以結點\(T(2, 1)\)爲根結點,我們想刪除其中的關鍵字F,直接刪除即可,這樣得到圖b

2. 若\(x\)是內部結點,又分以下3種情況討論:

A. 如果結點\(x\)中前於\(k\)的子結點\(y\)至少包含\(t\)個關鍵字,則找出\(k\)在以\(y\)爲根的子樹中的前驅\(k'\),遞歸的刪除\(k'\),並在\(x\)中用\(k'\)代替\(k\)(注意遞歸的意思)。

這個直接閱讀理解起來,可能比較困難,我們來看一個例子:

圖c

圖b圖c,我們發現,刪除了結點\(T(1, 0)\)中的關鍵字\(M\),由於\(M\)的左子樹的根結點,即結點\(T(2, 2)\)中有\(\{J, K, L\}\)三個結點(大於\(t-1\)個),因此我們將其中的關鍵字\(L\)\(M\)的前驅)提升到原\(M\)的位置,這樣\(L\)原來所在的結點只剩下\({J, K}\),仍然是合法的,但這樣導致其子結點“多”出了一個,我們還需將其子結點中的某個關鍵字提升到該結點中,之後又要處理子子結點……

到這時候你可能已經發現,這其實是一個遞歸的過程。拋開關鍵字提升的過程,我們實際上就是在做刪除操作:首先刪除\(M\),接下來刪除其子結點中的\(L\),再接下來是刪除其子子結點的某個關鍵字……於是我們在實現的時候,可以用遞歸的操作去完成它。

B. 如果前於\(k\)的子結點\(y\)中的關鍵字個數少於\(t\),但後於\(k\)的子結點\(z\)中的關鍵字至少有\(t\)個,則找出\(k\)在以\(y\)爲根的子樹的後驅\(k'\),遞歸地刪除\(k'\),並在\(x\)中用\(k'\)代替\(k\)

該情況和A類似,這裏不在贅述。

** C.** 若AB都不滿足,即關鍵字\(k\)的左右子結點\(y,z\)中的關鍵字的個數均小於t(爲\(t-1\)),則將\(k\)\(z\)的全部合併進\(y\),然後問題變爲了從\(y\)中刪除\(k\)。我們同樣用遞歸的方式來進行以上操作。

下面給出例子,我們要從圖c中刪除關鍵字\(G\),按照C中的操作,我們可以得到如下的結果:

圖d

二、待刪除的關鍵字\(k\)不在\(x\)中:

在該類情況下,我們假設關鍵字\(k\)在以\(x\)的子結點\(c_i\)爲根的子樹中(若關鍵字\(k\)確實在樹中)。

** 1.** 如果\(c_i\)以及\(c_i\)的所有相鄰兄弟都只包含\(t-1\)個結點,則將\(x.c_i\)與一個兄弟結點合併,並將\(x\)的一個關鍵字移動到新合併的結點,成爲中間關鍵字。

圖d中,我們從根結點開始遞歸刪除D。因爲根結點的兩個子結點中的關鍵字個數都爲\(t-1\),因此我們將這兩個子結點合併,並將根結點中的關鍵字p移動到合併的結點中,作爲中間結點。這樣可得到圖e

圖e

由於根結點已沒有了關鍵字,並且它只有一個子結點,因此我們直接刪除根結點,得到下圖:

圖e'

2. 如果\(c_i\)只包含\(t-1\)個關鍵字,但它的一個相鄰的兄弟至少包含\(t\)個關鍵字,則將\(x\)中的某個關鍵字降至\(c_i\),將相鄰的一個兄弟中的關鍵字提升至\(x\),並將該兄弟相應的孩子指針也移動到\(c_i\)中。

總之,以上兩種情況的處理都是爲了確保在遞歸下降時,子樹的根結點中的關鍵字個數至少爲\(t\)

以上便是整個刪除操作的算法,下面給出具體的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());
        }
    }
}

由於時間關係,暫時只實現了getput方法,其他方法以後有空再補上吧。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章