【数据结构(邓俊辉)学习笔记】高级搜索树01——伸展树

文章目录

  • 1. 逐层伸展
    • 1. 1 宽松平衡
    • 1. 2 局部性
    • 1. 3 自适应调整
    • 1. 4 逐层伸展
    • 1. 5 实例
    • 1. 6 一步一步往上爬
    • 1. 7 最坏情况
  • 2. 双层伸展
    • 2.1 双层伸展
    • 2.2 子孙异侧
    • 2.3 子孙同侧
    • 2.4 点睛之笔
    • 2.5 折叠效果
    • 2.6 分摊性能
    • 2.7 最后一步
  • 3 算法实现
    • 3.1 功能接口
    • 3.2 伸展算法
    • 3.3 四种情况
    • 3.4 查找算法
    • 3.5 插入算法
    • 3.6 删除算法
    • 3.7 综合评价

1. 逐层伸展

1. 1 宽松平衡

曾经介绍过AVL树,这是一种典型适度平衡的二叉搜索树。你应该还记得,为此我们需要为其中的每一个节点都定义并且引入一个名为平衡因子的指标,并且要求其中所有节点的平衡因子的数值必须介于-1到+1之间。尽管相对于理想平衡而言,这种形式的适度平衡,已经在条件上略有放松,但依然显得有些过于苛刻。如果我们注意到,我们还需要在AVL树的动态调整过程中保持这种特性,这种苛刻就更是显而易见了。
在这里插入图片描述

如果比喻作人,AVL树就犹如那种,时时小心处处谨慎的类型,那么能否成为那类更为潇洒的人呢?也就是说,我们可否秉承一种更为宽松的准则,同时又从长远,从整体来看,依然不失某种意义的平衡性呢?答案是肯定的,答案就是我们这节的主角——伸展树。

1. 2 局部性

实际上,引入伸展树的最初动机非常的基本和原始。具体来说,也就是试图利用所谓的局部性,那么什么是局部性呢?
在这里插入图片描述
所谓局部性是实际的计算机环境中普遍存在的一种规律或现象。具体来说,也就是刚被访问过的数据极有可能很快的被再次访问到。

在实际的生活中,你应该也有类似的经历。比如,你有可能在机场上偶遇某位时隔五年甚至十年的老朋友,但在紧接着下来的第二天,你们或许可能又会在另一个场合,比如某个会议上在次碰面。

这样一种现象或者规律在信息处理的过程中是普遍存在屡见不鲜的。比如,我们这里讨论的BST就是一个典型的例子,对于BST来说,这一规律可以具体描述为:我们每次刚刚访问过的某一个节点都有可能很快的会再次被我们访问到,或者推而广之,我么接下来访问的那个节点即便不是你刚访问过的那个节点,也不会离它太远,所谓的虽不中,亦不远矣。

通过此前的学习,我们应该知道:对于AVL树而言,每一次查找所需的时间都是logn,因此任意的连续m次查找,所需要的累计时间无非就是O(m*logn),这无可厚非。

那么进一步的,如果我们对数据的访问的确具有上述的局部性,那么我们对数据集的访问效率能否做到更高呢?

1. 3 自适应调整

不妨来考察一个简单的实例,也就是典型的线性结构——列表。我们知道在列表结构中所有元素都按照它们之间的逻辑关系,可以排列成一个线性的序列,相邻的元素通过引用确立前驱和后继关系。

我们知道在这样的一个结构中对任何一个元素的访问效率将主要取决于它在这个序列中所具有的秩。

具体来说,秩越小,也就是越靠前的元素,访问的效率越高。秩越大,也就是越靠后的元素,访问的效率越低。

如果对于各元素的访问是完全理想随机的,我们恐怕只得如此,而不能奢望有什么实质的改进。

然而如我们刚才所说的,如果对这个集合的元素的访问具有局部性,甚至有极强的局部性,我们就可以通过简明的策略来实现对整个数据集合的更高效访问。比如一种行之有效的办法是一旦有某个元素刚刚接受访问,我们就立即将它移动到这个序列的最前端。这种技巧背后的策略不难理解。

因为根据局部性,我们接下来将要访问的元素很可能就是我们刚刚访问的那个元素,而这个元素就在此前刚刚被送到这个序列的最前端,而对于这样一个最前端的元素而言,我们的访问是唾手可得最为便捷的。

从整个数据结构的生命周期而言,这样一个列表结构,即便最初是完全随机分布的,那么在经过足够长时间的使用之后,在某一段时间内,被集中访问的那些元素都会不约而同的集中到这个列表的前端去。我们已经知道,这个区域的访问效率是相应更高的。因此我们就可以在一个足够长的时间跨度之内获得比此前更高的访问效率。

在这里插入图片描述

