本文将主要讲述另一种树形结构,B 树;B 树是一种多路平衡查找树,但是可以将其理解为是由二叉查找树合并而来;它主要用于在不同存储介质之间查找数据的时候,减少 I/O 次数(因为一次读一个节点,可以读取多个数据);

一、结构概述

B 树,多路平衡查找树,即有多个分支的查找树;如图所示:

B 树主要应用于多级存储介质之间的查找,图中的蓝色节点为外部节点,代表下一级存储介质;绿色节点则为内部节点;同时我们将B 树按照其最大分支树进行分类,比如图中的则为4 阶B 树;

对于 m 阶 B 树(m >= 2):

  • 外部节点的深度统一相等,叶子节点的深度统一相等,树高等于外部节点深度;
  • 内部节点不超过(m-1)个关键码,不超过 m 路分支,同时不少于(⌈m /2⌉)路分支,但是根节点最少一路分支即可;
  • 所以 m 阶 B 树又称为(⌈m /2⌉,m)树;
public class BTree<E extends Comparable<? super E>> implements Iterable<E> {
private Node<E> root;
private final int order;
private final int MAX_KEYS;
private final int MIN_KEYS;
private int height;
private int totalSize; final class Node<T> {
Object[] values;
Node<T>[] children;
Node<T> parent;
boolean isLeaf;
int size; Node() {
this.values = new Object[order]; // 实际只有order-1个关键码,超出时会分裂;
this.children = new Node[order + 1]; // 同样+1;
this.isLeaf = true;
this.size = 0;
}
}
}

二、节点修复

因为B 树节点的在[ ⌈m/2⌉ - 1, m -1 ]之间,所以在动态插入和删除的过程中一定会发生不平衡,下面将介绍修复不平衡的几种方法;

1. 分裂

插入时当节点的关键码超过 m-1 ,就将大节点分为两个小节点;如图:

分裂时:

  • 将第 ⌊m/2⌋ 个关键码移入父节点;
  • 分成的两个节点,则成为新关键码的左右孩子节点;(需要新增节点,并移动节点信息);
  • 再递归的检查其父节点的关键码是否超过;

实现:

private void split(Node<E> p) {
Node<E> parent = p.parent;
if (parent == null) { // parent为null,即当前节点为root,需要上升高度(唯一会导致树高度增加的操作)
parent = new Node<E>();
parent.isLeaf = false; // 设置为非叶子节点
root = parent; // 更新root节点
height++; // 高度加1
} int mid = (p.size - 1) >>> 1; // 需要上一的关键码
Node<E> left = new Node<E>(); // 分裂,创建一个新的空节点
Node<E> right = p; // 右边节点为原来的节点
left.isLeaf = p.isLeaf; // 节点是否叶子,取决于分裂前是否叶子。 // 更新孩子节点的parent指针
if (!p.isLeaf) {
for (int i = 0; i <= mid; ++i) { // 左子树的孩子应该指向左子树。
p.children[i].parent = left;
}
}
parent.insertToNonLeaf((E) p.values[mid], left, right); // 把中间节点插入父节点。 int i, j;
// 拷贝右子树信息到左子树。
for (i = 0; i < mid; ++i) {
left.values[i] = right.values[i];
left.children[i] = right.children[i];
}
left.children[i] = right.children[mid];
left.size = mid; // 更新左子树关键字数量 // 删除右子树多余关键字和孩子,因为已经拷贝到左孩子中去了。
for (i = mid + 1, j = 0; i < right.size; ++i, ++j) {
right.values[j] = right.values[i];
right.children[j] = right.children[i];
}
right.children[j] = right.children[right.size]; // 更新最后一个孩子节点, 注意奇数j == mid,但偶数不是。。
right.size = right.size - mid - 1; // 更新右子树关键字数量
left.parent = parent; // 把子树的父亲节点更新
right.parent = parent;
if (parent.size > MAX_KEYS) // 如果父亲节点也达到最大关键字数量,需要递归分裂。
split(parent);
} int insertToNonLeaf(T key, Node<T> left, Node<T> right) {
int index = insertIndex(key);
if (index < 0) return index; for (int i = size; i > index; --i) {
values[i] = values[i - 1];
children[i + 1] = children[i];
}
children[index] = left;
children[index + 1] = right;
values[index] = key;
size++;
return index;
}

2. 旋转

删除节点时,可能会导致节点的关键码数量小于 ⌊m/2⌋,此时可以向他的左孩子或者右孩子,借一个关键码;如图:

图中:

  • 首先检查发现没有左兄弟,并且右兄弟可以借出
  • 然后左旋转父节点,父节点的关键码进入补齐,右兄弟的关键码进入父节点;

右孩子富裕时左旋:

