平衡树学习笔记

Posted OIerBoy

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了平衡树学习笔记相关的知识,希望对你有一定的参考价值。

平衡树

平衡树是一种基于二叉搜索树的数据结构。
满足:左儿子 \\(<\\)\\(<\\) 右儿子。
也就是一切小于根节点的在左边,一切大于根节点的在右边。
这样想要查找一个节点的位置时间复杂度就是 \\(O(\\log n)\\)

平衡树主要有三种:Splay,Treap,fhq_Treap。
还有其他的像替罪羊树,红黑色。

Splay

Splay 由于 LCT 滚出 NOI 的原因也跟着少用了。
但是本人以为还是 Splay 比较好用的。

核心操作

Splay 的基本操作就是将BST旋转,分左旋和右旋(其实都差不多)
旋转的要求:在不改变原有的中序遍历的前提下改变树的结构。
简化一下:把儿子节点与父亲节点的身份互换,且有BST性质。

旋转 \\(zig(x)\\),\\(zag(x)\\) 如下图:

具体描述

设要旋转的节点为 \\(x\\),它的父亲为 \\(y\\)\\(y\\) 的父亲为 \\(z\\)

  • \\(y\\) 的左儿子设为 \\(x\\) 的右儿子
  • \\(x\\) 的右儿子存在,将 \\(x\\) 的右儿子的父亲设为 \\(y\\)
  • \\(x\\) 的右儿子设为 \\(y\\)
  • \\(y\\) 的父亲设为 \\(x\\)
  • \\(x\\) 的父亲设为 \\(z\\)
  • \\(z\\) 存在,将 \\(z\\) 的某个子节点(原来 \\(y\\) 所在的子节点)设为 \\(x\\)

双旋操作

在 Splay 中,每加入一个新的节点就需要把它旋转到根。
设当前需旋转的节点为 \\(x\\),节点的旋转可分为以下三种:
\\(1.\\)\\(x\\) 的父亲是根,这时直接旋转即可。
\\(2.\\)父亲和 \\(x\\) 的儿子同侧(即同为左儿子或同为右儿子),这时先旋转父亲,再旋转 \\(x\\)
\\(3.\\)父亲和 \\(x\\) 的儿子不同侧,这时将 \\(x\\) 旋转两次。
如下图:

时间复杂度

对于这个时间复杂度的分析,我们需要用一下势能分析
\\(siz(x)\\) 表示以 \\(x\\) 为根节点的子树大小。
\\(\\phi(x)\\) 表示在进行 \\(x\\) 次操作后的势能函数。
\\(c(x)\\) 表示时间的时间复杂度。
\\(a(x)\\) 表示均摊的时间复杂度。
所以根据定义,我们可以得出:
\\(\\phi(x)=\\log(siz(x))\\)
\\(a(x)=c(x)+\\phi(x)-\\phi(x-1)\\)
\\(\\sum a(x)=\\phi(n)-\\phi(0)+\\sum c(x)\\)
因为根据 \\(\\phi(x)=\\log(siz(x))\\),所以现在肯定有:\\(|\\phi(n)-\\phi(0)|\\le n\\log n\\)
考虑每一次的 \\(\\Delta\\phi\\)

对于zig:

