首页 > 其他分享 >平衡树总结

平衡树总结

时间:2024-08-20 19:48:24浏览次数:4  
标签:总结 rt int 元素 textit 平衡 节点

平衡树刚看的时候觉得很不好评价。

但它毕竟就是个数据结构,跟线段树的用途一样,都是用来维护数据。想想你刚看线段树时候的感受,是不是和现在刚看平衡树差不多。

事实来看,平衡树也不复杂。本质都是二叉搜索树,只不过维护平衡的方式不一样罢了。平衡树的类型看似那么多,实际上也就学两种:FHQ Treap 和 Splay。实在不行,还有 pb_ds 兜底。

那就开干。


二叉查找树 (Binary Search Tree, BST)

特征

BST 有以下特征。

  1. 每个节点有唯一的键值,可以比较大小。
  2. 任意节点的键值大于其左子树所有节点的键值,小于其右子树所有节点的键值。
  3. 以任意节点为根的一棵子树,仍然是 BST。

优点

  • 可以轻松地插入节点、删除节点
  • 可以轻松地查询节点的排名或根据排名查找节点
  • 可以查询节点的前驱后继

尤其是第一点,相较于线段树等数据结构,BST 的轻松插入删除节点是其最大优势。

时间复杂度的退化

理论上来说,BST 的单次操作时间复杂度都是 \(O(\log N)\)。但是,在构造数据下,BST 会退化成链,时间复杂度退化至 \(O(N)\)。

平衡树就是为了解决这个问题的。平衡树有很多种,都是通过不同的方式维护 BST 的平衡。所以,平衡树就是 BST。

FHQ Treap

它是 Treap 的变体,由范浩强巨佬发明,救无数 OIer 于平衡树水火。

话说到平衡树要维护 BST 的平衡,Treap 就是其中一种维护方式。Treap 是 Tree 和 Heap 的合成词,顾名思义,就是给每个节点人为添加一个『优先级』,随机生成即可。对于键值,这棵树是一棵 BST;对于优先级,这棵树是一个堆。随机生成的优先级保证了时间复杂度从概率期望上看是 \(O(\log N)\)。

平衡树题目都需要动态维护树的平衡,在 FHQ 发明以前一直用的是旋转法。FHQ 相较于旋转法,不仅编码简单,还能用于区间翻转、移动、可持久化等多种场合,并一举代替了许多原本只能用 Splay 完成的题目。

FHQ 来维护所有平衡树操作的方法都只用到了分裂和合并这两个基本操作。而分裂的方法也有两种:按键值分裂和按排名分裂。

按键值分裂

分裂方法:将原树分裂为键值小于等于当前元素和键值大于当前元素的两棵。

这种适用于维护元素排名、不强调元素顺序基本场合。

为什么说是基本呢?因为在吕教练给我们的平衡树题单中,所有的这种场合都被我用 pb_ds 水掉了

void split(int u, int x, int &l, int &r) {
    if (u == 0) return l = r = 0, void();
    if (t[u].key <= x) {
        l = u;
        split(t[u].rs, x, t[u].rs, r);
    } else {
        r = u;
        split(t[u].ls, x, l, t[u].ls);
    }
    update(u);
}

int combine(int l, int r) {
    if (!l || !r) return l | r;
    if (t[l].pri > t[r].pri) {
        t[l].rs = combine(t[l].rs, r);
        return update(l);
    } else {
        t[r].ls = combine(l, t[r].ls);
        return update(r);
    }
}

可以看出,代码非常巧妙,也简单易写。

这里的 update 类似于线段树的 pushup,主要内容就是将左右节点维护的信息合并到父节点上。在模板题中,我们只需维护一个子树大小,所以 update 函数中的内容也很简单:

int update(int u) {
    t[u].sz = t[t[u].ls].sz + t[t[u].rs].sz + 1;
    return u;
}

接下来我们来看分裂和合并是如何维护平衡树的基本操作的。

  • 插入一个元素:将原树按键值分裂,然后新建一个节点,最后按『左树、新建节点、右树』的顺序将其合并。