好了,现在我们可以回到我们的二叉查找树,为了便于对比,我们不妨将BST的画法旋转90度,具体来说,也就是将树的顶部和树的底部分别与列表的头部与尾部对应起来。这种对应是有道理的,因为在BST中位于顶部的元素相对于位于其他位置,尤其是底部的元素而言访问效率要高得多。因此如果我们希望借助局部性对BST的访问效率做进一步的优化,就不妨参照列表的这种技巧,将在某一段时间内,经常要访问到的元素,通过某种方式尽可能的移送到更加接近树根的位置,也就是等效的说,要尽可能的降低他们的深度。

那么这样一种构思是否真正的可行呢?如果可行的话,具体的方法又是如何的呢?

1. 4 逐层伸展

根据刚才的分析,下一个刚被访问的节点很有可能就是刚被访问的节点,因此为了提高下一次访问操作的效率,一种最直接的办法莫过于将刚被访问的那个节点直接移送到根节点的位置。因此我们就得到了一个简明的策略。
在这里插入图片描述
也就是说,任何一个节点,一旦被访问,我们都应随即将它转移至树根的位置。

为了实现这样一个目标,我们所能够借助的手段,依然无非是此前所介绍过的等价变换(zig zag)。

具体来说,就是zig和zag变换,如果节点V在当前这一层是左孩子,我们就通过对它的父亲做一次zig旋转,使得二者在高度上彼此互换。对称的,如果节点V当前是右孩子,就对它的父节点P做一次zag旋转,同样地,经过这样的一个调整之后,节点V和它的父亲将在高度上互换位置。

总而言之,无论是经过一次zig旋转还是zag旋转,对应的节点V都可以在高度上上升一层。

既然如此,我们不妨反复的使用这一技巧,使V的高度逐层上升,知道最终抵达树根。

1. 5 实例

来看具体的实例,这是一棵BST。
在这里插入图片描述

假设我们要访问333,那么按照刚才的策略,我们需要在此后通过一系列的zig和zag旋转,将它转移到树根的位置。

我们看看具体的过程

  1. 从333开始,首先我们注意到它是右孩子,所以要找它的父节点285并且做一个逆时针的zag旋转,这样333就上升了一层。
    在这里插入图片描述
  2. 接下来它依然是右孩子,所以我们同样需要找到它的父亲,也就是021,并且对021做一次zag旋转,此时333已经成为一棵左孩子。
    在这里插入图片描述
  3. 需要对它目前的父亲,也就是448,做一次顺时针的zig旋转,同样333的高度,因此上升一层。这个过程将继续进行下去,也就是我们进而对新的父亲468做一次相应的顺时针旋转,以及对最后一个父亲641,做一个顺时针的旋转。
    在这里插入图片描述
    可以看到不出意外,333经过若干次zig和zag旋转的组合,最终抵达了树根。从而使得接下来对它的访问,可以几乎只需要常数的时间。如果将节点的这样一种调整过程,连贯的来看,我们就会发现,这个节点逐层上升的过程,是一个左右摇摆,不断伸展的过程,所以我们也称为这种过程为伸展 splay。

1. 6 一步一步往上爬

概括而言,对某个特定节点的伸展调整过程无非就是,自下而上,逐渐做单选调整。
在这里插入图片描述
非常有意思的是,如果借用《蜗牛》那首歌中一句歌词来形容这个过程是再贴切不过的了,在这首歌里,我们会唱到,我要一步一步往上爬,描述的难道不正是这样一个过程嘛。

按照刚才所设定的策略,也就是任何节点一经访问,就随即被推送至树根,我们已经可以得到某种意义上的伸展树。

然而不幸的是,尽管蜗牛那首歌的旋律和歌词都非常优美,但是就效率而言,那句一步一步向上爬所暗示的策略,并非是最佳的选择,原因在于它有最坏情况。

1. 7 最坏情况

在这里插入图片描述

这棵树有do,re,mi,fa,so,la,si类似于七个音符所构成的一个逐级上升的音阶。请注意,这棵树当前的形状可以形容为是汉字中的一撇。接下来,不妨假设作为一个周期,我们就按照do,re,mi,fa,so,la,si的次序来访问这个树中的各个节点。

  1. 首先是do,在对它一次查找访问之后,我们将通过伸展,将它推送至树根。
    在这里插入图片描述
  2. 接下来,该轮到re,同样,在对它访问之后,我们也需要通过伸展,将它推送至树根。
    在这里插入图片描述
  3. 再接下来,该轮到是mi,同样,在对它进行访问之后,我们也需要通过伸展使之成为新的树根。
    在这里插入图片描述
  4. 好,后续的过程类似,不妨直接来看下一个,以及最后的si。
    在这里插入图片描述
    请注意,经过这样一轮的访问之后,这棵树的形态,又重新的成为了汉字中的一撇。