\\[\\Delta\\phi=\\phi\'(p)-\\phi\'(p)+\\phi\'(x)-\\phi(x)=\\phi\'(p)-\\phi(x)\\le\\phi\'(x)-\\phi(x) \\]

对于zig-zig

\\[\\Delta\\phi=\\phi\'(z)+\\phi\'(y)+\\phi\'(x)-\\phi(z)-\\phi(y)-\\phi(x) \\]

\\[=\\phi\'(z)-\\phi\'(y)-\\phi(y)-\\phi(x) \\]

\\[\\le\\phi\'(x)+\\phi\'(z)-2\\phi(x) \\]

\\[\\le 3\\phi\'(x)-3\\phi(x)+(\\phi(x)+\\phi\'(z)-2\\phi\'(x)) \\]

\\[\\le 3(\\phi\'(x)-\\phi(x))-2k \\]

那么Splay到根的均摊代价为 \\(O(logn)\\)\\(zig\\) 只有一次操作,所以只会使均摊代价增加\\(k\\)
再算上 \\(\\phi(n)-\\phi(0)=n\\log n\\)
所以总时间复杂度为 \\(O(n\\log n)\\)

操作实现

讲了核心操作和时间复杂度后,我们来看看它可以支持的操作。
注:由于我们只能保证 Splay 本身的时间复杂度,所以我们就必须只能通过旋转来实现一些操作。

查询数值

给定一个数 \\(k\\),查询排名为 \\(k\\) 的数。

  • \\(k\\) 小于等于左子树大小,就向左子树走
  • 否则,将 \\(k\\) 先减去左子树大小和当前节点的 \\(cnt\\),使得 \\(k\\), 等于在右子树中的排名。然而若 \\(k\\) 小于等于 \\(0\\),说明已经找到,进行旋转,并返回当前节点权值。

查询 \\(x\\) 的前驱和后继

我们先将 \\(x\\) 节点旋转到根节点。

  • 前驱:就是 \\(x\\) 左子树中最右边的节点。
  • 后继:就是 \\(x\\) 右子树中最左边的节点。

删除节点 \\(x\\)

我们需要先将 \\(x\\) 旋转到根节点,从而去得到它的前驱和后继。
然后将前驱旋转到根,再将后继旋转到根,就会得到下图:

直接把 \\(x\\) 删去即可。

查询区间 \\(\\left[l,r\\right]\\)

我们需要将 \\(l\\) 的前驱旋转到根,再将 \\(r\\) 的后继旋转到根节点,点像删除操作。
\\(l\\) 前驱的右子树就是整个 \\([l,r]\\) 区间了。

fhq_Treap(无旋Treap)

前言

首先,我们要 \\(sto\\) 范浩强 \\(orz\\)
dalao 发明了 fhq_Treap,因为 fhq_Treap 是三种平衡树中最强悍的一种了,它可以维护值域,可以维护下标,可以维护区间修改,它还可以完成 splay 都完成不了的可持久化操作。其唯一弱于 splay 的就是在 LCT 上(但是 LCT 滚了,悲)。

什么是 fhq_Treap

fhp_Treap 首先是一个 Treap。
而 Treap 是什么呢
Treap=BST+Heap。
所以 Treap 就是一个拥有二叉搜索树的性质,但是每一个节点都通过一个附加权值来满足符合堆的性质。
而这个附加权值就是 Treap 的一个关键,它是通过随机生成一个 \\(key\\) 来维护一个近似的平衡。
而我们随机生成的 \\(key\\) 要想让它退化成链的概率是微乎其微的。(教练:你让随机 \\(key\\) 退化成链的概率就像你出门被核弹直接创死的概率一样小。)
但是一个旋转的 Treap 是有点恶心的。
所以,我们的无旋 Treap 就登场了。

核心操作

我们要想 Treap 不旋转,我们就需要一个可以顶替旋转的一种操作来实现,那就是拆分与合并。

split

split 就是把 Treap 以 \\(k\\) 值分为两棵 Treap。
对于我们遍历到每一个点,假如它的权值小于 \\(k\\),那么它的左子树,就要分到左子树里,然后再遍历它的右儿子,反之亦然。
因为它的最多操作次数就是一直分到底,时间复杂度就是 \\(O(\\log n)\\)
对于前 \\(k\\) 个版的,就像找第 \\(k\\) 大的感觉。每次减掉 \\(siz\\)
这个用递归就可以实现了。

merge

merge 就是把被分开的两颗 Treap 合并起来。
因为第一个 Treap 的权值都比较小,我们比较一下它的 \\(kay\\),假如第一个的 \\(key\\) 小,我们就可以直接保留它的所有左子树,再把第一个 Treap 变成它的右儿子,反之亦然。
也就是说,我们其实是遍历了第一个 Treap 的根定为最大节点,第二个 Treap 的根就是最小节点,时间复杂度就是 \\(O(\\log n)\\) 了。

操作实现

插入数值

插入一个权值为 \\(v\\) 的点。

  • 把 Treap 以权值 \\(v\\) 分成两颗。
  • 将权值 \\(v\\) 插入。
  • 再把两颗 Treap 合并以来。

删除数值

删除一个权值为 \\(v\\) 的点,很类似于插入操作。

  • 把 Treap 以权值 \\(v\\) 分成两颗。
  • 将权值 \\(v\\) 删去。
  • 再把两颗 Treap 合并以来。

Treap

平衡树 学习笔记

平衡树大名早有耳闻,而且据说会成为新考点。

今晚zhx大佬讲了一下基本概念和代码实现,我就把当时手写的笔记搬上来吧。

(中间被一找我debug代码的人卡了一个小时,我tm早知道不该帮他。。。就是道大爆搜自己看题解不行吗。。。。只能过样例但是爆0我怎么知道你错哪了。。。有毒吧卧槽。。)

(其实有很多不和谐的话,我自动屏蔽吧)

特别长的笔记,看看今晚能不能写完吧。。

由于时间仓促,再加算法略加高级,所以我的措辞应该会有严重不规范甚至完全错误的地方,还请大佬看到后指正,欢迎私信炮轰

只能作为参考,在定型之前不具备客观上的学习意义,各位看时请带着debug的眼神来看。

  • 平衡树的概念

  我们假设您知道线段树是怎么回事。我们知道,线段树可以进行区间操作,比如区间修改和区间查询,但是没有办法去执行一个增加和删除的操作。

  平衡树在功能上解决了线段树无法进行增删的缺陷。

  这次主要写两个操作,一个插入,一个删除。

 

  • 平衡树常见的类型

  当然有什么替罪羊树,朝鲜树之类的奇怪的平衡树就不说了。。

  SBT:Size Balanced Tree,节点大小平衡树。优点是快而且相对好写,缺点是相比于其他的平衡树功能不太全。

  treap:这也是今天课上讲的平衡树,其单词由tree和heap融合而成,也叫树堆。优点是比SBT更好写,但是速度比SBT慢。

  (我记得rqy大佬在Day0的时候给我说过一个叫二叉搜索树的东西,好像就是它了)

  RBT:Red Black Tree,红黑树,它比SBT还快,但是初学者的话。。。。大概3.5h左右能写出来。。。。

  splay:这个数据结构据说之前一段时间挺火,学平衡树的各位都要学这个算法。。。但是它。。。。。

  特  别  慢  ,  常  数  巨  大  无  比。

  怎么个慢法?前三个平衡树单次操作都是基本上O(logn)的,splay的单次操作虽然也写作O(logn),但是由于其巨大的常数,实际跑起来和O(sqrt(n))差不多,并且不太好写。

  但是功能极强。

  平衡树常见的一个操作叫旋转,今天讲的这个是没有旋转操作的可持久化treap,(虽然可持久化没讲完)

 

  • 一个普通的treap(二叉搜索树)是什么样的,有什么基本性质?

  先放结论。

  对于一个点,其左儿子永远比它小, 右儿子永远比它大。推论:对一个这样的treap,其中序遍历是有序的。

  对于一个T = {1,2,3,4,5},treap长的是这样的:

  

  看一下,是不是满足上面那个性质啊。

  上面我们说到插入操作。那么如果我要插入一个2.33,这个点应该在哪呢?

  既然插入,那么这个插入的位置一定要合法,也就是说,插入之后,也一定要满足这个性质。

  正常向的做法:我们从根节点开始比对,发现根节点是4,比2.33大,那么我们就去它的左儿子找位置,因为左儿子永远比根节点小。然后找到2,发现2比2.33大,那么我们就去2的右儿子找位置。然后找到3,发现3比2.33大,按照规律应该是找左儿子,但是3号点已经是最底端没有儿子了,怎么办?其实已经找到了,就在3的左儿子那里新开一个点,把2.33放进去即可。

  

  但我们发现,这样的操作如果变多了,树的某个链就有可能会变得很长很长,所以这样的插入方法插入的深度不会很深,这可以叫不平衡。但我们是平衡树啊,一定得想办法让它平衡才行。

  其实不同的平衡树那些不同的叫法,实质上都是平衡手段不同。

  这里补充一个操作,询问区间和。

  请看上面第一个图,如果我要询问区间[1,4]之和,线段树的做法肯定是分成左半段+右半段,然后把两个子节点的权值加起来。而平衡树不是,平衡树会把整个区间拆成诸如[1,3],[4,4],[5,5]这样的三段(也就是会单独把根节点拆开),然后再去求解区间和。

 

  回归正题,插入操作。

  我们之前说treap叫树堆,这里拆分成的tree代表一般平衡树,而heap代表一个堆(理论上大根小根都行,这里默认大根,下文讨论全部基于大根堆),其实堆不也是一种二叉树的形式吗。。它要求每个点的值都要比任何一个子节点都大,也就是从叶到根这个值是单调递增的。

  但是,细心的读者会发现,堆和平衡树的性质其实是矛盾的。那么我们应该怎么去解决这个矛盾呢?其实这里有一个合并思想,那就是在treap里面每个点都存储两个值,即key和value。

  这里的key满足堆的性质,而value就是treap里原来存的那个值,就是中序遍历满足平衡树性质那个。

  key的值在实际应用中一般都采用随机化。(rqy:为了防止出题人卡你)

  其实使用随机化可以保证树的深度会在logn级别。

  这样我们就得到了一个比较正经的treap。 

  

  (大括号内第一个值是key,第二个值是value,key已经随机化)

  回到我们最初的问题。如果要插入一个2.33,插哪里?

  我们假设与2.33相对应的key值是6,其他数不变。

  如果再用之前那个查询的方法显然是不可以的。我们需要新方法。

  首先我们列表:

key 1 8 2 9 7 6
value 1 2 3 4 5 2.33

 

 

 

  首先很显然呢根节点应该是{9,4},然后左儿子是{8,2},右儿子是{7,5}。对于{8,2}这个点,其左儿子是{1,1},右儿子是{6,2.33},{6,2.33}没有左儿子,只有右儿子{2,3}

  各位看一下, 是不是两个性质都满足了?

    

  接下来便是重头戏,merge操作和split操作。

  对于一个merge操作,有两个参数p1和p2,这个操作可以将以p1为根节点的treap与以p2为根节点的treap合并成一个新的treap,并返回这个treap。

  

  但这个操作是有一个前提的,那就是p1要小于等于p2。换句话说,是要让p1最大的节点的value值小于等于p2的最小的节点的value值。

 

  下一个操作叫split,它也是接受两个参数,一个是p一个是k。这个操作的意思是在以p为根的treap中把前k小的数拿出来,组成一个新treap。相当于一个拆分操作,把这个有n个节点的treap p拆成一个有k个节点的p1,一个有n-k个节点的p2。

  不难看出,split与merge操作互逆。

  这个拆分也是有特性的。它要求拆分出的p1小于等于p2。意思和上面merge是一样的。

  

 

   我们来演示一下这个插入和删除的过程。

  比如有一棵树P={1,2,3,4,5},我们要插一个2.33进去,在查找过程中发现了2.33<3,在3前面有两个元素,所以我们的操作是:首先执行split(p,2),把这棵树分成两棵比较小的树,这样分完应该是有p1{1,2}和p2{3,4,5}。下一步要怎么做?我们建立一个新treap,这个新treap只有2.33这个值,我们假设这个新treap为newp,然后去执行merge(merge(p1,newp),p2)就可以得到一个插入这个点的新的treap。

  我把它叫作:“三树并一插新点”。

 

  删除过程。我们假设有P={1,2,3,4,5},然后我们要把里面的{2}拿出来,这个要怎么做?做法是split两次,在{2}的左边和右边各一次,也就是要把树拆成这样的样子:{1}{2}{3,4,5},然后把{1}和{3,4,5}合并一下,就完成了删除操作。这一步操作其实是把{2}孤立了出来,使之不再属于原来的treap。

  我把它叫作:“二(子)树合并孤立点”。

 

  在实际操作上,假设我们要执行merge(p1,p2),我们知道对于treap的一个节点是有key和value两个值的,在实际操作的时候,还要记录某个点的的左儿子l和右儿子r。

  合并的话你肯定是要先决定根是哪个的。根据treap的堆性质,其最上面的点key值应该最大,而在原来的p1和p2里面也只可能会是两个根节点p1和p2最大,所以只可能会有这两个点中的其中一个当根。我们分两种情况,第一种是p1.key > p2.key,那么显然是以p1为根的吧。根据之前讨论的性质,插入要保证p1是小于等于p2的,所以p2要放在哪?是p1的右儿子。而p1的左儿子不是p2,所以左儿子维持不变,仍然是p1.l,而右儿子就会变成merge(p1.r,p2)。

  

  这个操作的优先级用堆的key值可以保证。可以看出这个操作是递归的。merge函数的返回值就是merge出的树的根节点。

  我们考虑第二种情况,p1.key < p2.key,同理,可以得到

  

  这个操作也是递归的。

  

  实际操作的split是如何呢?

  我们再来回顾一下split的定义:一个操作split(p,k),它会把以p为根节点的treap的前k小节点拆出来,我们依然是需要记录p的key,value,l和r。同时还要多记录一个p.size,它代表树中有多少节点。我们知道这个split是会把一个p拆成p1和p2的,这个p1便是理想状态下前k小的左子树,p2则包含p和p.r。

  
  这里分三种情况。第一种是k小于等于p.l.size,那么我们split(p.l,k),它返回两棵树{p1,p2},我们选其中的p1,不选p2的原因是p2并不包含{p,p.r},那么怎么办?只需要再构造一个{p,p.l,p.r}的treap返回就好。

  看到这里您也许就明白了,split返回两棵树,返回值有两个,实际写法上一般返回一个对组pair。

  第二种情况,k等于p.l.size+1,这里返回{p,p.l}与{p.r}两棵树。

  第三种情况,k大于p.l.size+1,返回{p,p.l,p.r}与{p2}。

 

 

  我当时记的差不多就这些。。。。。找个时间就发下代码吧。。。

 

  接下来是二分图匹配和网络流(dinic)的算法整理。

 

以上是关于平衡树学习笔记的主要内容,如果未能解决你的问题,请参考以下文章

平衡树 学习笔记

数据结构学习笔记04树(二叉树二叉搜索树平衡二叉树)

学习数据结构笔记(12) --- [平衡二叉搜索树(AVLTREE)]

数据结构之二叉搜索树和二叉平衡树学习笔记

数据结构 ---[实现平衡树(AVL Tree)]

数据结构 - 学习笔记 - 红黑树前传——234树