手撕B樹

一、概述

1.歷史

B樹(B-Tree)結構是一種高效存儲和查詢數據的方法,它的歷史可以追溯到1970年代早期。B樹的發明人Rudolf Bayer和Edward M. McCreight分別發表了一篇論文介紹了B樹。這篇論文是1972年發表于《ACM Transactions on Database Systems》中的,題目為"Organization and Maintenance of Large Ordered Indexes"。

這篇論文提出了一種能夠高效地維護大型有序索引的方法,這種方法的主要思想是將每個節點擴展成多個子節點,以減少查找所需的次數。B樹結構非常適合應用于磁盤等大型存儲器的高效操作,被廣泛應用于關系數據庫和文件系統中。

B樹結構有很多變種和升級版,例如B+樹,B*樹和SB樹等。這些變種和升級版本都基于B樹的核心思想,通過調整B樹的參數和結構,提高了B樹在不同場景下的性能表現。

總的來說,B樹結構是一個非常重要的數據結構,為高效存儲和查詢大量數據提供了可靠的方法。它的歷史可以追溯到上個世紀70年代,而且在今天仍然被廣泛應用于各種場景。

2.B-樹的優勢

B樹和AVL樹、紅黑樹相比,B樹更適合磁盤的增刪改查,而AVL和紅黑樹更適合內存的增刪改查。

假設存儲100萬的數據:

  • 使用AVL來存儲,樹高為:log_21000000≈20 (20次的磁盤IO很慢,但是20次的內存操作很快)
  • 使用B-樹存儲,最小度數為500,樹高為:3

B樹優勢:

  • 磁盤存儲比內存存儲慢很多,尤其是訪問磁盤的延遲相對較高。每次訪問磁盤都需要消耗更多的時間,而B樹的設計可以最大化地減少對磁盤的訪問次數。
  • 磁盤訪問一般是按塊讀取的,而B樹的節點通常設計為與磁盤塊大小一致。由于B樹是多路的,單次磁盤訪問通常會加載多個數據項,而不是像AVL樹和紅黑樹那樣每次只讀取一個節點。
  • 在磁盤中存儲B樹時,操作系統通常會將樹的部分結構加載到內存中以便快速查詢,避免了頻繁的磁盤訪問。
  • 在數據庫和文件系統中,數據通常是大規模的,存儲在外部存儲介質上。B樹特別適合大規模數據的增刪改查,因為它減少了不必要的磁盤訪問,能夠高效地執行復雜的數據操作。

二、特性

1.度和階

  • 度(degree):節點的孩子數
  • 階(order):所有節點孩子最大值

2.特性

  • 每個節點具有

    • 屬性 n,表示節點中 key 的個數
    • 屬性 leaf,表示節點是否是葉子節點
    • 節點 key 可以有多個,以升序存儲
  • 每個非葉子節點中的孩子數是 n + 1、葉子節點沒有孩子

  • 最小度數t(節點的孩子數稱為度)和節點中鍵數量的關系如下:

最小度數t 鍵數量范圍
2 1 ~ 3
3 2 ~ 5
4 3 ~ 7
... ...
n (n-1) ~ (2n-1)

其中,當節點中鍵數量達到其最大值時,即 3、5、7 ... 2n-1,需要分裂

  • 葉子節點的深度都相同

三、實現

1.定義節點

static class Node {
    // 關鍵字
    int[] keys;
    // 關鍵字數量
    int keyNum;
    // 孩子節點
    Node[] children;
    // 是否是葉子節點
    boolean leafFlag = true;
    // 最小度數:最少孩子數(決定樹的高度,度數越大,高度越小)
    int t;

    // ≥2
    public Node(int t) {
        this.t = t;
        // 最多的孩子數(約定)
        this.children = new Node[2 * t];
        this.keys = new int[2 * t -1];
    }
}

1.1 節點類相關方法

查找key:查找目標22,在當前節點的關鍵字數組中依次查找,找到了返回;沒找到則從孩子節點找:

  • 當前節點是葉子節點:目標不存在
  • 非葉子結點:當key循環到25,大于目標22,此時從索引4對應的孩子key數組中繼續查找,依次遞歸,直到找到為止。


    image.png

根據key獲取節點

