數據結構:2-3-4 樹 和 B 樹

目錄

我們為什么需要 2-3-4 樹?

2-3-4 樹的插入操作

從零推導代碼

B 樹 (B-Tree)

從零推導代碼


我們沿著自平衡樹的演化路徑繼續前進。我們已經學習了 2-3 樹如何通過“分裂與提升”來替代 AVL 樹的“旋轉”,但其修復過程是“自下而上”的。現在,我們來探討一種更主動、更高效的平衡策略,它直接引出了 B 樹的核心思想。

我們為什么需要 2-3-4 樹?

👉 回顧 2-3 樹的插入:我們先向下遍歷到葉子節點,執行插入。如果葉子節點(一個 3-節點)溢出,我們就分裂它,然后將一個 key 向上提升。

這個提升過程可能會引發連鎖反應,導致一路“分裂-提升”回到根節點。這是一個自下而上 (bottom-up) 的修復過程。

數據結構:2-3樹的插入 (Insertion)-CSDN博客

這個過程包含兩個階段:

  • 第一階段:從根向下遍歷,找到插入點。

  • 第二階段:如果發生分裂,再從葉子一路向上返回,進行修復。 在最壞的情況下,我們等于把從根到葉的路徑走了兩遍。

? 提出新問題:有沒有辦法只遍歷一次就完成插入和平衡?我們能否在向下遍歷的過程中,就“預處理”好路徑,確保當我們最終到達葉子節點時,它一定有空間?

💡解決方案:“主動分裂”

這個想法非常巧妙:在我們自上而下 (top-down) 的遍歷路徑上,一旦遇到一個“滿”的節點,就立刻主動將它分裂,然后再繼續向下走。

在 2-3 樹里,“滿”的節點是 3-節點。但如果我們分裂一個 3-節點,提升的 key 需要插入到它的父節點中。如果父節點也是一個 3-節點,那不是又回到原點了嗎?

? 為了讓這個策略萬無一失,我們需要引入一種新的節點類型,讓“滿”這個概念更清晰。我們引入 4-節點 (4-node)。

我們用例子:依次插入:10, 20, 25, 30, 40, 50

🌳 對照圖:Bottom-up vs Top-down

插入 10
普通:          [10]             
主動:          [10]             插入 20
普通:          [10 | 20]        
主動:          [10 | 20]        插入 25
普通:   葉子溢出再分裂            主動:   根滿 -> 先分裂[20]                          [20]/   \                        /     \[10]   [25]                  [10]     [25]插入 30
普通:         [20]                  主動:         [20]/   \                                /   \[10]  [25 | 30]                      [10]  [25 | 30]插入 40
普通:   到達右葉溢出后分裂          主動:   下探前發現右葉滿 -> 先分裂[20 | 30]                              [20 | 30]/    |    \                            /    |    \[10]  [25]  [40]                      [10]  [25]  [40]插入 50
普通:         [20 | 30]               主動:         [20 | 30]/    |    \                            /    |    \[10]  [25]  [40 | 50]                 [10]  [25]  [40 | 50]

一眼看出區別

  • 25 插入時:

    • 普通插入:先插到 [10|20],發現滿了,溢出后分裂。

    • 主動分裂:走到 [10|20] 時,先分裂再繼續走。

  • 40 插入時:

    • 普通插入:一路插到 [25|30],插入變成 [25|30|40] 后,再分裂。

    • 主動分裂:往下走時,看到 [25|30] 已滿,提前分裂,因此不需要回溯。

  • 最終樹:兩者 完全相同,但過程不同。

4-節點 (4-node):包含 3 個 key,有 4 個孩子。

現在,一棵 2-3-4 樹就誕生了。它的節點可以是 2-節點、3-節點或 4-節點。它的“完美平衡”規則(所有葉子在同一層)保持不變。


2-3-4 樹的插入操作

它的插入算法完全體現了“主動分裂”的思想,從而避免了向上的連鎖反應。