int newnode(int x) {
    t[++tot].sz = 1;
    t[tot].key = x;
    t[tot].pri = rand();
    return tot;
}

void ins(int x) {
    int l, r;
    split(rt, x, l, r);
    rt = combine(combine(l, newnode(x)), r);
}
  • 删除一个元素:分裂两次,将原树分裂为左树、中树(键值只有待删除元素)、右树,将中树的左右子树合并(相当于删除了一个待删除元素),最后再将其和左右树合并。(注意顺序)
void del(int x) {
    int l, r, p;
    split(rt, x, l, r);
    split(l, x - 1, l, p);
    p = combine(t[p].ls, t[p].rs);
    rt = combine(combine(l, p), r);
}
  • 查询一个元素的排名:按键值分裂,排名即为左树的大小加一,最后合并(相当于恢复原状)。
int rnk(int x) {
    int l, r;
    split(rt, x - 1, l, r);
    int res = t[l].sz + 1;
    rt = combine(l, r);
    return res;
}
  • 按排名查询元素:简单,从根节点开始,如果排名等于左子树,即为查询成功,直接返回;如果排名小于左子树,向左递归查询;如果排名大于左子树,向右递归查询(函数参数中的待查排名要减去左子树大小)。
int kth(int k, int u = rt) {
    if (k == t[t[u].ls].sz + 1) return t[u].key;
    if (k <= t[t[u].ls].sz) return kth(k, t[u].ls);
    return kth(k - t[t[u].ls].sz - 1, t[u].rs);
}
  • 查询元素前驱:按键值分裂,查找左树中的最大元素(利用 kth 函数操作),最后合并。
int pre(int x) {
    int l, r;
    split(rt, x - 1, l, r);
    int res = kth(t[l].sz, l);
    rt = combine(l, r);
    return res;
}
  • 查询元素后继:同理,按键值分裂后查找右树中的最小元素,最后合并。
int nxt(int x) {
    int l, r;
    split(rt, x, l, r);
    int res = kth(1, r);
    rt = combine(l, r);
    return res;
}

可以看出,所有的操作都只需维护一个根节点位置即 \(\textit{rt}\),所有的函数都只需传入一个待查参数即可返回结果,非常地模板化。

例题:P3369,P6136,P2286,P1486。

以上题目全部能用 pb_ds 解决,见下文。

按排名分裂

分裂方法:将原树分裂为位于当前元素左侧和位于当前元素右侧的两棵。

适用于强调元素顺序、不强调元素排名的高级场合。

void split(int u, int x, int &l, int &r) {
    if (u == 0) return l = r = 0, void();
    if (t[t[u].ls].sz + 1 <= x) {
        l = u;
        split(t[u].rs, x - t[t[u].ls].sz - 1, t[u].rs, r);
    } else {
        r = u;
        split(t[u].ls, x, l, t[u].ls);
    }
    update(u);
}

实际上也很显然,有点类似上文 kth 函数的查找方法。

例题:P3391,P4309,P4036。

P3391 中的『翻转』在二叉树中的处理方法是:以区间中任意数为轴翻转左右两边,然后对左右两部分再递归处理。所以我们很自然地有类似线段树的懒标记思想,每次通过分裂提取出翻转的区间,给这个区间打上懒标记,随后定期 pushdown 即可。

P4309 是一个 LIS 问题加入动态维护。我们注意到题目中的一个特性:插入元素是从小到大插入的。这意味着我们但凡插入元素,从序列开始到新元素的这个部分的 LIS 长度必定会加一。我们在 pushup 函数中多维护一个变量 \(\textit{len}\) 表示总树的 LIS 长度,而 \(\textit{key}\) 维护当前节点的 LIS 长度,按照如下方法去 pushup \(\textit{len}\) 即可。

\[\text t[u].\textit{len}=\max(\text t[\textit{ls}].\textit{len},\text t[\textit{rs}].\textit{len},\text t[u].\textit{key}) \]

