13.1 平衡树的构建
二叉搜索树(BST)算法对于多种应用都能很好地工作,但是在最坏情况下,都存在性能低下的问题。
一种在BST里产生较好平衡的方法是,周期性地再平衡二叉搜索树。用这种方法我们可以让大多数的二叉搜索树在线性时间里完全平衡。这样的再平衡可能改善针对随机键的性能,但它并不能保证,在动态符号表里避免二次性最坏情况的性能。一方面,再平衡运算之间的键序列的插入时间可能根据序列的长度呈二次性增长;另一方面,我们不想经常再平衡巨大的树,因为每一次再平衡运算至少花费相对于树大小的线性时间。
link balanceR (link h)
{
if (h ->N < 2) return h;
h = partR(h, h ->N/2);
h ->l = balanceR(h ->l);
h ->r = balanceR(h-> r);
return h;
}
如何给基于BST的符号表实现提供有保障的性能?它们分别是在算法设计里提供性能保证的三个一般方法的主要例子。这三种方法是:随机化(randomize),分摊(amortize),最优化(optimize)。
l 随机化算法把随机决策引入算法本身,目的是大大降低最坏情形发生的概率(不管输入是什么)。
l 分摊方式在某一时刻做额外的工作,以避免在将来做更多的工作,它能对每个运算的平均开销提供上限保证(所有运算的总开销由大量运算分摊)。
l 最优化方式是不辞辛苦地为每个运算提供性能保障。
13.2 随机BST
为了分析平均情况下二叉搜索树的性能开销,我们不妨假设项是按随机顺序插入的。在使用BST算法的环境中,这个假设的主要结果是,树里的每个节点为根节点的机会均等,而且,此条件性质对子树也成立。值得注意的是,可以把随机性引入算法中,使得在不对插入项的顺序作出任何假设的情况下,该性质仍然成立。其思想非常简单;当我们把一个新节点插入一个有N个节点的树里时,这个新节点出现在根部的概率应该为1/(N + 1)。所以,我们使用这个概率,简单作出随机决策来使用根插入。否则,我们就使用标准的BST插入方法。
13.2.1 随机BST操作
13.2.1.1 插入操作
link insertRandom (link h, Item item)
{
Key v = key (item);
Key t = key (h -> item);
if (h == z) return NEW (item, z, z, 1);
if (rand () < RAND_MAX /(h ->N + 1))
return insertT(h,item);
if (less (v,t))
{
h->l = insertR(h->l,item);
} else {
h->r = insertR(h->r,item);
}
(h->N)++;
return h;
}
void STinsertRandom(Item item)
{
head = insertRandom(head, item);
}
13.2.1.2 删除操作
link joinLRandom (link a, link b)
{
if (a == z) return b;
if (b == z) return a;
if (rand() / (RAND_MAX / (a->N + b ->N) + 1) < a->N)
{
a ->r = joinLRandom (a ->r, b);
fixN(a);
return a;
}
else {
b ->l = joinLR(a, b->l);
fixN(b);
return b;
}
}
link deleteRandom (link h, Key v)
{
if (h == z) return z;
link x;
Key t = key (h -> item);
if (less (v, t)) {h -> l = deleteR (h -> l, v); fixN(h -> l);}
if (less (t, v)) {h -> r = deleteR (h -> r, v); fixN(h -> r);}
if (eq(v, t))
{
x = h;
h = joinLRandom(h -> l, h -> r);
//fixN(h);
free (x);
x = NULL;
}
fixN(h);
return h;
}
void STdeleteRandom(Key v)
{
head = deleteRandom (head, v);
}
13.2.1.3 查找操作
随机BST的查找操作根标准BST的查找操作一样。
13.2.1.4 合并操作
link STjoinR (link a, link b)
{
if (a == z) return b;
b = STinsert(b, a->rec);
b->l = STjoin(a->l, b->l);
b ->r = STjoin(a->r, b->r);
fixN(b);
free(a);
return b;
}
link STjoin(link a, link b)
{
if (rand()/(RAND_MAX)/(a->N + b->N) + 1) < a->N)
STjoinR(a,b);
else STjoinR(b,a);
}
13.2.2 随机BST的性质
通过随机插入、删除、和合并的任意运算序列生成的树等价于根据树中的随机键排列生成的标准BST.
13.3 分裂BST
这一次,我们不是考虑递归性的单个旋转将新插入的节点带到树顶部,而是考虑两个旋转,将节点从根的孙子之一的位置向上带到树顶。首先,执行一个旋转将节点变成树根的孩子。然后,执行另一个旋转将它带到根。根据从根到被出入节点的两个链接方向性是否相同,有两种本质上不同的情况。当方向不同时,使用标准BST根插入法从下向上旋转;当方向相同时,从上向下旋转使用两次旋转(而不是标准BST根插入法的从下向上)。这两种方式都同样将新插入的节点带到根节点。这种看似无关紧要的区别,实际上有重要意义。分裂运算消除了二次性最坏情况,这正是标准BST的主要缺陷。
13.3.1 分裂BST插入操作
这个程序检查了从根的两步搜索以及执行适当旋转的4可能情况:
左 – 左:在根部右旋转两次。
左 – 右:在左孩子左旋转,然后在根部右旋转。
右 – 右:在根部左旋转两次。
右 – 左:在右孩子右旋转,然后再根部左旋转。
/*
*因为分裂BST需要知道相邻两个链接的方向,所以在一次递归调用之前需要进行两次判断,以决定旋转的方式。
*/
link splay(link h, Item item)
{
Key v = key(item);
if (h == z) return NEW(item, z, z, 1);
if (less (v, key(h ->item)))
{
if (h ->l == z) return NEW(item, z, h, h->N + 1);
if (less (v, key(h->l->item)))
{
h ->l->l = splay (h->l->l,item);
h = rotR(h);
} else {
h ->l->r = splay(h->l->r,item);
h->l = rotL(h->l);
}
return rotR(h);
} else {
if (h->r == z) return NEW (item, h, z, h ->N + 1);
if (less(key(h->r->item),v))
{
h->r->r = splay(h->r->r,item);
h = rotL(h);
} else {
h->r->l = splay(h->r->l,item);
h ->r = rotR(h->r);
}
return rotL(h);
}
}
void STinsertAmortize(Item item)
{
head = splay(head, item);
}
13.3.2 分裂BST查找操作
13.3.3 分裂BST性质
当使用分裂插入法在一颗BST中插入一个节点时,不仅将该节点带到了根,而且将其他在搜索路径上碰到的节点带到离根更近的位置。准确的说,我们执行的旋转将从根到碰到的任何节点之间的路径减少一半。如果我们实现搜索运算时,让它在搜索中执行分裂转换,这个性质同样成立。树中的一些路径会变得更长:如果我们不访问这些路径上的节点,其影响对我们来说就不重要。如果我们访问一条长路径上的节点,在完成后,它将变成一半长。因此没有一条路径可以带来高的开销。
当插入序列为有序时,分裂BST生成的树为最坏情况,有N-1层。但是通过少量的几次分裂搜索就可以实质性的改进这棵树的平衡性。
13.4 自顶向下2-3-4树
尽管通过随机BST和分裂BST可以得到性能保证,但都仍然存在特定的搜索运算时间为线性的可能性。因此,这两种类型的BST不是如下平衡树的基本问题的答案:是否存在某种类型的BST,能够保证每次插入和搜索运算都与树的大小成对数关系?
13.4.1 2-3-4树的定义
2-3-4搜索树是一棵或为空或包含3种类型节点的树:2-节点,它具有1个键、具有较小键的树的左链接以及具有较大键的树的右链接;3-节点,它具有2个键、具有较小键的树的左链接、具有节点键之间键值的树的中间链接以及具有较大键的树的右链接。4-节点,它具有3个键,具有由节点的键对应的区间定义的键值的树的4个链接。
13.4.2 2-3-4树的操作
2-3-4树中对树的操作主要为插入节点以及对4-节点的删除。
为了在一颗2-3-4树种插入新节点,可以像在BST中一样,先进行一次失败搜索,再插入该节点。如果搜索终止的节点是一个2-节点,只要将该节点变成一个3-节点。相似地,如果搜索在一个3-节点终止,只要将该节点变成一个4-节点。但如果搜索在一个4-节点终止,就需要对4-节点进行分解。在保持树平衡的同时,为新键腾出空间,首先将4-节点分成2个2-节点,将中间键上传到节点的父亲。如果其父亲也是4-节点,则继续分解,直到找到一个非4节点的父节点。
13.4.3 2-3-4树的性质
1. 平衡2-3-4搜索树是这样一棵2-3-4搜索树,它的所有空树的链接都与根距离相同。
2. 只有根节点的分解会使树增高,内部节点的分解不会影响树的高度。所以2-3-4树是自低向上生长的,而标准BST是自顶向下生长的。
3. 搜索N-节点2-3-4树时,最多访问lgN + 1个节点。
4. 向N-节点2-3-4树进行插入时,在最坏情况下,需要分解的节点数少于lgN + 1;平均来讲,需要分解的节点数小于1。
13.5 红黑树
其基本思想是将2-3-4树表示为标准BST(仅有2-节点),但为每个节点添加一个额外的信息位,来为3-节点和4-节点编码。我们将链接看作有两种不同的类型:红链接(red link),它将包含3-节点和4-节点的小二叉树捆绑在一起,还有黑链接(black link),它将2-3-4树捆绑在一起。
我们用红链接连接的3个2-节点来表示4-节点,用红链接连接的2个2-节点来表示3-节点。3-节点中的红链接可能是一个左链接或者一个右链接,因此,表示每个3-节点有两种方式。
13.5.1 红黑树定义
1. 红黑树中的节点要么为红节点要么为黑节点。
2. 根节点为黑节点。
3. 外部节点为黑节点。
4. 红节点的孩子为黑节点。
5. 从外部链接到根的所有路径都具有相同的黑节点数。
13.5.2 红黑树的操作
13.5.2.1 红黑树的插入操作
/*
这个函数使用红-黑表达方式实现2-3-4树中的插入。给类型STnode添加颜色位red(并相应扩展NEW),用1表示节点为红色,用0表示节点为黑色。空树是标记节点z的链接---一个具有指向自身的链接的黑色节点。
在沿树而下的路径中(在递归调用之前),检查4-节点,交换所有3个节点的颜色位来分解它们。当我们到达底部时,为被插入的项创建一个新红色节点,并返回它的一个指针。
在沿树而上的路径中(在递归调用之后),设置下向链接,它指向返回的链接值,然后检查是否需要旋转。如果搜索路径有两个具有相同方向的红色链接,则从顶部节点进行单个旋转,然后交换颜色位得到正确的4-节点。如果搜索路径有两个具有不同方向的红色链接,则从底部节点进行单个旋转,逐渐还原到其他情形。
*/
link RBinsert (link h, Item item, int sw)
{
Key v = key(item);
if (h == z) return NEW(item, z, z, 1, 1);
if ((h->l->red) && (h->r->red))
{
h->red = 1;
h->l->red = 0;
h->r->red = 0;
}
if (less (v, key(h->item)))
{
h->l = RBinsert(h->l, item, 0);
if (h->red && h->l->red && sw) h = rotR(h);
if (h->l->red && h->l->l->red)
{
h = rotR(h);
h->red = 0;
h->r->red = 1;
}
} else {
h->r = RBinsert(h->r, item, 1);
if (h->red && h->r->red && !sw) h = rotL(h);
if (h->r->red && h->r->r->red)
{
h = rotL(h);
h -> red = 0;
h ->l->red = 1;
}
}
fixN (h);
return h;
}
void STinsertRB(Item item)
{
head = RBinsert (head, item, 0);
head ->red = 0;
}
新插入的节点为红节点,插入红节点之后可能会违反性质4(此时没有一个2-3-4树的表示与其对应,所以需要调整)。
红黑树在插入操作时需要对4-节点进行分解操作,各种情况下对4节点的分解方法如下图所示。
13.5.2.2 红黑树的删除操作(未实现)
13.5.3 红黑树的性质
红黑树能保证对于所有的搜索和插入,所化的步数为对数关系。这是具有此性质的少数符号表实现之一,而且它也适用于库实现,其中被处理的键序列不能准确特征化。
根据随机键生成具有N个节点的红-黑树,在此树中的搜索平均约适用1.002lgN次比较。
13.6 跳表
这一节介绍的跳表,它初看之下完全与我们所讨论的基于树的方法不同,但实际上,它们紧密关联。它基于一种随机数据结构,几乎对于我们考虑的所有符合表ADT基本运算都能提供接近最优的性能。搜索过程中,在跳跃式通过表的大部分时,它使用了链表的节点中额外的链接。
13.6.1 跳表的定义
跳表是一种有序链表,其中的每个节点包含数量可变的链接,并且节点中的第i个链接单独实现链接,它跳过少于i个链接的节点。
13.6.2 跳表的操作
跳表中的节点有一个链接数组,因此,NEW需要分配该数组,并将所有的链接设置为标记z。常数lgNmax为表中允许的最多层数,对于小型表,它可以设置为5,对于大型表,可以设置为30 。与往常一样,变量N为表中的项数,lgN为层数。空表为具有lgNmax个链接的一个头节点,所有链接均设置为z,N和lgN设置为0。
typedef struct STnode* link;
struct STnode{Item item; link *next; int sz;};
static link head,z;
static int N, lgN;
link NEW(Item item, int k)
{
int i;
link x = malloc(sizeof *x);
x->next = malloc (k * sizeof (link));
x->item = item;
x->sz = k;
for (i = 0; i < k; i++) x->next[i] = z;
return x;
}
void STinit(int max)
{
N = 0;
lgN = 0;
z = NEW(NULLitem,0);
head = NEW(NULLitem,lgNmax);
}
13.6.2.1 跳表的插入
要在跳表中插入一个项,先生成一个新的j个链接的节点(概率为1/2j),然后,沿着链接进行搜索,当下移到底部j层的每一层时链接新节点。
int randX()
{
int i, j, t =rand();
for (i = 1, j = 2; i < lgNmax; i++, j+=j)
if (t > RAND_MAX/j) break;
if (i > lgN) lgN = i;
return i;
}
void insert(link t, link x, int k)
{
Key v = key(x->item);
if (less(v, key(t->next[k]->item)))
{
if (k < x->sz)
{
x->next[k] = t->next[k];
t->next[k] = x;
}
if (k == 0) return;
insertR(t, x, k - 1);
return;
}
insertR(t->next[k], x, k );
}
void STinsert(Key v)
{
insertR(head,NEW(v, randX(), lgN));
N++;
}
13.6.2.2 跳表的搜索
若k等于0,此代码就等价于单链表中的搜索。对于一般k值,如果它的键小于搜索键,则将表中的下一个节点移到k层,如果大于搜索键,则下移到k-1层。为了简化代码,假设所有的表以一个标记节点z终止,z为带有maxKey的NULLitem。
Item search(link t, Key v, int k)
{
if (eq(v, key(t->item))) return t->item;
if (less(v, key(t->next[k]->item)))
{
if (k == 0) return NULLitem;
return searchR(t, v, k - 1);
}
return searchR(t->next[k], v, k);
}
Item STsearch(Key v)
{
return searchR(head, v, lgN);
}
13.6.2.3 跳表的删除
要从跳表中删除具有已知节点键的节点,在发现它的链接的每一层取消其链接,然后再到达底层时释放它。
void deleteR(link t, Key v, int k)
{
link x = t -> next[k];
if (!less(key(x->item),v))
{
if (eq(v, key(x->item)))
{t->next[k] = x->next[k];}
if (k == 0) {free(x); return;}
deleteR(t,v,t - 1);
return;
}
deleteR(t->next[k], v, k);
}
void STdelete(Key v)
{
delete(head, v, lgN);
N--;
}
13.7 基于BST的内部搜索算法性能比较
本章所讨论的所有方法为一般的应用都提供了优秀的性能,而且对有兴趣开发高性能符号表实现的人来说,每个方法都有各自的长处。分裂BST将提供与自组织搜索方法同样优秀的性能,特别是在频繁访问小型键集的典型模式下;随机BST对于全功能的符号表BST来说,可能更快,也更易于实现;跳表易于理解,与其他方法相比,它以比较少的空间开销提供了对数关系的搜索时间,而对于符号表的库实现,红-黑BST最具吸引力,因为它提供了最坏情况下的性能保障,而且对于随机数据具备最快的搜索和插入算法。