可删除任意已知节点的左倾树

优先队列(priority queue)是一种抽象数据结构,它是一种容器,里面有一些元素,这些元素也称为队列中的节点(node)。优先队列的节点至少要包含一种性质:有序性,也就是说任意两个节点可以比较大小。为了具体起见我们假设这些节点中都包含一个键值(key),节点的大小通过比较它们的键值而定。不用说,键值也一定要满足有序性,比如是整数。优先队列有三个基本的操作:插入节点、取得最小节点、删除最小节点。

左倾树(leftist tree)是一种优先队列的实现。左倾树是一种二叉树,它的节点除了和二叉树的节点一样具有左右子树指针(left、right)外,还有两个属性:键值和距离(dist)。键值上面已经说过,是用于比较节点的大小。距离则是如下定义的:

一个具有空左子树或空右子树的节点称为外节点;一个节点的距离是这个节点到最近的外节点所经过的边的数目(最近的意思是边的数目最小),特别的,如果一个节点本身是外节点,则它的距离为0;而空节点的距离规定为-1(后面将会看到这样规定的理由)。在本文中,有时也提到一棵树的距离,这是指该树根节点的距离。

左倾树的各个属性满足下面两条性质:

 

  1. 一个节点的键值小于或等于它的左右子节点的键值(如果有子节点的话)。
  2. 一个节点的左子节点的距离大于或等于右子节点的距离。

这两条性质是对每一个节点而言的,可以简单的从中得出,左倾树的左右子树都是左倾树。

不难看到,左倾树的根节点是树中所有节点里键值最小的,它的每一棵子树也具有这样的性质。这样的性质称为堆性质,具有堆性质的数据结构称为堆(heap)。因此左倾树是一种堆。堆是实现优先队列的很好的数据结构,因为我们可以立即取到堆中的最小元素。

下图是一棵左倾树:

 

我们知道,一个节点必须经由它的子节点才能到达外节点。由于性质2,一个节点的距离实际上就是这个节点一直沿它的右边到达一个外节点所经过的边数,也就是说,我们有

性质3:一个节点的距离等于它的右子节点的距离加1。

外节点的距离为0,由于性质2,它的右子节点必为空节点。为了满足性质3,故规定空节点的距离为-1。

原则上,我们不必要性质2也可以使二叉树满足堆性质。性质2是为了使我们可以以更小的代价在优先队列的其它两个基本操作(插入节点、删除最小节点)进行后维持堆性质。稍后我们就会看到它的作用。

我们的印象中,平衡树是具有非常小的深度的,这也意味着到达任何一个节点所经过的边数很少。左倾树并不是为了快速访问所有的节点而设计的,它的目的是快速访问最小节点以及在对树修改后快速的恢复堆性质。从图中我们可以看到它并不平衡,由于性质2的缘故,它非常严重的倾向左侧,不过距离的概念和树的深度并不同,左倾树并不意味着左子树的节点数或是深度一定大于右子树。

左倾树的距离和平衡树的深度有着一个类似的性质,就是根节点的距离不会超过log2N,其中N为树的节点数。我们可以用归纳法证明

性质4:如果根节点的距离为d的话,那么这棵树至少含有2d个节点。

对树进行结构归纳。显然性质4对于一个节点的树成立,我们假设根节点的两棵子树,如果不空的话,满足性质4。当根节点的距离为d时,如果d等于0,显然树至少有1 = 20个节点;否则根据性质3,它的右子节点具有距离d-1。根据归纳假设,以这个右子节点为根节点的右子树(不是空树)至少有2d-1个节点,另外根据性质2,根节点的左子节点的距离不小于右子节点的距离,也就是说左子树也至少有2d-1个节点。这样整棵树加起来至少有2d-1 + 2d-1 + 1 = 2d + 1个节点。由此,证明了性质4成立,也就是根节点的距离不超过log2N。并且这个距离最接近log2N的时候是左倾树退化成为完全二叉树的时候。