private void leftRotate(Node<E> p) {
Node<E> right = rightSibling(p); // 获取右兄弟
int myRank = rankInChildren(p); // 获取在父节点中的秩
Object oldSeparator = p.parent.values[myRank];
p.values[p.size] = oldSeparator;
p.size++;
Object newSeparator = right.values[0];
Node<E> child = right.isLeaf ? null : right.children[0]; // 获取右兄弟中最小的关键码
int i;
for (i = 0; i < right.size - 1; ++i) {
right.values[i] = right.values[i + 1];
if (!right.isLeaf)
right.children[i] = right.children[i + 1];
}
if (!right.isLeaf) {
right.children[right.size - 1] = right.children[right.size];
child.parent = p;
p.children[p.size] = child;
}
right.size--;
p.parent.values[myRank] = newSeparator;
} private Node<E> rightSibling(Node<E> p) {
if (p == null || p.parent == null) // 根节点无兄弟节点
return null;
Node<E> parent = p.parent;
int i = rankInChildren(p);
if (i >= 0 && i < parent.size) {
return parent.children[i + 1];
}
return null;
}

左孩子富裕时右旋:

private void rightRotate(Node<E> p) {
Node<E> left = leftSibling(p);
int myRank = rankInChildren(p);
Object oldSeparator = p.parent.values[myRank - 1];
Node<E> child = null;
if (!left.isLeaf) {
child = left.children[left.size];
p.children[p.size + 1] = p.children[p.size];
} for (int i = p.size; i >= 1; --i) {
p.values[i] = p.values[i - 1];
if (!p.isLeaf)
p.children[i] = p.children[i - 1];
} if (!left.isLeaf) {
child.parent = p;
p.children[0] = child;
}
p.values[0] = oldSeparator;
p.size++;
Object newSeparator = left.values[left.size - 1];
left.size--;
p.parent.values[myRank - 1] = newSeparator;
} private Node<E> leftSibling(Node<E> p) {
if (p == null || p.parent == null) return null;
Node<E> parent = p.parent;
int i = rankInChildren(p); if (i >= 1) return parent.children[i - 1];
return null;
}

3. 合并

当左右孩子的关键码都不足以借出时,则将两个孩子合并,如图:

图中:

  • 首先左右兄弟都不足以借出
  • 从父节点借得一个关键码;
  • 然后以借得的关键码为粘合左右兄弟节点;
  • 最后需要检查父节点是否平衡;

实现:

private void merge(Node<E> p) {
Node<E> parent = p.parent;
assert (parent != null);
Node<E> left = p; // left node 或者是当前节点,即贫困节点,或者是当前节点的左兄弟节点。
Node<E> right = rightSibling(p);
if (right == null) {
left = leftSibling(p);
right = p;
}
int myRank = rankInChildren(left);
// 把父亲节点的Separator下移到需要合并的节点left
Object separator = parent.values[myRank];
left.values[left.size] = separator;
left.size++;
// 从父亲节点中删除Separator
for (int i = myRank; i < parent.size - 1; i++) {
parent.values[i] = parent.values[i + 1];
parent.children[i + 1] = parent.children[i + 2]; }
//FIXME
parent.values[parent.size - 1] = null;
parent.children[parent.size] = null;
parent.size--;
// 拷贝右节点到左节点
for (int i = 0; i < right.size; ++i) {
left.size++;
left.values[left.size - 1] = right.values[i];
if (!left.isLeaf) {
right.children[i].parent = left; // donot forget it.
left.children[left.size - 1] = right.children[i];
}
}
// 不要忘记最后一个孩子更新。
if (!left.isLeaf) {
right.children[right.size].parent = left;
left.children[left.size] = right.children[right.size];
}
// 如果父亲节点也贫困了,需要从父亲节点重新调整,直到满足平衡或者父亲节点就是root节点
if (parent.size < MIN_KEYS) {
if (parent.size == 0 && parent == root) {
root = left;
root.parent = null;
height--;
} else {
rebalancingAfterDeletion(parent);
}
}
}

三、查找

查找时采取逐层查找:

  • 查找不大于目标关键码的最大值;
  • 精确对比是否命中,若没有命中则深入孩子节点

实现:

public Node<E> search(E e) {
Node<E> v = root;
while (v != null) { // 逐层查找
int r = v.search(e); // 在当前节点中,找到不大于e的最大关键码
if (r >= 0 && cmp(e, v.values[r]) == 0) {
return v;
}
v = v.children[r + 1]; // 转入对应子树——需做I/O,最费时间
}
return null;
} int search(T key) {
int low = 0;
int high = size - 1; do {
int mi = (low + high) >> 1;
if (cmp(key, values[mi]) < 0) {
high = mi;
} else {
low = mi + 1;
}
} while (low < high); return --low;
}

四、插入

