术语
- 根
树最顶端的节点称为“根”,一棵树只有一个根 - 父节点
每个节点(除了根)都恰好有一条边向上连接到另外一个节点,上面这个节点就称为下面节点的“父节点” - 子节点
每个节点都可能有一条或者多条边向下连接到其它节点,下面的这些节点就称为它的“子节点” - 叶节点
没有子节点的节点称为“叶子节点”或者简称为“叶节点” - 子树
每个节点可以作为“子树”的根,它和它所有的子节点构成了这棵树的子树 - 路径
设想顺着连接节点的边从一个节点走到另一个节点,所经过节点的顺序排列就称为“路径”
二叉树
如果一棵树中每个节点最多只能有两个子节点,这样的树就称为“二叉树”,二叉树每个节点的两个子节点称为“左子节点”和“右子节点”。
如果我们给二叉树加一个额外的条件,就可以得到一种被称作二叉搜索树(binary search tree)的特殊二叉树。二叉搜索树要求:每个节点都不比它左子树的任意元素小,而且不比它的右子树的任意元素大。(如果我们假设树中没有重复的元素,那么上述要求可以写成:每个节点比它左子树的任意节点大,而且比它右子树的任意节点小)
平衡二叉树
平衡二叉树(Balanced Binary Tree)具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
二叉搜索树的查找过程
二叉搜索树可以方便的实现搜索算法。在搜索元素x的时候,我们可以将x和根节点比较:
- 如果x等于根节点,那么找到x,停止搜索 (终止条件)
- 如果x小于根节点,那么搜索左子树
- 如果x大于根节点,那么搜索右子树
当二叉搜索树平衡时达到最高搜索效率,时间复杂度为O(logN);当二叉搜索树单调插入数据时,搜索效率最低,此时二叉搜索树相当于链表,时间复杂度为O(N)
二叉搜索树的查找代码如下(仅考虑数据不重复的情况):
public Node find(int key) {
Node current = root;
while (current.data != key) {
if (key < current.data) {
current = current.left;
} else {
current = current.right;
}
if (current == null) {
return null;
}
}
return current;
}
二叉搜索树插入过程
二叉搜索树的插入相对简单,二叉查找树的插入过程如下:
- 若当前的二叉搜索树为空,则插入的元素为根节点
- 若插入的元素值小于根节点值,则将元素插入到左子树中
- 若插入的元素值不小于根节点值,则将元素插入到右子树中
二叉搜索树的插入代码如下(仅考虑数据不重复的情况):
public void insert(int key, double data) {
Node newNode = new Node();
newNode.key = key;
newNode.data = data;
if (root == null) {
root = newNode;
} else {
Node current = root;
Node parent;
while (true) {
parent = current;
if (key < current.key) {
current = current.left;
if (current == null) {
parent.left = newNode;
return;
}
} else {
current = current.right;
if (current == null) {
parent.right = newNode;
return;
}
}
}
}
}
二叉搜索树删除过程
二叉搜索树删除过程也分为三种情况:
- 待删除节点是叶节点,此时只要删除该节点,并修改其父节点的指针指向null即可
- 待删除节点只有一个子节点,此时只要将父节点的指针指向该节点的子树即可
- 待删除节点有两个子节点,此时需要找到该节点的后继节点,用后继节点来代替它
如何查找后继节点
一个节点的后继节点即所有比该节点大的节点集合中最小的那个节点。为此可以查找该节点的右子树的最左节点即可,如图:
查找后继节点代码如下:
private Node getSuccessor(Node delNode) {
Node successorParent = delNode;
Node successor = delNode;
Node current = delNode.right;
while (current != null) {
successorParent = successor;
successor = current;
current = current.left;
}
// 节点移位,参照/docs/二叉搜索树后继节点
if (successor != delNode.right) {
successorParent.left = successor.right;
successor.right = delNode.right;
}
return successor;
}
待删除节点有两个子节点的删除过程如图:
删除节点的代码如下:
public void delete(int key) {
Node current = root;
Node parent = root;
boolean isLeftChild = true;
while (current.data != key) {
parent = current;
if (key < current.data) {
isLeftChild = true;
current = current.left;
} else {
isLeftChild = false;
current = current.right;
}
if (current == null) {
// 未找到待删除的节点
return;
}
}
// 没有子节点
if (current.left == null && current.right == null) {
if (current == root) {
root = null;
} else if (isLeftChild) {
parent.left = null;
} else {
parent.right = null;
}
} else if (current.right == null) {
// 只有左节点
if (current == root) {
root = current.left;
} else if (isLeftChild) {
parent.left = current.left;
} else {
parent.right = current.left;
}
} else if (current.left == null) {
if (current == root) {
root = current.right;
} else if (isLeftChild) {
parent.left = current.right;
} else {
parent.right = current.right;
}
} else {
// 两个节点
Node successor = getSuccessor(current);
if (current == root) {
root = successor;
} else if (isLeftChild) {
parent.left = successor;
} else {
parent.right = successor;
}
successor.left = current.left;
}
}
红黑树
红黑树(英语:Red–black tree)是平衡二叉搜索树的一种实现方式,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。
红黑树必须满足以下的规则:
- 每一个节点不是红色就是黑色
- 根总是黑色的
- 如果节点是红色的,则它的子节点必须是黑色的(反之倒不一定必须为真)
- 该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同
- 如果一个黑色节点下面有一个红色节点和一个黑色节点,那么红色节点只能是左节点
旋转
旋转又分为左旋和右旋。通常左旋操作用于将一个向右倾斜的红色链接旋转为向左链接。
左旋如图所示:
代码如下:
private Node rotateLeft(Node node) {
Node x = node.right;
node.right = x.left;
x.left = node;
x.color = x.left.color;
x.left.color = RED;
return x;
}
右旋如图所示:
代码如下:
private Node rotateRight(Node node) {
Node x = node.left;
node.left = x.right;
x.right = node;
x.color = x.right.color;
x.right.color = RED;
return x;
}
颜色变换
在插入数据过程中,遇到一个黑色节点下面带有两个红色的子节点就要进行颜色变换。颜色变换规则如下:两个红色子节点变为黑色,黑色父节点通常变为红色,如果父节点是根节点的话,则父节点继续保持为黑色。
代码如下:
private void flipColors(Node node) {
node.color = !node.color;
node.left.color = !node.left.color;
node.right.color = !node.right.color;
}
红黑树的插入过程
红黑树在插入时,跟二叉搜索树的插入规则是一致的,唯一不同的是,红黑树要保持自身的平衡,而这可以通过旋转和颜色变换做到。切记,红黑树在旋转和颜色变换的过程中,必须遵守红黑树的几条规则。
代码如下:
public void insert(int key) {
root = insert(root, key);
// 根节点只能是黑色
root.color = BLACK;
}
private Node insert(Node node, int key) {
if (node == null) {
return new Node(key, RED);
}
if (key < node.key) {
node.left = insert(node.left, key);
} else if (key > node.key) {
node.right = insert(node.right, key);
} else {
node.key = key;
}
// 如果一个黑色节点下面的两个节点一个黑色,一个红色,则红色节点只能是左节点
if (isRed(node.right) && !isRed(node.left)) {
node = rotateLeft(node);
}
// 红色节点下面不能有红色节点
if (isRed(node.left) && isRed(node.left.left)) {
node = rotateRight(node);
}
// 当一个黑色节点下有两个红色节点,则要进行颜色变换
if (isRed(node.left) && isRed(node.right)) {
flipColors(node);
}
return node;
}
红黑树的查找和删除过程
红黑树的查找跟二叉搜索树的查找过程是完全一致的
红黑树的删除过程过于复杂,以致于很多程序员用不同的方法去规避它,其中一种方法是:为已删除的节点做标记而不实际删除它。这里不做进一步的讨论。
红黑树的详细实现可以参考:红黑树完整代码Java实现
2-3-4树
2-3-4树是一种多叉树,名字中的2、3和4的含义是指一个节点可能含有的子节点的个数。2-3-4树性质如下:
- 任一节点只能是 2 度节点、3 度节点或 4 度节点,不存在元素数为 0 的节点(2度节点和3度节点是指该节点有2个或者3个子节点)
- 所有叶子节点都拥有相同的深度(depth)
- 元素始终保持排序顺序
2-3-4树结构图如下:
2-3-4树的组织
为了方便起见,用从0到2的数字给数据项编号,用0到3给子节点链编号。节点中的数据项按照关键字升序排列,习惯上从左到右升序。还加上以下几点:
- 根是child0的子树的所有子节点的关键字值小于key0
- 根是child1的子树的所有子节点的关键字值大于key0并且小于key1
- 根是child2的子树的所有子节点的关键字值大于key1并且小于key2
- 根是child3的子树的所有子节点的关键字值大于key2
如图:
节点分裂
2-3-4树依靠节点分裂来保持自身的平衡性。2-3-4树分裂的规则是自顶向下的,如果根节点或者待插入的节点中数据项已满,就要进行分裂,分裂规则如下:
- 创建一个空节点,它是要分裂节点的兄弟,在要分裂节点的右边
- 待分裂节点右边的数据项移到右边节点中,左边的数据项保留在原有节点中,中间的数据项上升到父节点中
如图:
2-3树
2-3树也是一种多叉树,与2-3-4树类似,现在在很多应用程序中还在应用,一些用于2-3树的技术会在B-树中应用。
2-3树比2-3-4树少一个数据项和一个子节点。节点可以保存1个或者2个数据项,可以有0个、1个、2个或者3个子节点。其它方面,父节点和子节点的关键字值的排列顺序和2-3-4树是一样的。
节点分裂
2-3树节点分裂和2-4树节点分裂有很大的不同。2-3树节点分裂是自底向上的(即若插入数据时根节点数据项已满,不进行分裂,只有待插入的节点数据项满时才进行分裂),而且2-3树节点分裂必须用到新数据项。
树的外部存储
磁盘布局
计算机中的机械磁盘是由磁头和圆盘组成,每个圆盘上划分为多个磁道,每个磁道又划分为多个扇区。
磁盘的结构图如下:
磁盘读写原理
系统将文件存储到磁盘上时,按柱面、磁头、扇区的方式进行,即最先是第1磁道的第一磁头(也就是第1盘面的第1磁道)下的所有扇区,然后,是同一柱面的下一磁头,……,一个柱面存储满后就推进到下一个柱面,直到把文件内容全部写入磁盘。
系统也以相同的顺序读出数据。读出数据时通过告诉磁盘控制器要读出扇区所在的柱面号、磁头号和扇区号(物理地址的三个组成部分)进行(目前多是通过LBA线性寻址的方式定位)。
磁盘预读
由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费(磁盘旋转和磁头移动),磁盘的存取速度往往是主存的几百分之一,因此为了提高效率,要尽量减少磁盘I/O。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。
预读的长度一般为页(page)的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行(详情请参考页面置换算法以及虚拟内存)。
扩展
- 每个扇区的弧长是一样的吗?
目前大多数教程中给出的图片都是老式的机械磁盘的组成。在老式机械磁盘中,每个磁道的扇区弧长是不一样的。越靠内的磁道密度越大,存储的数据也就越多;越靠外的磁道密度越小,存储的数据也就越少。所以,虽然内外磁道的扇区弧长不一样,由于密度的原因,每个扇区存储的数据量仍然是一样的,都是512B。在新式磁盘中,内外磁道的扇区密度都是相同的,所以新式磁盘每个扇区的弧长都是一样的。
B-树和B+树
2-3树和2-3-4树是B树的一种特例,B树的操作与2-3树和2-3-4树大致相同,此处不在过多介绍。
B树为何适于外部存储
前面已经简单介绍过,磁盘控制器每次预读几个文件块的内容,所以对于磁盘读写来说,当需要的数据都在一个文件块中时,磁盘读写次数最少,此时效率是最高的。而B树设计将每个节点的数据项刚好填满一个文件块.
假设这样一种极端情况,如果每个文件块中只有一条记录是我们需要的。那么当我们获取第二条记录时又要重新从磁盘加载新的文件块。此时由于磁盘读取次数增多,导致程序的性能大大下降。
B+树
B+树是B-树的变形。B+树与B树的区别在于:
- B+树非叶子节点只保存索引,数据全部保存在叶子节点上
- B+树的所有的叶子节点组成了一张链表,便于数据遍历
- 对于有M个数据项的B+树,最多只会有M的子节点
如图:
MySQL存储引擎对B+树的优化
基于上文对2-3-4树和2-3树的讨论,传统的B+树也是按照50%的分裂方式,这样节点分裂后,新的节点中只有原来一半的数据量,不但浪费了空间,还造成节点的增多,从而加重磁盘IO的次数。在目前绝大部分关系型数据库中,都针对B+树索引的递增/递减插入进行了优化,新的分裂策略在插入新数据时,不移动原有页面的任何记录,只是将新插入的记录写到新页面之中,这样原有页面的利用率仍然是100%。
所以对于MySQL数据库来说,使用自增主键插入数据时就会形成一个紧凑的索引结构,近似顺序填满。由于每次插入时也不需要移动已有数据,因此效率很高,也不会增加很多开销在维护索引上。
如图:
参考资料
- 从MySQL Bug#67718浅谈B+树索引的分裂优化
- 红黑树完整代码Java实现
- 硬盘的读写原理
- Java数据结构和算法 [美]Robert Lafore著
- 算法导论 [美]Thomas H.Cormen,[美]Charles E.Leiserson,[美]Ronald L.Rivest,[美]Clifford Stein 著