/**
 * 根據key獲取節點
 * @param key
 * @return
 */
Node get(int key) {
    // 先從當前key數組中找
    int i = 0;
    while (i < keyNum) {
        if (keys[i] == key) {
            // 在當前的keys關鍵字數組中找到了
            return this;
        }
        if (keys[i] > key) {
            // 當數組比當前key大還未找到時,退出循環
            break;
        }
        i++;
    }
    // 如果是葉子節點,沒有孩子了,說明key不存在
    if (leafFlag) {
        return null;
    } else {
        // 非葉子節點,退出時i的值就是對應范圍的孩子節點數組的索引,從對應的這個孩子數組中繼續找
        return children[i].get(key);
    }
}

向指定索引插入key

/**
 * 向keys數組中指定的索引位置插入key
 * @param key
 * @param index
 */
void insertKey(int key,int index) {
    /**
     * [0,1,2,3]
     * src:源數組
     * srcPos:起始索引
     * dest:目標數組
     * destPos: 目標索引
     * length:拷貝的長度
     */
    System.arraycopy(keys, index, keys, index + 1, keyNum - index);
    keys[index] = key;
    keyNum++;
}

向指定索引插入child

/**
 * 向children指定索引插入child
 *
 * @param child
 * @param index
 */
void insertChild(Node child, int index) {
    System.arraycopy(children, index, children, index + 1, keyNum - index);
    children[index] = child;
}

2.定義樹

public class BTree {

    // 根節點
    private Node root;

    // 樹中節點最小度數
    int t;

    // 最小key數量 在創建樹的時候就指定好
    final int MIN_KEY_NUM;

    // 最大key數量
    final int MAX_KEY_NUM;

    public BTree() {
        // 默認度數設置為2
        this(2);
    }

    public BTree(int t) {
        this.t = t;
        root = new Node(t);
        MIN_KEY_NUM = t - 1;
        MAX_KEY_NUM = 2 * t - 1;
    }
}    

判斷key在樹中是否存在

/**
 * 判斷key在樹中是否存在
 * @param key
 * @return
 */
public boolean contains(int key) {
    return root.get(key) != null;
}

3.新增key

  • 1.查找插入位置:從根節點開始,沿著樹向下查找,直到找到一個葉子節點,這個葉子節點包含的鍵值范圍覆蓋了要插入的鍵值。
  • 2.插入鍵值:在找到的葉子節點中插入新的鍵值。如果葉子節點中的鍵值數量沒有超過B樹的階數(即每個節點最多可以包含的鍵值數量),則插入操作完成。
  • 3.分裂節點:如果葉子節點中的鍵值數量超過了B樹的階數,那么這個節點需要分裂。

如果度為3,最大key數量為:2*3-1=5,當插入了8后,此時達到了最大數量5,需要分裂:


image.png

分裂邏輯:
分裂節點數據一分為三:

  • 左側數據:本身左側的數據留在該節點
  • 中間數據:中間索引2(度-1)的數據6移動到父節點的索引1(被分裂節點的索引)處。
  • 右側數據:從索引3(度)開始的數據,移動到新節點,新節點的索引值為分裂節點的index+1

如果分裂的節點是非葉子節點:
需要多一步操作:右側數據需要和孩子一起連帶到新節點去:


非葉子節點分裂.png

分裂的是根節點:
需要再創建多一個節點來當做根節點,此根節點為父親,存入中間的數據。
其他步驟同上。


根節點分裂.png

分裂方法:

/**
 * 節點分裂
 * 左側數據:本身左側的數據留在該節點
 * 中間數據:中間索引2(度-1)的數據6移動到父節點的索引1(被分裂節點的索引)處
 * 右側數據:從索引3(度)開始的數據,移動到新節點,新節點的索引值為分裂節點的index+1
 * @param node 要分裂的節點
 * @param index 分裂節點的索引
 * @param parent 要分裂節點的父節點
 *
 */
