☰
B树与B+树:从磁盘IO到数据库索引的核心原理
2026/10/17 5:51:14 网站建设 项目流程

1. 为什么你总觉得B树、B+树很难

说实话,B树和B+树这两个名词,在计算机科班课程里往往被放在“数据结构与算法”的倒数第二章,前面刚被红黑树折磨完,后面紧接着就是索引、存储引擎这些大块头,大部分人在这个节点已经处于“看啥都像天书”的状态。再加上教材里的表述往往特别严谨,动辄就是“一棵m阶B树满足以下性质……”,一上来就把人劝退了。

但如果你换个角度想,B树和B+树本质上就是一种“为了减少磁盘IO而生的多路搜索树”。它不是一个高深莫测的数学结构,而是工程师被机械硬盘的物理特性逼出来的实用方案。你只要抓住两件事:第一,为什么要多路;第二,为什么叶子节点要串链表。这两条主线捋顺了,整棵树就完全在掌控之内了。

这篇文章我就按照自己当年从“背定义背到崩溃”到“手写B+树还能讲清楚为什么这么设计”的路径,把B树和B+树彻底拆开来说。不堆公式,不谈抽象复杂度证明,全程用工程视角、动手思路和真实场景来聊。看完如果你还有疑惑,欢迎带着具体场景来反驳我,我们一条条掰扯清楚。

顺便说一下,这篇内容适配的人群很广:正在准备面试的开发者、刚学完二叉搜索树想进阶的学生、写业务代码但一直没搞明白数据库索引原理的后端工程师,甚至是想给自己的存储工具选索引方案的独立开发者,都能从里面拿到可以直接用的认知和代码片段。

2. 从二叉搜索树到B树:一场被磁盘逼出来的进化

2.1 二叉搜索树到底哪里不行

我们都很熟悉二叉搜索树,左小右大,查找效率O(logn),看起来非常完美。但这里有一个前提假设:所有数据都住在内存里,而且内存随机访问代价极低。一旦数据量大到必须放在磁盘上,事情就变了。

磁盘随机读写一个数据块的时间,大概是内存访问的十万倍量级,这点你哪怕没做过底层开发也应该有感觉——程序跑得慢,十有八九慢在磁盘IO上。而二叉搜索树对应到磁盘存储里,每访问一个节点,大概率就是一次随机IO,因为父节点和子节点在磁盘上的物理位置完全没有关联性。树一高,查找一条记录可能要跳七八层,那就是七八次随机IO,这在OLTP场景里是不可接受的。

那怎么压缩树高?最直接的信息论思路是:从二叉变成多叉。每个节点不再是只有一个键和两个孩子,而是同时持有多个键和多个孩子,这样同样数量的数据,树的层数就能大幅下降。层数下降,意味着查找路径上需要访问的节点数变少,磁盘IO次数自然就少了。B树的核心动机,就这么一句话,完了。

2.2 B树的结构规则,用“房间”去类比

教材里B树的定义大概是这样的:一个m阶B树,每个节点最多有m个孩子、m-1个键;除根节点和叶子节点外,每个节点至少有ceil(m/2)个孩子;所有叶子节点在同一层。你先别急着头大,我换一个方式讲。

你想象一个酒店,每层有m个房间。每个房间里住了m-1个客人,这些客人的钥匙编号从左到右递增。每个客人的两间房之间,都挂着一道门,通往下一层的一个房间。下一层的房间会把客人按大小划分区间,比如当前房间有5个客人的钥匙编号分别是10、20、30、40、50,那么五个间隙分别是“小于10”“10到20之间”“20到30之间”“30到40之间”“40到50之间”“大于50”,每个间隙对应一个孩子节点。这样每个孩子节点里的所有键,都恰好在同一个区间里。这棵树长什么样?就是一个天然的、分层的有序结构。

为什么要限定“至少ceil(m/2)个孩子”?这叫除根节点外的半满约束。它的作用是防止节点退化。如果不设这个下限,极端情况下一个节点可以只有一个孩子,那B树就退化成链表了。所以m阶B树本质上是一个“自我保持平衡”的多叉排序树,它不像红黑树靠颜色和旋转维持平衡,而是靠“节点分裂”和“节点合并”来维持每一层都不低于半满,从而保证树高始终很低。

