<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>B树 on 方糖猫Blog</title>
    <link>https://ftmao.com/blog/tags/b%E6%A0%91/</link>
    <description>AI 视频、AI Agent 与工程实践笔记</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <lastBuildDate>Wed, 02 Nov 2022 09:30:00 +0800</lastBuildDate><atom:link href="https://ftmao.com/blog/tags/b%E6%A0%91/index.xml" rel="self" type="application/rss+xml" /><item>
      <title>《数据结构与算法》教材笔记（树篇）：从二叉查找树一路推导到 B&#43; 树，为什么数据库索引非它不可</title>
      <link>https://ftmao.com/blog/posts/data-structures-tree-to-btree/</link>
      <pubDate>Wed, 02 Nov 2022 09:30:00 +0800</pubDate>
      <guid>https://ftmao.com/blog/posts/data-structures-tree-to-btree/</guid>
      <description>&lt;h1 id=&#34;数据结构与算法教材笔记树篇从二叉查找树一路推导到-b-树为什么数据库索引非它不可&#34;&gt;《数据结构与算法》教材笔记（树篇）：从二叉查找树一路推导到 B+ 树，为什么数据库索引非它不可&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;目标读者：学过一遍数据结构、能写二叉树遍历，但被问到&amp;quot;B 树和红黑树有什么区别、为什么 MySQL 用 B+ 树&amp;quot;就说不囫囵的同学。