刚才那样的访问周期,可以类似于此的一系列快照来描述
在这里插入图片描述
可以看到,如果这棵树的规模是n的话,那么第一个节点所需要的访问成本应该大致为n,第二个节点呢,大致为n-1,而第三个呢,是n-2,以及n-3,以及n-4,以及最终的1。

在这里插入图片描述
概括而言,整个周期中各自访问所对应的计算成本是按算术级数变化的,整个周期的长度为n,因此根据我们在第一章已掌握的技巧,可以推断出,整个周期内所需要的计算成本累计因该是 n 2 n^2 n2量级。如果将这个计算成本分摊到整个周期内的n次操作,我们就会发现,每次操作的分摊成本居然高达Ω(n)。

这个量不仅与AVL树的logn相去甚远,而且反过来不难发现,它已经退化成了最原始的线性序列。比如说,list或者vector的水平,因此,这样一种效率以及在背后决定这一效率的伸展策略,都是不能令我们满意的,然而好消息是问题的根源并不在于伸展本身,而是在于我们没有把它运用好。

那么我们又应该如何在原有的伸展策略基础上进行改进呢?我们马上就可以看到,从量上讲,这种改进并不大,只需要做一处非常非常微妙的改进。这就犹如画龙点睛那个故事一样,作为一条龙,我们目前的伸展策略实际上已经相当完备,它所缺乏的只是能够体现其灵魂的那样一只眼睛,没错,不是一双眼睛,而仅仅是一只。

2. 双层伸展

2.1 双层伸展

为伸展树这条龙点上那只传神之眼的是著名计算机科学家Tarjan。
在这里插入图片描述
他的这一点睛之笔可以概括为不再是逐层的去进行伸展,而是将它改变为两层两层的进行。

具体来说,我们需要反复考察,当前正在伸展的节点V、它的父亲p以及祖父g,并且根据这祖孙三代的相对位置,经过至多两次旋转,使得V能够一次性的上升两层。当然,他们可能的相对位置无非上图四种,也就是zig-zig,zag-zag,以及zig-zag和zag-zig。

2.2 子孙异侧

我们先来看所谓之字形的情况,也就是zig-zag或者是zag-zig。
在这里插入图片描述
比如上图(左一)就是一种,具体来说,节点V是左孩子,而他的父亲p却是右孩子。此时,我们的改进策略是:

  1. 首先围绕着节点p做一次顺时针的旋转,从而得到上图(中间)状态,可以看到,v确实上升了一层。
  2. 接下来,我们再进而围绕着此前的祖父g做一次逆时针的zag旋转,从而将这一局部子树转化为这样一种拓扑结构(右图)。

可以看到,最终的效果是节点v抵达了此前祖父的高度。因此,整个过程的实质调整效果可以理解为是V上升了两层。

然而经过仔细观察,细心的你或许会发现,这样一种调整方法并没有什么稀罕之处,因为它与我们此前所介绍的AVL树的双旋调整是完全等效的。一个zig再加上一个zag,无论是过程还是最终的结果。

而且进一步的对比你会发现,这样一个调整过程,实际上与刚才的逐层调整,也没有任何实质的区别。

首先对父节点做旋转,进而对祖父节点进行旋转,难道这不就是逐层伸展的另一种等效形式嘛,换汤不换药。

如果你能够观察到这些现象,那非常好,因为这的确就是事实。那条龙已经具有一只眼睛,而真正的差别在于另一只眼。

2.3 子孙同侧

实际上,那另一只神奇的眼睛只有在zig-zig或zag-zag的情况下才会发光。
在这里插入图片描述
不妨只考虑其中一种,比如zig-zig情况,也就是节点v和他的父亲p都是左孩子的情况。在这里为了便于对比,不妨将这祖孙三代逐层伸展调整结果先画出来,也就是v首先经过父节点p的一次zig旋转上升一层,进而再通过祖父节点g的一次zig旋转再上升一层。我们已经对此非常熟悉,整个过程平淡无奇。我们刚才也讲过,它会导致最坏情况。

我们再来看看,Tarjan所点出的那只眼睛,或者说他所建议的调整方法。这里关键中的关键是我们需要首先越级,从祖父而不是父节点来开始旋转。

  1. 具体来说,经过祖父节点的一次顺时针zig旋转,节点p以及v都会上升一层。
  2. 接下来,进而对这棵局部子树新的树根也就是p再做一次zig顺时针旋转,从而使得v能够继续上升一层,成为这棵局部子树的树根。

将新的这种调整方法的结果与此前的逐层调整方法的结果做一对比,坦诚的讲第一次看到Tarjan为这条龙所点上的这只眼睛的时候,我并没有察觉到它有什么与众不同之处,现在你,或许也是这样,没错,各种的奥妙的确不易察觉。因为从效果来看,二者无非都是将v这个节点提升了两层而已,然而他们毕竟在局部拓扑结构上还是有微妙的差异,更重要的是,这种局部的微妙差异,将导致全局的不同,而那种不同将是根本性的,颠覆性的。

