在前面我们说了二叉查找树,它在最坏的情况下是很糟糕的。下面我们来说一种查找树,这种这查找树能够保证无论如何构造它,它的运行时间都是对数级别的。理想情况下我们希望能够保持二分查找树的平衡性。在一棵含有N个结点的树中,我们希望树的高度为lgN,这样我们就能保证所有查找能在lgN次比较内结束,就和二分查找一样。
2-3查找树
为了保证查找树的平衡性,我们需要一些灵活性,因此在这里我们允许树中的一个结点保存多个键。确切的说,我们将一颗标准的二叉查找树中的结点称为2-结点(含有一个键和两条链),而现在我们引入3-结点,它含有两个键和三条链接。
2-3查找树或为一颗空树,或者有以下结点组成:
- 2-结点,含有一个键(及其对应的值)和两条链接,左链接指向的2-3树中的键都小于该结点,右链接指向的2-3树中的键都大于该结点。
- 3-结点,含有两个键(及其对应的值)和三条链接,左链接指向的2-3树中的键都小于该结点,中链接指向的2-3树中的键都位于该结点的两个键之间,右链接指向的2-3树中的键都大于该结点。
和以前一样,我们将指向一颗空树的链接
一颗完美平衡的2-3查找树中的所有空链接到根结点的距离应该是相同的。
查找
将二叉查找树的查找算法一般化我们就能够直接得到2-3查找算法。要判断一个键是否在树中,我们先将它和根结点中的键比较。如果它和其中任意一个相等,查找命中;否则我们就根据比较的结果找到指向相应区间的链接,并在其指向的子树只能够递归的继续查找。如果这是个空链接,表示查找未命中。
插入新键
要在2-3树中插入一个新结点,我们可以和二叉树一样先进行一次未命中的查找,然后把新结点挂在树的底部。但这样的话树无法保持完美的平衡性。我们使用2-3树的主要原因就在于它能够在插入后继续保持平衡。
向2-结点中插入新键
如果未命中的查找结束于一个2-结点,那么事情就变得简单的多:我们只要把这个2-结点替换成一个3-结点,将要插入的键保存在其中即可。如果未命中的查找结束于一个3-结点,事情就要麻烦一些。
向一颗只含有一个3-结点的树中插入新键
在考虑一般情况之前,先假设我们需要向一颗只含有一个3-结点的树中插入一个新键。这棵树中有两个键,所以在它唯一的结点中已经没有可插入新键的空间了。为了将新键插入,我们先临时将新键存入该结点中,使之成为一个4-结点(含有3个键和4条链接)。之后我们将它转换为一颗由3个2-结点组成的2-3查找树,其中一个结点(根)含有中键,一个结点含有3个键中的最小者(和根结点的左链接相连),一个结点含有3个键中的最大者(和根结点的右链接相连)。这棵树既是一颗含有3个结点的二叉查找树,同时也是一颗平衡的2-3树,因为其中所有的空链接到根结点的距离都相等。插入前树的高度为0,插入后树的高度为1.
向一颗父结点为3-结点的3-结点中插入新键
现在假设未命中的查找结束于一个父结点为3-结点的结点。我们再次和刚才一样构造一个临时的4-结点并分解它,然后将它的中键插入到它的父结点中。但父结点也是一个3-结点,因此我们在用这个中键构造一个临时4-结点,然后在这个节点上进行相同的变换,即分解这个父结点并将它的中键构造一个新的临时4-结点,然后在这个节点上进行相同的变换,即分解这个父结点并将它的中键插入到它的父节点中去。推广到一般情况,我们就这样一直往上不断分解临时的4-结点并将中键插入到更高的父结点中,直到遇到一个2-结点并将它替换为一个不需要继续分解的3-结点,或者是到达3-结点的根。
如果从插入结点到根结点的路径上全都是3-结点,我们的根结点最终变成了一个临时的4-结点。此时我们可以按照一颗树只有一个3-结点的树中插入新键的方法处理这个问题。我们将临时的4-结点分解为3个2结点,是得树的高度加1。如下图所示:
总的来说就是临时4-结点拆分为2-结点,2-结点变为3-结点的过程。
局部变换
将一个4-结点分解为一颗2-3树可能有六种情况,都总结到了下图中:
和标准的二叉查找树由上向下的生长不同,2-3树的生长是由下向上的。
在一棵大小为N的2-3树中,查找和插入操作访问的结点必然不超过lgN个。
但是,我们和真正的实现还有一段距离。尽管我们可以用不同的数据类型表示2-结点和3-结点并写出变换所需的代码,但用这种直白的表示方法实现大多数的操作并不方便,因为需要处理的情况实在太多。我们需要维护两种不同类型的结点,将被查找的键和结点中的每个键进行比较,将链接和其他信息从一种结点复制到另一种结点,将结点从一种数据类型转换为另一种数据类型,等等。平衡一棵树的初衷是为了消除最坏的情况,但我们希望这种保障所需要的代码能够越少越好。
幸好我们存在一种途径我们只需要付出一点点代价就能用同一的方式完成所有变换。
预知详情请移步下一篇。