B樹——算法導論(25)

1. 簡介

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

從功能上看,B樹是爲磁盤或其餘存儲設備設計的,可以有效的下降磁盤的I/O操做數,所以咱們常常看到有許多數據庫系統使用B樹或B樹的變種做爲儲存的數據結構;從結構上看,B樹的結點能夠有不少孩子,從數個到數千個,這一般依賴於所使用的磁盤的單元特性。java

以下圖,給出了一棵簡單的B樹。node

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}\)字節。數據結構

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

  1. 磁盤存取次數
  2. CPU時間

咱們用讀出或寫入磁盤的信息的頁數來衡量磁盤存取的次數。注意到,磁盤存取時間並非常量——它與當前磁道和所需磁道之間的距離以及磁盤的初始旋轉狀態有關,可是爲了簡單起見,咱們仍然使用讀或寫的頁數做爲磁盤存取總時間的近似值。學習

在一個典型的B樹應用中,所需處理的數據很是大,以致於全部的數據沒法一次轉入主存。B樹算法將所需頁面從磁盤複製到主存,若進行了修改,以後則會寫回磁盤。所以,B樹算法在任什麼時候刻都只須要在主存中保存必定數量的頁面,主存的大小並不限制被處理的B樹的大小。this

下面用幾行僞代碼來模擬對磁盤的操做。設x爲指向一個對象的指針,咱們在使用x(指向的對象)時,須要先判斷x指向的對象是否在主存中,若在則能夠直接使用;不然須要將其從磁盤讀入到主存,而後才能使用。spa

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樹的結點類
     */
    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 。若此時第 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\) 提高到其父結點中,剩餘關鍵字隨着結點一分爲二。

B樹結點的分裂

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

須要注意的是,將中間關鍵字提高至父結點後,又可能致使父結點過滿,此時須要用一樣的方法處理父結點。此過程可能會持續發生,造成自底向上的分裂現象。

既然如此,能夠採用一種更加巧妙的辦法:在逐層向下查找待插入關鍵字的位置過程當中,只要遇到滿的結點,就進行分裂。這樣一來,當關鍵字提高到父結點時,就不會形成父結點過滿了。

特別地,因爲根結點沒有父結點,對於過滿的根結點,須要新建一個空的根結點,原根結點中間位置的關鍵字上升到新建的空結點中。以下圖所示:

由上咱們能夠看出,對滿的非根結點的分裂不會使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樹定義中要求的最小關鍵字個數 \(t - 1\) 多 1),這樣可以使得咱們能夠把 \(x\) 中的 1 個關鍵字移動到子結點中,所以,咱們能夠採用遞歸降低的方法將關鍵字從樹中刪除,而不須要任何「向上回溯」(但有一個例外,以後會看到)。

爲了如下說明的方便,咱們爲樹中的節點編上座標,規定 \(T(a, b)\) 表示樹 \(T\) 中,第 \(a\) 層,從左往右數,第 \(b\) 個結點。例如:根結點的座標是 \(T(1, 1)\);另外,須要提早說明的是如下例子中樹的最小度數 \(t = 3\)

下面分兩大類狀況討論。

1、待刪除的關鍵字 \(k\) 剛好在 \(x\) 中:

其中又分 2 小類狀況:

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

這種狀況比較簡單,如下面 2 張圖爲例,咱們即將刪除第 1 張圖中 \(T(3, 2)\) 結點中的關鍵字 \(F\) ,刪除後的 B 樹如第 2 張圖片所示:

圖a
圖b

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

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

文字理解起來可能比較困難,下面結合一個例子來講明:

圖c

如上面圖 (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: 若 AB 情形都不知足,即關鍵字 \(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\) ,這能夠採用前面討論的過程來解決。

最終獲得的結果以下圖所示:

圖d

2、待刪除的關鍵字 \(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'

情形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());
        }
    }
}

因爲時間關係,暫時只實現了getput方法,其餘方法之後有空再補上吧。

相關文章
相關標籤/搜索