实操里最常用的阶数是多少?这取决于你的磁盘页大小。以InnoDB的默认页大小16KB为例,如果键是8字节,加上指针和其它开销,一个节点大概能存四五百个键,也就是说一棵三层的B+树可以轻松支撑几千万行数据的索引查找。这就是为什么数据库索引用B+树而不是红黑树或者二叉搜索树——不是谁更“高级”,而是谁能在磁盘上少跑几个来回。

2.3 分裂与合并:B树自我维护的命脉

B树的插入和删除之所以让很多人困惑,就是因为分裂和合并时那些边界情况特别多。但说穿了就是两个动作:

插入时,先沿查找路径找叶子位置,如果叶子没满就直接插入。如果满了,就把这个节点从中间砍成两半,中间那个键上升到父节点。如果父节点也满了,那就继续往上分裂,最坏情况一路裂到根,树高一吧,就生成一个新的根。这个过程自底向上,不会破坏“所有叶子层同一层”的约束。

删除时,如果删完之后节点里的键数量低于下限,就需要看兄弟节点能不能借一个键过来,这就叫借位或旋转;如果兄弟也很穷,那就跟兄弟节点合并成一个节点,同时把父节点中间的键拉下来。合并如果导致父节点也低于下限,就继续往上合并,甚至最后根被清空,树高减一。

我把这两个过程的代码逻辑放在后面一节详细展开,这里你要建立的最重要直觉是:B树的每一次形态变化,本质上都是一次“数据搬运”,而搬运的目标永远是维持树的平衡。这个直觉建立了,你再看任何B树相关的问题都会轻松很多。

3. 深入B+树:为什么商业数据库几乎全是它的形状

3.1 B+树和B树的核心差别,就三条

如果你已经理解了B树,再看B+树只需要记住三个不同点。

第一,B+树的内部节点不再存数据,只存键和指针。键在内部节点里只是一个“路标”,真正的数据(或者指向数据行的指针、行ID)全部存放在叶子节点。第二,所有叶子节点通过指针串成了一个有序链表,从头到尾可以按序遍历。第三,每个叶子节点同样有键数量下限的要求,但内部的键数可以更少一些,因为内部节点纯粹为了索路。

这三条差异看上去很小,但实际上带来了三个巨大的工程收益。第一,因为内部节点不存数据,同样大小的磁盘页能放更多的键,树更矮,IO次数更少。第二,因为叶子节点之间有链表,范围查询和排序扫描不再需要每次都从根节点出发,直接从头叶子节点顺序往后扫就行,这对数据库的“索引范围扫描”和“全表扫描”来说是质的飞跃。第三,所有实际查询最终都会落在一个确定的叶子节点上,查询路径长度恒定,这使得估计算法代价和设计缓存策略变得异常简单,因为叶子节点才是真正的“热点”。

3.2 范围查询的降维打击

我举一个具体例子,你立刻就能明白B+树为什么在数据库领域几乎是一统天下。假设一个表有1000万行,主键索引是B+树。你要查主键从100000到100500这500条记录。B+树的执行方式是:在树里走一次找到“100000”这个键所在的叶子节点,然后从这个节点开始,通过叶子节点之间的链表一路往后遍历,直到遇到大于100500的键值为止。整个过程树内只需要一次随机IO,剩下全是叶子链表上的顺序IO,效率极高。

换成一棵B树试试。B树的所有节点都可能存数据,所以你要先找到100000这个键,如果它在内部节点里,你就直接读到了第一条数据,然后接着问题来了:下一条大于100000的键在哪里?没人知道。你还得回到树里重新查找一次。500条记录,你差不多要做500次树内查找,哪怕每次都是log级别的,那也意味着大量的随机IO。这种差距在OLTP场景里是致命的。所以你在实际工程中几乎找不到用B树做关系型数据库主索引的案例,反而是在文件系统、键值存储的某些场景里会见到B树的变种,因为它们更关注单点写入的局部性。

3.3 引擎级别的一个容易被忽略的细节:叶子节点的页组织

B+树还有一个容易被忽略的工程细节——每个叶子节点,通常对应一个磁盘页。以InnoDB为例,默认页16KB,这个页既是磁盘IO的最小单位,也是内存缓冲池里缓存的最小单位。这就带来一个连锁设计:B+树的每个节点容量上限对齐页大小,也就是一个页能塞多少键就定义为多少。插入时发现页满了,首先要做的是把一部分数据挪到新页,同时修改父节点的指针信息,而这个修改父节点的过程也意味着可能有新的页写入。所以你看B+树的写放大问题,其实根源就在这:写一条记录,可能触发一次叶子节点分裂,并且连带父节点更新。这也是LSM树存储引擎在某些写密集场景下反而胜过B+树的原因之一。