2.4 点睛之笔

为了切实的感受和领会这只眼睛的神奇之处,我们不妨来看这样一个实例。
在这里插入图片描述
依然是汉字中的一撇,只不过为了更好的体现效果,我们这里取了更长的一撇。接下来我们不妨恶意的来访问其中最深的那个节点,因为这样的话,所消耗的时间将会最多。

  1. 那么按照Tarjan建议,此时我们不仅要关心节点1的父亲,也就是2,同时也要关注它的祖父,也就是3,我们的第一次旋转,应该在祖父而不是父亲的位置上进行,因此,针对这种情况,我们首先对节点3做一次顺时针的zig。
    在这里插入图片描述
  2. 接下来才轮到对原先的父亲也就是2来做一次zig,从而形成这样一种局面。v还是v,只不过上升了两层。
    在这里插入图片描述
  3. 节点v,也就是001,拥有了新的一个父亲和新的一个祖父。
    在这里插入图片描述
    继续采用Tarjan建议的方法,具体来说,要对新的祖父也就是5做一次zig旋转。
    在这里插入图片描述
    然后再对新的这个父亲4做一次zig旋转。
    在这里插入图片描述
  4. 那么接下来继续采用Tarjan的建议,将会通过怎样的一系列双层调整,最终使目标节点1,伸展到树根呢?连续的看下这个过程。 一次双层调整
    在这里插入图片描述
    又一次双层调整
    在这里插入图片描述
    以及再一次
    在这里插入图片描述
    再一次
    在这里插入图片描述
    以及最终再一次,来看下最终这棵树的全貌。
    在这里插入图片描述

看到效果了吗?不出意外,节点1被伸展到了树根,然而这只是最基本的一个任务。

我们注意到,作为这样一个调整过程的副产品,整棵树的树高已经有了本质的变化。

你应该记得,我们此前针对逐层伸展所举的那个最坏的例子。没错,那个例子之所以很坏,是因为每次尽管它能够将目标节点调整到树根,但是整棵树的高度却会不得不按照算数级数,亦步亦趋的小步的变化。于是呢?使得恶意者每次都可以去试图访问它最深的那个节点,从而导致累计的平方量级的时间复杂度,以及分摊意义上的线性时间。

而现在呢?我们至少可以看出那种恶意的方法将会失效,因为我们这棵树的形态得到了有效的优化。

具体来说,树的高度大致缩减为原先的一半,接下来如果我们继续恶意的试图去访问它的新的这个最低点,也就是003,我们就会发现,它会继续的优化调整。来看下,调整的过程以及结果。
在这里插入图片描述
可以看到,树的高度在此前的基础上又进而缩减了一半。

2.5 折叠效果

为了更好的看到树高的变化效果,我们不妨来看一棵更大的伸展树。
在这里插入图片描述
这是一棵由31个节点所构成的高度为30的伸展树。 我们继续试图恶意的来访问其中最深的也是访问成本最高的那个节点1。请留意在访问这个节点之后,对它进行的双层调整过程。尤其是观察,在每一局部,按照Tarjan所建议的方式进行调整之后,对于这棵树的整体所产生的效果。我们来看一下这棵调整之后伸展树的拓扑结构。
在这里插入图片描述
不出我们的意料,全树的高度大致也缩减了一半。接下来如果我们继续试图恶意的访问其中最深的那个节点,那么在访问完这个节点之后,将同样的通过一系列的双层伸展,将这个节点推送至树根,而且最重要的一个特性是,这棵树的高度会因此继续缩减一半。
在这里插入图片描述

2.6 分摊性能

在这里插入图片描述

通过刚才的那个实例不难发现,Tarjan所建议的这种新方法,具有某种意义上的路径折叠效果。

具体来说,包括最坏节点在内的任何一个节点,一旦经过访问,再经过此后的双层调整之后,这个节点所对应的那条路径长度就会随即折减一半。

我们甚至可以说,这种效果具有某种意义上的智能。

既然在一棵BST中,我们非常忌讳很深的节点,那么这种折叠效果,自然就会具有对坏节点的修复作用。这就犹如含羞草那样,一旦它感受到威胁,就会通过迅速的收缩,将自己的弱点隐藏起来。

因此在采用Tarjan所建议的这种新的策略之后,刚才所举的那种最坏情况,将不至持续的发生。实际上可以严格的证明,按照新的策略,就分摊意义而言,单趟伸展操作所需要的时间都不会超过logn。

也就是说,我们现在不仅足以应对此前所涉及的那种最坏情况,而且也不会有任何其他的最坏情况,这是一个最好不过的消息了。

2.7 最后一步

在这里插入图片描述
当然从完整性角度来看,还有一个细微之处需要补充。也就是如果v的深度是奇数而不是偶数,那么在最终必然会发生一种情况,也就是v只有父亲而没有祖父。此时节点v的父亲就应该是全树的根。