姊妹篇：《&lt;a href=&#34;https://ftmao.com/blog/blog/posts/algorithm-and-data-structures-basics/&#34;&gt;算法与数据结构入门学习总结&lt;/a&gt;》是&lt;strong&gt;刷题向&lt;/strong&gt;的——讲双指针、滑动窗口、DP 模板，目标是过 LeetCode。这篇是&lt;strong&gt;教材向&lt;/strong&gt;的——讲每种结构&amp;quot;为什么被发明出来&amp;quot;，目标是看懂工程世界。两篇文章覆盖的内容几乎不重叠，建议先读哪篇都行。
实战印证：《&lt;a href=&#34;https://ftmao.com/blog/blog/posts/mysql-practical-guide/&#34;&gt;MySQL 实战指南&lt;/a&gt;》里讲的索引失效、覆盖索引，《&lt;a href=&#34;https://ftmao.com/blog/blog/posts/mongodb-beginner-to-mastery/&#34;&gt;MongoDB 从入门到精通&lt;/a&gt;》里讲的 WiredTiger B 树页、页分裂，底层原理都在这篇里。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;0-这篇只讲一件事树是怎么一步步被逼成-b-树的&#34;&gt;0. 这篇只讲一件事：树是怎么一步步被逼成 B+ 树的&lt;/h2&gt;
&lt;p&gt;教材里的结构很多，数组、链表、栈、队列、哈希、图……但刷题篇已经覆盖了大部分，这篇把 70% 的篇幅压在一根主线上——&lt;strong&gt;树的进化链&lt;/strong&gt;：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    A[&amp;#34;二叉查找树 BST&amp;lt;br/&amp;gt;有序 但会退化&amp;#34;] --&amp;gt;|&amp;#34;痛点: 退化成链表&amp;#34;| B[&amp;#34;AVL 树&amp;lt;br/&amp;gt;严格平衡&amp;#34;]
    B --&amp;gt;|&amp;#34;痛点: 旋转太频繁&amp;lt;br/&amp;gt;写多读少顶不住&amp;#34;| C[&amp;#34;红黑树&amp;lt;br/&amp;gt;近似平衡 工程妥协&amp;#34;]
    C --&amp;gt;|&amp;#34;痛点: 再高也是二叉&amp;lt;br/&amp;gt;树太高 磁盘 I/O 多&amp;#34;| D[&amp;#34;2-3 树&amp;lt;br/&amp;gt;一个节点放多个 key&amp;#34;]
    D --&amp;gt;|&amp;#34;推广&amp;#34;| E[&amp;#34;m 阶 B 树&amp;lt;br/&amp;gt;阶数对齐磁盘页&amp;#34;]
    E --&amp;gt;|&amp;#34;痛点: 内节点存数据&amp;lt;br/&amp;gt;范围扫描难&amp;#34;| F[&amp;#34;B+ 树&amp;lt;br/&amp;gt;内节点纯索引&amp;lt;br/&amp;gt;叶节点链表&amp;#34;]
    style F fill:#ffd699&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;每一步都不是&amp;quot;更高级&amp;quot;，而是&lt;strong&gt;上一步在某个具体场景下撑不住了&lt;/strong&gt;。记住这条链上的每个箭头，你就永远不会把 B 树讲成背定义。&lt;/p&gt;
&lt;p&gt;另外教材里还有两个刷题篇没覆盖的点——哈希冲突的两种解法、图的最短路径与最小生成树——放在最后简单补全。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;1-二叉查找树一切的开始和它的死穴&#34;&gt;1. 二叉查找树：一切的开始，和它的死穴&lt;/h2&gt;
&lt;p&gt;二叉查找树（BST）的规则一句话：左子树所有值 &amp;lt; 根 &amp;lt; 右子树所有值。查找、插入、删除平均都是 O(log n)，看起来很美。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;死穴：它不管自己的形状。&lt;/strong&gt; 按 1, 2, 3, 4, 5, 6, 7 的顺序插入，你会得到：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TD
    A[1] --&amp;gt; B[2]
    B --&amp;gt; C[3]
    C --&amp;gt; D[4]
    D --&amp;gt; E[5]
    E --&amp;gt; F[6]
    F --&amp;gt; G[7]
    style A fill:#ffcccc&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;这棵树退化成了&lt;strong&gt;链表&lt;/strong&gt;，查找变成 O(n)。而&amp;quot;按顺序插入&amp;quot;在真实世界里太常见了——自增 ID、时间戳，天然就是有序的。BST 的平均复杂度是拿&amp;quot;数据随机到达&amp;quot;换来的，工程上没人敢赌这个。&lt;/p&gt;
&lt;p&gt;所以需要&lt;strong&gt;平衡&lt;/strong&gt;：插入删除时主动调整形状，保证树高稳定在 log n。怎么调？教材给了两个答案，一个完美主义，一个实用主义。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;2-avl-树完美主义的代价&#34;&gt;2. AVL 树：完美主义的代价&lt;/h2&gt;
&lt;p&gt;AVL 的规则：&lt;strong&gt;任意节点的左右子树高度差不超过 1&lt;/strong&gt;。这是最严格的平衡，树高被死死压在约 1.44 log n，查找最快。&lt;/p&gt;
&lt;p&gt;代价是：每次插入删除破坏平衡时，要做&lt;strong&gt;旋转&lt;/strong&gt;来修复。四种失衡形态：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TB
    subgraph LL[&amp;#34;LL 型：左孩子的左子树插入&amp;#34;]
        direction LR
        L1[&amp;#34;  30        右旋        20&amp;lt;br/&amp;gt;  /           →        /  \&amp;lt;br/&amp;gt;20                 10   30&amp;lt;br/&amp;gt;/&amp;lt;br/&amp;gt;10&amp;#34;]
    end
    subgraph RR[&amp;#34;RR 型：右孩子的右子树插入&amp;#34;]
        direction LR
        R1[&amp;#34;10          左旋          20&amp;lt;br/&amp;gt;  \\          →         /  \&amp;lt;br/&amp;gt;  20                 10   30&amp;lt;br/&amp;gt;    \\&amp;lt;br/&amp;gt;    30&amp;#34;]
    end
    subgraph LRRL[&amp;#34;LR / RL 型：拐弯失衡 → 先转成 LL/RR 再转&amp;lt;br/&amp;gt;（两次旋转）&amp;#34;]
        LR1[&amp;#34;先对子树反向旋转&amp;lt;br/&amp;gt;再对根旋转&amp;#34;]
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;旋转本身不复杂，麻烦的是频率：&lt;strong&gt;插入最多旋 2 次就停，但删除可能要从底向上旋 O(log n) 次&lt;/strong&gt;。读多写少的场景没问题，写多读少的场景（比如频繁更新的索引）旋转本身就吃掉了平衡带来的收益。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;学习心得&lt;/strong&gt;：我当年把四种旋转的图背得滚瓜烂熟，考试一过全忘了。后来发现根本不用背——&lt;strong&gt;LR 就是&amp;quot;先左旋成 LL，再右旋&amp;quot;&lt;/strong&gt;，四种形态只是&amp;quot;一次还是两次、往哪边转&amp;quot;的组合。理解失衡的方向比记图重要。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;3-红黑树工程妥协的胜利&#34;&gt;3. 红黑树：工程妥协的胜利&lt;/h2&gt;
&lt;p&gt;红黑树放弃了&amp;quot;严格平衡&amp;quot;，换成一组染色规则：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;每个节点非红即黑；&lt;/li&gt;
&lt;li&gt;根和叶子（NIL）是黑；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;红色节点的孩子必须是黑&lt;/strong&gt;（不能有两个红挨着）;&lt;/li&gt;
&lt;li&gt;任意节点到其所有后代叶子的路径上，&lt;strong&gt;黑节点数量相同&lt;/strong&gt;（黑高一致）。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这四条合起来保证的不是&amp;quot;左右一样高&amp;quot;，而是一个更弱的结论：&lt;strong&gt;最长路径不超过最短路径的 2 倍&lt;/strong&gt;。树高 ≈ 2 log n，比 AVL 略高，但换来的是插入删除平均只需 O(1) 次旋转。&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TB
    subgraph AVL对比[&amp;#34;AVL vs 红黑树&amp;#34;]
        direction LR
        V1[&amp;#34;AVL&amp;lt;br/&amp;gt;高 1.44·log n&amp;lt;br/&amp;gt;查得快&amp;lt;br/&amp;gt;删最多旋 log n 次&amp;#34;]
        V2[&amp;#34;红黑树&amp;lt;br/&amp;gt;高 2·log n&amp;lt;br/&amp;gt;查得稍慢&amp;lt;br/&amp;gt;旋 O(1) 次收工&amp;#34;]
    end
    V1 -.-&amp;gt;|&amp;#34;读写均衡时&amp;lt;br/&amp;gt;工程选它&amp;#34;| V2
    style V2 fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;红黑树是工程世界的宠儿：Java 的 &lt;code&gt;TreeMap&lt;/code&gt;、Linux 内核的 CFS 调度器、epoll 的就绪队列、C++ 的 &lt;code&gt;std::map&lt;/code&gt;，全是它。&lt;strong&gt;面试被问&amp;quot;为什么用红黑树不用 AVL&amp;quot;，标准答案就是这一句：读多写少用 AVL（查得快），读写均衡用红黑树（调整便宜）。&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 1：手撕红黑树是最不值的投入&lt;/strong&gt;
面试准备期我花了一周想手写红黑树的删除——三种 Case、镜像三种、还有双重黑的修复，写完 200 行测试还是漏了一个分支。后来和做了十年基础设施的朋友聊，他说：&amp;ldquo;我工作里一次都没写过红黑树，但我每周都在做红黑树式的决策——&lt;strong&gt;在&amp;rsquo;严格&amp;rsquo;和&amp;rsquo;够用&amp;rsquo;之间选够用&lt;/strong&gt;。&amp;ldquo;那一周的真正收获不是会写了，而是看懂了工程权衡的形状。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;4-2-3-树b-树的真身换个方向解决平衡&#34;&gt;4. 2-3 树：B 树的真身，换个方向解决平衡&lt;/h2&gt;
&lt;p&gt;AVL 和红黑树都是&amp;quot;二叉 + 旋转修复&amp;rdquo;。2-3 树换了个思路：&lt;strong&gt;允许一个节点放多个 key，靠节点分裂来维持平衡&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;节点分两种：2-节点（1 个 key，2 个孩子）、3-节点（2 个 key，3 个孩子）；&lt;/li&gt;
&lt;li&gt;所有叶子在&lt;strong&gt;同一层&lt;/strong&gt;（绝对平衡，比 AVL 还狠）；&lt;/li&gt;
&lt;li&gt;插入永远发生在叶子：叶子满了（临时变成 3 个 key）就&lt;strong&gt;中间 key 向上分裂&lt;/strong&gt;，父节点跟着调整，可能一路裂到根——树是这样&amp;quot;从下往上长&amp;quot;的。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一次分裂长这样：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TB
    subgraph BEFORE[&amp;#34;插入 7，叶子已有 5 6，临时三 key 超载&amp;#34;]
        B1[&amp;#34;      [9]&amp;lt;br/&amp;gt;  [5,6]  ← 插入7后变 [5,6,7]&amp;#34;]
    end
    subgraph AFTER[&amp;#34;中间 key 6 上提，裂成两个节点&amp;#34;]
        A1[&amp;#34;        [6,  9]&amp;lt;br/&amp;gt;   /     |      \\&amp;lt;br/&amp;gt; [5]   [7]   [...]&amp;#34;]
    end
    BEFORE --&amp;gt; AFTER&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;2-3 树的意义不在它自己（几乎没人直接用），而在于它证明了一件事：&lt;strong&gt;平衡不一定靠旋转，也可以靠&amp;quot;节点容量 + 向上分裂&amp;rdquo;&lt;/strong&gt;。把这个思路的容量从 2 个 key 放大到几百个，就是 B 树。&lt;/p&gt;
&lt;p&gt;顺带一提：&lt;strong&gt;红黑树其实就是 2-3 树的二叉表示&lt;/strong&gt;——把 3-节点的两个 key 用一条红色边连起来，就是红黑树。理解了 2-3 树再回头看红黑树规则，&amp;ldquo;不能两个红挨着&amp;quot;就是&amp;quot;一个 3-节点最多两个 key&amp;rdquo;，瞬间全部通顺。这是教材推导最美的一段。&lt;/p&gt;
&lt;h2 id=&#34;5-b-树为磁盘而生的矮胖树&#34;&gt;5. B 树：为磁盘而生的矮胖树&lt;/h2&gt;
&lt;h3 id=&#34;51-为什么二叉树到了磁盘上就不行了&#34;&gt;5.1 为什么二叉树到了磁盘上就不行了&lt;/h3&gt;
&lt;p&gt;到这里要引入这篇的真正主角：&lt;strong&gt;磁盘 I/O&lt;/strong&gt;。内存里比较一次是纳秒级，磁盘读一次是毫秒级（机械盘）到几十微秒级（SSD），&lt;strong&gt;差着 4~6 个数量级&lt;/strong&gt;。所以磁盘世界的复杂度单位不是&amp;quot;比较次数&amp;quot;，而是&amp;quot;&lt;strong&gt;读了几个磁盘页&lt;/strong&gt;&amp;quot;。&lt;/p&gt;
&lt;p&gt;一棵 100 万个 key 的平衡二叉树，高约 20 层——查一个 key 最坏要 &lt;strong&gt;20 次磁盘 I/O&lt;/strong&gt;。这就是为什么二叉平衡树统治内存、却进不了磁盘。&lt;/p&gt;
&lt;p&gt;B 树的解法简单粗暴：&lt;strong&gt;把每个节点做到一个磁盘页那么大&lt;/strong&gt;（InnoDB 默认 16KB）。一页能放几百个 key，树高立刻塌下来：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TB
    subgraph BTREE[&amp;#34;一棵 m 阶 B 树（每个节点 = 一个 16KB 磁盘页）&amp;#34;]
        ROOT[&amp;#34;根页&amp;lt;br/&amp;gt;[k1 | k2 | ... | k400]&amp;lt;br/&amp;gt;1 次 I/O&amp;#34;]
        ROOT --&amp;gt; L2A[&amp;#34;内页&amp;lt;br/&amp;gt;400 个 key&amp;lt;br/&amp;gt;1 次 I/O&amp;#34;]
        ROOT --&amp;gt; L2B[&amp;#34;内页 ...&amp;#34;]
        ROOT --&amp;gt; L2C[&amp;#34;内页 ...&amp;#34;]
        L2A --&amp;gt; L3[&amp;#34;叶页层&amp;lt;br/&amp;gt;每页 400 条记录&amp;lt;br/&amp;gt;1 次 I/O&amp;#34;]
    end
    NOTE[&amp;#34;100 万 key：树高 ≈ 2~3&amp;lt;br/&amp;gt;= 最多 3 次磁盘 I/O&amp;#34;]&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;算一笔账：每页 400 个 key，两层就是 400 × 400 = 16 万，三层就是 6400 万。&lt;strong&gt;1 亿条数据的索引，3 次 I/O 定位&lt;/strong&gt;——这就是&amp;quot;千万级数据 B 树也就 4 层&amp;quot;（MongoDB 篇里的那句话）的出处。&lt;/p&gt;
&lt;h3 id=&#34;52-m-阶-b-树的定义五条一条都不能少&#34;&gt;5.2 m 阶 B 树的定义（五条，一条都不能少）&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;每个节点最多 &lt;strong&gt;m 个孩子&lt;/strong&gt;（m 阶）；&lt;/li&gt;
&lt;li&gt;非根节点至少有 &lt;strong&gt;⌈m/2⌉ 个孩子&lt;/strong&gt;（保证节点不会太空）；&lt;/li&gt;
&lt;li&gt;有 k 个孩子的节点恰好有 &lt;strong&gt;k-1 个 key&lt;/strong&gt;，key 排序，充当孩子之间的&amp;quot;路标&amp;quot;；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;所有叶子在同一层&lt;/strong&gt;；&lt;/li&gt;
&lt;li&gt;根至少 2 个孩子（除非是叶子）。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;插入和删除是 2-3 树逻辑的放大版：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    subgraph 插入[&amp;#34;插入：叶子满了就分裂&amp;#34;]
        I1[&amp;#34;叶子 key 数 = m&amp;lt;br/&amp;gt;超载&amp;#34;] --&amp;gt; I2[&amp;#34;中间 key 上提&amp;lt;br/&amp;gt;裂成两半&amp;lt;br/&amp;gt;各留 ⌈m/2⌉-1 个&amp;#34;]
        I2 --&amp;gt; I3[&amp;#34;父节点也满？&amp;lt;br/&amp;gt;继续向上裂&amp;lt;br/&amp;gt;最坏裂到根 树高+1&amp;#34;]
    end
    subgraph 删除[&amp;#34;删除：节点太空就借/并&amp;#34;]
        D1[&amp;#34;key 数 &amp;lt; ⌈m/2⌉-1&amp;#34;] --&amp;gt; D2{&amp;#34;兄弟有富余？&amp;#34;}
        D2 --&amp;gt;|有| D3[&amp;#34;向兄弟借一个&amp;lt;br/&amp;gt;（父key下来 兄弟key上去）&amp;#34;]
        D2 --&amp;gt;|没有| D4[&amp;#34;和兄弟合并&amp;lt;br/&amp;gt;父key拉下来&amp;lt;br/&amp;gt;可能连锁合并&amp;#34;]
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;&lt;strong&gt;B 树的树高增长只有一种方式：根节点分裂。&lt;/strong&gt; 这保证了它永远不会退化——这是比 BST 强在根上的地方。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;踩坑故事 2：自增主键 vs UUID，B 树结构的真实代价&lt;/strong&gt;
早年在 MySQL 里给一张日志表用了 UUID 做主键，量小的时候没感觉，过了 5000 万行插入速度腰斩再腰斩。原因就在 B 树的插入路径：&lt;strong&gt;自增主键永远往最右的叶子追加&lt;/strong&gt;，页满了裂一次，顺序 I/O；&lt;strong&gt;UUID 是随机落到任意叶子&lt;/strong&gt;，每插一条都可能读一个冷页、裂一个热页，还伴随着页分裂后的空间碎片（页利用率掉到 60% 多）。换回自增 BIGINT 后写入吞吐翻了近 3 倍。这不是玄学，就是 B 树分裂频率的差。&lt;strong&gt;这也是为什么分布式 ID 方案（雪花 ID）都强调&amp;quot;趋势递增&amp;quot;&lt;/strong&gt;——在讨好 B+ 树。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;6-b-树补上最后一刀数据库的终点站&#34;&gt;6. B+ 树：补上最后一刀，数据库的终点站&lt;/h2&gt;
&lt;p&gt;B 树已经很好了，B+ 树只改了两处，却是数据库索引的定论：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TB
    subgraph B树[&amp;#34;B 树：key 和数据混在一起&amp;#34;]
        BR[&amp;#34;内节点&amp;lt;br/&amp;gt;[key+data | key+data]&amp;#34;]
        BR --&amp;gt; BL1[&amp;#34;叶节点&amp;lt;br/&amp;gt;key+data&amp;#34;]
        BR --&amp;gt; BL2[&amp;#34;叶节点&amp;lt;br/&amp;gt;key+data&amp;#34;]
        BN1[&amp;#34;一个 key 的数据&amp;lt;br/&amp;gt;可能在内节点&amp;lt;br/&amp;gt;范围查询要跨层&amp;lt;br/&amp;gt;中序遍历上蹿下跳&amp;#34;]
    end
    subgraph BPLUS树[&amp;#34;B+ 树：内节点纯索引，数据全在叶子&amp;#34;]
        PR[&amp;#34;内节点&amp;lt;br/&amp;gt;[key | key | key]&amp;lt;br/&amp;gt;只当路标 不放数据&amp;#34;]
        PR --&amp;gt; PL1[&amp;#34;叶节点&amp;lt;br/&amp;gt;key+data&amp;#34;]
        PR --&amp;gt; PL2[&amp;#34;叶节点&amp;lt;br/&amp;gt;key+data&amp;#34;]
        PR --&amp;gt; PL3[&amp;#34;叶节点&amp;lt;br/&amp;gt;key+data&amp;#34;]
        PL1 --&amp;gt;|&amp;#34;双向链表&amp;#34;| PL2
        PL2 --&amp;gt;|&amp;#34;双向链表&amp;#34;| PL3
        PN1[&amp;#34;范围查询：定位起点后&amp;lt;br/&amp;gt;顺着链表扫&amp;lt;br/&amp;gt;全是顺序 I/O&amp;#34;]
    end
    style BPLUS树 fill:#e8f4e8&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;两处改动，每一处都打在磁盘场景的七寸上：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;改动一：内节点不存数据，只存 key。&lt;/strong&gt; 同样 16KB 一页，存&amp;quot;key+数据指针&amp;quot;可能只能放 100 个，纯 key 能放 1000 个。阶数 ×10，树再矮一层。而且内节点少且热，&lt;strong&gt;整个上层索引经常能全塞进内存&lt;/strong&gt;，真实查询只有最后一步读叶子才碰磁盘。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;改动二：叶子之间串成双向链表。&lt;/strong&gt; &lt;code&gt;WHERE age BETWEEN 20 AND 30&lt;/code&gt;、&lt;code&gt;ORDER BY ... LIMIT&lt;/code&gt; 这类范围扫描，B 树要在层间来回跳（随机 I/O），B+ 树定位到起点后顺着链表扫，&lt;strong&gt;顺序 I/O&lt;/strong&gt;。机械盘时代这是百倍差距，SSD 时代预读和缓存依然偏爱顺序访问。&lt;/p&gt;
&lt;p&gt;再附赠两个由此推出的性质，都是 MySQL 篇里出现过的实战规则：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;聚簇索引下，叶子就是数据行本身&lt;/strong&gt;，所以按主键查 = 一次定位，而二级索引叶子存的是主键值，要回表再查一次——这就是&amp;quot;覆盖索引为什么快&amp;quot;；&lt;/li&gt;
&lt;li&gt;B+ 树的有序性 + 左前缀路标结构，决定了 &lt;code&gt;LIKE &#39;%abc&#39;&lt;/code&gt; 没法走索引（开头通配，路标失效），&lt;code&gt;LIKE &#39;abc%&#39;&lt;/code&gt; 可以——MySQL 篇那句&amp;quot;会全表扫描&amp;quot;的底层解释就在这。&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一句话记住 B 树 vs B+ 树&lt;/strong&gt;：B 树是&amp;quot;查到什么都是赚&amp;quot;（内节点也可能命中数据），B+ 树是&amp;quot;每次都老实走到叶子，但换来更矮的树和丝滑的范围扫描&amp;quot;。数据库查询里范围扫描是家常便饭，所以 B+ 树赢了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&#34;7-顺手补两个教材盲点&#34;&gt;7. 顺手补两个教材盲点&lt;/h2&gt;
&lt;h3 id=&#34;71-哈希冲突链地址-vs-开放寻址&#34;&gt;7.1 哈希冲突：链地址 vs 开放寻址&lt;/h3&gt;
&lt;p&gt;刷题篇讲过哈希表&amp;quot;用空间换时间&amp;quot;，教材会多问一步：两个 key 撞进同一个桶怎么办？&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart LR
    subgraph 链地址[&amp;#34;链地址法（Java HashMap）&amp;#34;]
        A1[&amp;#34;桶 0 → k1 → k2 → k3&amp;lt;br/&amp;gt;（Java 8 后链表超 8 个转红黑树）&amp;#34;]
    end
    subgraph 开放寻址[&amp;#34;开放寻址法（Redis dict 扩容期 / Python dict）&amp;#34;]
        B1[&amp;#34;桶被占？&amp;lt;br/&amp;gt;按探查序列找下一个空位&amp;lt;br/&amp;gt;线性 +1 / 二次 +i² / 双重哈希&amp;#34;]
    end&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;取舍：&lt;strong&gt;链地址实现简单、删除容易，但链表指针追逐对 CPU 缓存不友好；开放寻址数据紧凑缓存友好，但负载因子一高（&amp;gt;0.7）探查序列雪崩，且删除要打墓碑标记&lt;/strong&gt;。Java 选前者，Python 选后者，没有谁对谁错，又是&amp;quot;看场景&amp;quot;。&lt;/p&gt;
&lt;h3 id=&#34;72-图的两个经典算法家族&#34;&gt;7.2 图的两个经典算法家族&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;最短路径&lt;/strong&gt;：单源无负权用 &lt;strong&gt;Dijkstra&lt;/strong&gt;（贪心 + 优先队列，O(E log V)）；有负权边用 &lt;strong&gt;Bellman-Ford&lt;/strong&gt;（松弛 V-1 轮，顺带能检测负环）；全源用 &lt;strong&gt;Floyd&lt;/strong&gt;（三层循环的 DP，O(V³)，图小图稠才用）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;最小生成树&lt;/strong&gt;：&lt;strong&gt;Kruskal&lt;/strong&gt; 按边排序 + 并查集判环（适合稀疏图）；&lt;strong&gt;Prim&lt;/strong&gt; 从点出发贪心扩边（适合稠密图）。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;工程里 Dijkstra 出场率最高——导航、网络路由（OSPF）都是它的变体。并查集在刷题篇讲过，这里正好闭环。&lt;/p&gt;
&lt;h2 id=&#34;8-把整条链放回工程世界&#34;&gt;8. 把整条链放回工程世界&lt;/h2&gt;
&lt;p&gt;学完整条进化链，回头看各个系统的选型，会发现全部是这条链上的位置决策：&lt;/p&gt;
&lt;div class=&#34;code-block&#34;&gt;
    &lt;button class=&#34;code-copy&#34; type=&#34;button&#34; hidden aria-label=&#34;Copy code to clipboard&#34;&gt;
      &lt;span class=&#34;code-copy-label&#34; aria-hidden=&#34;true&#34;&gt;Copy&lt;/span&gt;
    &lt;/button&gt;
    &lt;pre tabindex=&#34;0&#34;&gt;&lt;code class=&#34;language-mermaid&#34; data-lang=&#34;mermaid&#34;&gt;flowchart TB
    MEM[&amp;#34;纯内存 读写均衡&amp;lt;br/&amp;gt;Java TreeMap / epoll / CFS&amp;#34;] --&amp;gt;|红黑树| R1[&amp;#34;近似平衡 旋转便宜&amp;#34;]
    MEM2[&amp;#34;纯内存 读极多&amp;lt;br/&amp;gt;静态字典&amp;#34;] --&amp;gt;|AVL| R2[&amp;#34;严格平衡 查得最快&amp;#34;]
    DISK[&amp;#34;磁盘 点查为主&amp;lt;br/&amp;gt;MongoDB WiredTiger&amp;#34;] --&amp;gt;|B 树| R3[&amp;#34;页对齐 矮胖&amp;lt;br/&amp;gt;内节点也能命中数据&amp;#34;]
    DISK2[&amp;#34;磁盘 点查+海量范围扫描&amp;lt;br/&amp;gt;MySQL InnoDB / 绝大多数关系库&amp;#34;] --&amp;gt;|B+ 树| R4[&amp;#34;叶子链表&amp;lt;br/&amp;gt;范围扫描顺序 I/O&amp;#34;]
    LOG[&amp;#34;写极多 追加型&amp;lt;br/&amp;gt;LevelDB / RocksDB / HBase&amp;#34;] --&amp;gt;|LSM 树| R5[&amp;#34;干脆放弃原地更新&amp;lt;br/&amp;gt;写全部顺序化&amp;#34;]
    style DISK2 fill:#ffd699&lt;/code&gt;&lt;/pre&gt;
  &lt;/div&gt;&lt;p&gt;最后一行 LSM 树是题外话但值得一提：当写压力大到 B+ 树的页分裂和随机写都扛不住时，LevelDB/RocksDB 们干脆换了世界观——&lt;strong&gt;不原地改，只追加&lt;/strong&gt;，把随机写变成顺序写，读的时候多层合并。这是&amp;quot;B 树家族应付不了的场景长出了新物种&amp;quot;的又一例，思路和本篇主线一脉相承：&lt;strong&gt;没有最好的结构，只有最匹配 I/O 模式的结构。&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;思考与感悟&#34;&gt;思考与感悟&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. 数据结构课的隐藏主线是&amp;quot;权衡&amp;quot;，不是&amp;quot;结构&amp;quot;。&lt;/strong&gt; 教材目录按结构分章，但真正的线索是：每个结构都在某个维度上多要了一点、在另一个维度上让了一步。AVL 要了查询速度让了写入成本，红黑树反过来；B+ 树要了范围扫描让了内节点命中。考试考结构，工作考权衡。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. &amp;ldquo;为什么&amp;quot;比&amp;quot;怎么做&amp;quot;值钱。&lt;/strong&gt; 红黑树的旋转规则我忘光了，但&amp;quot;为什么不能两个红挨着 = 2-3 树节点容量&amp;quot;这个理解我记了十年。背规则应付考试，理解动机应付工作——工作中没人让你手写 B 树分裂，但天天有人问&amp;quot;这个索引为什么这么建&amp;rdquo;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 磁盘那节课值整门课的一半。&lt;/strong&gt; 很多人学完数据结构，认知还停留在&amp;quot;比较次数&amp;quot;的世界。B 树这一课的真正教学目的，是把你的复杂度单位从&amp;quot;操作次数&amp;quot;切换到&amp;quot;I/O 次数&amp;quot;。这个切换一旦完成，后面学数据库、学存储引擎、学缓存，全是同一件事的不同切面。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. 这条链还没完。&lt;/strong&gt; B+ 树不是终点——LSM 树在写密集场景抢了它的地盘，跳表在 Redis 里用更简单的实现达到了类似的效果，AI 时代的向量索引（HNSW）干脆用图结构解决相似度搜索。数据结构这门课永远在续写，但写法永远是一样的：&lt;strong&gt;一个新场景出现，旧结构哪里疼，新结构就长在哪里。&lt;/strong&gt;&lt;/p&gt;
</description>
    </item>
    
  </channel>
</rss>