但B+树作为读密集和事务型场景的王者,这个代价是值得的。我实际调过一些慢查询,发现很多性能问题的根源根本不是“B+树不够快”,而是索引设计得不好,导致定位到叶子节点的路径很长,或者叶子节点里扫描的数据量过大。这些我们在后面排查部分一起聊。

4. 亲手实现一个极简B+树:操作细节全拆解

4.1 设计取舍:我用Python写一个“能跑”的教学版

理论讲清楚了,动手才是最牢固的理解方式。我带你写一个极简的B+树,用Python,重点不放在性能上,而是放在逻辑完整性上。代码不会很长,但是插入、分裂、查找、范围遍历这几条主路径必须都有。

先定义节点结构。B+树的节点分两类,内部节点和叶子节点,我用一个字段标记。每个节点内部维护一个键列表和一个孩子列表(或数据列表)。为了方便写代码,我先不严格实现“删除和合并”,因为删除逻辑的边界条件特别多,全部塞进来容易把主线淹没。我先写插入和查找,以及范围遍历,删除合并我单独画一张边界条件表给你,你再自己补全。

class BPlusNode: def __init__(self, leaf=False): self.leaf = leaf self.keys = [] self.children = [] # 内部节点指向子节点,叶子节点指向数据或None self.next = None # 叶子链表指针,仅在叶子节点用

这里有个细节要提前讲清楚:内部节点的children长度永远是keys长度+1,而叶子节点的children长度和keys长度相等。这个约束是B+树写代码时最容易崩的地方。我见过至少三个初学者实现的版本都是在这个数量关系上搞混,导致索引越界。你写代码之前先把这两条规则写在注释里,能省很多debug时间。

4.2 查找逻辑:从根出发,精确命中叶子

查找过程本质上是反复“根据当前节点的keys选择下一个孩子”。如果当前节点是叶子节点,就在keys里做二分查找,命中就返回数据,没命中就返回不存在。如果当前节点是内部节点,就找到“第一个大于等于目标键”的位置,如果存在相等键则下一步走下一个孩子,否则走前一个孩子。这个“相等键向左还是向右”的设计每本教材习惯不一样,你只要保证查找和插入用的是同一种规则就行,否则数据会串位。

代码如下:

def search(root, key): node = root while not node.leaf: i = 0 while i < len(node.keys) and key >= node.keys[i]: i += 1 node = node.children[i] # 现在 node 是叶子节点 for i, k in enumerate(node.keys): if k == key: return node.children[i] # 返回数据 return None

这个循环里最关键的一行就是while i < len(node.keys) and key >= node.keys[i],它决定你去哪个子树。这里我用的是“>=”,意味着等于的键会向右走,因为B+树内部节点的键都是子树里最小的键的复制(或者是路标性质的键),真正的数据只在叶子层。向右走才能保证最终到达包含所有相同键的叶子节点。

4.3 插入逻辑:分裂是核心难点

插入只有两种情况。叶子节点没满,直接按序插入keys和data,完事。叶子节点满了,就要分裂。怎么分裂?选中间位置,把后半段keys和data都搬到一个新叶子节点,更新新节点的next指针和原节点的next指针,然后把新节点的第一个键插入到父节点中。如果父节点也满了,就递归往上分裂。如果一直裂到根,就创造一个全新的根节点。

我这里给出一个精简化但完整的插入核心代码片段:

def insert(root, key, value): # 返回新的根,因为根可能被分裂 if root is None: node = BPlusNode(leaf=True) node.keys.append(key) node.children.append(value) return node result, new_node_key, new_node = _insert_recursive(root, key, value) if new_node is not None: new_root = BPlusNode(leaf=False) new_root.keys = [new_node_key] new_root.children = [root, new_node] return new_root return root

递归函数的返回设计是:第一个返回值表示操作是否正常结束,第二个是新分裂出来的节点的“门槛键”,第三个是分裂产生的新节点本身。如果第三个返回值非空,说明当前层发生了分裂。如果某个节点没分裂,第三个返回值就是None。

这里我要特别讲一个新手最容易踩的坑:分裂叶子节点时,新节点要带走一半键,同时把“第一个键”上升到父节点。这个“第一个键”在原叶子节点里仍然保留一份。这就是B+树内部节点“只存路标、不存数据”的一种体现。但分裂内部节点时,被提升到父节点的那个键,要从孩子节点里移除,因为它已不再是孩子节点本身的“区间最小值路标”,而是变成了更高层的路标。这个区别如果你没注意,写出来的树会出现数据冗余和查询错乱。