我们说这个问题并不严重,因为这种情况只可能在整个伸展的过程中出现一次,而且只会出现在最后。如果真出这种情况,我们就可以视具体的形态,也就是v此时究竟是左孩子还是右孩子相应的做一次zig或者是zag旋转。

既然这种情况在整个伸展的过程中只会出现一次,因此从渐进的意义而言并不会实质的影响整个调整过程的复杂度。

至此,我们应该能够理解Tarjan所建议的这种双层调整的策略不仅是完备的而且是行之有效的,那么这样一种双层调整的策略,又当如何具体实现呢?

3 算法实现

3.1 功能接口

在这里插入图片描述
这里再次采用模板类的形式给出伸展树的接口定义。可以看到,作为BBST的又一变种,它自然可以在我们此前已经设计并实现的BST类的基础上通过派生直接得到。

具体的与AVL树一样,我们也需要重写其中的insert和remove这样两个动态的操作接口。而与AVL树不同的是,在这里我们还需要重写search接口。

回顾一下,在其他的众多变种中,search接口都可以视作是静态的,也就是说,它并不会导致树中节点之间拓扑连接关系的变化。然而在伸展树中,正如我们此前已经看到的,这是个例外。即便是查找操作,也会引起全树的结构变化,因此,这个接口在这里也需要重写。

然而正如我们马上就要看到的,这个接口对于伸展树而言举足轻重。这个接口内部的实质功能无非是完成伸展,因此在这里我们也设计并提供了一个保护类型的splay接口,用以实现这个功能。

3.2 伸展算法

这个居于核心位置的伸展功能,大致可以实现如下。
在这里插入图片描述
其原理如此前我们所介绍的。

  1. 首先要确定待伸展的节点V,以及它的父节点p和祖父节点g,只要二者同时存在,我们就要执行一次双层调整。
  2. 具体来说无非是分4种情况分别处理。这4种情况,可以通过嵌套的两重if-else判断来加以识别。比如,如果我们判定V是左孩子,那我们就知道,应该属于zig类型,那么至于是zig-zig或者是zig-zag,只需在进而检查父节点是左右孩子。相应我们还可以区分zag-zag和zag-zig。
  3. 每经过一次双层伸展,我们都需要将局部的这棵新的子树接入到原树中对应的位置,并且更新相应节点的高度信息。
  4. 在所有的双层伸展都已完成之后,如我们刚才所言,还有可能要执行一次单层的调整,直至最终,整个伸展得以完成。而V呢,顺利的成为了新的树根。因此,我们也需要对这个树根做以标识。

那么这里所分出的4种情况,又当如何具体的分别应对呢?

3.3 四种情况

在这里插入图片描述
我们不妨来看其中的一种,也就是zig-zig的情况。

你应该记得,在这种情况下,v以及p都是左孩子。我们也知道,按照Tarjan的策略,应该就局部这棵子树(上左图)调整为这个样子(上右图)。具体来说,也就是g将成为p的右孩子,而p要成为v的右孩子。
  ~  
请注意,这样的变换依然是一个等价变换,局部的这3个节点,以及他们下属的4棵对应的子树,在中序遍历意义上的次序将保持不变。将他们沿垂直方向都投影到水平线上,就可以验证这一点。

这里采用的方法与此前的3+4重构非常类似,我们不妨忽略具体的旋转过程,而代之以直接拼接的方式。

  1. 也就是说,我们需要将p的右孩子当作g的左孩子。这个效果可以由这一句 attachAsLChild(g,p->rc)完成。
  2. 那么下一句attachAsLChild(p,v->rc)呢?它的作用是将v的右孩子,也就是这个X转作p的左孩子。
  3. 而这一句attachAsRChild(p,g)呢?其效果是将g作为p的右孩子。
  4. 而最后这句attachAsRChild(v,p)的效果雷同。

类外的3种情况与上述雷同。

3.4 查找算法

再看伸展树的查找接口应当如何实现,这里给出一种可能的实现方法。
在这里插入图片描述
首先与常规的BST一样,也需要调用searchIn这个接口,在以root为根的BST中,查找数值为e的节点。

我们知道,查找可能成功,也可能不成功。

  1. 如果查找成功,我们就需要随即将命中的节点p,通过刚刚介绍的splay算法伸展至树根,并且将命中的节点,也就是树根返回。
  2. 如果是失败的情况呢?虽然没有命中的节点,但是我们知道会有一个搜索路径的终点_hot,在这里,我们也将这个终点通过splay算法伸展至树根。

所以概括而言,无论是成功或者失败,我们都会在树根处获得一个相等或者近似的节点,不难理解这种处理手法的原理正是为了充分利用我们此前所介绍的局部性。需要强调的是,既然在内部需要调用splay算法调整树的拓扑结构,所以对于伸展树而言,search接口将不再是一个静态的操作。这也是伸展树区别其他同类BBST的最本质特点。