算法推導

  1. 定義“滿”節點:在 2-3-4 樹中,只有 4-節點是“滿”的。

  2. 向下遍歷:從根節點開始,尋找 key 的插入路徑。

  3. 主動分裂:在遍歷路徑上,只要遇到一個 4-節點,就立即執行分裂。

    • 將 4-節點的中間 key 向上提升到其父節點。

    • 剩下的兩個 key 分裂成兩個 2-節點,并接收原來 4-節點的 4 個孩子。

🔍分裂的優雅之處

  • 當我們分裂一個 4-節點時,它的父節點是什么狀態?因為我們是自上而下分裂的,所以父節點一定不是一個 4-節點(如果是,它早被分裂了)。

  • 這意味著,父節點必然是 2-節點或 3-節點,它保證有空間來接收從下面提升上來的 key

  • 因此,分裂操作永遠不會向上傳播。它是一個局部的、常數時間的操作。

?? 當我們最終到達葉子節點時,由于我們一路掃清了所有 4-節點,這個葉子節點必然不是 4-節點。它必定有空間。

最終直接將 key 插入這個有空間的葉子節點即可。

2-3-4 樹的插入操作通過“自上而下”的主動分裂,將一個可能需要向上回溯的復雜過程,變成了一個單次向下遍歷的簡單過程,效率更高。


從零推導代碼

步驟 1: 節點結構

我們需要一個能容納 3 個 key 和 4 個 children 的結構。

// 2-3-4 樹的節點結構
struct Node {int numKeys;int keys[3];Node* children[4];Node* parent; // 仍然有用,尤其是在分裂時Node(int key, Node* p = nullptr) {numKeys = 1;keys[0] = key;parent = p;for (int i = 0; i < 4; ++i) children[i] = nullptr;}// ... 其他輔助函數 ...bool isLeaf() const { return children[0] == nullptr; }void sort() { std::sort(keys, keys + numKeys); }
};

這個結構和我們之前為 2-3 樹設計的“溢出”結構幾乎一樣,但在 2-3-4 樹中,這是它的標準形態。

步驟 2: 插入入口和分裂邏輯