有了上面的4个性质,我们可以开始讨论左倾树的操作了。传统的左倾树严格按照优先队列所需的基本操作设计,因此它只涉及到删除根节点,这对应于删除优先队列的最小节点。根节点是没有父节点的。我们要实现删除树中任意节点的操作,如果被删除的节点不是根节点,那么删除后它将留下左子节点、右子节点和父节点没有着落。由此,我们的节点结构中必须存放一个指向父节点的指针,以便立即找到被删除节点的父节点。我们的节点结构如下:

 

typedef struct _S_node_t node_t, *node_p, **node_pp; struct _S_node_t { int key; int dist; node_p parent, left, right; };

单节点的树一定是左倾树,因此向左倾树插入一个节点可以看作是对两棵左倾树的合并。删除根节点也是如此,在根节点删除后,剩下的两棵子树都是左倾树,需要把它们合并。看来,左倾树的合并是非常重要的操作,幸好我们可以进行非常高效的合并。由于上述的原因,左倾树也被称作可合并的堆(mergeable heap)。

合并操作A + B = C是递归进行的。首先假定两棵树都不空(否则的话返回另一棵树就行了),我们选择根节点较小的那一棵树A的根节点Aroot作为合并后的树C的根节点,而这棵树的左子树Aleft还作为C的待定左子树Cleft?,然后将这棵树的右子树Aright和另一棵树B递归的合并成一棵树Aright + B,把这棵树作为C的待定右子树Cright?

 

为什么是待定呢(注意下标中的问号)?由于Cright?是一棵新左倾树,我们不能肯定Cleft?的距离不小于Cright?的距离,一旦Cleft?的距离比Cright?的距离小,则需要交换C的这两棵子树。最后我们将C根节点的距离更新为它最终右节点的距离加1。

不难验证,经这样合并后的树C符合性质1和性质2,因此是一棵左倾树。至此左倾树的合并就完成了。

合并的复杂度是多少呢?从上面的过程可以看出,每一次递归合并的开始,都需要分解其中一棵树,总是把分解出的右子树参加下一步的合并。根据性质3,一棵树的距离决定于其右子树的距离,而右子树的距离在每次分解中递减,因此每棵树A或B被分解的次数分别不会超过它们各自的距离。根据性质4,分解的次数不会超过log2NA + log2NB,其中NA和NB分别为左倾树A和B的节点个数。因此合并最坏情况下的复杂度是O(log2NA + log2NB)。

如果在合并右子树和另一棵树之后什么也不用干,那么就不需要递归了。然而,我们在递归返回后干了什么?根据左右子树的距离决定是否交换以及调整根节点的距离。不要忘了,我们数据结构中含有一个父节点指针,通过它可以根据子树找到父节点,所以针对我们的数据结构,可以写出非递归的程序。

 

node_p merge(node_p t1, node_p t2) { node_p p, q = 0, l = t1, r = t2; node_pp pp = &p; while ( l && r ) { if ( l->key < r->key ) { *pp = l; l = l->right; } else { *pp = r; r = r->right; } (*pp)->parent = q; q = *pp; pp = &(q->right); } /* ...

合并完的树需要赋给一个指针上,除了一开始外,在循环过程中,这个指针是父节点的右子树指针。为了赋值,我们必须用指针的指针pp来记住变量的地址。q指向合并以后的树的父节点,这样可以把新合并的树的父节点指针指向父节点。l和r在每次循环开始总是指向了要合并的两棵树。

当要合并的某一棵树为空时,循环就结束了,我们简单的把剩下不空的那棵树赋给pp指向的接受合并结果的指针。

 

... */ if ( l ) { l->parent = q; *pp = l; } else if ( r ) { r->parent = q; *pp = r; } else *pp = 0; /* ...

接着,我们从最后被合并的树的父节点q开始顺着调整它的左右子树和距离,并沿着父节点指针回溯到根节点。

 

... */ while ( q ) { if ( !(r = q->right) ) q->dist = 0; else if ( !(l = q->left) ) { q->left = r; q->right = 0; q->dist = 0; } else { if ( l->dist < r->dist ) { q->left = r; q->right = l; q->dist = l->dist+1; } else q->dist = r->dist+1; } q = q->parent; } return p; } /* merge */

刚才讲过,插入新节点的操作就是一个简单的合并。我们只要先把新节点完成为一棵树就行了。

 

void ins(node_pp root, node_p node) { node->right = node->left = 0; node->dist = 0; *root = merge(*root, node); }