那么伸展树的另外两个动态接口,也就是insert和remove又当如何实现呢?

3.5 插入算法

先来考察插入算法。
在这里插入图片描述
按照直观的思维,调用BST标准的插入算法,然后再按照伸展树的规则,将新节点伸展至树根的位置。

然而我们发现,这种实现方法在这里显得过于迂回曲折。

因为无论如何在真正实施插入操作之前,我们必然已经调用过一次search接口,而我们刚刚也已重写过的search接口,实际上已经集成了一个splay操作。也就是说,即便查找可能失败,在此之后,根节点也必然是_hot节点。

你应该记得,我们的新节点本来就应该作为_hot的左或右孩子接入树中的,既然_hot已经在根节点处唾手可得,我们为什么还要费尽周折的去做更复杂的操作呢?沿着这一思路,我们不妨按照上面这种图给出的流程,来完成伸展树的插入算法。

  1. 具体来说,首先调用重写之后的search接口,而且不失一般性,我们的查找是失败的,并且记失败前最终的节点为t,也就是我们此前所说的_hot。
  2. 接下来,集成在search接口内部的那个splay操作,自然会将这个_hot推送至树根位置,如这个图所示(左二)。
  3. 接下来,我们不妨从逻辑上,将整棵树在t这个位置上一分为二,比如将 t 与它的右子树分离开。
  4. 接下来,我们可以引入节点v,并且将t以及它的后代作为V的左子树,而原先从t处分离出来的右子树呢?将作为v的右子树,重新接入这棵树中。

纵观整个过程以及最终的结果,我们不难发现,其效果与通常的插入完全一样。具体来说,不仅使得一个新节点得以顺利插入树中,而且同样使它处于树根的位置,就像它曾经被推送上去。

3.6 删除算法

再考察节点删除算法
在这里插入图片描述
同样的,按照直观的思维,我们或许会首先按照BST的标准删除算法实施删除,再按照伸展树的约定,将与之领进的节点,比如_hot伸展到树根的位置。同样的,这种方法本来也无可厚非,但是在此时,也依然显得有些迂回。

这背后的原因也是类似的,因为不失一般性,如果在删除操作之前的search操作是成功的,那么在查找之后,待删除的目标节点必然已经被推送到了树根的位置。既然如此,我们为何不随即就在树根的位置附近来完成这样一次删除操作呢?具体过程如上图所示。

  1. 同样的,我们也需要经过一次查找,对待删除的节点进行定位,而且不失一般性,不妨假设成功。
  2. 于是在紧随其后集成在search接口内的伸展操作之后,这个待删除节点自然也就会被伸展并推送到树根。
  3. 接下来呢?既然他就是待删除的元素,我不妨将这个节点释放。

于是从逻辑上看这个节点的左右子树就变得彼此分离,剩下的任务是,如何将他们重新合并起来。这里有很多种方法。

  1. 比如可以在右子树中找到一个最小的节点(如果你一时还想不起如何才能找到这个节点,那么我的建议是不妨回过头看一看此前所介绍的中序遍历算法迭代版)。关于这样一个节点,我们不难发现,尽管它是右子树中最小者,相对于左子树来说,它却应该是最大者。
  2. 因此接下来,只要将原先的左子树作为这个节点的左子树连接上去,就不仅可以完成拓扑结构上的连接,而且能够保持所有元素的中序遍历次序,也就是说我们顺利的将他们合二为一了。

而这样一个过程的整体效果呢?同样是我们所期望的,具体来说,所需要的删除的节点确实被删除了 ,而且在此之后,作为树根的节点,是一个与此前那个节点非常临近的一个。也就是说,在此后局部性将可以继续得到充分的利用。

3.7 综合评价

最后我们来对伸展树的性能和特点做一个综合的回顾。
在这里插入图片描述
首先需要再次强调的是相对此前的AVL树,伸展树显得更为灵活和变通,它不需要再记录节点的高度或者平衡因子之类的附加信息,因此从编程实现的角度来看会更为简便易行。而另一方面,就时间复杂度而言,依然可以确保是在logn的范围内,这也与此前的AVL树相当。

请注意,这样一个复杂度界限并没有任何更多的先决条件,包括我们最初所介绍的局部性条件。

事实上,当局部性存在的时候,伸展树的性能还会更高。

不妨考虑一种极端的情况,也就是局部性非常强的时候,在这个时候,即便数据集的规模能够达到n,在相当长的一段时间之内,我们所访问的数据可能只是其中很少的一部分,比如只有远远小于n的k个,而我们的操作次数呢?却要远远大于n。
  ~  