public void split(Node node, int index, Node parent) {
    // 沒有父節點,當前node為根節點
    if (parent == null) {
        // 創建出新的根來存儲中間數據
        Node newRoot = new Node(t);
        newRoot.leafFlag = false;
        newRoot.insertChild(node, 0);
        // 更新根節點為新創建的newRoot
        this.root = newRoot;
        parent = newRoot;
    }

    // 1.處理右側數據:創建新節點存儲右側數據
    Node newNode = new Node(t);
    // 新創建的節點跟原本分裂節點同級
    newNode.leafFlag = node.leafFlag;
    // 新創建節點的數據從 原本節點【度】位置索引開始拷貝 拷貝長度:t-1
    System.arraycopy(node.keys, t, newNode.keys, 0, t - 1);
    // 如果node不是葉子節點,還需要把node的一部分孩子也同時拷貝到新節點的孩子中
    if (!node.leafFlag) {
        System.arraycopy(node.children, t, newNode.children, 0, t);
    }
    // 更新新節點的keyNum
    newNode.keyNum = t - 1;

    // 更新原本節點的keyNum
    node.keyNum = t - 1;

    // 2.處理中間數據:【度-1】索引處的數據 移動到父節點【分裂節點的索引】索引處
    // 要插入父節點的數據:
    int midKey = node.keys[t - 1];
    parent.insertKey(midKey, index);

    // 3. 新創建的節點作為父親的孩子
    parent.insertChild(newNode, index + 1);

    // parent的keyNum在對應的方法中已經更新了
}

新增方法:

/**
 * 新增key
 *
 * @param key
 */
public void put(int key) {
    doPut(root, key, 0, null);
}

/**
 * 執行新增key
 * 1.查找插入位置:從根節點開始,沿著樹向下查找,直到找到一個葉子節點,這個葉子節點包含的鍵值范圍覆蓋了要插入的鍵值。
 * 2.插入鍵值:在找到的葉子節點中插入新的鍵值。如果葉子節點中的鍵值數量沒有超過B樹的階數(即每個節點最多可以包含的鍵值數量),則插入操作完成。
 * 3.分裂節點:如果葉子節點中的鍵值數量超過了B樹的階數,那么這個節點需要分裂。
 * @param node 待插入元素的節點
 * @param key 插入的key
 * @param nodeIndex  待插入元素節點的索引
 * @param nodeParent 待插入節點的父節點
 */
public void doPut(Node node, int key, int nodeIndex, Node nodeParent) {
    // 查找插入位置
    int index = 0;
    while (index < node.keyNum) {
        if (node.keys[index] == key ) {
            // 找到了 做更新操作 (因為沒有維護value,所以就不用處理了)
            return;
        }
        if (node.keys[index] > key) {
            // 沒找到該key, 退出循環,index的值就是要插入的位置
            break;
        }
        index++;
    }
    // 如果是葉子節點,直接插入
    if (node.leafFlag) {
        node.insertKey(key, index);
    } else {
        // 非葉子節點,繼續從孩子中找到插入位置 父親的這個待插入的index正好就是元素要插入的第x個孩子的位置
        doPut(node.children[index], key , index, node);
    }
    // 處理節點分裂邏輯 : keyNum數量達到上限,節點分裂
    if (node.keyNum == MAX_KEY_NUM) {
        split(node, nodeIndex, nodeParent);
    }
}

4.刪除key

情況一:刪除的是葉子節點的key

節點是葉子節點,找到了直接刪除,沒找到返回。

情況二:刪除的是非葉子節點的key

沒有找到key,繼續在孩子中找。
找到了,把要刪除的key和替換為后繼key,刪掉后繼key。

平衡樹:該key被刪除后,key數目<key下限(t-1),樹不平衡,需要調整

  • 如果左邊兄弟節點的key是富裕的,可以直接找他借:右旋,把父親的旋轉下來,把兄弟的旋轉上去。


    image.png
  • 如果右邊兄弟節點的key是富裕的,可以直接找他借:左旋,把父親的旋轉下來,把兄弟的旋轉上去。
    image.png
  • 當沒有兄弟是富裕時,沒辦法借,采用向左合并:父親和失衡節點都合并到左側的節點中


    image.png

詳細右旋流程:


旋轉.png

處理孩子:


處理孩子.png

向左合并詳細流程:


向左合并詳細流程.png

根節點調整的情況:


image.png

失衡調整代碼

/**
 * 樹的平衡
 * @param node 失衡節點
 * @param index 失衡節點索引
 * @param parent 失衡節點父節點
 */