我把递归函数的核心片段也给你看一眼:

def _insert_recursive(node, key, value): if node.leaf: # 插入叶子 idx = 0 while idx < len(node.keys) and node.keys[idx] < key: idx += 1 node.keys.insert(idx, key) node.children.insert(idx, value) if len(node.keys) <= MAX_KEYS: return None, None, None # 分裂叶子 mid = len(node.keys) // 2 new_node = BPlusNode(leaf=True) new_node.keys = node.keys[mid:] new_node.children = node.children[mid:] node.keys = node.keys[:mid] node.children = node.children[:mid] new_node.next = node.next node.next = new_node return None, new_node.keys[0], new_node # 内部节点,先向下递归 idx = 0 while idx < len(node.keys) and node.keys[idx] < key: idx += 1 result, up_key, new_child = _insert_recursive(node.children[idx], key, value) if new_child is None: return None, None, None # 把新孩子插入到当前内部节点 node.keys.insert(idx, up_key) node.children.insert(idx + 1, new_child) if len(node.keys) <= MAX_KEYS: return None, None, None # 分裂内部节点 mid = len(node.keys) // 2 up_key = node.keys[mid] new_node = BPlusNode(leaf=False) new_node.keys = node.keys[mid + 1:] new_node.children = node.children[mid + 1:] node.keys = node.keys[:mid] node.children = node.children[:mid + 1] return None, up_key, new_node

注意一个细节,分裂叶子节点时取中间位置用的是len(node.keys) // 2,而分裂内部节点时,我们取mid = len(node.keys) // 2之后,把up_key单独拿出来,key的划分是左半部分不含mid、右半部分不含mid,而children的划分是左半部分包含0到mid、右半部分包含mid+1到最后。原因我上面说了,内部节点的键是“路标”,提走之后不再留在原节点里。

4.4 范围查询:叶子链表的妙用

一旦叶子节点之间形成了链表,范围查询就成了最简单的一个函数:先找到左边界键所在的叶子节点,然后向右遍历,直到超过右边界。

def range_query(root, left, right): node = root while not node.leaf: i = 0 while i < len(node.keys) and left >= node.keys[i]: i += 1 node = node.children[i] result = [] while node: for k, v in zip(node.keys, node.children): if left <= k <= right: result.append((k, v)) elif k > right: return result node = node.next return result

这个实现的时间复杂度是 O(logn + m),n是总键数,m是命中的结果数。m部分取决于你要扫多少个叶子节点,如果范围很大,那这个扫描是顺序IO级别的,既高效又简单。你拿这个函数去对比一下在普通二叉搜索树里做范围查询要写多少代码、要经过多少次回溯,感觉会非常明显。

我把这个教学版的完整实现放在了一个可运行的脚本里,里面还加了简单的可视化打印,方便你自己插入几十个随机键观察分裂过程。我个人建议你别直接复制粘贴就跑,先自己跟着写一遍,尤其是把打印逻辑加进去,亲眼看看一个节点满了之后是怎么裂成两个、父节点是怎么长出第一个键的。这个过程只要亲手过一次,你后面看任何B+树的源码都会觉得眼熟。

5. 面试和工程中的高频问题:这些问题其实都是同一个道理

5.1 为什么不用红黑树做数据库索引

这个问题几乎每次技术面试都会出现。表层回答是红黑树是二叉树,树高太高。但面试官想听的其实是更深一层的理解:数据量一大,二叉树的层数跟多叉树差距就体现出来了。如果有一亿条记录,二叉搜索树的查找路径大概是27层,而一个4阶B+树只需5层左右,换成更大的阶数,可能就3层。每一层在磁盘里就是一次随机IO,27次和3次差距是数量级的。

但有些面试官也会追问:那内存里的红黑树是不是就完胜B+树?这还真不一定。Redis的有序集合就是用跳表而不是B+树,Java的TreeMap是红黑树,这些都是纯内存场景。所以正确答案不是“B+树全面优于红黑树”,而是“B+树在磁盘IO场景下通过压低树高来减少随机访问次数”。你一旦能把“场景决定结构”这个逻辑讲清楚,面试官通常会认为你真懂索引。

5.2 为什么不直接哈希索引