由于合并的其中一棵树只有一个节点,因此插入操作最坏时的复杂度是O(log2N)。

接下来是关于删除任意已知节点的操作。之所以强调“已知”,是因为这里所说的任意节点并不是根据它的键值找出来的,左倾树本身除了可以迅速找到最小节点外,不能有效的搜索指定键值的节点。故此,我们不能要求:请删除所有键值为100的节点。

前面说过,优先队列是一种容器。对于通常的容器来说,一旦节点被放进去以后,容器就完全拥有了这个节点,每个容器中的节点具有唯一的对象掌握它的拥有权(ownership)。对于这种容器的应用,优先队列只能删除最小节点,因为你根本无从知道它的其它节点是什么。

但是优先队列除了作为一种容器外还有另一个作用,就是可以找到最小节点。很多应用是针对这个功能的,它们并没有将拥有权完全转移给优先队列,而是把优先队列作为一个最小节点的选择器,从一堆节点中依次将它们选出来。这样一来节点的拥有权就可能同时被其它对象掌握。也就是说某个节点虽不是最小节点,不能从优先队列那里“已知”,但却可以从其它的拥有者那里“已知”。

这种优先队列的应用也是很常见的。设想我们有一个闹钟,它可以记录很多个响铃时间,不过由于时间是线性的,铃只能一个个按先后次序响,优先队列就很适合用来作这样的挑选。另一方面使用者应该可以随时取消一个“已知”的响铃时间,这就需要进行任意已知节点的删除操作了。

我们的这种删除操作需要指定被删除的节点,这和原来的删除根节点的操作是兼容的,因为根节点肯定是已知的。上面已经提过,在删除一个节点以后,将会剩下它的两棵子树,它们都是左倾树,我们先把它们合并成一棵新的左倾树。

 

void del(node_pp root, node_p node) { node_p p, q; int d; p = merge(node->left, node->right); /* ...

现在p指向了这棵新的左倾树,如果我们删除的是根节点,那么只要把p赋给根指针任务就完成了。

 

... */ if ( node == *root ) { *root = p; return; } /* ...

不过,如果被删除节点node不是根节点就有点麻烦了。这时p指向的新树的距离有可能比原来node的距离要大或小,这势必有可能影响原来node的父节点q的距离,因为q现在成为新树p的父节点了。于是就要仿照合并操作里面的做法,对q的左右子树作出调整,并更新q的距离。这一过程引起了连锁反应,我们要顺着q的父节点链一直往上进行调整,然而难道我们一定要进行到根节点为止吗?

 

回溯到根节点肯定可以得到正确的结果。但是,如果q处在左倾得很厉害的树的最左边,那么回溯路径的长度可就要大大增加了。这样做不能保证复杂度为O(log2N),也就失去了左倾树的意义。我们必须在某个适当的地方及时停止回溯。

首先,让我们先把p连回到q去,这要通过判断node原来是q的左子树还是右子树来决定连到哪里。为了效率起见,我们使用自动变量d来记住树p的距离加1,也就是有可能成为节点q的新距离。

 

... */ q = node->parent; if ( p ) { p->parent = q; d = p->dist+1; } else d = 0; if ( q->left == node ) q->left = p; else q->right = p; /* ...

现在不得不进一步分析一下新树p的距离,这个值加1以后也就是d了。如果d和q的原有距离一样,那么不管p成为q的左子树还是右子树,我们都不需要对q作出任何调整,删除任务也就完成了。

如果d小于q的现有距离,那么q的距离必须调整为d,而且如果p是左子树的话,说明d来自左子树,比q现有的右子树距离小,因此要交换子树。由于q的距离缩小了,那么q的父节点也要做出同样的处理。通过将d增加1,q赋给p并且q的父节点赋给q,我们回到了循环开始的情况。

 

... */ while ( q && d < q->dist ) { if ( q->left == p ) { q->left = q->right; q->right = p; } q->dist = d++; p = q; q = q->parent; }; /* ...

在这种情况下,由于q的距离只能缩小,当循环结束时,要么根节点处理完了,q为空;要么p是q的右子树并且d等于q的距离;如果d大于q的距离,那么p一定是q的左子树,否则会出现q的右子树距离缩小了,但是加1以后却大于q的距离的情况,不符合左倾树的性质3。不论哪种情况,删除操作都可以结束了。

即便如此,我们不是还是可能到达根节点吗?不错,但是这里我们不用考虑这个。注意一下,这个循环的每一次都会将d加1,而在循环体内,d最终将作为某个节点的距离。根据性质4,任何节点,如果以它为根节点的子树的节点数为M的话,它的距离都不会超过的log2M,子树的节点数M肯定不超过整棵树的节点数N,所以循环体的执行次数不会超过log2N。

剩下就是另外一种情况了,那就是新树p的距离增大了,也就是d比q的现有距离要大。这是可能的,考虑删除最上面那张图的节点(10)。在这种情况下,如果距离增大的p是左子树,那么我们无所谓,不用去理会它。只要考虑一直是右子树距离增大的情况。这时有两种可能:一种是距离增大后的右子树仍然比q的左子树距离小,那么我们直接调整q的距离就行了,同上面的循环一样,通过将d加1,q赋给p(这保持了p的距离增加的循环初始条件),q的父节点赋给q,我们回到了循环开始;另一种是要将q的左右子树交换以及调整q的距离,交换完了以后q的右子树是原来的左子树,它的距离只能等于或大于q的原有距离,如果等于成立,下一轮循环就不用进行了,否则通过将q的新距离加1赋给d,我们仍旧可以回到循环的初始条件。

 

... */ while ( q && d > q->dist && q->right == p ) { if ( q->left->dist+1 < d ) { d = q->left->dist+1; q->right = q->left; q->left = p; } q->dist = d++; p = q; q = q->parent; } } /* del */