比如你用电脑的过程往往就是这样,尽管你的硬盘上所存放的数据文件等等是数以万计甚至几百万计,但是在很短时间内,你所经常使用的数据往往只是其中非常低的一个百分比。比如当你在online上苦苦做题的几天之内,你所访问的数据很可能是你的硬盘中屈指可数的那么几个文件,是不是。
  ~  
也就是说,这类情况的特点,可以概括为尽管我们所存放的数据非常的多,但是在相当长的时间内,我们所访问的只是其中非常小的一部分,如果我们把这个数据集组织为一棵BST,并且采用伸展树的策略进行自我调整,那么可想而知的是,经过一段时间的使用之后,我们最常用的那部分数据都会逐步的集中到树根的附近,以至于其他部分的数据几乎可以忽略掉,他们存在与否,已经不甚重要了,也就是说,我们完全可以认为数据集无非就是一个规模为k而不是n的一棵BBST。
  ~  
这样一棵规模为k的BBST其访问的效率自然就应该接近于每次是logk而不是logn。因此对于任何一个足够长的操作序列而言,其中每一次操作的均摊时间复杂度也就大致是logk,当然在达到这种状态之前,我们大致要经过常规的n次操作,每次操作的时间复杂度是logn。
在这里插入图片描述
  ~  
你使用电脑的经历也应该能够验证这样一个规律,难道不是吗?你的每一台新安装的电脑,不都是在经过一段时间的应用之后,就会变得非常顺手,使得在接下来的足够长的时间之内都能够飞速的运转。是的,这是因为通常的操作系统都充分利用了数据访问的局部性,从而使得缓存的命中率能够达到经可能的高。

当然,尽管具有以上诸多优点,我们不得不说,伸展树也并非十全十美,比如尽管它的分摊效率很好,但它依然不能够保证杜绝单次最坏情况的出现。

实际上,在此前已经看到伸展树的形状在任何时刻通常都是不平衡,甚至是极其不平衡。

因此我们完全可能在某一个不幸的时刻需要访问一个足够深甚至是最深的节点。尽管伸展树在此后会随即将这条路径缩短近一半,但是在此前的这次查找过程中你不得不仍需付出沉重的代价。因此在对单次操作效率十分敏感的场合,伸展树依然是不能适用和胜任的

这类例子虽然不多,但也的确存在,比如在手术室里,控制手术器械的程序断乎不能采用伸展树这一类的数据结构。

当然也正如我们所看到的伸展树的复杂度分析是一个非常复杂的课题,在这里我们只是以形象的方式通过举例进行了一定的验证,而严格的证明却要更加复杂。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/778254.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

uniapp H5页面设置跨域请求

记录一下本地服务在uniapp H5页面访问请求报跨域的错误 这是我在本地起的服务端口号为8088 ip大家可打开cmd 输入ipconfig 查看 第一种方法 在源码视图中配置 "devServer": {"https": false, // 是否启用 https 协议,默认false"port&q…

vb.netcad二开自学笔记5:ActiveX链接CAD的.net写法

一、必不可少的对象引用 使用activex需要在项目属性中勾选以下两个引用,若找不到,则浏览定位直接添加下面两个文件,可以看到位于cad的安装路径下,图中的3个mgd.dll也可以勾选。 C:\Program Files\Autodesk\AutoCAD 2024\Autodes…

(数据大屏)(Hadoop)基于SSM框架的学院校友管理系统的设计与实现+文档

💗博主介绍💗:✌在职Java研发工程师、专注于程序设计、源码分享、技术交流、专注于Java技术领域和毕业设计✌ 温馨提示:文末有 CSDN 平台官方提供的老师 Wechat / QQ 名片 :) Java精品实战案例《700套》 2025最新毕业设计选题推荐…

Java线上接口耗时分析神器 Arthas

介绍 程序员的日常,总是离不开“调优”和“排查”。尤其当线上环境出现问题,性能瓶颈把人逼疯。这时候,你就需要一款像 Arthas 这样的神器来救场。 什么是 Arthas? 简单来说,Arthas 是阿里巴巴开源的 Java 诊断工具…

前端八股文 对$nextTick的理解

$nexttick是什么? 获取更新后的dom内容 为什么会有$nexttick ? vue的异步更新策略 (这也是vue的优化之一 要不然一修改数据就更新dom 会造成大量的dom更新 浪费性能) 这是因为 message (data)数据在发现变化的时候,vue 并不会立刻去更…

学习笔记——动态路由——IS-IS中间系统到中间系统(区域划分)

三、IS-IS区域划分 根据IS-IS路由器邻居关系,可以将IS-IS划分为两个区域——骨干区域和非骨干区域。(注意,这里的区域不是上文中提到的Area ID)由L2的IS-IS邻居构成的区域为骨干区域,由L1的IS-IS邻居构成的区域为非骨…

c与c++的内存管理

给出内存四个分区名字:栈区、堆区、全局区(俗话也叫静态变量区)、代码区(也叫代码段)(代码段又分很多种,比如常量区) 当然也会看到别的定义如: 两者都正确,记…

