前言:2-3树的资料比较少,国内的某本参考书关于2-3树的介绍和国外的还不一样,网上搜了一下,发现这一篇翻译的介绍比较详细,不错,于是转贴一下。
资料来源于:http://blog.donews.com/sowen/
前言
备注:文中可能偶尔多用了英文,倒不是卖弄,很多时候只是习惯性的,因为如果你平时接触的东西都是英文的,你写下来的时候自然想到的是英文字眼,而不是多一层先翻译成中文。还有一些是我只记得英文资料上的定义,比如我写之前想到 balanced tree,却不知道中文应该是什么,查google才知道应该翻译成平衡树。还有的原因是可能英文解释能更清晰表达就直接使用英文了。反正这篇东西的对象都是同行,我相信大部分人都有阅读英文资料的习惯,因此一定能够了解。如果阁下不喜欢中文夹着英文的东西,请不必阅读了,谢谢。
tree 在计算机数据结构里是一种非常重要的东西,介绍性的东西就不多讲了,树多数用于检索量比较大的地方,比如数据库和硬盘文件排列之类。树分很多种,最简单的当然是 BST (binary search tree), 中文应该是 二叉检索树,这个东西虽然简单,可是毛病很多,比如很容易出现 worse case,就是检索中最不愿意看到的线性检索(一棵只有右子树或者左子树的树);而且BST不是balanced的。所以,BST一般很少应用,前人发明了很多其他的树,比如AVL,B-TREE,2-3 tree, 2-3-4 tree 等等。这篇文章要介绍的就是2-3 tree,主要是因为它的中文资料……似乎没有(反正我没有在google上找到)。而 2-3-4 tree 只是一个变种,最后会简单介绍一下。
本文的实现代码我用了JAVA,本来这种需要访问指针的东西,我更想用c++,但考虑易读性还是JAVA比较好点,比较适合介绍性质的代码。
特性
2-3 tree 严格来说只有两个要求:
1.所有的结点有2个或者3个子树
2.所有的leaves(叶)都在同一个级别上。
不知道这样的中文解释是否够清楚,解释一下就是:叶是树中最后一个结点,它们的高度(path from root)都必须一样;每个结点最多只能有3个子树。
比如下面这棵树就是一个合法的2-3 tree
<10 20>
/
|
/
/
|
/
<5> <15> <30 40>
其实2-3 tree也是从BST演变过来的,小值就在左边,大值在右边。每个结点最多只有两个值,一个小,一个大。如果一个结点有两个值,这个结点称为full,那么如果它有子树,那么它一定有3个子树。如上图所示,小值(<10) 在左子树,大值 (>20) 在右子树,而中间值( 10<x<20 ),就在中间的子树。
为什么要这样排列?
因为它是balanced的!一棵高度为k的2-3 tree有2k – 1 到 3k – 1 之间的叶;一棵有n elements 的2-3 tree 高度最小为 1+log3n, 最大为 1+log2n。它的检索时间为O(logn)。
检索的pseudo code
LOOP until curr = nil
IF ( data = curr.small OR data = curr.large )
Return curr
ELSE IF ( data < curr.small )
curr = curr.left
ELSE IF ( data > curr.small AND data < curr.large )
curr = curr.middle
ELSE
curr = curr.large
也可以通过递归来做,跟BST差不多,检索并不是本文介绍的目的,因为实在没什么好说的。
2-3 tree的结点结构
最简单的结构如下
struct tree23Node {
int small,
int large,
tree23Node left,
tree23Node middle,
tree23Node large
}
插入
对于2-3 tree,所有的插入(新值)都必须发生在leaf。所以,首先找到新值应该所在的leaf,然后根据这个leaf的情况做出判断:
1. 该点只有一个元素。直接加入就可以了,判断是small还是large。
2. 该点full(small和large都有值),其父结点不是full。那就split该结点,并把中间值推上去。
3. 该点和其父结点都full。如果父结点full,表示父结点已经有3个子树。那就需要一直split 并一直往上推,直到发生以下情况:
1) 父结点最多只有3个子树
2) 父结点是根,创建一个新root,保存中间值。树的高度增加1
最好的解释其实还是例子,请先看一棵合法的2-3 tree如下:
<53>
/ /
/ /
<27> <65, 78>
/ / / | /
<12> <39,43> <60> <69,74> <93>
树一
1) 加入 15
新加入一个值的时候,要记住,所有的新增一定发生在leaf。所以第一步我们需要找到15的位置。不错,就是在<12>那里。这个 leaf 只有一个元素,就是说不是full,这是最简单的一种情况,直接把15加在那个leaf就可以了。因为15>12,所以树会变成
<53>
/ /
/ /
<27> <65, 78>
/ / / | /
<12,15> <39,43> <60> <69,74> <93>
树二
我们可以再检查一下,是否满足2-3 tree的两个要求。(1) 所有结点只有2个或者3个子树,这个满足了。(2)所有的leaves都在同一个级别(高度一样),这个也满足了。
2) 加入22
这次好像有点麻烦了,22似乎应该加在<12,15>这个leaf上,可是它已经full了。如果加入后,应该变成
<53>
/ /
/ /
<27> <65, 78>
/ / / | /
<12,15,22> <39,43> <60> <69,74> <93>
树三 (not valid yet)
按照前面提到的插入要求,需要split这个结点,split之后,该结点应该变成<12> <15> <22>三个结点,然后把中间那个推上去。(为什么呢?因为只有中值上去变成父树里的值,原来的large变成的那个结点才能变成中子树的结点,还记得它的特性吗?所以,最后的树应该变成
<53>
/ /
/ /
<15,27> <65, 78>
/ | / / | /
<12> <22> <39,43> <60> <69,74> <93>
树四
请自行检验是否合法2-3 tree
3) 现在让我们试试新加入 32
首先,大家都知道32应该在那个leaf开始动作了吧。不错,就是<39,43>,加入后,这个leaf应该变成<32,39,43>;full了,要split,变成<32><39><43>,再把中值推上去,变成以下的一棵树
<53>
/ /
/ /
<15,27,39> <65, 78>
/ | | / / | /
<12><22><32><43> <60> <69,74> <93>
树五(not valid yet)
很显然,这不是一个合法的树。因为有结点有超过3个子树,而且有结点超过两个元素(full)。那么就需要继续split,然后又是中值往上推,直到见到只有两个子树的结点<53>。所以,再split一次后的树应该变成:
<27,53>
/ | /
/ | /
<15> <39> <65, 78>
/ / / / / | /
<12> <22> <32> <43> <60> <69,74> <93>
树六
4) 在【树六】的基础上加入 34,35,37,后,2-3 tree应该变成如下:
<27,53>
/ | /
/ | /
<15> <34,39> <65, 78>
/ / / | / / | /
<12> <22> <32><35,37><43> <60> <69,74> <93>
树七
你对了吗?
1) 最后,让我们看看最复杂的一种情况,如果在【树七】中,加入36,应该变成怎样?
我们可以一步一步来,首先,找到36应该在的leaf,<35,37>,加入后变成<35,36,37>,该leaf变成full了。Split,然后推中值上去。变成如下:
<34,36,39>
/ | | /
<32> <35> <37> <43>
好,父树也变成full了,这种情况我们见过,再split和再推中值上去。
<27,36,53>
/ | | /
<15> <34> <39> <65,78>
// / / / / / | /
… <32><35><37><43> …
嗯,现在我们的root也变成full了。那就在split一次,然后新建一个root,保存中值。 变成
<36>
/ /
<27> <53>
/ / / /
<15> <34> <39> <65,78>
………………………………… (以下省略,大家都知道下面怎么连接了吧)
这个时候,我们的2-3 tree高度增加了1
到这里,已经列举了所有增加新值的例子。如果还不是太清晰,那我就在此总结一下吧:
l 新值的增加总是发生在leaf。(它最后是否在leaf上就很难说,但增加这个动作永远是发生在leaf上,所以第一步永远都是找到新值应该位于的leaf)
l 如果一个结点有三个值,我们说它是full了,需要split,把中值推到父结点上去。
l 如果一个结点需要split而它又刚好是root,那么就新创建一个root,保存那个中值。树的高度增加1。
接下来我们要做什么呢?大家是想看删除的特性吗?嗯,我只能说,删除相当复杂,即使只是BST的删除也很麻烦,大家都还记得BST的删除吧。我现在可以说的2-3 tree的删除大概是和新增反过来的,新增是往上推一个值,删除是往下找一个子树合并。
我不想立刻就开始讲删除,因为我想趁大家还记得新增的特性时让大家看看代码,加深大家对新增的印象。
我的代码- 新增部分
前面我给大家看过一个最简单的structureof 2-3 treenode,那个结构其实也可以做,但是比较麻烦。因为我们需要往上读父结点,如果没有parent指针,一般都是两种方法,一是用临时变量保存,这种方法在2-3tree里不是太现实,因为你不知道要往上查多少次;另外一种方法是用回溯,把所有顺序访问过的结点放到stack里,而我前面也说过,2-3tree的高度最大为log2n +1,所以stack的实现用数组都可以,比较方便。如果stack为空,表示我们已经到root了。但是用stack的话,我们就只能用循环。(大家都明白为什么吧)
如果在c++里,我们还可以用递归回到父结点上;但是java里参数传递没有引用传递(也有人说JAVA只有引用传递,都是说法一个,这里不追究)这个概念,所以我们很难用递归一步一步往上走。
不过既然写程序的是我们,我们可以对那个结构稍做修改,以适应我们的需要。
请参照上面那些树的图,有没有发现很多时候新增动作发生之后,一个结点会出现4个子树,3个值。这是最大的可能发生的情况,那么我们就给结构3个值,4个子树。同时为了方便,也给它一个父结点的引用,以及一个itemCount的值标志该结点有多少个值。
因此,新的tree23Node结构如下:
public class tree23Node { int small; // the small item in this node int large; // the large item in this node int temp; // stores a temporary item
tree23Node left; // specifies left child tree23Node middle; // specifies middle child tree23Node right; // specifies right child int itemCount; // how many items in this node
// 以下一个结点用来储存临时子树,为什么设置成为private,是因为我打算将读取它们的动作都放在这个class里,就自然不需要让别人看到它们。 private tree23Node rtNode; private tree23Node parent; }
|
下面是tree23Node的constructor,一共有两个,很容易看到,这里就不注释了。
需要注意的是,我设置了default value是 -1,其实也是最小值。所以我是假定后面的2-3 tree 加入的值都是正数。当然你也可做相应修改以至能够接受负数。
tree23Node() { itemCount=0; small = large = temp = -1; left = middle = right = parent = null; } // end tree23Node constructor
tree23Node(int newVal) { itemCount = 1; small = newVal; large = temp = -1; left = middle = right = parent = null; } // end tree23Node constructor |
同时tree23Node还提供两个方法,一个是isLeaf,返回真如果该node是一个leaf;代码如下(对不起,注释我一般习惯用英文):
/**************************************************************** * method isLeft * purpose: tell whether this node is a leaf or not. * (a leaf is a node that has no child) * return true if it is a leaf ***************************************************************/ public boolean isLeaf() { boolean retval = false; if (left==null && middle ==null && right==null) { retval = true; } return retval; } // end method isLeaf |
另外一个重要的方法是 split,这个代码会放到最后。
下一篇,我们会看到tree23 class 里面的insert方法
对于 class tree23,我提供了一个public的insert方法,如下:
public void insert(int data) { // if the root is empty, which means the tree is emtpy. // create the tree. otherwise, call a private method
if (root==null) { root = new tree23Node(data); } else { insert(root, data); } } // end insert |
同时,我也提供了一个private的insert方法,其实也可以放到同一个地方,主要就是免得一个方法太长而已。该方法代码如下:
private void insert(tree23Node curr, int data) { // stores the node in a temporary variable
tree23Node ptr = curr;
// for 2-3 tree, all new item must be inserted in a leaf. // find the correct leaf firstly // 还记得我前面说过的吧,第一步永远都是先找到一个leaf
while (!ptr.isLeaf()) { if (data <= ptr.small && ptr.left!=null) { ptr = ptr.left; } else if (data>=ptr.large && ptr.right!=null) { ptr = ptr.right; } else if (data<ptr.large && ptr.middle!=null) { ptr = ptr.middle; } }
// 下面首先判断两种情况,如果当前的leaf只有一个元素,直接放进去就是了
if (ptr.itemCount==1) { if (ptr.small <= data) { ptr.large = data; ptr.itemCount ++; } else { ptr.large = ptr.small; ptr.small = data; ptr.itemCount ++; } } // end outer if
//否则的话,这个leaf已经full了,因为它有两个值。那我就split它好了。但是在split之前,我先做一件事,就是先还是照加入新值,并按顺序排列好。就是说,如果新值最小,就放到small,把原来的small放到large,把原来的large放到temp。如果新值最大,就放到temp;如果它是中间值,就放到large,而原来的large就放到temp。
else { if (ptr.large <= data) { ptr.temp = data; } else if (ptr.small <= data) { ptr.temp = ptr.large; ptr.large = data; } else { ptr.temp = ptr.large; ptr.large = ptr.small; ptr.small = data; }
// increase the item count of this node, which // should be 3 now
ptr.itemCount++;
// call split method and overwrite root
root=ptr.split(); // split 方法返回的是一棵完整的树
} // end else } // end method insert |
终于到了最激动人心的时候,就是 class tree23Node 的split方法。
为什么要把这个方法放到tree23Node里面呢?一开始我只是觉得会方便一些,因为比较split的时候,对每个结点的各种属性都需要访问和改变,如果在this里面访问,可以不需要写 “[对象].” 这样。呵呵,很无聊的原因吧。
其实,放到tree23Node还是有原因的,从OO的概念上看,tree23.split()没有意义,无论它是否private。Split这个操作是属于一个node的。另外,从可读性和操作性上,放在tree23里都相当麻烦,我也看过不少这样做的代码,大家也不妨去google查查看。
在显示代码之前,先做一些说明。
Split方法只有当一个node是full的时候才被调用,而且返回值是一棵完整的2-3tree。
该结点其中small是最小值,large是中间值,temp包含最大值。我们要做的是:
1. 将最小值和最大值分开,变成独立的两个node,让我们叫一个是小结点,一个是大结点
2. 把中间值推到父结点。
1)如果父结点是空的,那么表示我们已经到了root,创建一个新root包含那个中间值,并连接小结点到left,大结点到right,返回新创建的root
2) 看看this是属于父结点的哪一个子树
a) 属于左子树,推上去的值一定是一个父结点的最小值,需要保存在small里
如果父结点是full的,先把原来的right连接到rtNode里做个备份,原来的large放到temp里,small放到large,推上去的值放在small里。现在轮到父结点需要split了,请在这里记住rtNode不为null。
如果父结点不是full,那大家都知道怎么做了。
b)属于右子树,推上去的一定是父结点的最大值,需要保存在large(也可能在temp里,要看父结点是否full)
然后如上,分析父结点是否为full
c) 属于中子树,如果父子树有中子树,那么表示它一定已经full了。
最后,看看父结点的 rtNode是否为空,如果不是,表示需要继续往上split。递归调用。否则就一直循环推上去直到root,返回。
可能我上面解释的不太清楚,还是让我们看代码吧
tree23Node split() { tree23Node retval, node1, node2; int s, m, l;
s = small; m = large; l = temp;
// firstly, prepare two new nodes that are splited // in this node. That means, I have two new nodes that // one has the smallest item, another has the largest item. // Then, I will pass the middle item up.
node1 = new tree23Node(s) ; node2 = new tree23Node(l);
// there are two cases // 1. this is the root, I just need to create a new root // that contains the middle item and connect two new // nodes to it // 2. this is not the root, I need to do the following things // 1) consider this node is parent’s left, middle, or // right, to do different connections // 2) if parent is still full, recursively split
if (parent==null) { // create a new node contains the middle value
retval = new tree23Node(m); retval.left = node1; retval.right = node2; } else { // get the parent node
retval = parent;
// if this node is parent’s left child
if (retval.left==this) { // if parent is full already, now parent should // have four children(1 is in rtNode), three items
if (retval.itemCount>1) { retval.rtNode = retval.right; retval.right = retval.middle; retval.temp = retval.large; }
retval.left = node1; retval.middle = node2; retval.large = retval.small; retval.small = m; } else if (retval.right==this) { // if parent of this is not full, now parent // should have 3 children, otherwise 4, 1 is in // rtNode
if (retval.itemCount==1) { retval.middle = node1; retval.right = node2; retval.large = m; } else { retval.right = node1; retval.rtNode = node2; retval.temp = m; } } else if (retval.middle == this) { // if this is the parent’s middle child // it means parent must have 2 items, it // has been full, I need to store one in rtNode
retval.middle = node1; retval.rtNode = retval.right; retval.right = node2; retval.temp = retval.large; retval.large = m; } else { //should never happen
System.out.println(”pointer error!”); System.exit(0); }
// 我已经把新元素加到父结点去了,它是否合法我暂时不管,总之我先增父结点的总数,减掉当前结点的总数
retval.itemCount++; itemCount–;
} // end else parent != null
// fix the new nodes
node1.parent = node2.parent = retval;
// if this node is not a leaf, I also need to fix // new nodes’ children and those parents
if (!isLeaf()) { node1.left = left; node1.right = middle; node2.left = right; node2.right = rtNode;
if (node1.left!=null) { node1.left.parent = node1; }
if (node1.right!=null) { node1.right.parent = node1; }
if (node2.left!=null) { node2.left.parent = node2; }
if (node2.right!=null) { node2.right.parent = node2; } } // end if
// now I can clean the resources
left = middle = right = rtNode = parent = null;
// if parent still has an rtNode, it means parent is full // now, it needs to be splited too.
if (retval.rtNode!=null) { return retval.split(); }
// if I am here, that means I have splited everything // now I move to the top, return the root
while (retval.parent!=null) { retval = retval.parent; }
return retval;
} // end method split |
到这里,新增就全部讲完了。真是累得可以,明天继续。
删除
这次,让我们直接看例子,请回顾之前看到的【树一】
1. 最简单的情况就是,删除一个leaf结点的值后,改结点仍然不会空。比如删除【树一】的39,43,69,73(是说一次删除一个,不是指依次删除)。提到的值都是位于leaf上,而且这些结点都是已经有两个值;不过要记得移除了large值的话,不需要多做什么;但移除small值,需要将large值设置为-1,然后把原来的large移动到small上。
2. 在【树一】上移除12
如果移走了12,树会变成如下:
<53>
/ /
/ /
<27> <65, 78>
/ / / | /
<> <39,43> <60> <69,74> <93>
树八 (not valid yet)
<27> 的左子树空了,这不合法。这时候,我们需要向<12>的“亲戚”(sibling)借一个值,也就是<27>的右子树,它刚好有两个值。将小值39推上去,再将27拉下来,最后如下
<53>
/ /
/ /
<39> <65, 78>
/ / / | /
<27> <43> <60> <69,74> <93>
树九
这里使用的策略是,当删除一个结点的值会识得该结点变空,就尝试向它的亲戚结点借一个值,如果可能的话。
3. 删除非leaf结点的值
这次让我们删除【树九】的65,这是一个在非leaf结点上的值。
因为当一个结点非leaf,它一定有子树,因此也必定存在有“inorder successor”(这个东西真的不知道怎么翻译,这个跟BST的删除一样,找到一个最小的比65大的元素)
所以,我们可以找到69,然后跟65替换,最后的树变成
<53>
/ /
/ /
<39> <69, 78>
/ / / | /
<27> <43> <60> <74> <93>
树十
4. 删除一个元素,其亲戚结点无值可借
比如【树十】我们要删除74,它右边的亲戚结点只有一个值,无法借过来。还记得我之前曾经提过吗,对,我们把父结点的值拉下来,就刚好如同我们新增时候所做的反过来。我们可以将69拖下来,并与60合并。最后树变成
<53>
/ /
/ /
<39> <78>
/ / / /
<27> <43> <60,69> <93>
树十一
请自己尝试在【树十一】上增加74,看看是否和【树十】一样
5. 删除一个元素,其亲戚结点无值可借,而且父结点只有一个元素,无法下推
比如,我们要从【树十】中删除43,如果我们像刚才所说的那样做,把父结点拖下来合并,树就会变成这样:
<53>
/ /
/ /
<> <69, 78>
/ / / | /
<27,39> <> <60> <74> <93>
树十二 (not valid yet)
这明显不合法。我们需要做的时候重复一次,再把父结点(39的父结点是53)拖下来合并,原来39的位置变成53了,53的位置又变空了。可是这次原来39的位置有亲戚子树<69,78>可以借。请参照情况2,把小值69推上去,因为53去了左子树那边,需要找它的inordersuccessor(找到60),把它移到53的右子树去。最终,完整的树变成
<69>
/ /
/ /
<53> <78>
/ / / /
<27,39> <60> <74> <93>
树十三
现在是一棵合法的2-3树了,但是这次你不能用将43重新插入来检验是否正确,因为如果重新插入43会变成
<69>
/ /
/ /
<39,53> <78>
/ | / / /
<27> <43> <60> <74> <93>
树十四
6. 删除一个元素,其亲戚结点无法借值,其父结点只有一个元素,其父结点的亲戚结点也无法借值……
这是最复杂的情况,其实也就是需要不断的重复步骤,直到出现以下情况
1) 当一个结点有两个元素
2) 当一个结点的亲戚结点可以借值
3) 当已经到达root
当最后一种情况发生的时候,root的两个子树就合并,原来的root就被删除,新的root出现,树的高度减1
总结
删除元素i的方法
1) 找到包含元素i的结点n
2) 如果n是一个leaf,先删除i
如果n不是一个leaf,找到i的inorder successor,与i交换,然后删除i
3) 如果这个时候n不是空,那么删除完成
如果n变空了,恶梦开始(^^),我们需要修复树
a) 检查n的亲戚结点,如果有两个元素,借一个过来(要注意借过来的元素和父结点元素的安排)比如你从左子树借一个过来,那当然是借一个左子树的large,然后原来的父结点元素拖下来;如果是从右子树借一个,那当然是借small,还要同时把small变成large,再把small变成新父结点,原来的父结点放到空结点去。(如果觉得比较混乱请自己画个图就清楚了)
b) 如果没有亲戚结点有两个元素,那就直接把父结点拖下来合并,让父结点变成空。
c) 递归的往上重复,直到遇到在情况6中所述的三种情形。
代码
首先我要给大家看的是 tree23里面的delete方法
public void delete(int data)
{
tree23Node ptr, found;
int tmp;
found = locate(data); // 首先我找到该元素所在的结点
if (found==null)
{
return;
}
else
{
if (!found.isLeaf()) // if it is not a leaf
{
if (found.small==data && found.itemCount>1)
{
ptr = found.middle;
}
else
{
ptr = found.right;
}
// 这里我是找 inorder successor
while(ptr.left!=null) ptr = ptr.left;
// 然后交换需要删除的那个值
if (found.small==data)
{
tmp = found.large;
found.small = ptr.small;
ptr.large = tmp;
}
else
{
tmp = found.large;
found.large = ptr.smll;
ptr.large = tmp;
}
}
else // found is a leaf
{
ptr = found;
}
// 交换完毕了就删除元素
if (ptr.large == data)
{
ptr.large = -1;
ptr.itemCount–;
}
else if (ptr.small==data)
{
ptr.small = ptr.large;
ptr.large = -1;
ptr.itemCount–;
}
// if it is empty, fix the root
// 这里我用了跟新增一样的技巧,调用fix方法修复,然后返回整棵树
if (ptr.itemCount == 0)
root = ptr.fix();
} // end if found==null
} // end delete |
剩下的工作,就是在 tree23Node里增加一个方法fix,不需要任何参数,该方法调用的时候this一定是一个空结点,然后就往上找就是。具体实现的步骤已经说得很清楚了,而方法也类似split那样。
如果感兴趣的朋友不妨自己试试,倒的确是一个锻炼的好机会。
后记
这篇东西纯粹是介绍性质的文章,因为2-3树在国外是很受重视的,可惜似乎国内倒不太关注。我在google上找到有人问,却没有什么比较详细的中文资料。于是才决定写这篇东西。写得比较潦草,也没有非常专业的口吻,不妥的地方还请指教。非常欢迎转载,但是请不要自己修改后发表到盈利的报刊杂志上,只限网上转载,转载的时候也请声明出处,谢谢。