public void balance(Node node, int index, Node parent) {
    if (node == root) {
        // 如果是根節點 當調整到根節點只剩下一個key時,要替換根節點 (根節點不能為null,要保證右孩子才替換)
        if (root.keyNum == 0 && root.children[0] != null) {
            root = root.children[0];
        }
        return;
    }
    // 拿到該節點的左右兄弟,判斷節點是不是富裕的,如果富裕,則找兄弟借
    Node leftBrother = parent.childLeftBrother(index);
    Node rightBrother = parent.childRightBrother(index);

    // 左邊的兄弟富裕:右旋
    if (leftBrother != null && leftBrother.keyNum > MIN_KEY_NUM) {
        // 1.要旋轉下來的key:父節點中【失衡節點索引-1】的key:parent.keys[index-1];插入到失衡節點索引0位置
        // (這里父親節點旋轉走的不用刪除,因為等會左側的兄弟旋轉上來會覆蓋掉)
        node.insertKey(parent.keys[index - 1], 0);

        // 2.0 如果左側節點不是葉子節點,有孩子,當旋轉一個時,只需要留下原本孩子數-1 ,把最大的孩子過繼給失衡節點的最小索引處(先處理后事)
        if (!leftBrother.leafFlag) {
            node.insertChild(leftBrother.removeRightMostChild(), 0);
        }

        // 2.1 要旋轉上去的key:左側兄弟最大的索引key,刪除掉,插入到父節點中【失衡節點索引-1】位置(此位置就是剛才在父節點旋轉走的key的位置)
        // 這里要直接覆蓋,不能調插入方法,因為這個是當初旋轉下去的key。
        parent.keys[index - 1] = leftBrother.removeRightMostKey();

        return;
    }
    // 右邊的兄弟富裕:左旋
    if (rightBrother != null && rightBrother.keyNum > MIN_KEY_NUM) {
        // 1.要旋轉下來的key:父節點中【失衡節點索引】的key:parent.keys[index];插入到失衡節點索引最大位置keyNum位置
        // (這里父親節點旋轉走的不用刪除,因為等會右側的兄弟旋轉上來會覆蓋掉)
        node.insertKey(parent.keys[index], node.keyNum);

        // 2.0 如果右側節點不是葉子節點,有孩子,當旋轉一個時,只需要留下原本孩子數-1 ,把最小的孩子過繼給失衡節點的最大索引處(孩子節點的索引比父親要多1)
        if (!rightBrother.leafFlag) {
            node.insertChild(rightBrother.removeLeftMostChild(), node.keyNum + 1);
        }

        // 2.1 要旋轉上去的key:右側兄弟最小的索引key,刪除掉,插入到父節點中【失衡節點索引-1】位置(此位置就是剛才在父節點旋轉走的key的位置)
        // 這里要直接覆蓋,不能調插入方法,因為這個是當初旋轉下去的key。
        parent.keys[index] = rightBrother.removeLeftMostKey();

        return;
    }
    // 左右兄弟都不夠,往左合并
    if (leftBrother != null) {
        // 向左兄弟合并
        // 1.把失衡節點從父親中移除
        parent.removeChild(index);

        // 2.插入父節點的key到左兄弟 將父節點中【失衡節點索引-1】的key移動到左側
        leftBrother.insertKey(parent.removeKey(index - 1), leftBrother.keyNum);

        // 3.插入失衡節點的key及其孩子到左兄弟
        node.moveToTarget(leftBrother);
    } else {
        // 右兄弟向自己合并
        // 1.把右兄弟從父親中移除
        parent.removeChild(index + 1);
        // 2.把父親的【失衡節點索引】 處的key移動到自己這里
        node.insertKey(parent.removeKey(index), node.keyNum);
        // 3.把右兄弟完整移動到自己這里
        rightBrother.moveToTarget(node);
    }
}

刪除key

/**
 * 刪除指定key
 * @param node 查找待刪除key的起點
 * @param parent 待刪除key的父親
 * @param nodeIndex 待刪除的key的索引
 * @param key 待刪除的key
 */