P4036 题目给定的操作是求最长公共前缀 + 动态插入修改。首先字符串肯定 Hash 存储没有问题,插入修改编码都容易,重点在于如何维护 Hash 值。但前提是我们知道了子树大小,Hash 值也很好维护了。

\[\text t[u].\textit{hs}=\text t[\textit{ls}].\textit{hs}\times\mathrm P^{\text t[\textit{rs}].\textit{sz}+1}+\text t[u].\textit{key}\times\mathrm P^{\text t[\textit{rs}].\textit{sz}}+\text t[\textit{rs}].\textit{hs} \]

还是很好理解的。最后就是求 \(\operatorname{LCQ}\) 函数,相较于暴力的比较,由于范围是确定的,所以可以二分答案解决。时间复杂度 \(O(M\log L)\)。当然此题暴力可过

Splay

在 FHQ Treap 下被边缘化的平衡树。不想讲,等到我学某更高级的数据结构时再说吧。

pb_ds

相当于按键值分裂的平衡树模板,简单讲一下它的用法。

头文件 + 命名空间:

#include <bits/extc++.h>
using namespace __gnu_pbds;

格式:

tree<pii, null_type, less<pii>, rb_tree_tag, tree_order_statistics_node_update> t;
  • 第一个参数表示要维护的平衡树的元素类型。这里填成 pii 是因为 pb_ds 默认情况下类似一个 set 而不是 multiset,所以插入相同元素时要赋予不同的第二值。
  • 第二个参数表明你想不想实现 map 的效果,如果不想就填成 null_type
  • 第三个参数是排序函数,如果填成 greater<pii> 就变成从大到小查找排名了。
  • 第四个参数决定了平衡树的内部实现类型,一般填 rb_tree_tag 即红黑树效率最高。
  • 第五个参数决定了你的平衡树是否支持上文的 rnk 函数和 kth 函数的功能。很长,但打多了也就记住了。如果你不需要,你就只需填前两个参数就行了,后三个参数都有默认指定。

下面说一下如何用 pb_ds 的平衡树通过 P3369 模板题。

  • 插入一个元素:如下,其中 \(i\in[1,n]\),相当于给相同元素赋予了不同的第二值。
t.insert(make_pair(k, i));
  • 删除一个元素:题目保证了待删除的元素一定存在,所以用 lower_bound 可以删除一个存在于平衡树中的待删除的元素。
t.erase(t.lower_bound(make_pair(k, 0)));
  • 查询一个元素的排名:排名的定义为小于待查询元素的元素个数加一,所以函数参数不一定需要在平衡树中存在。
cout << t.order_of_key(make_pair(k, 0)) + 1 << '\n';
  • 按排名查询元素:注意给出的排名要减一作为函数参数。
cout << t.find_by_order(k - 1)->first << '\n';
  • 查询元素前驱:pb_ds 对此没有特定函数,需要我们善用 lower_bound
cout << (--t.lower_bound(make_pair(k, 0)))->first << '\n';
  • 查询元素后继:pb_ds 对此也没有特定函数,需要我们善用 upper_bound
cout << (t.upper_bound(make_pair(k, INT_MAX)))->first << '\n';

由此可见,pb_ds 的平衡树基本可以实现按键值分裂的 FHQ Treap 的所有功能了。

pb_ds 的缺点是过于模板化导致不够灵活,只能拿来做一些板题。pb_ds 默认不支持 multiset 也导致其很麻烦,做树套树就更不容易。

番外:ETT (Euler Tour Tree)

标签:总结,rt,int,元素,textit,平衡,节点
From: https://www.cnblogs.com/laoshan-plus/p/18370203

