想象你是一位城市图书馆的总管理员,手中握着一本神奇的“图书总目录”。这本目录不是普通的小册子,而是有着数千页、每页能记录上百条书目的巨型册子。更有趣的是,这本目录的每一页都像是一个“路标”——告诉你某本书大致在哪个区域,同时指引你翻到下一页更详细的分类。这就是B树和B+树的日常:在浩如烟海的数据中,用最少的翻页次数找到你要的那本书。 数据库就像这座巨大的图书馆,而B树和B+树就是它的“索引系统”。它们是多路平衡搜索树,专门解决一个核心问题:在亿级数据中,如何用极少的磁盘访问次数找到目标数据。B+树更进一步,将所有数据整整齐齐地排在叶子节点(图书馆的“书架底层”),并用双向链表串成一条“数据长龙”,让范围查询变得像沿着书架顺序扫过一样畅快。本文将带你从零理解这些“图书目录”的内部结构、查找逻辑和分裂合并的自我维护机制。
对AI从业者而言,理解B树和B+树是掌握数据库索引优化、设计高效数据存储系统的基石,无论是构建RAG系统、特征存储还是大规模推荐系统,索引设计都直接决定系统的吞吐量与延迟。
可视化演示
展示B树多路平衡搜索树结构、B+树叶子链表范围查询优化、节点分裂操作与磁盘I/O效率的交互式动画。
本节配有交互动效(进入视口后自动加载,约 3KB)。
B树多路平衡搜索树 每个节点存储关键字与子节点指针 m阶B树每个节点最多m个子节点 m阶B树:每节点最多m-1个关键字 B树通过多路分支降低树高减少磁盘I/O
多路平衡结构
想象图书馆的“图书总目录”不是一本薄薄的册子,而是一棵倒着长的“目录树”。它的特殊之处在于:每个“橱窗”(节点)不只放一个书名标签,而是能放一长排标签。这就是B树与普通二叉树最本质的区别——每个节点不再是“一夫一妻”只能生两个孩子,而是能同时“抚养”多个子树,像一个拥有多个抽屉的档案柜。
从专业角度说,B树是一棵多路平衡搜索树。所谓“多路”,是指每个节点最多可以拥有m个子树(m称为阶数);“平衡”是指所有叶子节点都在同一层,这保证了从根到任意叶子的路径长度相同。为什么要这样设计?让我们回到图书馆的场景:如果目录册太厚,每次查询都要翻很多页;如果太薄,又需要太多册子。B树巧妙地让每个节点装下尽可能多的条目,从而压缩整棵树的高度。
一棵含有N个键的m阶B树,高度h满足:最坏情况下(每个节点只有最少的键数)高度约为$\displaystyle \log_{\lceil m/2 \rceil}\frac{N+1}{2}$。当m=1000时,即便是10亿条数据,树高也只需要4层左右。这意味着查找任何一个数据,最多只需读4个磁盘页。
回到图书馆的比喻:B树就像一位聪明的图书管理员,他不把整座图书馆的书名都列在一本册子里,而是分层管理——每层有多个“分目录册”,每册记录了下一层分册的位置。查找时,你只需要沿着正确的分册一路往下,而不是从头翻到尾。
- h 表示树的高度从根节点到叶子节点的路径长度,即查找最多需要访问的节点层数。
- m 是树的阶数每个节点最多允许的子节点个数。阶数越大,节点能装的键越多,树越矮。
- N 是总键值数量树中存储的所有数据条数。N越大,树自然需要更多层来容纳。
- 对数的底是⌈m/2⌉这表示每个节点至少有⌈m/2⌉个孩子,所以树的高度以这个数为底对数增长。底越大,对数越小,树越矮。
节点阶数选择
让我们继续探讨“图书目录册”的设计细节。假设你是管理员,要决定每本分目录册能容纳多少条目。如果每册只能记2条,那册子会很多,查找时要换很多册;如果每册能记100条,册子变少,但每册变厚,翻页(读写)成本上升。这个“每册容量”就是B树的阶数(m)。
阶数m定义了一个节点中最多能有多少个孩子指针,因此最多有m-1个键值。更精确地说,一棵m阶B树满足以下约束:每个节点最多有m个孩子、m-1个键;除根节点外,每个节点至少有⌈m/2⌉个孩子、⌈m/2⌉-1个键;根节点至少有2个孩子(除非它是叶子)。这些数字不是随意定的,而是为了保证树不会退化成链表。
以m=5为例,一个内部节点最多持有4个键(记为k1<k2<k3<k4),这些键把数据范围分成5个区间,分别对应5个孩子指针。查找时,先与4个键比较,确定走哪个区间。这一设计让二叉树的“一次比较一分为二”升级为“一次比较多路分支”,效率倍增。
回到图书馆的比喻:阶数就是“分目录册”的页数。图书管理员会根据书架大小(页大小)和每本书标签的宽度(键大小),计算出每册目录恰好多大最划算。太大浪费纸张,太小增加翻册次数。
- 下限 ⌈m/2⌉ - 1除根节点外,每个节点至少要有一半“半满”以上的键,防止节点过于稀疏、树退化成低效结构。
- 上限 m - 1每个节点最多能容纳的键数。超过这个数就触发分裂操作。
- m 通常取偶数如512或1024。实际工程中m往往由磁盘页大小和键大小共同决定,而不是随意选择。
高效查找路径
现在,想象你拿着总目录在图书馆找一本《算法导论》。你不会从第一层书架开始逐一扫描,而是先看总目录册——哦,它在“计算机科学”区;再翻开该区的分目录册——哦,它在“算法”分区的第3排;最后到第3排直接抽出那本书。B树的查找算法就是这样一个层层缩小的过程。
从根节点开始,在当前节点的键值序列中顺序查找(或二分查找),找到目标键则返回;否则根据大小关系确定目标可能所在的子树区间,进入孩子节点继续同样的操作,直到叶子节点。由于B树的高度通常只有3-5层,这意味着单次查找最多需要3-5次磁盘I/O,每次I/O读取一个完整的节点(一个磁盘页)。
查找过程中,每深入一层,数据范围就缩小为当前的1/m左右。假设有N条数据,每层最多“分流”m路,层数为$\displaystyle \log_m N$。B树查找的时间复杂度为O(log_m N),而磁盘I/O次数与树高成正比。这也是B树优于二叉搜索树的核心:同样的数据量,B树更“矮胖”,I/O次数更少。
回到图书馆的比喻:高效的查找路径就像经验丰富的管理员,他从不逐个书架地找,而是通过“总目录→分区目录→书架”层层定位。B树将这种分层检索固化在数据结构中,让每次查找都有明确的路径指引。
- log_m N 表示层数以m为底N的对数,即查找最多需要经过的节点层数。底数m越大,层数越少。
- 每层比较成本在一个节点内查找键的时间为O(log(m-1))或O(m),但由于m通常为百级且节点常驻内存,这部分成本远小于磁盘I/O。
- 总成本 = 层数 × 节点内查找磁盘I/O次数是主导因素,所以降低层数是核心优化目标。
范围查询优化
假设读者想借“计算机”相关的全部书籍,从《计算机组成原理》到《计算机网络》。如果使用普通B树,你需要找到第一本书后,又得回到树的更高层重新定位下一本,就像在图书馆里从书架A跑到检索台再跑回书架B,来回折腾。而B+树完美解决了这个问题。
B+树对B树做了关键改造:所有键值数据只存储在叶子节点,内部节点只存“路标”(键的副本)。更重要的是,所有叶子节点通过双向链表串联在一起。当你要查询某个范围内的所有数据时,先快速定位到范围起点所在的叶子节点,然后沿着链表的next指针一路向后扫,直到超出范围,整个过程无需再访问内部节点。
这一设计的精妙之处在于,范围查询的时间复杂度从B树的每查一条数据都要走一遍树(O(k log_m N)),降低为“一次定位+顺序遍历”(O(log_m N + k))。其中k是返回的记录条数。这对数据库的"SELECT WHERE id BETWEEN……"类查询是革命性的优化。
回到图书馆的比喻:B+树给所有书架底层装了一条“传送带”,管理员找到第一本“计算机”主题的书后,只需站在传送带前一路接书,不用再跑回检索台。这就是为什么现代数据库几乎都采用B+树做索引。
- O(log_m N) 表示定位成本先走树找到范围起始数据所在的叶子节点。
- k 表示返回的记录数从起点叶子沿链表依次读取,每读一条数据只访问一次磁盘,且是顺序访问。
- 顺序 I/O 的加速磁盘顺序读比随机读快百倍以上,所以B+树的实际性能提升远大于理论复杂度所示。
分裂与合并
图书馆每天都有新书上架。当某本分目录册写满了条目(节点键数达到m-1),再要插入新条目怎么办?图书管理员的办法是:把这本册子从中间撕开,分成两本薄册子,中间的条目升级到上一层目录(父节点)。这就是B树的节点分裂。
分裂操作发生在插入路径上。当一个节点键数达到m-1,再插入第m个键时,取中间的键提升到父节点,左右两部分分别形成两个新节点。如果父节点也因此饱和,继续向上分裂,最坏情况一直分裂到根节点,树的高度随之增加1。分裂的方向是自底向上的。
删除操作则是分裂的逆过程——合并。当节点键数低于下限⌈m/2⌉-1时,先尝试从相邻兄弟节点“借”一个键(通过父节点中转);如果兄弟也处于“半饥饿”状态,则将两个节点合并成一个,父节点相应减少一个键。如果父节点因此低于下限,合并继续向上传导。这一过程保证了B树在频繁增删后依然保持平衡。
回到图书馆的比喻:分裂就像书太多时把单册目录拆成两册,合并就像书被借走太多后把两册目录合成一册。这些操作确保了每本册子都保持“半满以上”的利用率,既不多占空间,也不浪费纸张。
- 分裂触发当节点键数达到上限m-1,再插入新键时触发分裂。
- 中间键上提取⌈m/2⌉位置的键作为新分界,提升到父节点。
- 均分剩余键⌈m/2⌉-1个键留在左节点,剩余键进入右节点。
- 合并条件删除后节点键数 < ⌈m/2⌉-1,且兄弟节点也处于最小键数,无法借位时执行合并。
磁盘I/O优化
为什么B树和B+树的“大节点”设计如此重要?让我们从物理存储的视角看。磁盘读写的最小单位是页(通常4KB或16KB),每次I/O都要经历“寻道+旋转+传输”的过程,其中寻道延迟高达数毫秒。相比之下,内存访问只需纳秒级——两者相差5个数量级。
B树的天才之处在于:让每个节点的大小恰好等于一个磁盘页。这样每次I/O读取一页,恰好得到一个完整的节点,物尽其用。而如果像二叉搜索树那样一个节点只存一个键,每次I/O只读到一个键,浪费了整页的空间。用图书馆的比喻说:你雇了一辆卡车去取一本书,但每次只取一本就回来——这是巨大的浪费。B树让每辆卡车都装得满满当当。
另一个关键概念是局部性原理:B树查找时,访问的节点在磁盘上往往相邻存放,尤其是叶子节点在B+树中是物理连续的。这使数据库可以预读相邻页,进一步减少I/O次数。MySQL InnoDB的页大小默认为16KB,索引节点充分填充,就是为了最大化单次I/O的信息量。
回到图书馆的比喻:图书管理员深知——从仓库调书(磁盘I/O)最耗时,所以他会精心设计每本册子恰好装满一辆推车,不多不少。页大小与节点大小的匹配,正是这种“一辆推车一车书”的智慧。
- 页大小P磁盘一次I/O读取的字节数,MySQL默认16KB。
- 键大小s每个索引键占用的字节数,比如INT类型4字节,VARCHAR视长度而定。
- 阶数m的推导每页能装的键数约等于页大小除以键与指针大小之和。
数据库索引应用
理解了B+树的原理后,让我们看看它在真实世界中的模样。MySQL的InnoDB存储引擎是B+树最著名的用户。InnoDB中的聚簇索引就是B+树:表数据本身以B+树的形式组织在磁盘上,叶子节点存储完整的行数据,主键作为键值。这意味着"SELECT * FROM user WHERE id=5"只需沿B+树走3-4层就能定位到完整的行。
InnoDB的二级索引(非聚簇索引)也是B+树,但叶子节点存储的是主键值而非完整行。当通过二级索引查找时,先找到主键,再回表查聚簇索引——这是“两棵树”的配合。MongoDB则使用B树的变种(WiredTiger存储引擎),因为MongoDB的文档模型更多是点查,且不需要大范围的范围查询。
为什么数据库如此青睐B+树?核心在于 I/O效率:B+树的内部节点不存数据,因此同样的页能存更多键,树更矮,I/O更少;叶子节点的链表让范围查询如鱼得水;加上聚簇索引让频繁查询的主键数据物理有序存放,更是锦上添花。
回到图书馆的比喻:聚簇索引就像把每本书直接按编号顺序放在书架上,找到编号就找到书;二级索引则像书名索引卡,先查到编号,再去书架取书。明白这层关系,你就能理解为什么“SELECT *“用辅助索引可能比主键索引慢——多了一次”从索引卡到书架”的往返。
- 聚簇索引表的主键索引,叶子节点存储完整行数据,表数据与索引一体。
- 二级索引非主键索引,叶子节点存储主键值,需要“回表”查询完整数据。
- 回表成本一次二级索引查询 = 走二级索引树 + 走聚簇索引树 = 2倍I/O。
AI 应用场景
向量索引的底层支撑
阶段:数据存储与检索
解决的问题:RAG(检索增强生成)系统中,需要从海量向量库中快速找到与用户查询最相似的文本片段。传统线性扫描在千万级向量下延迟不可接受。虽然现代向量数据库多采用HNSW等近似最近邻算法,但B+树仍然在标量过滤和结构化预筛选中扮演重要角色——先通过B+树索引按时间、类别等标量字段缩小候选集,再做向量相似度计算。
具体案例:想象一个客服机器人的知识库有500万条FAQ。用户问“如何退款”,系统先用B+树索引筛选出“退款”相关分类的10万条候选,再对这10万条做向量相似度计算,最终返回Top 5答案。若没有B+树预筛选,每次查询都要计算500万次向量距离,延迟将从50ms飙升至3秒。
特征存储的实时读取
阶段:特征工程与在线推理
解决的问题:在线推荐系统需要实时读取用户特征(如最近浏览的10个商品ID)。这些特征以键值对形式存储在Redis或数据库中,每条特征都是"key → feature"。当特征量达到亿级时,Redis的内存成本过高,而传统数据库随机读太慢。使用MySQL B+树索引做持久化存储,可以兼顾内存效率和毫秒级延迟。
具体案例:某短视频推荐系统将用户最近行为特征写入MySQL,以user_id为聚簇索引。推荐服务收到请求后,通过B+树索引O(log_m N)的I/O路径(约3次磁盘访问)读取用户特征,实测P99延迟从120ms降至15ms。这就是B+树“矮胖”结构的实战价值。
日志系统的时序写入
阶段:数据采集与预处理
解决的问题:AI训练数据的日志流水线需要按时间戳顺序写入,并按时间范围批量读取(如“读取昨天14:00-15:00的日志”)。B+树的时间戳索引天然支持这类时序数据的高效写入和范围扫描。相比之下,如果使用哈希索引,范围查询将变成全表扫描。
具体案例:一个LLM训练数据管线每天产生200GB日志,时间戳作为B+树聚簇索引。写入时,数据按时间顺序追加到叶子节点右侧,磁盘顺序写性能极佳;清洗任务按“小时级范围”扫描数据时,通过叶子链表顺序读取,吞吐量达到800MB/s。
Python 演示
class BPlusTreeNode: """B+树节点: keys存储键, children存储子节点(仅内部节点), next_node指向下一个叶子节点(仅叶子节点)""" def __init__(self, is_leaf=False): self.is_leaf = is_leaf self.keys = [] self.children = [] self.next_node = None # 叶子节点的链表指针 class BPlusTree: def __init__(self, order=4): self.root = BPlusTreeNode(is_leaf=True) self.order = order # 阶数, 类比图书馆分目录册的容量 def find_leaf(self, key): """从根到叶子查找路径, 返回目标所在的叶子节点""" node = self.root while not node.is_leaf: idx = 0 while idx < len(node.keys) and key >= node.keys[idx]: idx += 1 node = node.children[idx] return node def insert(self, key): """插入键到B+树""" leaf = self.find_leaf(key) pos = 0 while pos < len(leaf.keys) and leaf.keys[pos] < key: pos += 1 leaf.keys.insert(pos, key) if len(leaf.keys) <= self.order - 1: return # 分裂叶子节点 mid = len(leaf.keys) // 2 new_leaf = BPlusTreeNode(is_leaf=True) new_leaf.keys = leaf.keys[mid:] leaf.keys = leaf.keys[:mid] new_leaf.next_node = leaf.next_node leaf.next_node = new_leaf # 将分裂后的中间键提升到父节点 self._insert_into_parent(leaf, new_leaf.keys[0], new_leaf) def _insert_into_parent(self, left, key, right): """将中间键提升到父节点""" if left is self.root: new_root = BPlusTreeNode() new_root.keys = [key] new_root.children = [left, right] self.root = new_root return parent = self._find_parent(self.root, left) idx = 0 while idx < len(parent.keys) and parent.keys[idx] < key: idx += 1 parent.keys.insert(idx, key) parent.children.insert(idx + 1, right) if len(parent.keys) <= self.order - 1: return # 分裂内部节点 mid = len(parent.keys) // 2 mid_key = parent.keys[mid] new_internal = BPlusTreeNode() new_internal.keys = parent.keys[mid + 1:] new_internal.children = parent.children[mid + 1:] parent.keys = parent.keys[:mid] parent.children = parent.children[:mid + 1] self._insert_into_parent(parent, mid_key, new_internal) def range_query(self, start, end): """范围查询: 先定位起点, 再沿链表顺序扫描""" leaf = self.find_leaf(start) result = [] while leaf: for k in leaf.keys: if start <= k <= end: result.append(k) elif k > end: return result leaf = leaf.next_node return result def _find_parent(self, node, child): """找到child节点的父节点""" if node.is_leaf: return None for i, c in enumerate(node.children): if c is child: return node if not c.is_leaf: result = self._find_parent(c, child) if result: return result return None # 演示代码if __name__ == "__main__": tree = BPlusTree(order=4) keys = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 25, 35, 45, 55] for k in keys: tree.insert(k) print("插入完成, 范围查询 [25, 60] 的结果:") print(tree.range_query(25, 60)) print("预期输出: [25, 30, 35, 40, 45, 50, 55, 60]")
class BPlusTreeNode:定义节点结构。keys存键,children存子节点指针,next_node是叶子节点的链表指针,对应B+树“叶子串联”的核心特征。def find_leaf(self, key):查找路径的核心方法。从根节点逐层向下,根据键值大小选择正确分支,直到叶子节点。这就是“图书管理员层层翻目录”的过程。while not node.is_leaf:只要当前节点不是叶子,就继续向下层走。内部节点起“路标”作用,不含实际数据。idx += 1在节点内顺序比较键值,找到正确的子节点指针位置。这就是为什么B+树每个内部节点的孩子数比键数多1。if len(leaf.keys) <= self.order - 1: return插入后检查是否超容。键数不超过m-1则无事发生,否则需要分裂。mid = len(leaf.keys) // 2分裂时找到中间位置,将键一分为二。这是“分目录册从中间撕开”的代码体现。new_leaf.next_node = leaf.next_node; leaf.next_node = new_leaf维护链表的连接关系。分裂后新旧叶子要正确链接,否则范围查询会断链。def range_query(self, start, end):范围查询的核心。先定位到起点,然后沿next_node依次遍历,直到超过范围上界。while leaf:沿链表遍历的循环。每个叶子节点检查完所有键,再跳到下一个叶子。这就是B+树范围查询不需要回溯的原因。
常见误区
误区B树就是更扁平的二叉搜索树,每个节点最多存两个键值,只是把相同结构多复制了几层。我学的时候一直以为B树就是把普通树多叠了几层而已。
正解B树的核心是“多路”而非“二叉”。一个m阶B树的节点可以存m-1个键、拥有m个子节点。比如m=5的B树,一个节点就有4个键,划分出5个数据区间。这种设计让相同数据量下树高从log₂N降到log_mN,I/O次数锐减。用图书馆的比喻说,二叉树每次翻一页目录,B树每页目录能看多行条目。
为何常见:大家先学二叉树,再学B树时容易用“二叉”的认知框架去套。实际上B树正是为了突破二叉树磁盘I/O次数过多的瓶颈而设计的——如果只是把二叉树的“格子”做大一点,不会带来数量级的性能飞跃。
误区B+树把数据全放到叶子节点,内部节点只存索引键,那么查找一个具体数据肯定比B树多走几层,查询点数据时性能更差。
正解对于单点查询,B+树只在最坏情况下比B树多访问一层,即从叶子节点回溯到内部节点。但别忘了两层抵消:一是B+树内部节点不存数据,同一页能容纳更多键,树更矮;二是B+树的叶子节点之间用链表连接,范围查询时无需回溯,整体性能大幅提升。数据库的真实场景中,范围查询远多于单点查询。
为何常见:只比较“单点查找”这一个维度,忽略了数据库工作负载的全局特征。实际上数据库查询中范围查询(如"SELECT WHERE id BETWEEN")非常普遍,B+树的链表设计正是为此量身定制。
误区既然阶数m越大树越矮,那就把m设成10000,这样树高只有1层,查询一次I/O就够了,数据库岂不是快如闪电?
正解节点大小受磁盘页大小限制。m如果太大,一个节点超过一页,一次I/O读不完,反而需要多次I/O,得不偿失。工程上m = 页大小 / (键大小 + 指针大小),比如页16KB、键8字节,m约为1000。InnoDB就是这么精心计算出来的。节点过小则浪费页空间,过大则I/O次数增加。
为何常见:把“树的高度”当成唯一的性能指标,忽略了磁盘I/O的成本模型。真实世界中,单个节点的读取成本不是常数——节点超过一页后,读取成本线性增长。
误区B+树就是把B树的内部节点“掏空”了,数据全放在叶子节点。除了这个区别,两者结构完全相同。
正解两者还有更大区别:B+树的叶子节点之间用双向链表连接,支持范围查询和顺序遍历;B+树的数据都集中在叶子,磁盘上物理连续,顺序读性能极佳;B+树的内部节点只存键,可以容纳更多键,树更矮。这些特点共同决定了B+树在数据库中的统治地位。
为何常见:初学者只注意到“数据是否在叶子节点”这个直观差异,忽略了链表连接、物理存储等更重要的设计动机。实际上“叶子链表”和“数据集中”是一对组合拳,共同实现了范围查询的优化。
误区节点满了就拆成两半,然后在父节点加个新条目就结束了。如果父节点也满了,那就再多拆一层而已,过程很直观。
正解分裂的关键是“中间键上提”。具体过程:节点已有m-1个键,插入新键后变m个键,取第⌈m/2⌉个键上升到父节点,左右两侧各⌈m/2⌉-1个键形成两个子节点。如果父节点因此也满了,分裂继续向上传播,直到根节点——树的高度增加1。这个过程保证了所有叶子始终在同一层。
为何常见:把分裂简单理解为“一分为二”,忽略了中间键上提和向上递归传播的细节。实际上“上提”才是维持搜索树性质的关键——它定义了父节点键与子节点数据范围的边界关系。
面试题
Q1. 在一棵m阶B树中,除根节点外,每个节点至少有多少个孩子?
- A.m/2
- B.⌈m/2⌉← 正确答案
- C.⌈m/2⌉ - 1
- D.m - 1
答案:B。m阶B树的节点孩子数下限是⌈m/2⌉,对应键数下限为⌈m/2⌉-1。就像图书馆的分目录册至少要装一半的条目才不算浪费。选项A是m/2而非上取整,C是键数下限而非孩子数,D是键数上限。
Q2. B+树相较于B树,最核心的结构改进是什么?
- A.内部节点不存储数据,数据全在叶子节点← 正确答案
- B.每个节点可以有更多键
- C.查找时从叶子向上回溯
- D.使用二分查找
答案:A。B+树最核心的改进是内部节点只存索引键(不存数据),所有数据集中在叶子节点,并通过链表串联叶子。这让内部节点能容纳更多键、树更矮,同时支持高效范围查询。选项B的“更多键”不是结构改进,C的方向反了,D是节点内的优化手段而非结构改进。
Q3. 以下哪些数据库或存储引擎使用B+树作为索引结构?
- A.MySQL InnoDB← 正确答案
- B.MongoDB WiredTiger
- C.Redis
- D.PostgreSQL(默认堆表+索引)← 正确答案
答案:AD。MySQL InnoDB的主键索引就是B+树聚簇索引;PostgreSQL的默认索引类型是B+树。MongoDB的WiredTiger使用B树变种(存储文档数据),Redis是纯内存的哈希表或跳表。InnoDB和PostgreSQL都用B+树支撑范围查询和排序。
Q4. 对于一棵m=100的B+树,要查找10亿(约2^30)条数据,最多需要多少次磁盘I/O?
- A.3
- B.4
- C.5
- D.30← 正确答案
答案:D。B+树高度约等于log_m N = log₁₀₀(10⁹) ≈ 4.5,向上取整为5层,但根节点一般在内存中缓存,所以实际I/O次数是4-5次。更精确地,100阶B+树每个节点有100个分支,3层能容纳100³=10⁶,4层能容纳10⁸,5层能容纳10¹⁰。要容纳10⁹需要5层,减掉缓存根节点,最多4次I/O。选项D的30是log₂N,忽略了多路分支。
Q5. 在B+树中执行范围查询(如查询键值在[a, b]之间的所有记录),时间复杂度是多少?(k为返回记录数)
- A.O(log_m N)
- B.O(k × log_m N)
- C.O(log_m N + k)← 正确答案
- D.O(N)
答案:C。B+树范围查询先沿树找到起点叶子O(log_m N),然后沿叶子链表顺序读取k条记录O(k)。总成本是O(log_m N + k)。如果每次都要回溯走树,那是B树的痛点O(k·log_m N)。B树的范围查询每条记录都要从头走树,所以相对低效。
Q6. 关于B+树叶子节点的双向链表,以下说法哪些正确?
- A.只支持从前往后的顺序遍历
- B.支持从任一叶子向后的高效顺序扫描← 正确答案
- C.支持从后往前的逆序扫描← 正确答案
- D.链表连接的是所有内部节点
答案:BC。双向链表让B+树既支持正向顺序扫描,也支持逆序扫描,这对排序和范围查询非常友好。选项A错误——双向链表明显支持两个方向。选项D错误——链表连接的是所有叶子节点,而非内部节点。
Q7. 向一棵已满的m阶B+树插入新键时,以下哪项描述是正确的分裂过程?
- A.节点分裂为两个节点,中间键保留在左节点
- B.节点分裂为两个节点,中间键上提到父节点← 正确答案
- C.直接将新键插入到父节点,原节点不变
- D.删除最老的键,为新键腾出空间
答案:B。B+树节点已满时插入新键,节点内共有m个键,取第⌈m/2⌉个键(中间键)上提到父节点,左右两侧形成两个新节点。中间键上提是关键——它成为父节点新的分界键。选项A保留在左节点会破坏搜索树性质,C和D不符合B树的分裂维护机制。
Q8. B树中一个节点最多可以有多少个孩子?
- A.m - 1
- B.m← 正确答案
- C.m + 1
- D.2m
答案:B。m阶B树的每个节点最多有m个孩子和m-1个键。孩子数比键数多1——就像书架上的m-1个隔板划分出m个储物空间。选项A的m-1是键数上限,C和D无此概念。
Q9. 在B+树中执行"SELECT * FROM t WHERE id BETWEEN 100 AND 200"时,数据库的查询过程包含哪些步骤?(MySQL InnoDB)
- A.从根节点沿B+树定位到id=100的叶子节点← 正确答案
- B.沿叶子节点的链表顺序扫描直到id>200← 正确答案
- C.从找到的叶子节点回溯到根节点,再重新查找每个id
- D.每条记录都从根节点重新查找一次
答案:AB。B+树范围查询的精髓就是定位起点后顺序扫描。从根节点出发找到起点叶子O(log_m N),然后沿链表向后读取直到超出范围上界。选项C和D描述的是B树的做法——每条记录都重新走树,这正是B+树用链表优化的痛点。
Q10. 为什么B+树的内部节点不存储数据,只存储键值?
- A.为了节省内存空间
- B.为了让同一页能容纳更多键,降低树高← 正确答案
- C.为了简化插入操作
- D.为了让查找算法更简单
答案:B。内部节点只存键不存数据,意味着同样的页大小能容纳多得多的键,从而m更大、树更矮、I/O更少。比如键8字节+指针8字节,一页16KB能放约1000个键。这是B+树对磁盘I/O优化的决定性设计。选项A不是主要目的,C和D是副产品而非根本动机。