算法原理系列:2-3查找树

你。 提交于 2019-12-08 04:22:30

2-3查找树

第一次接触它是在刷数据结构那本书时,有它的介绍。而那时候只是单纯的理解它的节点是如何分裂,以及整个构建过程,并不清楚它的实际用处,所以看了也就忘了。而当看完《算法》查找章节时,顿时有种顿悟,喔,原来如此啊。所以,提出来的这些有趣的结构千万不能割裂来看,它的演变如此诱人,细节值得品味。

结构缘由

首先,搞清楚2-3查找树为什么会出来,它要解决什么样的问题?假设我们对它的基本已经有所了解了。先给它来个简单的定义:

2-3查找树:

  • 一种保持有序结构的查找树。
  • 可以维持动态平衡的有序查找树。

从上述定义就可以看出,它到底是为了解决什么问题,在上一篇文章中,介绍了【查找】的演变过程,详细请参看博文这里。其中最后优化到了BST这种树的结构。但我们都知道BST它对数据的输入是敏感的,如最坏情况下,每次put()key是有序的,那么构造出来的BST树,就相当于一个链表,那么对于每个元素的查找,它的性能就相当糟糕。而2-3树就是为了规避上述问题而设计发明出来的模型。现在请思考该如何设计它呢?

这里我们从BST遇到的实际问题出发,提出设计指标,再去思考利用些潜在的性质来构建2-3树。这部分内容,没有什么理论根据,而是我自己尝试去抓些字典的性质来构建,而2-3树的诞生过程并非真的如此,所以仅供参考。

构建2-3树

字典的两个主要操作为:查找和插入。而在前面一篇文章说到,作为有序表,查找性能和插入性能最理想的状态为O(lgn),这点可以说明,BST作为树形结构,已经完全符合字典的设计了,而如果从一个全新的结构去构建字典显然已经没有多大的必要了。

BST最大的问题在于,它对输入敏感,针对有序的插入,它构建出来的结构相当于是链表。为什么会出现这种情况?

  • 作为有序插入,每当有新节点加入时,树没有选择【节点去向】的权力。(这好像是构建有序树的特质,树也无力改变,真惨!)
  • 树失去了分配【节点去向】的权力,自然就没办法动态改变它的高度。(出现极端情况的原因)

那么你会问了,难道就不能当输入到一定量时,发现树的深度太深,直接全局调整不行么?有了全局信息,不就能调控,分配每个节点了么。的确,我们要引出以下原因:

  • 调控可以,但为了拿到这些全局信息,我们需要遍历整个BST,而此时BST相当于链表,遍历一次的代价已经高于查找的效率,何必呢。
  • 在插入时动态调整是最佳的,而当树已经生成时,再去做树的大调整,显然实际有点难以操作。(这两条的认识都比较感性)

综上,字典key的有序性影响了【节点去向】,树失去了【分配权】,其次结构随插入时,树的【动态调整】优于【全局调整】。所以,我们需要设计一种结构能够符合:

  • 拥有分配权
  • 可以动态调整

指标提出来了,但真的要设计出这样的结构的确不是一般人能做的,好在,这世界有太多的大牛了,我们可以参考人家的思路。

分配权

为什么BST会失去分配权力?因为它没有可以权衡的信息,在BST中,每个节点只能存储了一个key,每当有新的节点插入时,进行比较后,就自动选择路径到它的子树中去了,它无法停留。节点的去向我们是无法改变的,已由有序性决定,但我们是否可以决定它的【去】和【留】,它到这节点就一定要构建新的节点?不能停留在旧的节点上么?

从宏观的角度来看这件事情的话,如果我么能做到key值插入节点的【停留】,是否能够利用它来做树结构调整呢?答案呼之欲出!

我就不卖关子了,直接给出2-3树的其中一个基本定义:

一棵2-3查找树或为一颗空树,或由以下节点组成:

  • 2-节点:含有一个键和两条链接,左链接指向的2-3树中的键都小于该节点,右链接指向的2-3树中的键都大于该节点。
  • 3-节点:含有两个键和三条链接,左链接指向的2-3树中的键都小于该节点,中链接指向的2-3树中的键都位于该节点的两个键之间,右链接指向的2-3树中的键都大于该节点。

!!!传统的树定义即为2-节点,但2-3树查找树的定义多了个3-节点,而3-节点,也就是为了让节点能够停留,而设计出来的新结构,它具有缓存能力?哈哈,可以这么理解。意思就是说,现在树多了一条权力,不再是节点说了算,你不是老大!树可以选择我把你【放在这】还是【找你的子树去】。对树来说是件好事,起码可以分配你了吧!所以分配这件事需要资源累积。

alt text

数据结构有了,我们先来看看它的查找,暂且忽略它是怎么构建的。我们只需要知道两个事实,每个节点最多可以存储两个键,三个分叉。比较选择子树和BST是一样的,对每个节点比较,然后选择合适的子树,进行下一步的递归比较。

alt text
左图是命中情况,右图是未命中,跟着图一步步走,就能理解整个查找过程了,这里我就不废话了。

动态平衡

要知道什么是动态平衡,就必须知道什么是平衡,这也是我第一次思考平衡这个概念,我们就拿树中对平衡的定义,粗略解释下。

树的平衡:
任何节点的左子树和右子树之间的高度差不能超过1。

alt text

所以很明显(a)图是平衡的,而(b)图是不平衡的。其实还要思考一个问题,平衡这个概念为何而出?定义树的平衡有它的必要性么?很显然,一个完全不平衡的树,在做查找时,它就是线性级别的性能,而平衡的二叉树,同样的数据量,但有效利用了平衡性,它的查找性能则能降到对数级别,这些都可以在数学上证明,此处只做感性认识。

