深耕 SQL Server 多年,對 B-Tree 的核心概念早已烂熟於心。網路上鮮少見到完整的 C# 或 Java 實作範例,索性親自動手打造一個,過程中更對許多底層細節有了更透徹的理解。
B-Tree 是 1970 年由 R. Bayer 與 E. McCreight 提出,專為輔助儲存(磁碟)設計的平衡多路查找樹。在檔案系統與資料庫索引中廣泛應用,核心目標是極大減少磁碟 I/O 操作。雖然發明者未說明「B」的由來,但學術界普遍將其解釋為 Balance(平衡)。國內常誤稱為「B-樹」,實則源自英文 B-Tree 的直譯。
N 個鍵值與 N+1 個指標;鍵值數不得小於最大容量的 1/2(即最小度數 t 滿足 t-1 ≤ N ≤ 2t-1)。電腦儲存體系分為主存(CPU 快取、RAM)與輔存(HDD、SSD)。主存速度極快(奈秒級)但單位成本高;輔存容量大、成本低,但存取速度受限於機械結構(毫秒級),兩者相差 10 萬倍以上。
磁碟包含多個高速旋轉的盤片(7200~15000 RPM),搖臂移動磁頭讀寫。一次完整旋轉尋找資料約需 8.33 ms(7200 RPM),遠慢於記憶體的 100 ns。為攤銷機械延遲,磁碟採用頁(Page)為最小讀寫單位(SQL Server 為 8 KB)。
B-Tree 將根節點常駐主存,其餘節點置於輔存。查找元素 Y 時:讀取根節點(主存)→ 依指標讀取一層子節點(1 次 I/O)→ 再讀取葉節點(第 2 次 I/O)即可定位。對比二元搜尋樹可能需數十次 I/O,B-Tree 將查詢延遲壓縮到極致。
I/O 次數取決於樹高 h。對於最小度數 t、總鍵值數 N 的 B-Tree:
t 越大,樹高越低。SQL Server 節點固定 8092 位元組,鍵值越窄(如 INT vs GUID),單節點容納的鍵值越多,t 越大,樹高越矮,查詢效能越強——這就是建議聚集索引使用窄鍵的根本原因。
若 t=1(每節點僅 1 鍵值),樹高趨近無窮大,因此 B-Tree 強制每節點至少 2 個子節點(t ≥ 2)。
public class TreeNode<T> where T : IComparable<T> {
public int ElementCount = 0;
public IList<T> Elements = new List<T>();
public IList<TreeNode<T>> Pointers = new List<TreeNode<T>>();
public bool IsLeaf = true;
}
採用泛型 T 並限制 IComparable<T>,確保鍵值可比較。ElementCount 追蹤實際鍵值數,Pointers 長度恆為 ElementCount + 1。
public class BTree<T> where T : IComparable<T> {
private const int MaxKeysPerNode = 4; // 最小度數 t = 3
public TreeNode<T> Root { get; private set; }
public BTree() {
Root = new TreeNode<T> { ElementCount = 0, IsLeaf = true };
// 模擬:將根節點寫入磁碟(1 次 I/O 寫)
}
}
當節點鍵值達上限 MaxKeysPerNode 時插入新鍵值,必須分裂:
private void SplitNode(TreeNode<T> parent, int childIndex, TreeNode<T> fullNode) {
var newNode = new TreeNode<T> { IsLeaf = fullNode.IsLeaf };
int mid = MaxKeysPerNode / 2; // 中間索引
int moveCount = MaxKeysPerNode - mid - 1; // 移往新節點的鍵值數
// 1. 移動鍵值到新節點
for (int i = 0; i < moveCount; i++)
newNode.Elements.Add(fullNode.Elements[mid + 1 + i]);
// 2. 若非葉節點,同步移動子指標
if (!fullNode.IsLeaf) {
for (int i = 0; i <= moveCount; i++)
newNode.Pointers.Add(fullNode.Pointers[mid + 1 + i]);
}
// 3. 原節點保留前半部
fullNode.ElementCount = mid;
// 4. 父節點騰出位置,插入中間鍵值與新指標
parent.Pointers.Insert(childIndex + 1, newNode);
parent.Elements.Insert(childIndex, fullNode.Elements[mid]);
parent.ElementCount++;
// 模擬:父、原、新三節點寫回磁碟(3 次 I/O 寫)
}
private void InsertNonFull(TreeNode<T> node, T key) {
int i = node.ElementCount - 1;
if (node.IsLeaf) {
// 葉節點:直接插入並保持有序
while (i >= 0 && key.CompareTo(node.Elements[i]) < 0) i--;
node.Elements.Insert(i + 1, key);
node.ElementCount++;
// 模擬:寫入磁碟(1 次 I/O 寫)
} else {
// 內部節點:尋找下層子節點
while (i >= 0 && key.CompareTo(node.Elements[i]) < 0) i--;
int childIdx = i + 1;
// 下層若已滿,先分裂再決定走左/右
if (node.Pointers[childIdx].ElementCount == MaxKeysPerNode) {
SplitNode(node, childIdx, node.Pointers[childIdx]);
if (key.CompareTo(node.Elements[childIdx]) > 0) childIdx++;
}
InsertNonFull(node.Pointers[childIdx], key);
}
}
public void Insert(T key) {
if (Root.ElementCount == MaxKeysPerNode) {
var newRoot = new TreeNode<T> { ElementCount = 0, IsLeaf = false };
SplitNode(newRoot, 0, Root); // 分裂舊根
InsertNonFull(newRoot, key);
Root = newRoot; // 樹高 +1
} else {
InsertNonFull(Root, key);
}
}
public (TreeNode<T> Node, int Index) Search(T key) {
return SearchRecursive(Root, key);
}
private (TreeNode<T>, int) SearchRecursive(TreeNode<T> node, T key) {
int i = 0;
while (i < node.ElementCount && key.CompareTo(node.Elements[i]) > 0) i++;
if (i < node.ElementCount && key.CompareTo(node.Elements[i]) == 0)
return (node, i);
if (node.IsLeaf) return (null, -1);
// 模擬:從磁碟讀取子節點(1 次 I/O 讀)
return SearchRecursive(node.Pointers[i], key);
}
B-Tree 以「多路、平衡、節點對齊磁碟頁」三大特性,成為關聯式資料庫與現代檔案系統的索引基石。透過親手實作,可深刻體會:
完整原始碼下載:BTreeCsharp.rar