$Date:\ Dec\ 24\ 2020$
Tag: Code
平衡树学习笔记(指针版)
算是再放送了,虽然以前就会平衡树但是指针版存在细节上的不同,并且有支持完全动态开点和也许常数小的优点。最近用指针实现了其中的几种,在此记录一下, 也可以作为普通平衡树的学习笔记。
P.s. 本Blog代码较长,故完整代码折叠于 More about … 中, 如:
More about hello world
1 | std::cout << "Hello World!" << std::endl; |
Content:
[Treap]
More about treap
1 | // multi set using normal treap |
容易理解且便于实现的平衡树, 基于 tree + heap 实现。期望 $O(logn)$ 的查询修改复杂度。
应该也是许多人第一个接触到的平衡树。
分析:
首先 treap 是一课二叉搜索树, 需要时刻满足 $leftson.key$ < $now.key$ < $rightson.key$, 即左儿子关键值 < 当前结点关键值 < 右儿子关键值, 同时每个结点建立时随机产生的优先级 $priority$ 满足堆的关系。
由于 $priority$ 是随机生成的,并且符合堆的形态, 所以 treap 树高是期望 $logn$ 的, 从而保证了复杂度。
首先我们定义一个 node 结构来保存平衡树所需要的信息:
1 | struct node { |
旋转是 treap 的核心操作, 在保证用二叉搜索树 $leftson.key$ < $now.key$ < $rightson.key$ 性质的前提下交换儿子于父结点的相对位置, 达到维护 $priority$ 满足堆的排序要求。这里借用两张图片来讲解旋转过程。
上图中 C 经过左旋和 A 交换了位置, 同时若在第二张图中 A 经过右旋可以得到第一张图,可见左旋和右旋是一种对称操作。
有一种较为简单的记忆方式,即父结点 $p$ 在 $d$ (0/1, 下文 $!d$ 表示 0/1 取反) 方向上的儿子 $q$ 向上旋转时。
将 $q$ 在 $!d$ 方向上的儿子接给 $p$ 至 $d$ 方向,将 $p$ 接至 $q$ 的 $!d$ 方向, 其他方向上的儿子保持不变(可以结合图片理解)。
下面是旋转代码:
1 | void rotate(node* &p, bool d) { // d = 0 左子旋转,为右旋,d = 1 右子旋转,为左旋 |
P.S. 不建议分开写旋转,因为 $splay$ 上分类旋转将难以记忆, 可以在写 $treap$ 时就开始使用对称写法。
然后就是简单的 insert :
1 | void insert(node* &now, _Tp key) { |
关于 less & equal 的说明
less 是自定义的比较函数,可以理解为 ‘<’,即平衡树内的小于号,定义为:
1
std::function<bool(_Tp, Tp)> less;
关于 std::function 的说明
可以将 std::function\理解为传入 (_Tp, _Tp) 并返回 bool 的一个函数,同时支持 lambda 表达式的匿名类型。 这么写是为了初始化 less 函数的时候支持 lambda 表达式(毕竟笔者是 lambda 狂魔)。 想深入了解的可以学习一下 c++11 特性。 equal 判断两值是否相等,调用了两次 less, 原型为:
1
bool equal(const _Tp& x, const _Tp& y) { return (!less(x, y)) && (!less(y, x)); }
1 | bool erase(node* &now, _Tp key) { // 删除 key, 成功返回 true, 失败返回 false |
[二叉搜索树的一般操作]
treap 的基本操作已经讲完了,但还有一些属于二叉搜索树的一般操作,也在此一并讲解。
在二叉搜索树上的操作复杂度通常为树高,在平衡树上通常为 $O(logn)$。下文若没有特殊说明则复杂度同此。
1. k_th: 查询第 $k$ 大
维护该操作可在搜索树上根据 $size$ 和 $k$ 的关系二分查找。该方法适用于大部分平衡树,复杂度为搜索树树高, 在平衡树上通常为 $O(logn)$。
1 | node* kth(node* now, int k) { |
关于 ptr 函数的说明
- ptr 是一个防止node指针为空时访问内部元素出现错误的函数。
若 now 不为空返回 now, 否则指向一个空结点。 原型为:1
node* ptr(node* now) { return !now ? &fall_back : node; }
2. rank:查询 $x$ 的排名
在树上递归查找出 $x$ 的位置,树上位于 $x$ 左侧的元素数量 + 1 即为 $x$, 复杂度同样为 $O(logn)$。
1 | int rank(node* now, _Tp key) { |
3. pre: 查询 $x$ 的前驱
即查询第一个严格小于 $x$ 的数,一般分为两个步骤:
- 在树上寻找到 $x$ 的结点 $p$。
- 找到树上 $p$ 左侧树中的最大值。
具体实现方法:
- 在树上二分寻找 $x$,若当前点大于等于 $x$, 直接向左子树递归直到当前点小于 $x$。
- 若当前点小于 $x$,向右子树递归, 对返回结果和自己取 max 后返回(该写法遇到空结点返回一个即小值)。
也可以理解为找到第一个小于 $x$ 的点, 然后一直向右儿子递归,直到右儿子为空,取最后一个非空点。
1 | node* pre(node* now, _Tp key) { |
4. next: 查询 $x$ 的后继
即查询第一个严格大于 $x$ 的数,可以看作 pre 的对称操作, 一样分为两步:
- 在树上寻找到 $x$ 的结点 $p$。
- 找到树上 $p$ 右侧树中的最小值。
和求 pre 实现方法类似, 直接上代码:
1 | node* next(node* now, _Tp key) { |
[Splay]
More about splay
1 | // multi set using splay |
Robert Tarjan的又一神奇数据结构, 通过不断旋转维护树的平衡。拥有极强的可拓展性,可以维护区间,凸包以及 Link/Cut Tree。同时拥有均摊 O(logn) 的优秀复杂度。
一点趣闻
P.S. 根据 wikipedia 显示, Tarjan 发明 LCT(1982) 的时间比 Splay(1985) 早,网上好像也没有具体解释。难道当时就有FHQ-treap了(雾)
分析:
Spaly 的复杂度基于均摊分析后 O(logn),本蒟蒻并不会均摊分析。
所以就只好放一篇来自机房皇帝周指导优秀的 Splay 复杂度讲解:[均摊分析学习笔记]啦!
[FHQ-Treap]
More about fhq-treap
1 | // multi set using fhq treap |
基于treap且代码简短的数据结构。只有 split 和 merge 两个核心操作,因此支持区间操作。 在拥有期望$O(logn)$复杂度的同时常数较小。由于不用旋转的特性支持可持久化
Splay, FHQ-treap, etc. 具体讲解先鸽一鸽,好累。
放在最后, 不属于平衡树家族的:
[Heap]
Learn More About Heap
More about heap
1 | // heap |
简单且有效的结构,也是 Treap 的基础之一。可以 $O(logn)$ 实现插入和弹出, 并且支持 $O(1)$ 查询极值。
虽然 Heap 不算平衡树但是他对理解 Treap 有一定的帮助(我都写了那就拿出来看看吧)。
分析:
相信你对如何维护堆有一定了解 (至少我相信你会用 std::priority_queue)
这里主要还是讲一下指针版 Heap 和数组版的差异。
其实维护 Heap 只需要保证两个性质:
- 整棵树是一棵完全二叉树 - 保证复杂度。
- 父亲结点的关键值 < 儿子结点的关键值(以小根堆为例) - 维护最值。
首先考虑维护完全二叉树这一点, 在数组版本中只需要将 $p * 2$ 和 $p * 2 + 1$ 分别作为 $p$ 的左右儿子即可, 并且每次添加删除操作时处理完全二叉树中的最后一个结点就行了。
那如果在指针版中这样实现呢? 很遗憾并不行,首先你需要从 root 向下递归访问最后一个结点,而且无法通过下标知道父结点(否则需要额外维护大量内容),并且在多次删除后可能存在树不平衡的情况,使得复杂度极大增加。
- 但是我们可以换一个思路维护完全二叉树的行值, 去尽量保持 heap 的平衡。
定义如下结构维护每个结点大小和左右儿子,以及 key 值, :
1 | struct node { |
在插入时,比较左右子树大小, 若左子树大小 < 右子树大小, 则向左儿子插入, 否则向右儿子插入, 同时更新当前 size, 直到当前结点为空结点,新建结点完成插入。
显然,一棵满二叉树的左右子树应当是一样大的,当我们以这种策略插入的时候,heap 会逐渐向满二叉树靠近,从而接近平衡。
- 那如何维护性质2 : 父结点 < 子结点呢?
很简单, 在递归插入的时候发现当前的点的关键值 $now$->$key$ < 插入值 $key$, 则将当前点的关键值 $now$->$key$ 和 插入关键值 $key$ 进行 swap 交换, 对 $now$ 的子树插入原本的 $now$->$key$, 并将插入值 $key$ 赋给 $now$, 就可以在递归插入的时候维护父子之间的有序性了(不理解可以动手玩一玩)。
于是我们得到了这样的 insert 代码:
1 | node* _insert(node *now, _Tp key) { |
再让我们实现一下从堆顶弹出的操作。
由于是删除结点,会使结点数减少,为了尽量不改变 heap 的形态, 我们将堆顶下沉到叶子结点后删除。
下沉过程可以理解为将堆顶改为一个极大值后更新堆的信息。 下沉时不断比较左右儿子的 $key$ 选取较小的作为新的父亲,将自身和较小的儿子交换,直至抵达叶结点后完成删除。
实现好的 delete 代码如下:
1 | node* _delete(node *now) { |
这样 Heap 的核心操作就实现好了 QaQ。
[指针实现细节]
$I$. null 指针
在使用指针实现任何内容时,都应注意是否访问到了 null, 否则将会出现大量的 runtime error。这也是指针实现平衡数中非常重要的一点。
解决方法很多,上述四份代码中提供了三种:
- 将指针初始化 NULL 或者 nullptr (c++11 or later), 在访问时某个具体内部变量时使用函数判断指针是否为空。
例如 FHQ-Treap 或者 Heap 中的 size 函数:这种方法适用于需要访问变量种类较少的情况。 在 FHQ-Treap 和 Heap 中, 实现各类函数如 rank, kth 等时, 我们只关心当前点 $now$ 的 $key$ 值, 和左右儿子的 $size$。 而 $now$ 为空容易解决,1
static int size(node* now) { return !now ? 0 : now->siz; }
在大多数函数中直接 return 就可以了, 而儿子结点的 $size$ 则可能在不进入儿子的情况下访问, 在儿子为空时还需要返回 $size = 0$, 当然可以用三目运算符实现:但是这样代码可读性很差,而且写起来较累,故采用函数将其封装起来。1
!son ? 0 : son->siz;
这个方法缺点也十分明显,在需要访问多个变量的时候,需要多个函数实现,十分繁琐, 而且极大的破坏了指针的逻辑清晰感。
- 定义一个函数,在指针为空时将其指向一个事先定义好的空结点而非 NULL 从而避免错误。
如 Treap 中的 ptr 函数:$fall$_$back$ 就是事先定义好的空结点。1
node* ptr(node *now) { return !now ? &fall_back : now; }
该方法适用性较强,大部分情况都可以使用。但有一点需要注意, 在对内容进行修改时,需要判断被修改的指针是否是空结点 $fall$_$back$,而通过该函数无法得知。 故在修改时需要直接判断 $now$ 是否为空,该函数起不到作用。
所以建议在只需要访问内容,而不进行大量修改时采用该方法。同时该方法会在多个指针嵌套时略显混乱,破坏代码的清晰感。
- 创建一个空结点, 同时定义一个指针 $null$ 指向该空结点, 在初始化指针的时候就将其初始化成 $null$。
如 Splay 中的 $*null$ 指针:适用于绝大部分情况, 不过不能直接用 !now 来判断指针为空,而应改用:1
node fall_back, *null = &fall_back;
这是显然的,因为 $null$ 是一个有内容的指针, 所以 $null$ 不为 $0$, 故不能使用 $!now$ 来判断。1
if(now != null) { /*do something*/}
在进行修改同样需要判断被修改的指针是否为 $null$, 若不慎将 $null$ 指向的空结点修改了,通常会导致错误。
该方法可以做到不破坏指针写法特有的美感,在 Splay 这样指针关系较多的数据结构中最能体现优势。 故建议在指针嵌套较多的时候使用,能使代码清晰易读。$II$. 查找失败时的返回值