那什么是动态平衡呢?定义如下:

树的动态平衡:
在对树进行插入操作时,每个动态的状态都能满足静态的平衡条件。

动态平衡是时时刻刻的,在新数据插入前,它是平衡的,而一旦当数据插入导致树结构不平衡时则立马进行调整。这思想很重要,因为后续的平衡二叉树算法都是基于这个原则实现的。原因也说了,如果不去时刻维护,要获得全局信息代价高昂且全局调整难度大于局部调整。

有上述性质,我们不难判断BST不是一个能够自平衡的结构,在最坏情况下它的缺陷很明显,对于有序key的插入,树的深度+1。那么问题来了,假设我现在要插入三个有序的key值如A E S

BST的做法已经很明显了,生成如下结构A -> E -> S。我们来看看2-3树,刚才定义了3节点,我们就尝试性的让最开始的两个节点停留在根节点,于是有如下所示:

alt text
很明显,在插入第三个节点时,我们就只剩下一个选择了,让它去子树上找位置去,这意味着它和BST的插入本质上是一样的,并没有利用缓存的能力。但其实这缓存有个很好的性质,它有了两个节点的信息(大于1节点的局部信息),可以对三个key值在插入时刻进行比较,而一旦能达到这能力,此树就可以做自我调整了。如:我找三个树的中间值,把它变成三个节点的BST树!相比于直接把下一节点插入到子树中去,它利用了两个元素的信息做了些调整,而调整后的树,是个平衡的二叉树。

所以接下来的事情,就是当有更多元素插入时,如何让这个2-3树在做调整时,时刻保持动态平衡。唉,令人遗憾的是这想法直接就由上面那种最简单的情况得到了,如上,我们没理由把节点往下插。用个形象的比喻,树根在生长时,有它的随意性,因为扎根没给它任何限制。而现在我们做了一件可怕的事情,我们在树根生长的土壤中给它加了一层隔板,限制它的向下发展,而不去约束它的向上势头,但我们都知道,不管向上怎么发展,它始终是头部为一个根节点,而底部为大量叶子节点的终极形态。

alt text

是不是很形象,所以2-3树就形成了一个基本插入原则,每当有新的元素插入时,追根溯源到最底层(也就是那层隔板),当有存放它的位置时,2-节点还尚有一个存储空间,它就存放。而当没有存放位置时,3-节点都被塞满了,那它开始【分裂】,分裂操作是不能破环【不准向下插入】原则的,所以它只能向上影响【父结点】。

所以有了上述原则,也就有了书中的对一些插入情况的讨论。

  1. 向一棵只含有一个3-节点的树中插入新键。(树的初始态)
  2. 向一个父节点为2-节点的3-节点中插入新键。(子树的分裂1)
  3. 向一个父节点为3-节点的3-节点中插入新建。(子树的分类2)
  4. 分解根节点。(树的向上生长态)

在前文中,我们已经图解了树的初始态,此处就不在解释了。操作2和操作3是在子树中最基本的两个操作,它们唯一的区别在于父结点一种是【2节点状态】而操作3的父结点是【3节点状态】。

父节点:2-节点,子节点:3-节点
alt text
很明显,元素一定是先沉底的,此处元素沉底在最左边,但由于超过了3-节点的存储能力,所以它必须分裂,不能向下分裂,所以只能往上了,影响它的父节点,但父节点可以再容纳一个元素,所以只需要把X元素放入父节点即可。

父节点:3-节点,子节点:3-节点
alt text
此处和操作2唯一的区别在于,子节点分裂后,把一个元素加入到了它的父节点,但也超过了父节点的存储能力,所以还要继续向上分裂,直到有容下它的父节点。它和操作2本质上是一回事,但是为了表示分裂的传递性,所以被拎出来重点讨论了下。

接着就剩下最后一个问题了,上述两操作是不会影响树的深度的,不信你自己模拟操作一遍,而真正影响树的深度在于操作4,只有当根节点为3-节点时,此时有元素插入沉底后,不断向上裂变,很不幸如果影响到根节点,那么就执行操作4,我冒个头出来,哈哈,是不是形象。
alt text

我们再来看看一个23树的整体构建轨迹加深理解。

alt text

好了,整体的23树的构建已经阐述完毕了,原本想看看书上是怎么实现的,让我继续加深理解,结果却在书中找到这样一段话,也是让我很无语,但它所提出的思想值得学习。

《算法》

但是,我们和真正实现还有一段距离。尽管我们可以用不同的数据类型表示2-节点和3-节点并写出变换所需的代码,但用这种直白的表示方法实现大多数操作并不方便,因为需要处理的情况实在太多。我们需要维护两种不同类型的节点,将被查找的键和节点中的每个键进行比较,将链接和其他信息从一种节点复制到另一种节点,将节点从一种数据类型转换到另一种数据类型,等等。实现这些不仅需要大量的代码,而且它们所产生的额外开销可能会使算法比标准的二叉查找树更慢。平衡一棵树的初衷是为了消除最坏情况,但我们希望这种保障所需的代码能够越来越好。幸运的是你将看到,我们只需要一点点代价就能用一种统一的方式完成所有变换。

欲言又止,但很有爱,起码它有进一步的实现版本了,具体是什么,我也卖个关子,下回分解。

参考文献

  1. Robert Sedgewick. 算法 第四版[M]. 北京:人民邮电出版社,2012.10
  2. Cormen. 算法导论[M].北京:机械工业出版社,2013
  3. 算法原理系列:查找
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!