2-3-4树
引言
在二叉树中,每个节点有一个数据项,最多有两个子节点。如果允许每个节点可以有更多的数据项和更多的子节点,这就是多叉树。2-3-4树就是多叉树,它的每个节点最多有四个子节点和三个数据项。理解它可以更好地理解B树以及B+树。
B树也是一种多叉树,专门用在外部存储中来组织数据(主存储的外部,指的是磁盘驱动器)。
它的应用场景非常广泛。例如:目前大部分数据库系统及文件系统都采用B树或其变种B+树作为索引结构。B+Tree是数据库系统实现索引的首选数据结构。
因此学习B树、B+树这种数据结构对了解数据库系统的实现原理是非常有意义的
2-3-4树
如下图所示:
图中上面的三个节点有子节点,底层的六个节点都是叶节点,没有子节点。2-3-4树中所有的叶节点总是在同一层上
名字的含义
2-3-4树名字中的2,3,4的含义是指一个节点可能含有的子节点的个数。
对于非叶节点有三种可能的情况:
- 有一个数据项的节点总是有两个子节点
- 有两个数据项的节点总是有三个子节点
- 有三个数据项的节点总是有四个子节点
总之,非叶节点的子节点数总是比它含有的数据项多1.设子节点链接的个数为L,数据项的个数为D,那么:
L=D+1。
叶节点没有子节点,然而它可能含有一个,两个或三个数据项。所以,空节点是不会存在的。
因为2-3-4树最多可以有4个子节点的节点,也可以称之为4叉树。
二叉树和2-3-4树的联系
相同之处
都是平衡树
不同之处
- 二叉树的节点的子节点个数最多是2个,2-3-4树最多可以有4个
- 二叉树的非叶节点也可以只有1个子节点,2-3-4树不允许只有一个子节点,有一个数据项的节点必须总是保持有两个链接。
2-3-4树的组织形式
2-3-4树中一般不允许出现重复关键字值,所以就拿4-节点的子树来说,它的子节点的关键字值不等于A,B,C。如下图所示:
查找
查找特定关键字值得数据项和在二叉树中的搜索例程类似。从根开始,除非查找的关键字值就是根,否则选择关键字值所在的合适范围,转向那个方向,直到找到为止。
插入
新的数据项总是插在叶节点里,在树的最底层。
如果插入到有子节点的节点里,子节点的编号就要发生变化来保持树的结构,这保证了节点的子节点比该节点的数据项多1.
- 查找时没有碰到满节点时,插入比较简单,如下图所示
- 如果向下寻找要插入位置的路途中,碰到满节点的情况,插入就变得复杂了。发生这种情况时,节点必须分裂。正是这种分裂保证了树的平衡。下面重点介绍一下几种分裂情况。
节点分裂(4-节点变成两个2-节点)
节点分裂的操作步骤:(将要分裂的节点中的数据项设为A、B和C)
- 创建一个新的空节点。它是要分裂节点的兄弟,在要分裂节点的右边
- 数据项C移到新节点中
- 数据项B移动到要分裂节点的父节点中
- 数据项A保留在原来的位置上
- 最右边的两个子节点从要分裂节点处断开,连到新节点上。
根的分裂(4-节点变成3个2-节点)
如果一开始查找插入点时就碰到满的根时,插入过程更复杂一些:
- 创建新的根,它是要分裂节点的父节点
- 创建第二个新的节点,它是要分裂节点的兄弟节点
- 数据项C移动到新的兄弟节点中
- 数据项B移动到新的根节点中
- 数据项A保留在原来的位置上
- 要分裂节点最右边的两个子节点断开连接,连到新的兄弟节点中。
在下行路途中分裂
所有满的节点是在下行路途中分裂的,分裂不可能向回波及到树上面的节点。任何要分裂的节点的父节点肯定是不满的,因此该节点不需要分裂就可以插入数据项B。当然,如果父节点的子节点分裂时它已经有两个子节点了,它就变满了。这意味着下次查找碰到它的时候需要分裂
下图是空树中一系列的插入过程,有四个节点分裂了,两个是根,两个是叶节点
案例演示插入
在下面的2-3-4树中插入140
1.首先根分裂
2.查找插入位置
3.节点分裂
4.查找插入位置
5.完成
6.全景图如下
Java实现234树
package test12;
/**
* 存储在节点中的数据项
*/
public class DataItem {
//数据项内容
public long dData;
public DataItem(long dd) {
dData = dd;
}
/**
* 展示方法 格式为 "/27"
*/
public void displayItem() {
System.out.print("/" + dData);
}
}
package test12;
public class Node {
//最多4个节点
private static final int ORDER = 4;
//存放子节点数组
private Node childArray[] = new Node[ORDER];
//存放本节点数据项的数组
private DataItem itemArray[] = new DataItem[ORDER - 1];
//定义该节点的数据项数
private int numItems;
//定义该节点的父节点
private Node parent;
/**
* 给当前节点连接子节点
*
* @param childNum 子节点的索引位置
* @param child 子节点
*/
public void connectChild(int childNum, Node child) {
childArray[childNum] = child;
if (child != null) {
child.parent = this;
}
}
/**
* 当前节点断开子节点的连接,返回它
*
* @param childNum 指定要断开节点的索引位置
* @return
*/
public Node disconnectChild(int childNum) {
Node tempNode = childArray[childNum];
childArray[childNum] = null;
return tempNode;
}
/**
* 根据key值查找数据项
*
* @param key
* @return
*/
public int findItem(long key) {
for (int j = 0; j < ORDER - 1; j++) {
if (itemArray[j] == null) {
break;
} else if (itemArray[j].dData == key) {
return j;
}
}
return -1;
}
/**
* 插入数据项
*
* @param newItem
* @return
*/
public int insertItem(DataItem newItem) {
// 假设节点不为空
numItems++;
long newKey = newItem.dData;
//从当前节点的数据项数组的尾部开始
for (int j = ORDER - 2; j >= 0; j--) {
//如果数据项为空,继续从数组尾部向首部方向移动
if (itemArray[j] == null) {
continue;
} else {
long itsKey = itemArray[j].dData;
//如果要插入的数据项比当前对比的数据项小,将当前数据项的位置后移。为要插入的数据项腾一个空位置
if (newKey < itsKey) {
itemArray[j + 1] = itemArray[j];
} else {
//说明查找到了插入的位置,可以进行插入,并返回插入节点的索引位置
itemArray[j + 1] = newItem;
return j + 1;
}
}
}
//数据项数组为空,直接插入第一个位置并返回索引
itemArray[0] = newItem;
return 0;
}
/**
* 移除数据项
* 并返回移除的数据项
*
* @return
*/
public DataItem removeItem() {
DataItem temp = itemArray[numItems - 1];
itemArray[numItems - 1] = null;
numItems--;
return temp;
}
/**
* 展示节点
* 格式"/24/56/74/"
*/
public void displayNode() {
for (int j = 0; j < numItems; j++) {
itemArray[j].displayItem();
}
System.out.println("/");
}
public Node getChild(int childNum) {
return childArray[childNum];
}
public Node getParent() {
return parent;
}
/**
* 是否是叶子节点
*
* @return
*/
public boolean isLeaf() {
return (childArray[0] == null) ? true : false;
}
public int getNumItems() {
return numItems;
}
/**
* 根据索引得到数据项
*
* @param index
* @return
*/
public DataItem getItem(int index) {
return itemArray[index];
}
/**
* 判断节点是否已满
*
* @return
*/
public boolean isFull() {
return (numItems == ORDER - 1) ? true : false;
}
}
package test12;
public class Tree234 {
//根节点
private Node root = new Node();
/**
* 根据给定关键字值查找数据项
*
* @param key
* @return
*/
public int find(long key) {
//从根开始查找
Node curNode = root;
int childNumber;
while (true) {
if ((childNumber = curNode.findItem(key)) != -1) {
//查找到
return childNumber;
} else if (curNode.isLeaf()) {
//如果curNode是叶子节点的话,直接返回
return -1;
} else {
//深度搜索 如果在当前节点找不到数据项,且当前节点不是叶子节点,需要转向节点的子节点进行查找
curNode = getNextChild(curNode, key);
}
}
}
/**
* 获取当前节点的子节点
*
* @param theNode 当前节点
* @param theValue 搜索关键字值
* @return
*/
public Node getNextChild(Node theNode, long theValue) {
int j;
//假定当前节点不是空的,不满,也不是叶子节点
int numItems = theNode.getNumItems();
//遍历当前节点的数据项的数组
for (j = 0; j < numItems; j++) {
//如果搜索关键字值小于正在比较的数据,那就表明可在此节点的左子节点上
if (theValue < theNode.getItem(j).dData) {
return theNode.getChild(j);
}
}
//在此节点的右子节点上
return theNode.getChild(j); // return right child
}
/**
* 插入
*
* @param dValue
*/
public void insert(long dValue) {
//从根节点开始查找要插入的位置
Node curNode = root;
//将插入的数据构造成节点
DataItem tempItem = new DataItem(dValue);
while (true) {
//当前节点已满
if (curNode.isFull()) {
//分裂节点
split(curNode);
curNode = curNode.getParent();
// 查找更低一级的子节点
curNode = getNextChild(curNode, dValue);
} else if (curNode.isLeaf()) {
// 当前节点是叶子节点,跳出查找 插入节点的逻辑。去做插入操作
break;
} else {
// 当前节点不满,也不是叶子节点,查找更低一级的子节点
curNode = getNextChild(curNode, dValue);
}
}
//查找到了,就进行插入
curNode.insertItem(tempItem);
}
/**
* 节点分裂
*
* @param thisNode
*/
public void split(Node thisNode) {
// 假设节点不为空
DataItem itemB, itemC;
Node parent, child2, child3;
int itemIndex;
//从当前节点中删除最右边的两个数据项并保存
itemC = thisNode.removeItem();
itemB = thisNode.removeItem();
//断开最右边两个子节点的连接,保存它们的引用
child2 = thisNode.disconnectChild(2);
child3 = thisNode.disconnectChild(3);
//创建一个新节点,它将置于被分裂节点的右边
Node newRight = new Node();
//根节点分裂
if (thisNode == root) {
//创建一个新的根节点
root = new Node();
//将根节点置为父节点
parent = root;
//当前节点连在根节点的第一个子节点的位置
root.connectChild(0, thisNode);
} else {
//普通节点分裂
parent = thisNode.getParent();
}
// 处理parent节点,将B数据项插入节点中
itemIndex = parent.insertItem(itemB);
int n = parent.getNumItems(); // total items?
// move parent's connections
for (int j = n - 1; j > itemIndex; j--) {
Node temp = parent.disconnectChild(j); // one child
parent.connectChild(j + 1, temp); // to the right
}
// 将新创建的节点连接到parent节点的子节点的位置(这个索引位置是上面腾出来)
parent.connectChild(itemIndex + 1, newRight);
// 处理新节点
newRight.insertItem(itemC); // item C to newRight
newRight.connectChild(0, child2); // connect to 0 and 1
newRight.connectChild(1, child3); // on newRight
}
/**
* 展示树
*/
public void displayTree() {
recDisplayTree(root, 0, 0);
}
private void recDisplayTree(Node thisNode, int level,
int childNumber) {
System.out.print("level=" + level + " child=" + childNumber + " ");
thisNode.displayNode(); // display this node
// 递归调用每个节点的孩子节点
int numItems = thisNode.getNumItems();
for (int j = 0; j < numItems + 1; j++) {
Node nextNode = thisNode.getChild(j);
if (nextNode != null) {
recDisplayTree(nextNode, level + 1, j);
} else {
return;
}
}
}
}
package test12;
import java.io.*;
import java.util.Scanner;
class Tree234App {
public static void main(String[] args) throws IOException {
long value;
Tree234 theTree = new Tree234();
theTree.insert(50L);
theTree.insert(40L);
theTree.insert(60L);
theTree.insert(30L);
theTree.insert(70L);
while (true) {
System.out.print("请选择 展示(s), 插入(i), or 查找(f): ");
Scanner sc = new Scanner(System.in);
String choice = sc.next();
switch (choice) {
case "s":
theTree.displayTree();
break;
case "i":
System.out.print("输入要插入的数据: ");
value = sc.nextInt();
theTree.insert(value);
break;
case "f":
System.out.print("输入要查找的数据: ");
value = sc.nextInt();
int found = theTree.find(value);
if (found != -1)
System.out.println("找到: " + value);
else
System.out.println("没有找到: " + value);
break;
default:
System.out.print("不合法的输入\n");
}
}
}
}
运行结果如下:
2-3-4树和红黑树
历史上,2-3-4树先被提出,后来红黑树由它发展而来
2-3-4树转化为红黑树的三条规则:
- 把2-3-4树中的每个2-节点转化为红黑树的黑色节点
- 把每个3-节点转化为一个子节点和一个父节点,子节点红色,父节点黑色
- 把每个4-节点转化成一个父节点和两个子节点,子节点红色,父节点黑色
2-3-4树和红黑树操作的等价性
2-3-4树中使用节点分裂保持平衡
红黑树中使用颜色交换和旋转保持平衡。
4-节点分裂和颜色变换
下图中2-3-4树的一个4-节点在分裂的样子,图中也展示了与2-3-4树等价的红黑树,虚线部分等价于4-节点。
3-节点分裂和旋转
下图中一棵2-3-4树转化为红黑树的效果,不同的是选择3-节点的两个数据项的哪个来做父节点。
由图可看到图B是不平衡的,C是平衡的。对图B的红黑树,可以采取右旋(并进行颜色变换)来保持平衡,得到的结果和图C一致
2-3-4树的效率
类比红黑树,查找过程每层都要访问一个节点,可能是查找已经存在的节点,也可能是插入一个新节点,红黑树的层数大约是log₂(N+1),所以搜索时间与层数成比例。
假设2-3-4树中每个节点最多可以有4个子节点,如果每个节点都是满的,树的高度应该和log4(N)成正比。以2为底的对数和以4为底的对数底数相差2,因此,在所有节点都满的情况下,2-3-4树的高度大致是红黑树的一半,2-3-4树的高度大致在log₂(N+1)和log₂(N+1)/2之间。
另一方面,每个节点要查看的数据项就更多了,这会增加查找时间,因为节点中使用线程搜索来查看数据项,使查找时间增加的倍数和M成正比,总的查找时间和Mlog4(N)成正比。
有些节点有一个数据项,二个数据项,有的有三个。如果平均算的话,M=2。2log4(N)。
根据大O表示法的规则,2-3-4树的查找时间和平衡二叉树大致相等,都是O(logN)
2-3树
和2-3-4树类似,2-3树的节点比2-3-4树少存一个数据项和少一个子节点。节点可以保存1个或者2个数据项,可以有0个,1个,2个或3个子节点。
节点分裂
查找,插入都和2-3-4树类似。节点分裂有很大的不同。
外部存储
2-3-4树是多叉树的例子,多叉树是指节点的子节点多于两个并且数据项多于1个。另一种多叉树,B树,针对于在外部存储器上的数据,这种数据结构起到很大的作用。
外部存储特指某类磁盘系统,例如硬盘。
访问外部数据
遇到的问题
第一:在磁盘驱动器中访问数据比在主存中要慢得多。
第二:一次需要访问很多记录
一次访问一个数据块
当读写头到达正确的位置后开始读(写)过程,驱动器可以很快把大量数据转移到主存中,因为这个原因,为了简化驱动器的控制装置,在磁盘上的数据按块存储。
磁盘驱动器每次最少读或写一个数据块的数据。块总是2的倍数