public void doRemove(Node node, Node parent, int nodeIndex, int key) {
    // 找到被刪除的key
    int index = 0;
    // 循環查找待刪除的key
    while (index < node.keyNum) {
        if (node.keys[index] >= key) {
            //找到了或者沒找到
            break;
        }
        index++;
    }
    // 如果找到了 index就是要刪除的key索引;
    // 如果沒找到,index就是要在children的index索引位置繼續找

    // 一、是葉子節點
    if (node.leafFlag) {
        // 1.1 沒找到
        if (!found(node, key, index)) {
            return;
        }
        // 1.2 找到了
        else {
            // 刪除當前節點index處的key
            node.removeKey(index);
        }
    }
    // 二、不是葉子節點
    else {
        // 1.1 沒找到
        if (!found(node, key, index)) {
            // 繼續在孩子中找 查找的孩子的索引就是當前index
            doRemove(node.children[index], node, index, key);
        }
        // 1.2 找到了
        else {
            // 找到后繼節點,把后繼節點復制給當前的key,然后刪除后繼節點。
            // 在索引+1的孩子里開始,一直往左找,直到節點是葉子節點為止,就找到了后繼節點
            Node deletedSuccessor = node.children[index + 1];
            while (!deletedSuccessor.leafFlag) {
                // 更新為最左側的孩子
                deletedSuccessor = deletedSuccessor.children[0];
            }
            // 1.2.1 當找到葉子節點之后,最左側的key就是后繼key
            int deletedSuccessorKey = deletedSuccessor.keys[0];
            // 1.2.2 把后繼key賦值給待刪除的key
            node.keys[index] = deletedSuccessorKey;
            // 1.2.3 刪除后繼key 再調用該方法,走到情況一,刪除掉該后繼key: 起點為索引+1的孩子處,刪除掉后繼key
            doRemove(node.children[index + 1], node, index + 1, deletedSuccessorKey);
        }
    }

    // 樹的平衡:
    if (node.keyNum < MIN_KEY_NUM) {
        balance(node, nodeIndex, parent);
    }
}

節點相關方法:

        /**
         * 移除指定索引處的key
         * @param index
         * @return
         */
        int removeKey(int index) {
            int deleted = keys[index];
            System.arraycopy(keys, index + 1, keys, index, --keyNum - index);
            return deleted;
        }

        /**
         * 移除最左索引處的key
         * @return
         */
        int removeLeftMostKey(){
            return removeKey(0);
        }

        /**
         * 移除最右邊索引處的key
         * @return
         */
        int removeRightMostKey() {
            return removeKey(keyNum - 1);
        }

        /**
         * 移除指定索引處的child
         * @param index
         * @return
         */
        Node removeChild(int index) {
            Node deleted = children[index];
            System.arraycopy(children, index + 1, children, index, keyNum - index);
            children[keyNum] = null;
            return deleted;
        }

        /**
         * 移除最左邊的child
         * @return
         */
        Node removeLeftMostChild() {
            return removeChild(0);
        }

        /**
         * 移除最右邊的child
         * @return
         */
        Node removeRightMostChild() {
            return removeChild(keyNum);
        }

        /**
         * 獲取指定children處左邊的兄弟
         * @param index
         * @return
         */
        Node childLeftBrother(int index) {
            return index > 0 ? children[index - 1] : null;
        }

        /**
         * 獲取指定children處右邊的兄弟
         * @param index
         * @return
         */
        Node childRightBrother(int index) {
            return index == keyNum ? null : children[index + 1];
        }

        /**
         * 復制當前節點到目標節點(key和child)
         * @param target
         */
        void moveToTarget(Node target) {
            int start = target.keyNum;
            // 當前節點不是葉子節點 說明有孩子
            if (!leafFlag) {
                // 復制當前節點的孩子到目標節點的孩子中
                for (int i = 0; i <= keyNum; i++) {
                    target.children[start + i] = children[i];
                }
            }
            // 復制key到目標節點的keys中
            for (int i = 0; i < keyNum; i++) {
                target.keys[target.keyNum++] = keys[i];
            }
        }
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,505評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,556評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,463評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,009評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,778評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,218評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,281評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,436評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,969評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,795評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,993評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,537評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,229評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,659評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,917評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,687評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,990評論 2 374

推薦閱讀更多精彩內容