public boolean add(E key) {
if (key == null) {
return false;
}
if (root == null) {
root = new Node<E>();
this.height = 1;
this.totalSize = 0;
}
boolean inserted = insert(key, root);
if (inserted) {
++totalSize;
++modCount;
return true;
} else {
return false;
}
} private boolean insert(E key, Node<E> p) {
assert (p != null);
if (!p.isLeaf) { // 总是插入到叶子中,不可能直接插入到内部节点
int index = p.insertIndex(key); // 获取插入位置,如果 < 0说明已存在
if (index < 0) // index < 0 说明key已存在
return false;
return insert(key, p.children[index]); // 插入的位置就是孩子的位置
}
boolean inserted = p.insertToLeaf(key) >= 0; // p是叶子节点,直接插入。 if (p.size > MAX_KEYS) { // 如果关键字多于最大关键字数量,需要分裂节点。
split(p);
}
return inserted;
} int insertToLeaf(T key) {
int index = insertIndex(key);
if (index < 0)
return index;
for (int i = size; i > index; --i) {// 移动向右key
values[i] = values[i - 1];
}
values[index] = key;
++size;
return index;
}

五、删除

public boolean remove(E e) {
if (root == null) {
return false;
}
boolean isRemoved = remove(e, root);
if (isRemoved) {
--totalSize;
++modCount;
}
return isRemoved;
} private boolean remove(E e, Node<E> p) {
if (p.isLeaf) { // 删除的关键字在叶子节点中,直接删除,然后重新调整
boolean isRemoved = p.deleteFromLeaf(e);
if (p.size < MIN_KEYS) {
rebalancingAfterDeletion(p); // rebalances the tree
}
return isRemoved;
}
int index = p.binarySearch(e);
if (index < 0) { // 不在吃节点中,递归从子树中查找。
return remove(e, p.children[-index - 1]); // -index - 1就是插入位置,即孩子节点位置。
}
// 删除的是内部节点,需要寻找左子树最大节点(或者右子树中最小节点)作为新分隔符替换删除的关键字。
Node<E> leftLeaf = leftLeaf(p, index);// 寻找左子树最右节点。
Object candidate = leftLeaf.values[leftLeaf.size - 1];
//从叶子节点中移除候选节点
leftLeaf.values[leftLeaf.size - 1] = null;
leftLeaf.size--;
//候选节点作为分隔符替代删除的节点。
p.values[index] = candidate;
//重新调整树使其平衡。
if (leftLeaf.size < MIN_KEYS) {
rebalancingAfterDeletion(leftLeaf);
}
return true;
} boolean deleteFromLeaf(T key) {
int index = binarySearch(key);
if (index < 0)
return false;
for (int i = index; i < size; ++i) {
values[i] = values[i + 1];
}
this.size--;
return true;
} private void rebalancingAfterDeletion(Node<E> p) {
if (p == root) { // 说明p是root节点,不需要处理
return;
} Node<E> left = leftSibling(p); // 获取左兄弟
if (left != null && left.size > MIN_KEYS) { // 左兄弟很富裕, 右旋转。
rightRotate(p);
return;
} Node<E> right = rightSibling(p); // 右兄弟
if (right != null && right.size > MIN_KEYS) { // 如果右兄弟节点富裕,左旋转。
leftRotate(p);
return;
} merge(p);
}

总结

  • 通常情况下B 树节点的大小设置会和缓存页相当,以保证一次能够获取更多的关键码,以减少 I/O;
  • B 树仍然还有很多的变种,甚至红黑树也和(2,4)B 树息息相关,后面的章节会继续讲到;