class Tree234 {
private:Node* root;// 分裂函數是核心void splitChild(Node* parent, int childIndex);public:Tree234() : root(nullptr) {}void insert(int key);
};void Tree234::insert(int key) {if (root == nullptr) {root = new Node(key);return;}Node* current = root;while (true) {// ----------------------------------------------------// 第一步:主動分裂滿節點// ----------------------------------------------------if (current->numKeys == 3) {// 如果根節點是 4-節點,特殊處理if (current == root) {Node* newRoot = new Node(current->keys[1]);splitChild(newRoot, 0); // 偽代碼,實際更復雜root = newRoot;} else {// 分裂內部節點// (這需要找到 current 在其父節點中的索引,然后分裂)}// 分裂后,需要從父節點重新開始判斷往哪走,邏輯復雜// 讓我們換一種更優雅的實現方式}// 如果是葉子節點,插入并結束if (current->isLeaf()) {current->keys[current->numKeys] = key;current->numKeys++;current->sort();return;}// ----------------------------------------------------// 第二步:繼續向下遍歷// ----------------------------------------------------// (找到正確的孩子,current = child)}
}

面的實現思路暴露了一個問題:在循環中分裂當前節點 current 后,需要重新調整 current 指針,代碼會變得混亂。B 樹的實現給出了一個更優雅的解決方案,我們馬上會看到。

2-3-4 樹的關鍵思想:它是 B 樹的一個具體實例,并且它與另一種著名的自平衡樹——紅黑樹(Red-Black Tree)是等價的,可以相互轉換。它最重要的貢獻是引入了自上而下的主動修復思想。


B 樹 (B-Tree)

我們從 BST 開始,為了平衡引入了 AVL 樹(旋轉),然后探索了 2-3 樹(分裂),再到 2-3-4 樹(主動分裂)。這些結構都假設數據存儲在內存 (RAM) 中。在內存中,指針跳轉和 key 的比較速度都很快。

當數據量巨大,無法全部放入內存,必須存儲在磁盤 (Hard Disk Drive, HDD)固態硬盤 (Solid State Drive, SSD) 上時,游戲規則徹底改變了。

  • 根本矛盾:CPU 訪問內存的速度,比訪問磁盤的速度要快數萬倍甚至百萬倍

  • 瓶頸分析:在對樹進行操作時,從磁盤讀取一個節點(Node)到內存中,是一次磁盤 I/O。這個 I/O 操作的時間開銷,遠遠超過節點加載到內存后,我們對它進行的所有 key 的比較操作。

?? 既然磁盤 I/O 是最昂貴的操作,那么我們的算法設計的核心目標必須是:盡可能地減少磁盤 I/O 的次數。

💡?如何減少 I/O 次數?

  • 在樹的查找過程中,I/O 的次數正比于樹的高度。

  • 因此,我們必須不惜一切代價降低樹的高度。

  • 思想飛躍:如何極限地壓縮樹的高度?讓樹變得極度地“矮胖”。怎么實現?讓每個節點都變得巨大,可以容納海量的 key

? B 樹的誕生 B 樹就是這個思想的直接產物。它是一個廣義的 2-3-4 樹。我們不再局限于 2、3、4 這幾個數字,而是定義一個度 (degree) t

  • 每個節點最少有 t-1key,最多有 2t-1key

  • 每個節點最少有 t 個孩子,最多有 2t 個孩子(根節點除外)。

  • 關鍵設計:t 的選擇,通常使得一個大小為 2t-1 的 B 樹節點,其總體積正好等于一個磁盤頁(Page)的大小(例如 4KB, 8KB, 16KB)。

📌我們每次執行一次磁盤 I/O,就能將成百上千個 key 加載到內存中。這使得樹的分支因子(branching factor)極大,樹的高度被極度壓縮。一個存儲了上億個條目的 B 樹,其高度可能只有 3 或 4 層!這意味著最多只需要 3-4 次磁盤讀取就能找到任何數據。

B 樹是為外部存儲而生的終極平衡查找樹。它通過讓節點大小與磁盤塊大小相匹配,將樹高壓縮到極致,從而最小化昂貴的磁盤 I/O 操作。


從零推導代碼

步驟 1: 節點結構

keychildren 的數量不再固定,必須是動態分配的。

// B 樹的節點
class BTreeNode {
public:int t;            // 最小度 (Minimum degree)int numKeys;      // 當前 key 的數量int* keys;        // key 數組 (大小為 2t-1)BTreeNode** children; // 孩子指針數組 (大小為 2t)bool isLeaf;      // 是否是葉子節點BTreeNode(int degree, bool leaf) {t = degree;isLeaf = leaf;// 根據度 t 動態分配內存keys = new int[2 * t - 1];children = new BTreeNode*[2 * t];numKeys = 0;}// ... 需要析構函數來釋放內存 ...
};

代碼解釋

  • t (通常稱為最小度) 是 B 樹最重要的參數。

  • keyschildren 都是動態分配的指針,它們的大小由 t 決定。

  • isLeaf 標志非常重要。B 樹明確區分葉子和內部節點,這能簡化很多操作。

步驟 2: 插入操作的整體框架

B 樹的插入完美地運用了 2-3-4 樹的“自上而下主動分裂”思想。

class BTree {
private:BTreeNode* root;int t;// 私有輔助函數void insertNonFull(BTreeNode* node, int key);void splitChild(BTreeNode* parent, int childIndex);public:BTree(int degree) {root = nullptr;t = degree;}void insert(int key);
};void BTree::insert(int key) {// ----------------------------------------------------// 第一步:處理根節點// ----------------------------------------------------if (root == nullptr) {root = new BTreeNode(t, true);root->keys[0] = key;root->numKeys = 1;return;}// ----------------------------------------------------// 第二步:如果根節點滿了,必須先分裂根// 這是保證“父節點必有空間”的前提// ----------------------------------------------------if (root->numKeys == 2 * t - 1) {// 創建一個新的根節點BTreeNode* newRoot = new BTreeNode(t, false); // 新根不是葉子newRoot->children[0] = root;// 分裂舊的根splitChild(newRoot, 0);// 更新樹的根root = newRoot;// 現在從新根開始,為 key 找到正確的插入路徑insertNonFull(root, key);} else {// 如果根沒滿,直接從根開始尋找插入位置insertNonFull(root, key);}
}

步驟 3: 核心輔助函數 splitChildinsertNonFull

// 分裂父節點的第 i 個孩子(這個孩子必須是滿的)
void BTree::splitChild(BTreeNode* parent, int i) {BTreeNode* fullChild = parent->children[i];// 1. 創建新的兄弟節點BTreeNode* newSibling = new BTreeNode(t, fullChild->isLeaf);newSibling->numKeys = t - 1; // B樹分裂后,每個新節點有 t-1 個 key// 2. 將滿孩子節點的后 t-1 個 key 復制到新兄弟節點for (int j = 0; j < t - 1; j++) {newSibling->keys[j] = fullChild->keys[j + t];}// 3. 如果滿孩子不是葉子,將其后 t 個孩子指針復制到新兄弟if (!fullChild->isLeaf) {for (int j = 0; j < t; j++) {newSibling->children[j] = fullChild->children[j + t];}}// 4. 更新滿孩子節點的 key 數量fullChild->numKeys = t - 1;// 5. 將父節點中,i 位置之后的孩子指針向后移動一位for (int j = parent->numKeys; j >= i + 1; j--) {parent->children[j + 1] = parent->children[j];}// 6. 將新兄弟節點連接到父節點parent->children[i + 1] = newSibling;// 7. 將父節點中,i 位置之后的 key 向后移動一位for (int j = parent->numKeys - 1; j >= i; j--) {parent->keys[j + 1] = parent->keys[j];}// 8. 將滿孩子節點的中間 key 提升到父節點parent->keys[i] = fullChild->keys[t - 1];parent->numKeys++;
}// 在一個“保證不滿”的節點中插入 key
void BTree::insertNonFull(BTreeNode* node, int key) {int i = node->numKeys - 1;// ----------------------------------------------------// 情況一:當前節點是葉子節點// ----------------------------------------------------if (node->isLeaf) {// 從后往前找到 key 的插入位置,并移動元素while (i >= 0 && node->keys[i] > key) {node->keys[i + 1] = node->keys[i];i--;}node->keys[i + 1] = key;node->numKeys++;return;}// ----------------------------------------------------// 情況二:當前節點是內部節點// ----------------------------------------------------// 找到 key 應該插入的子樹while (i >= 0 && node->keys[i] > key) {i--;}i++; // i 現在是目標子樹的索引// 檢查目標子樹是否已滿if (node->children[i]->numKeys == 2 * t - 1) {// 如果滿了,就地分裂splitChild(node, i);// 分裂后,父節點增加了一個key,需要重新判斷應該去哪個子樹if (key > node->keys[i]) {i++;}}// 遞歸地向“保證不滿”的子樹插入insertNonFull(node->children[i], key);
}

代碼解釋

  • insert: 它的唯一職責是確保根節點在調用 insertNonFull 之前不是滿的。它通過預先分裂根節點來做到這一點,這是整個算法優雅的關鍵。

  • insertNonFull: 這是遞歸的主體。它只處理不滿的節點。在向下遞歸之前,它會“偵察”下一層的節點。如果目標子節點是滿的,它會調用 splitChild 來處理,然后再安全地遞歸下去。

  • splitChild: 這是 B 樹的核心操作。它精確地執行“分裂-提升”的邏輯,涉及到大量的數組操作,將一個滿的節點分裂成兩個半滿的節點,并將一個 key 提升給父節點。

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/bicheng/94708.shtml
繁體地址,請注明出處:http://hk.pswp.cn/bicheng/94708.shtml
英文地址,請注明出處:http://en.pswp.cn/bicheng/94708.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

Python爬蟲實戰:構建港口物流數據采集和分析系統

1. 引言 1.1 研究背景與意義 在全球化背景下,港口作為 “一帶一路” 倡議的關鍵節點,其運營效率直接影響國際貿易流通速度。港口管理部門、物流企業及貿易公司需實時掌握船舶動態、貨物吞吐量等信息以優化調度、降低成本。然而,這些信息分散于: 港口官方網站(如上海港、…

新型隱蔽惡意軟件利用TP-Link、思科等路由器漏洞獲取遠程控制權

攻擊概況安全研究人員近期發現針對多品牌網絡設備的新型惡意軟件攻擊活動&#xff0c;受影響設備包括DrayTek、TP-Link、Raisecom和思科等廠商的路由器。2025年7月期間&#xff0c;攻擊者通過利用嵌入式Web服務中的未授權命令注入漏洞傳播隱蔽加載程序。初始入侵通過簡單的HTTP…

對線性代數伴隨矩陣的深入理解

伴隨矩陣的幾何直觀&#xff1a;縮放倍率為det?(A)n?1\det (A)^{n-1}det(A)n?1的逆變換。 A?A?∣A∣EA\cdot A^*|A|EA?A?∣A∣E 最終得到的結果是將原像空間各基向量縮放了det?(A)\det (A)det(A)倍&#xff0c;故空間總體上是被放大了∣A∣n|A|^{n}∣A∣n倍。 為什么是…

uni-app 組件之自定義導航欄

前言上一篇簡單的介紹了一下什么是組件&#xff0c;即組件是一個單獨且可復用的功能模塊的封裝。所以這篇文章主要在實際開發中自己動手封裝一個簡單的導航欄組件&#xff0c;當然在插件市場有很多&#xff0c;但是自己動手封裝一個才能真正領會其中的意義。一、自定義組件1.創…

android vehicle

Android Vehicle HAL架構-騰訊云開發者社區-騰訊云 Android vehicle車輛屬性新增demo-CSDN博客 【IVI】VehicleService啟動_android ivi-CSDN博客

【人工智能】神經網絡的優化器optimizer(三):RMSProp動態自適應學習率優化器

一、算法核心原理 RMSProp&#xff08;Root Mean Square Propagation&#xff09;是深度學習先驅Geoffrey Hinton在2012年提出的優化算法&#xff0c;它基于AdaGrad算法的改進&#xff0c;創新性地解決了傳統梯度下降法中學習率固定不變的局限性。該算法的核心機制在于采用指數…

全面解析了Java微服務架構的設計模式

一、引言&#xff1a;微服務架構的背景與優勢隨著互聯網應用的復雜度不斷提升&#xff0c;傳統的單體架構&#xff08;Monolithic Architecture&#xff09;在可維護性、可擴展性、部署靈活性等方面逐漸暴露出瓶頸。微服務架構&#xff08;Microservices Architecture&#xff…

本地組策略編輯器圖形化工具

本地組策略圖形化工具&#xff0c;添加用戶權限分配功能。這將包括常見的用戶權限策略設置&#xff1a; 目前版本在優化中&#xff0c;后續會添加更多功能。 # GroupPolicyGUI.ps1 # 需要以管理員權限運行Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName …

深度學習卷積神經網絡項目實戰——超市商品分類

卷積神經網絡項目實戰 1.項目簡介 1.1項目名稱 ? 基于CNN實現超市商品的混合顆粒度分類&#xff08;500分類&#xff09; 1.2 項目簡介 ? 該項目旨在通過卷積神經網絡&#xff08;CNN&#xff09;實現超市商品的混合顆粒度分類&#xff0c;主要針對商品的不同種類進行分…

網站如何被搜索引擎收錄(Google、Bing、百度等)

1. 【Google 收錄】注冊 Google Search Console&#xff1a; https://search.google.com/search-console添加網站&#xff08;主域名、子域名都加&#xff09;驗證所有權&#xff08;用 DNS、HTML 文件、Meta Tag 都可以&#xff09;提交 Sitemap&#xff08;/sitemap.xml&…

JDK 8 → JDK 17 升級說明書(面向 Spring Boot / Spring Cloud / Spring )

自從 JDK 8 發布以來&#xff0c;Java 語言在持續進化&#xff0c;帶來了許多新的功能和性能改進。而 JDK 17 作為一個長期支持版本&#xff08;LTS&#xff09;&#xff0c;在許多方面超越了 JDK 8&#xff0c;不僅提升了語言本身的能力&#xff0c;還進一步提高了性能、可維護…

【ElasticSearch】使用docker compose,通過編寫yml安裝es8.15和kibana可視化界面操作,go連接es

使用 Docker 安裝 Elasticsearch Docker 搭建 Elasticsearch Kibana 環境&#xff0c;并在過程中標注常見問題和解決方案。 1. 準備工作 在開始之前&#xff0c;請確認你本地已經安裝了&#xff1a; 工具版本建議檢查方式Docker≥ 20.xdocker -vDocker Compose≥ 2.xdocker …

《C 語言文件操作補充:字符串格式化與隨機讀寫全解析》

目錄 一. sprintf函數和sscanf函數 1.1 sprintf 函數&#xff1a;將格式化數據寫入字符串 1.2 sscanf 函數&#xff1a;從字符串中格式化讀取數據 二. 文件的隨機讀寫 2.1 fseek 函數&#xff1a;移動文件讀寫指針 2.2 ftell 函數&#xff1a;獲取當前指針位置 2.3 rew…

SOME/IP-SD報文中 Entry Format(條目格式)-理解筆記4

逐字段解析 AUTOSAR SOME/IP Service Discovery 中的 Entry 格式。我們將它拆解成幾個部分&#xff0c;并用清晰的排版和比喻來確保每個字段都得到解釋。&#x1f4dc; Entry 的完整結構&#xff1a;三層蛋糕 一條完整的 SD Entry 信息就像一塊三層蛋糕&#xff0c;從上到下分別…

在 vue3 和 vue2 中,computed 計算屬性和 methods 方法區別是什么

在 Vue 2 和 Vue 3 中&#xff0c;computed&#xff08;計算屬性&#xff09;和 methods&#xff08;方法&#xff09;都是處理數據邏輯的方式&#xff0c;但它們在緩存機制、使用場景、執行時機等方面有顯著區別&#xff0c;且這些區別在兩個版本中保持一致。 1. 緩存機制&…

android 改機系列之-虛擬攝像頭-替換相機預覽畫面

Android Native 層實現跨進程 YUV 視頻幀共享&#xff1a;基于抽象 Unix Socket 的高效通信方案。基于AOSP13源碼或者lineage20 或相近版本。非hook 或者lsp 等插件方案。 1.引言 在某些定制化 Android 應用場景中&#xff0c;我們可能需要動態替換系統相機的預覽畫面 —— 例…

SSM從入門到實戰:2.5 SQL映射文件與動態SQL

&#x1f44b; 大家好&#xff0c;我是 阿問學長&#xff01;專注于分享優質開源項目解析、畢業設計項目指導支持、幼小初高的教輔資料推薦等&#xff0c;歡迎關注交流&#xff01;&#x1f680; 12-SQL映射文件與動態SQL &#x1f4d6; 本文概述 本文是SSM框架系列MyBatis進…

vue+vite打包后的文件希望放在一個子目錄下

比如我們常規操作是打包的項目文件直接放在域名下面。如果我們希望把項目放在子域名下面應該怎么處理呢&#xff1f;需要兩個步驟vite.config.js里面指定base的路徑假設我們希望放在子目錄加做call那么我們可以這樣base:/call/,注意不是build目錄哈。return的最外層。如果本地和…

Java:Docx4j類庫簡介及使用

1.簡介 Docx4j 是一個功能強大的 Java 類庫&#xff0c;專門用于創建和操作 Microsoft Open XML 格式&#xff08;如 Word DOCX、PowerPoint PPTX 和 Excel XLSX&#xff09;的文件。它深受 Java 開發者喜愛&#xff0c;特別是在需要自動化處理 Office 文檔的場景下。 下面是一…

【機械故障】旋轉機械故障引起的振動信號調制效應概述

系列文章目錄 提示&#xff1a;學習筆記 機械故障信號分析 共振峰 旋轉機械故障引起的振動信號調制效應概述系列文章目錄一、研究背景與意義二、故障引起的調制效應分類三、非平穩信號分析方法3.1 時頻分析方法3.2 信號分解方法一、研究背景與意義 在工程實踐中&#xff0c;可…