相关文章

  • 深度学习--时间序列预测方法总结
    时间序列预测是分析和预测一系列时间顺序数据的方法。不同的时间序列预测方法在应用中根据数据特征和目标有不同的适用性。以下是时间序列预测方法的详细总结,包括概念、原理和Python实现代码。1.简单平均法(SimpleAverageMethod)概念与原理:简单平均法是最简单的时间序列......
  • 20240820(周二)AH股行情总结:A股三大指数收跌近1%,游戏传媒板块大涨,工行超中国移动成市值
    A股三大股指集体下挫,创业板指跌1.34%。国债期货收盘多数上涨,30年期主力合约涨0.22%。工商银行股价再创历史新高,盘中市值超过中国移动。“黑神话”概念股大涨,浙版传媒涨停,华谊兄弟涨超10%,新迅达20CM涨停。周二,A股三大指数均收跌近1%受《黑神话:悟空》大热带动,A股游戏、传媒板......
  • 8月总结
    考试test20240811password:190788test20240819password:193387比赛CodeforcesRound959sponsoredbyNEAR(Div.1+Div.2)CodeforcesRound964(Div.4)CodeforcesRound966(Div.3)知识点分治刷题板板刷花专题练习(待更新)DPDS......
  • 2024 Summer_Camp 做题总结 下
    CloseVertices思路很明显,这是一道点分治题目,但有两个限制条件,考虑将两个条件排序起来,双指针找第一个条件,树状数组维护第二个条件,但是同一个子树内不能重复统计,所以将答案减去每个子树内的答案。代码#include<iostream>#include<algorithm>#defineintlonglongusingnam......
  • Hadoop学习总结
    除了HDFS和MapReduce,Hadoop生态系统还包含了许多其他强大且实用的工具和框架,它们在数据分析和处理领域中发挥了重要作用。其中,Hive、Pig和Spark是几个尤为重要的组件,它们为处理和分析大数据提供了更高层次的抽象和简化的操作流程。在这篇报告中,我将分享我对这些工具的探索与使用经......
  • 树上合并 目前的总结
    启发式合并(set)元素少的set中的元素一一插入元素多的set中。时间两只\(\log\)。空间最坏为\(n\)这个级别(结点数)(这是在默认一个结点最多增加一个元素的情况下)。log数据结构时间也是两只\(\log\)。dsuontree好像[也叫“树上启发式合并”](??)。[一般](?)是重链剖分一样划分......
  • 8.19日总结
    今天是周一,果然大脑放松了两天,回来工作效率都提高了,一上午解决了两个问题,上周五搞半天也没搞定。第一个就是新板子无法升级的问题,排查了好久也没发现问题所在,进入BOOT区后只会发送00,当时考虑是占用了外部晶振的IO口,但是我们没有使用外部晶振,那两个IO口做普通IO口使用。把电阻取下......
  • 2024.8 总结
    杂题【YBOJ】Pair题目描述给出二维平面上的\(n\)个点,第\(i\)个点的坐标为\(x_i,y_i\)。定义点\(i\)与点\(j\)之间的距离为\(\frac{|x_i-x_j|+|y_i-y_j|}{\sqrt{(x_i-x_j)^2+(y_i-y_j)^2}}\),求平面上两点的距离最大为多少。($1\len\le10^5$)解题思路首先,我们......
  • tcp与udp的总结+connect阻塞+tcp三次握手、四次挥手+常见的服务器IO(发送数据+接收数
    一,TCP与UDP的基本总结TCP(传输控制协议)和UDP(用户数据报协议)是两种主要的传输层协议。TCP是面向连接的,提供可靠、顺序的传输,适用于需要高可靠性的应用,如网页浏览和文件传输。它通过重传机制和流量控制确保数据完整性。UDP是无连接的,速度快但不保证数据的可靠性和顺序,适用于对实时性......
  • [Mysql]日志刷盘总结
    Mysqlredolog的刷盘时机mysql正常关闭的时候redologbuffer写入超过一半的时候后台线程每隔一秒写入磁盘一次0把redologbuffer中的内容刷盘2把pagecache中的内容刷盘事务提交的时候0每次提交事务,redolog留在buffer中不写入磁盘1每次提交事务,redolog写入磁......