一、概述
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來存儲,樹高為:
(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,需要分裂:
分裂邏輯:
分裂節點數據一分為三:
- 左側數據:本身左側的數據留在該節點
- 中間數據:中間索引2(度-1)的數據6移動到父節點的索引1(被分裂節點的索引)處。
- 右側數據:從索引3(度)開始的數據,移動到新節點,新節點的索引值為分裂節點的index+1
如果分裂的節點是非葉子節點:
需要多一步操作:右側數據需要和孩子一起連帶到新節點去:
分裂的是根節點:
需要再創建多一個節點來當做根節點,此根節點為父親,存入中間的數據。
其他步驟同上。
分裂方法:
/**
* 節點分裂
* 左側數據:本身左側的數據留在該節點
* 中間數據:中間索引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
詳細右旋流程:
處理孩子:
向左合并詳細流程:
根節點調整的情況:
失衡調整代碼
/**
* 樹的平衡
* @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];
}
}