哈希索引在做等值查询时确实快,O(1)复杂度。但它的劣势在于无法做范围查询。范围查询需要把哈希表里所有数据拉出来做全表扫描,这在关系型数据库里是不可接受的。B+树能同时支持等值查询和范围查询,而且索引有序,天然配合排序、去重、分组等操作。所以在实际数据库里往往是B+树索引为主、哈希索引为辅,哈希索引只在某些内存引擎的等值查询场景里作为补充。

5.3 联合索引的最左前缀原则和B+树有什么关系

很多人背过“最左前缀原则”,但不明白为什么。我一句话讲透:B+树的键是有序排列的,联合索引(a,b,c)在叶子节点里按a排序,a相等时按b排序,b相等时按c排序。这个排序规则决定了你查询时如果跳过a直接查b,无法利用索引的“有序性”来做区间定位,因为整棵树的第一个排序维度就是a。你拿着b条件来到树里,根本不知道往哪个子树走。

但如果你查的是a等于固定值、b在某区间内,那走索引就很顺畅,因为第一维完全命中,第二维在局部是有序的。这个问题的本质,就是B+树的键排序规则对查询模式形成的硬约束。理解了这一点,你设计联合索引的时候就会很自然地想:查询条件里哪个字段的过滤性最强、哪个是范围条件,把它们按顺序排进索引,而不是随手建一个“所有字段都丢进去”的联合索引。

5.4 聚簇索引、非聚簇索引与回表

InnoDB的聚簇索引就是B+树,数据行直接存在叶子节点里,所以找到了叶子节点就找到了数据行。非聚簇索引(二级索引)的叶子节点存的是主键值,所以如果二级索引里没有你要的字段,查完叶子节点后还要拿着主键去聚簇索引里再查一次,这个动作叫回表。为什么非聚簇索引不直接存行指针?因为如果发生页分裂或数据移动,行指针就会失效,而存主键值可以跟着B+树的调整自动适配新位置。这个“拿主键再查一次”的代价,就是回表的随机IO来源。所以很多性能优化方案是“覆盖索引”,也就是让查询的字段全部包含在二级索引里,省掉回表。

5.5 页分裂的代价和填充因子

当你插入一个键恰好落在一个已经满的页上,页分裂就发生了。分裂不是只新建一个页那么简单,它需要移动一半数据到新页,还要修改父节点的指针,而父节点可能在另一个磁盘块上,这就会引发额外的IO。更麻烦的是,分裂后两个页在磁盘上往往不相邻,原本紧凑的顺序扫描也可能变成跳跃式的。所以在实际运维中,可以预判到频繁插入时,会在建索引时设置一个合理的填充因子,比如80%,留出一些空位来减少分裂次数。这个参数选得好,写入性能能明显改善。

我在一个模拟项目X里做过一次压测,同样一批数据,填充因子从100%改成85%之后,批量插入的TPS提升了接近两倍,而磁盘碎片率下降了很多。这就是B+树“工程参数”对系统性能的直接影响,不写代码的人很难体会到。

6. 动手实验:自己改参数观察B+树的行为

6.1 实验一:阶数对树高的影响

你手上如果有教学版代码,可以把MAX_KEYS从3改成8、改成32,插入同样数量比如1000条随机键,然后打印最终树高。你会看到树高明显下降。阶数越大的背后代表的是每个节点能存更多的键,对应到磁盘页大小不变的情况下就是键的字节数越小。所以你可以直观感受到为什么整型主键索引通常比字符串主键索引更高效,因为键短意味着一个页里能塞更多键,树更矮,IO更少。

6.2 实验二:插入顺序对结构的影响

再做一个实验:分别用升序、降序、乱序插入同样1000条键,看最终生成的树的形态和叶子节点链表的状态。你会发现在升序插入的情况下,叶子节点更容易触发频繁的分裂,因为每次插入都打在最后一个叶子上。而乱序插入时,分裂会散布在不同节点之间,前期分裂次数并没有明显增多,但最终树更均衡。这个实验能帮你直观理解为什么顺序插入未必是B+树的最优写入模式,也让你理解批量建索引时为什么会建议先按索引键排序再导入,因为那样可以利用B+树底层的批量构建优化,避免一次次分裂。

6.3 实验三:物理页大小与缓存命中率的关系(理论推演)