数据结构系列(4)之 B 树的更多相关文章

  1. 数据结构图文解析之:树的简介及二叉排序树C++模板实现.

    0. 数据结构图文解析系列 数据结构系列文章 数据结构图文解析之:数组.单链表.双链表介绍及C++模板实现 数据结构图文解析之:栈的简介及C++模板实现 数据结构图文解析之:队列详解与C++模板实现 ...

  2. 【C#数据结构系列】查找

    一:查找 1.1 基本概念和术语 查找(Search)是在数据结构中确定是否存在关键码等于给定关键码的记录的过程.关键码有主关键码和次关键码.主关键码能够唯一区分各个不同的记录,次关键码通常不能唯一区 ...

  3. 【JavaScript数据结构系列】00-开篇

    [JavaScript数据结构系列]00-开篇 码路工人 CoderMonkey 转载请注明作者与出处 ## 0. 开篇[JavaScript数据结构与算法] 大的计划,写以下两部分: 1[JavaS ...

  4. JAVA数据结构系列 栈

    java数据结构系列之栈 手写栈 1.利用链表做出栈,因为栈的特殊,插入删除操作都是在栈顶进行,链表不用担心栈的长度,所以链表再合适不过了,非常好用,不过它在插入和删除元素的时候,速度比数组栈慢,因为 ...

  5. C语言数据结构基础学习笔记——B树

    2-3树:是一种多路查找树,包含2结点和3结点两种结点,其所有叶子结点都在同一层次. 2结点:包含一个关键字和两个孩子(或没有孩子),其左孩子的值小于该结点,右孩子的值大于该结点. 3结点:包含两个关 ...

  6. hdu 2527:Safe Or Unsafe(数据结构,哈夫曼树,求WPL)

    Safe Or Unsafe Time Limit: 2000/1000 MS (Java/Others)    Memory Limit: 32768/32768 K (Java/Others)To ...

  7. 数据结构(十一)B树

    之前的二叉排序树,平衡二叉树都是基于二叉树的实现,但是在搜索过程中,效率和树的深度有关,所以就想到把二叉树改为多叉树,B树和B+树都基于多叉树的实现 多路查找树 B树 定义   应用场景   B+树 ...

  8. 大白话5分钟带你走进人工智能-第二十六节决策树系列之Cart回归树及其参数(5)

                                                    第二十六节决策树系列之Cart回归树及其参数(5) 上一节我们讲了不同的决策树对应的计算纯度的计算方法, ...

  9. <数据结构系列3>队列的实现与变形(循环队列)

    数据结构第三课了,今天我们再介绍一种很常见的线性表——队列 就像它的名字,队列这种数据结构就如同生活中的排队一样,队首出队,队尾进队.以下一段是百度百科中对队列的解释: 队列是一种特殊的线性表,特殊之 ...

  10. <数据结构系列2>栈的实现与应用(LeetCode<有效的的括号>)

    首先想要实现栈,就得知道栈为何物,以下一段摘抄至百度百科: 栈(stack)又名堆栈,它是一种运算受限的线性表.其限制是仅允许在表的一端进行插入和删除运算.这一端被称为栈顶,相对地,把另一端称为栈底. ...

随机推荐

  1. BZOJ_1877_[SDOI2009]晨跑_费用流

    BZOJ_1877_[SDOI2009]晨跑_费用流 题意: Elaxia最近迷恋上了空手道,他为自己设定了一套健身计划,比如俯卧撑.仰卧起坐等 等,不过到目前为止,他 坚持下来的只有晨跑. 现在给出 ...

  2. 【SAP HANA】新建账户和数据库(2)

    开启HANA Studio,进入到User和Role的目录,这两个地方是创建账号和权限的. 新建用户 输入用户名和密码即可. 注意,如果系统里有同名的Catalog(数据库)存在的话,会报错,因为默认 ...

  3. ASP.NET Core 实战:基于 Dapper 扩展你的数据访问方法

    一.前言 在非静态页面的项目开发中,必定会涉及到对于数据库的访问,最开始呢,我们使用 Ado.Net,通过编写 SQL 帮助类帮我们实现对于数据库的快速访问,后来,ORM(Object Relatio ...

  4. Python的垃圾回收机制(引用计数+标记清除+分代回收)

    一.写在前面: 我们都知道Python一种面向对象的脚本语言,对象是Python中非常重要的一个概念.在Python中数字是对象,字符串是对象,任何事物都是对象,而它们的核心就是一个结构体--PyOb ...

  5. Win10+RTX2080深度学习环境搭建:tensorflow、mxnet、pytorch、caffe

    目录 准备工作 设置conda国内镜像源 conda 深度学习环境 tensorflow.mxnet.pytorch安装 tensorflow mxnet pytorch Caffe安装 配置文件修改 ...

  6. 「拥抱开源, 又见 .NET」系列第三次线下活动简报

    「拥抱开源, 又见 .NET」 随着 .NET Core的发布和开源,.NET又重新回到人们的视野. 自2016年 .NET Core 1.0 发布以来,其强大的生命力让越来越多技术爱好者对她的未来满 ...

  7. GDAL读取的坐标起点在像素左上角还是像素中心?

    目录 1. 问题 2. 结论 3. 例外 1. 问题 笔者在处理地理栅格数据的时候,总是会发生偏差半个像素的问题. 比如说通过ArcMap打开一张.tif,查看其地理信息:同时用记事本打开.tfw,比 ...

  8. Doskey命令详解

    转自:https://blog.csdn.net/u012993732/article/details/48626921 调用 Doskey.exe,它撤回 Windows XP 命令.编辑命令行并创 ...

  9. JVM之GC算法、垃圾收集算法——标记-清除算法、复制算法、标记-整理算法、分代收集算法

    标记-清除算法 此垃圾收集算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记对象,它的标记过程前面已经说过——如何判断对象是否存活/死去 死去的对象就会 ...

  10. 关于int main( int argc, char* argv[] ) 中arg和argv参数的解析及调试

    https://blog.csdn.net/LYJ_viviani/article/details/51873961 https://stackoverflow.com/questions/30241 ...