本文的目的是从B树的起源讲起,再到java语言完整的实现,以达到对B树有一个全面的认识,如果你打算学习并实现B树(但是能在有生之年去实现一遍B树的人很少),那么看完本文就应该可以了。如果你想找B树的应用,那本文不适合。

B树的起源

我一直坚信,一个东西或一项技术的出现一定是有原因的,如果我们能找到那个原因,就能像创造者一样思考为什么要这样,为什么那个人不是我?下面开始。

在1970年,Bayer&McCreight发表的论文《ORGANIZATION AND MAINTENANCE OF LARGE ORDERED INDICES 》(大型有序索引的组织和维护)中提出了一种新的数据结构来维护大型索引,这种数据结构在论文中称为B-Tree,看论文的摘要:

ABSTRACT
Organization and maintenance of an index for a dynamic random access file is considered. It is assumed that the index must be kept on some pseudo random access backup store like a disc or a drum. The index organization described allows retrieval, insertion, and deletion of keys in time proportional to b 树 java b树java实现_java

翻译过来:
考虑组织和维护动态随机访问文件的索引, 假设索引必须保存在某些伪随机访问备份存储中,如光盘或鼓。 所描述的索引组织必须在b 树 java b树java实现_B树图解_02时间内成比例地检索,插入和删除键,其中b 树 java b树java实现_java_03是索引的大小,b 树 java b树java实现_B树_04是依赖于设备的自然数,使得方案的性能变得接近最优。 存储利用率至少为50%,但通常要高得多。 索引的页面组织在一个特殊的数据结构中,即所谓的B树。 分析该方案〜获得性能界限,并计算近似最优k。 已经使用高达100,000个键的索引进行了实验。 在具有2311个光盘的IBM 360/44上,可以维持大小为15,000(i00,000)的索引,平均每秒9次(至少4次)事务。

B树的定义1

还是先看看B树在论文中如何定义的:

b 树 java b树java实现_java_05

b 树 java b树java实现_b 树 java_06

b 树 java b树java实现_B-tree_07:代表树的高度,b 树 java b树java实现_B树_04是个自然数,一个B树要么是空的,要么满足以下条件:
1.所有叶子节点到根节点的路径长度相同,即具有相同的高度;

2.每个非叶子和根节点(即内部节点)至少有b 树 java b树java实现_java_09个孩子节点,根至少有2个孩子;

3.每个节点最多有b 树 java b树java实现_B-tree_10个孩子节点。

4.每个节点内的键都是递增的(后文提到)

看完了这几个定义,你肯定会问:b 树 java b树java实现_B树_04是什么? 好吧,论文在插入一节提到了:

b 树 java b树java实现_java_12

意思就是:这个B树的每个节点实际上代表了一页(Page),这一页可以看成是一个磁盘块的大小,比如1024,那么就可以存储1024个键,那么这个k就等于512,因为一页最大为2k,最小为k个键,k看来可以是自己定义的,根据你的存储硬件来决定。下图是论文中的一页,可看成是个数组:

b 树 java b树java实现_java_13

B树定义2

但是,大家在网上搜索时会发现定义并不唯一,普遍采用了m阶(代表order,中文翻译为阶)来定义:

  1. 每个节点最多有m个孩子。
  2. 每个内部节点(除去叶节点和根节点)至少有⌈m/2⌉(向上取整)孩子。
  3. 如果根不是叶节点,则根至少有两个孩子。
  4. 所有叶子都出现在同一层。
  5. 具有k个孩子的非叶节点包含k-1个键,当然节点内的键也是递增的。

这个版本是Knuth’s 提出的,可以在下面的期刊中找到: The Art Of Computer Programming 第494页,定义为如下:

b 树 java b树java实现_B-tree_14

定义比较

在网上可能会有这样的比较:一个是按度(degree,树的度为最大节点的度,节点的度就是节点的孩子数),也就是上面的定义1,一个是按阶(order),也就是定义2。

其实,稍微对比一下就会发现,这两个定义是同一个东西,他们的关系就是 b 树 java b树java实现_B树图解_15(因为 b 树 java b树java实现_B树图解_16对应着b 树 java b树java实现_java_17,开始定义很明确,所以如果非要比,那么b 树 java b树java实现_java_18一般是奇数) .当然,也不用纠结,看看下文为啥会有这些改变。

还有个关键点:即便是每个节点没有存满,也需要分配固定大小的容量,所以才有了存储利用率大于50%一说。

这样的话,定义就基本明白了。

为何术语不统一

为什么关于B树的术语在文献上并不统一(内容来自wiki)。

Order如何定义