实际的InnoDB里,页大小是建表时写死的。假如你用一个4KB的页和16KB的页对比。同样的数据量,4KB页会导致树更高层数更多,查询时需要读更多页;但4KB页也有好处,就是缓存池里能装下更多页,对于热点分散的场景反而命中率更高。所以页大小不是越大越好,它取决于你的工作负载是偏向范围扫描(喜欢大页)还是随机点查(喜欢小页)。这又是一个“场景决定参数”的案例。

我建议你自己动手改改教学版代码里的MAX_KEYS,把插入次数从100改成10000,观察树高的变化曲线。这个实验花不了半小时,但对直觉的培养比看十篇博客都有效。

7. 常见问题与排查技巧实录

7.1 写了半天代码,查询结果永远少一条

这个问题的根源几乎都是内部节点的键复制逻辑出了问题。要么是分裂内部节点时被提升的键没有从子节点里移除,导致查询走了错误的分支;要么是查找时>=和>的方向不一致。排查方法很简单:打印整棵树的键分布,把每层的keys列出来,手动模拟一次查找路径,对比每一步实际走的子树和理论应该走的子树。定位到第一个不一致的节点,基本上就是bug所在。我在教学版代码里加了可视化打印函数,就是专门为这个场景设计的。

7.2 数据库索引失效,排查思路是什么

通常情况下,“索引失效”不是B+树本身坏了,而是查询写法让优化器认为走索引不划算或者走不了。最常见的几种:对索引列使用了函数、隐式类型转换、like以通配符开头、联合索引跳过了最左列。我一般排查顺序是:先explain看type和key字段,确认是否走索引,再看访问类型是const还是range还是all。如果走了索引但仍慢,就继续看是否回表次数过多、是否扫描的叶子节点范围太大。有个我常用的技巧:在explain的extra列看到“Using filesort”或“Using temporary”的时候,优先检查排序字段和分组字段是否包含在索引里。因为B+树天然有序,排序如果能利用索引顺序,就不需要额外的排序操作。

7.3 写放大和读放大:B+树无法回避的代价

没有一个数据结构是完美的。B+树的读性能极佳,但代价是写放大和空间放大。写放大来自页分裂和父节点更新,一次插入可能波及多个页。空间放大来自分裂时新页可能只利用了一半空间,以及页填充率的下降。LSM树就是针对这个痛点提出了“先写内存、再批量落盘、后台合并”的思路,把随机写转成顺序写,从而在写密集场景下胜出。如果你在系统的实际压测里发现B+树引擎的写入吞吐上不去,优先检查是不是写放大太大,再检查磁盘本身的随机写性能。这个优化链路和读优化是相反的,容易忽略。

7.4 一个小技巧:如何快速判断一棵B+树的健康程度

判断一棵B+树是否健康,不需要看复杂的统计指标,你只需要随机抽几个叶子节点,检查它们的填充率是否都明显高于50%。如果很多叶子节点都只有一两个键,说明删除和合并逻辑可能设计得比较悲观,空间浪费很大。另外一个简单检查:从最左叶子沿链表走一遍,看是否有点击不到的“空洞页”,这也能反映页分裂后的碎片化程度。在日常运维里我会定期查一下索引的碎片率,如果碎片率超过阈值,就做一次索引重组或重建。这个操作放在业务低峰期执行,对读取性能的恢复非常明显。

8. 我的几点私人体会

B树和B+树的学习曲线之所以显得陡峭,很大程度是因为市面上大多数资料都在讲“定义和性质”,而不是讲“为什么需要它”。我自己当年第一次亲手写完一个可运行的B+树之后,才发现那些背了很多遍的“m阶”“ceil(m/2)”“所有叶子同一层”根本不是需要死记的结论,而是顺着“减少IO、维持平衡”这两个目标自然推导出来的产物。从那以后,我看任何树形结构都会先问一句:它解决的是什么场景下的什么痛点?把这个问题想清楚,结构细节几乎不用看都能猜个大概。

你如果现在正卡在B树和B+树的泥潭里,我的建议很直接:别再看教程了,拿一段代码,把插入和查找跑通,打印出每一次分裂的中间状态,盯住“键的上升”和“指针的重排”这两件事。这个过程走一遍,你对整个数据结构的理解会比读十篇文章都深刻。最后再分享一个小技巧:写B+树代码时,时刻默念“内部节点的children比keys多一个,叶子节点的children和keys一样多”,你就能避开大多数新手都会踩的坑。数据结构这种东西,说到底是“手里有代码,心里不慌”,多跑几个demo,比什么都强。本文章由ChatGPT辅助创作。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询