注意这个循环体中我们直接使用q的左子树,它不可能为空,因为我们一直是从右子树升上来的,d在循环体开始时总是q的右子节点的距离加1,并且d大于q的距离(即d至少为1),这说明q的右子树不空,根据性质2,左子树也不可能空。一直从右子树升上来这个事实同样说明了循环的次数不会超过某个log2M,其中M为某个子树的节点数,也就是不会超过log2N。

最后我们看到这样一个事实,就是这两个循环只会执行其中一个。如果进入了上一个循环再退出后,我们已经知道要么q为空,要么d等于q的距离,要么p是q的左子树。这三种情况都不会导致下一个循环的进行。直观上来讲,如果合并后的新子树导致了父节点的一系列距离调整的话,要么就一直是往小调整,要么是一直往大调整,不会出现交替的情况。

我们已经知道合并出新子树p的复杂度最坏是O(log2Mnodeleft + log2Mnoderight),向上调整距离的复杂度最坏是O(log2N),其中M为相应子树的节点数,N为整个树的节点数,故删除操作的最坏情况的复杂度是O(log2N)。如果左倾树非常倾斜,实际应用情况下要比这个快得多。

左倾树之所以是很好的优先队列实现,是因为它能够捕捉到具有堆性质的二叉树里面的一些其它有用信息,没有将这些信息浪费掉。根据堆性质,我们知道,从根节点向下到任何一个外节点的路径都是有序的。存在越长的路径,说明树的整体有序性越强,与平衡树不同(平衡树根本不允许有很长的路径),左倾树尽大约一半的可能保留了这个长度,并将它甩向左侧,利用它来缩短节点的距离以提高性能。这里我们不进行严格的讨论,左倾树作为一个例子大致告诉我们:放弃已有的信息意味着算法性能上的牺牲。下面是最好的左倾树:有序表(插入操作是按逆序发生的,自然的有序性被保留了)和最坏的左倾树:平衡树(插入操作是按正序发生的,自然的有序性完全被放弃了)。

 

这里介绍的算法对传统左倾树算法增加了对任意已知节点的删除,以及利用父节点指针消除合并操作中的递归。其它部分参阅了现有的教学文献(这种文献很普遍,恕不一一列出)。顺便说一下,如果要像通常那样删除最小节点,只要执行del(&root, root)就行了,其中root指向根节点。

具体实现参见leftist.c

 

Traceback:http://home.macau.ctm.net/~kewei/youbing/leftist-tree.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值