Adobe Acrobat添加时间戳服务器

文章目录 前言一、Adobe Acrobat添加时间戳服务器1.打开Adobe Acrobat软件2.点击【菜单】→ 【首选项】3.点击【安全性】→【更多】4.点击【新建】5.输入【名称】→【服务器URL】 前言 一、Adobe Acrobat添加时间戳服务器 1.打开Adobe Acrobat软件 2.点击【菜单】→ 【首选项…

广州佛山中山数据中心机房搬迁公司

随着数据中心的发展和迭代,必然面临数据中心搬迁。数据中心搬迁听来简单,其实涉及诸多方面,如信息迁移的安全性、业务的连续性、搬迁的规范性、方案的可行性、组织的统一性等。友力科技(广州)有限公司,自原…

IT专业入门,高考假期预习指南—初识产品经理BRD、MRD 和 PRD

七月来临,各省高考分数已揭榜完成。而高考的完结并不意味着学习的结束,而是新旅程的开始。对于有志于踏入IT领域的高考少年们,这个假期是开启探索IT世界的绝佳时机。作为该领域的前行者和经验前辈,你是否愿意为准新生们提供一份全…

Python + OpenCV 开启图片、写入储存图片

这篇教学会介绍OpenCV 里imread()、imshow()、waitKey() 方法,透过这些方法,在电脑中使用不同的色彩模式开启图片并显示图片。 imread() 开启图片 使用imread() 方法,可以开启图片,imread() 有两个参数,第一个参数为档…

算法库应用--Brute - Force算法串匹配(顺序串)

学习贺利坚老师关于B-F算法的算法库 数据结构例程——串的模式匹配(Brute-Force算法)_sqstring s, t; strassign(s,"ababcabcacbabcaccab");-CSDN博客 本人规则解析博客 串的匹配 (Brute - Force 算法)_brute force算法-CSDN博客\ 版本更新日志…

郭明錤:苹果将为Vision Pro推出红外摄像头款AirPods

在科技界,苹果公司的每一次创新都备受瞩目。近日,著名苹果分析师郭明錤透露了一个令人振奋的消息:苹果计划在2026年推出配备红外摄像头的新款AirPods,这款耳机将特别优化与Apple Vision Pro头显的空间体验。这一消息不仅预示着苹果在音频设备领域的又一次技术飞跃,也进一步…

工作手机怎么做好业务员工作微信的监控管理

什么是工作手机管理系统? 工作手机管理系统是专为企业管理设计的员工微信管理,它通过监控通讯记录、保障数据安全、自动检测敏感行为、永久保留客户信息等功能,帮助企业提升销售效率、维护客户资源安全,并确保业务流程的合规性。…

04-ArcGIS For JavaScript的可视域分析功能

文章目录 综述代码实现代码解析结果 综述 在数字孪生或者实景三维的项目中,视频融合和可视域分析,一直都是热点问题。Cesium中,支持对阴影的后处理操作,通过重新编写GLSL代码就能实现视域和视频融合的功能。ArcGIS之前支持的可视…

在 PostgreSQL 中,如何处理数据的版本控制?

文章目录 一、使用时间戳字段进行版本控制二、使用版本号字段进行版本控制三、使用历史表进行版本控制四、使用 RETURNING 子句获取更新前后的版本五、使用数据库触发器进行版本控制 在 PostgreSQL 中,处理数据的版本控制可以通过多种方式实现,每种方式都…

开源六轴协作机械臂myCobot 280接入GPT4大模型!实现更复杂和智能化的任务

本文已经或者同济子豪兄作者授权对文章进行编辑和转载 引言 随着人工智能和机器人技术的快速发展,机械臂在工业、医疗和服务业等领域的应用越来越广泛。通过结合大模型和多模态AI,机械臂能够实现更加复杂和智能化的任务,提升了人机协作的效率…

zerotier-one自建根服务器方法五

一、简介 前面几篇文章已经写完了自己建立服务器的方法,今天写一下我在使用过程中遇到的问题和解决方法。 二、准备工作 准备一个有公网IP的云主机。 要稳定性、安全性、不差钱的可以使用阿里、腾讯等大厂的云服务器。 本人穷屌丝一枚,所以我用的是免…

gcc/g++的四步编译

目录 前言1.预处理(进行宏替换)2.编译(生成汇编)3.汇编(生成二进制文件)4. 链接 (生成可执行文件)a. 动态库 && 动态链接b. 静态库 && 静态链接c. 验证d. 动静态链接…

指针回顾.

指针的主要作用:提供一种间接访问数据的方法 1.地址:区分不同内存空间的编号 2.指针:指针就是地址,地址就是指针 3.指针变量:存放指针的变量称为指针变量,简称为指针 1.指针的定义 int *p NULL; int *q NULL; char *p NULL; double *p NUL…