Bayer&McCreight(1972),Comer(1979)(参考论文:https://dl.acm.org/citation.cfm?doid=356770.356776 )和其他人将B树的Order定义为非根节点中的最小键数。 Folk&Zoellick(1992)指出此术语含糊不清,因为最大键数不明确。Order为3的 B树可能最多包含6个键或最多7个键。 Knuth(1998)通过将Order定义为最大子项数(比最大键数多一个)来避免此问题,也就是我们现在普遍使用的。

叶子(Leaf)如何定义

叶子一词也不一致。 Bayer&McCreight(1972)认为叶子层是最底层的键,但Knuth认为叶子层低于最底层的键,没有孩子也没有指针(Folk&Zoellick 1992)。

实现的多样性

所以B树有许多种实现。在某些设计中,叶子可能包含整个数据记录; 在其他设计中,叶子可能只保存指向数据记录的指针。但这些并不是B树的基础概念。

为简单起见,大多数作者假设有一定数量的键适合节点。基本假设是键大小是固定的,节点大小是固定的。在实践中,可以使用可变长度的键(Folk&Zoellick 1992)。

理解与实现

关于阶定义的实现

关于普遍存在的一种按照阶的不完全的实现方式: https://algs4.cs.princeton.edu/code/edu/princeton/cs/algs4/BTree.java.html 这个算法需要注意2点:

  1. 上面代码里的m必须是偶数的,为什么,可以查看:stackoverflow,回答是这并不绝对,只是考虑到性能方面的影响,所以,我进行了一点点修改,让它可以传入奇数的m,具体代码查看:B树实现代码片
  2. 这颗B树实现简单,但是只能在叶子节点上存储数据,也就是说非叶节点只有key,没有value。

但是这不是本文要讲的方式,本文按照度的定义来讲,b 树 java b树java实现_java_19代表一个自定义的整数(b 树 java b树java实现_b 树 java_20)表示树的最小度数,一个节点(除根外)的key的范围为(b 树 java b树java实现_B-tree_21),下面具体看。

本文遵从的定义

我们按照《算法导论》第3版第18章中的B树定义来实现:

  1. 所有叶子节点到根节点的路径长度相同,即具有相同的高度;
  2. 每个非叶子和非根节点(即内部节点)至少有t-1个孩子节点;根至少2个孩子
  3. 每个节点最多有2t个孩子节点。
  4. 每个节点内的键都是递增的
  5. 每个节点的孩子比key的个数多1

这次用 b 树 java b树java实现_java_19 来表示B树的最小度数(任意节点最少有 b 树 java b树java实现_java_19

b 树 java b树java实现_java_24

这样树的节点构成如下:

/**
	 * B树中的节点。
	 */
	private static class BTreeNode<K, V> {
		/**
		 * 节点的项,按键非降序存放
		 */
		private List<Entry<K, V>> entries;
		/**
		 * 内节点的子节点
		 */
		private List<BTreeNode<K, V>> children;
		/**
		 * 是否为叶子节点
		 */
		private boolean leaf;
		/**
		 * 键的比较函数对象
		 */
		private Comparator<K> kComparator;

		private BTreeNode() {
			entries = new ArrayList<>();
			children = new ArrayList<>();
			leaf = false;
		}
		...

其中每个节点包括键值对的Entry和指向子节点的children指针列表,他们的长度差一,如下图:

b 树 java b树java实现_B-tree_25

而每个节点里的一个Entry就是存放key和value的键值对:

private static class Entry<K, V> {
		private K key;
		private V value;

		public Entry(K k, V v) {
			this.key = k;
			this.value = v;
		}
		
		// getter/setter
		
		@Override
		public String toString() {
			return key + ":" + value;
		}
	}

查询

和排序二叉树的搜索很类似,只是换成多叉和多项。
输入key,记住每个节点的key都是有序的

  1. 从根节点开始找,如果根节点里有,则返回;否则找到对应的下标去子节点递归搜索;
  2. 如果到了叶子节点还没找到,那就找不到。

下面是部分代码,帮助理解,详细请在后面看完整代码:

public V search(K key) {
		return search(root, key);
	}
	
	private V search(BTreeNode<K, V> node, K key) {
		// 在一个节点内搜索
		SearchResult<V> result = node.searchKey(key);
		if (result.isExist()) {
			return result.getValue();
		} else {
			if (node.isLeaf()) {
				return null;
			} else {
				// 进入递归
				search(node.childAt(result.getIndex()), key);
			}
		}
		return null;
	}

插入

插入稍微复杂一点,涉及到节点满了之后的拆分,也是递归进行的。设我们的 b 树 java b树java实现_B树_26, 则一个节点最多3个key,至少1个key,至多4个孩子。

从1-10进行示例:

先插入1,3,2,形成根节点

b 树 java b树java实现_B树_27

再插入4,在插入4之前就判断出此时已经超过最大容量3,需要分割

b 树 java b树java实现_b 树 java_28


根据具体实现,我们先分割,再插入,分割会先提出了b 树 java b树java实现_b 树 java_29项也就是3作为新的父节点,再把4插进去,这是个递归的过程,因为4比3大,所以是插入到3的右子树里。

b 树 java b树java实现_B树图解_30


继续插入5

b 树 java b树java实现_B树_31


然后是6,发现已经满了,于是分割出4,上升与父节点合并

b 树 java b树java实现_B-tree_32


接着7和8,到8的时候依然先分割,于是我们发现根节点已经满了

b 树 java b树java实现_java_33


我们接着插入9和10,插入10时,7,8,9节点先分割,于是8会上升

b 树 java b树java实现_B树_34


但是这时候根节点已满,所以继续递归的拆分,提出4

b 树 java b树java实现_B树图解_35


最终结果如下

b 树 java b树java实现_java_36

关于代码的实现,大家看看源码就明白了。

删除

删除比较麻烦,但是不复杂,只是分的情况比较多,可以分为以下3种:

  1. 要删除的key在叶子节点
  2. 要删除的key在当前节点,但不是叶子
  3. 要删除的key不在当前节点,可能在子节点中

所以按这3种情况,下面分别讨论。

删除1

要删除的key在叶子节点,直接删除就好:

b 树 java b树java实现_b 树 java_37


删除8后

b 树 java b树java实现_B树图解_38

删除2

要删除的key在当前节点,但不是叶子,这可以继续分为2种情况

删除2.1

当前节点左或右子节点key数>=t,如下:

b 树 java b树java实现_B树图解_39


我们删除45,从根节点出发,发现45就在根节点,并且它的左右孩子的key数都大于 b 树 java b树java实现_B树_26,于是我们用左孩子的最后一个key 43替换45,然后递归删除左孩子的43.

b 树 java b树java实现_B-tree_41


如果左孩子的key数<t,那就用右孩子的第一个去替换,然后递归删除右孩子的第一个key。

删除2.2

当前节点的左右孩子都只有t-1个key,小于t 。比如下面,我们要删除5,发现5的左右孩子都只有一个key

b 树 java b树java实现_B-tree_42


这时候就可以合并了,合并规则是左孩子的key+要删除的key+右孩子的key,然后合并到左孩子,合并的中间状态如下:(为啥可以合并?因为合并后的key数刚好为b 树 java b树java实现_b 树 java_43

b 树 java b树java实现_java_44


然后递归删除左孩子的5,结果如下:

b 树 java b树java实现_B-tree_45

删除3

要删除的key不在当前节点,可能在子节点中. 这依然分2种情况。

删除3.1

当前节点的一个相邻兄弟的key数>=t,如下,右孩子的key数>=2

b 树 java b树java实现_B-tree_46


我们要删除1,于是进行一次左旋(假如是左孩子的key数>=t,则进行右旋),然后递归删除左节点的1(旋转后还没有删除1,下面的图是最终结果)

b 树 java b树java实现_java_47

删除3.2

当前节点的所有相邻兄弟的key数都=t-1,如下,当前在3

b 树 java b树java实现_B-tree_48


于是我们进行合并,类似前面2.2的合并:

b 树 java b树java实现_B-tree_49


再递归的删除3(注意,本例很简单,但很可能2和4节点还有子节点,所以要递归进行,不只是为了删除,也可能是调整结构)

b 树 java b树java实现_b 树 java_50

删除如何调整结构

删除还有可能调整结构,即便什么都没删除,下面是一个例子:这是个打印出来的B树

b 树 java b树java实现_B树图解_51


如果我们删除9,9并不存在,但会得到以下结果:

b 树 java b树java实现_B树图解_52


为什么?仔细一分析就知道第一步时就满足删除3.2的情况了,所以进行了合并,这个例子在我源码的测试代码里。

另外,这只是根据实现的不同而不同,不一定都会改变原树。

复杂度分析

简单的分析一下复杂度把。需要先明白一个概念:
B树为访问外存而生,而什么时候访问呢?就在每次访问节点的时候会查一次磁盘IO,节点内的查询是在内存种进行的,而在访问子节点时就需要访问外存,而最多访问h(树的高度)次节点,因为每一层只会访问一个节点。所以,时间都花在访问节点上。

  1. b 树 java b树java实现_java_53,高度为b 树 java b树java实现_java_54的树,b 树 java b树java实现_B树图解_55,满足 b 树 java b树java实现_java_56
  2. 查询,插入,删除复杂度:b 树 java b树java实现_B-tree_57

解释:b 树 java b树java实现_java_58次访问外存操作,因为每个节点的b 树 java b树java实现_b 树 java_59 个,所以每个节点的时间为:b 树 java b树java实现_B树图解_60, 每一层只访问一个节点,所以总共最多访问b 树 java b树java实现_B-tree_07次节点 ,所以复杂度为 b 树 java b树java实现_B-tree_62

完成代码


总结

关于B的含义

这个不是重点,但这个B树直接就出现在论文中了,作者也没做说明,所以只能猜测:

  1. Rudolf Bayer 和 Ed McCreight 于1972年,在Boeing Research Labs 工作时发明了B 树
  2. Balanced(平衡的), broad(宽的) 或 bushy(浓密的)
  3. Bayer:他的名字