__gnu_pbds 中 tree 的扩展应用

在机房痛苦地调试了一整天后的一个初步成果。

如果不想看废话可以直接跳到最后一节:入土

本文语言标准为 C++20C++11 及以上均能正常使用。

文中代码均有以下开头:

#include<bits/stdc++.h>
#include<ext/pb_ds/assoc_container.hpp>
#include<ext/pb_ds/tree_policy.hpp>
using namespace std;
using namespace __gnu_pbds;

本文中 x 代表一种元素,it 代表迭代器,n 一般为树的大小。

前言——关于 tree

首先一般来说它的实现是红黑树,所以空间常数会偏大。大概是 Treap 的 1.5 倍。

同时为了实现迭代器和自定义模板等复杂的功能,时间常数也偏大,大概是 Treap 的 1.1~1.2 倍。

但是它的空间回收比较优秀,对于大部分平衡树相关的题目能有效解决。

同时其基础版本有相当简短的实现。

基础——建立与功能

建立

众所周知,__gnu_pbds 命名空间中的 tree 为我们提供了一个现成的平衡树。

一般来说这样定义一棵平衡树(以 int 型为例):

tree<int, null_type> tr;

第二项所填的 null_type 是指该树没有映射对象。

操作总览

此时它支持以下几种操作(常用):

操作 作用 效率
insert(x) tr 中插入一个数 x O(logn)
erase(...) 删除 tr 中的元素。 O(logn)
lower_bound(x) 查找第一个大于等于 x 的元素。 O(logn)
upper_bound(x) 查找第一个大于 x 的元素。 O(logn)

其支持的标准 STL 容器函数有:size()empty()begin()end()

不支持 emplace()

操作概述

insert()

tree 中插入一个数 x

tr.insert(x);

因此还有一个扩展函数:copy_from_range()

tr.copy_from_range(itl, itr);

其功能是将 [itl,itr) 间的数据插入 tr 中。时间复杂度 O(Tlogn)T 是插入元素的个数。

tree 中的数据是不可重的,和 set 一样。

erase()

erase() 常用的有两种语法:

  • erase(x):删除元素 x。返回一个 bool 类型的值。1 为删除成功,0 为删除失败。
  • erase(it):删除迭代器 it 指向的元素。随后迭代器 it 会失效。但是树中指向别的元素的迭代器不变。请保证 it 是有效的,否则可能出现 Segmentation Fault。

lower_bound()upper_bound()

用法与 set 基本一致。

在这里不在赘述。

begin()end()

tree 是内部有序的,和 set 一样。

join()split()

join(b):将 b 树并入本树,要求是元素类型相同,且值域互相不交。

若值域相交,合并操作无法完成,并且抛出 join_error 错误。

慎用。

split(v,b):将大于 v 的元素移到 b 树中,b 树中原本的元素被清除。

建议 split()join() 搭配使用。

用来模拟 FHQ Treap


看完这一堆功能,我们发现这和 set 差不多的样子。

但是它的功能远强于 set

入门——tree 的实现以及 Cmp_Fn 的使用

实现

tree 类是这样定义的:

template<typename Key, typename Mapped, typename Cmp_Fn = std::less<Key>,
typename Tag = rb_tree_tag,
template<typename Node_CItr, typename Node_Itr,
typename Cmp_Fn_, typename _Alloc_>
class Node_Update = null_node_update,
typename _Alloc = std::allocator<char> > class tree

我们可以在 Tag 这里更改它的实现。

内置的共有三种。

名称 实现方式 单次操作效率
rb_tree_tag 红黑树 O(logn)
splay_tree_tag Splay O(logn)
ov_tree_tag vector + sort O(n)

洛谷 P3369 【模板】普通平衡树 为例。

ov_tree_tag TLE on #9 #10 #11 #12 #14。直接毙掉。时间复杂度过高,基本过不去。

splay_tree_tag TLE on #14。最好是不用。

rb_tree_tag 时间就很宽裕,最慢的点 #14 跑了 29ms。

所以还是用 rb_tree_tag 吧...

比较函子

如果 Cmp_Fn 由默认的 less<> 变为了 greater<>,其函数的行为会发生变化。

lower_bound(x):查找第一个小于等于 x 的元素。

upper_bound(x):查找第一个小于 x 的元素。

之后的排名等操作就由从小到大变为了从大到小。

上手——映射与可重元素

映射

刚刚提到了一个东西:树的映射对象。

它其实和 map 容器基本相同。

建立

map 相似。

tree<int, int> tr;

其功能和 map<int, int> tr; 相同。

注意它没有 count() 函数。但是有 find() 函数。

操作

用法基本相同,只是少了 count() 函数。复杂度也与 map 相同。

在部分测试中,tree 的效率高于 map

有时前者又略低于后者。

可重操作(简单)

基本想法

如果为每个元素带一个时间戳 clockn,那么原本的元素 x 变为了一个 pair<int, int>{x, clockn}

这时就可以实现元素的可重了。

这时需要对相应的操作进行修改。

实例

洛谷 P3369 【模板】普通平衡树 为例。

建立

tree<pair<int, int>, null_type> tr;

插入

插入一个数 x

tr.insert({x, ++clockn});

删除

删除一个数 x(若有多个相同的数,应只删除一个)。

auto it=tr.lower_bound({x,0});
if(it==tr.end()) return;
tr.erase(it);

压行(保证数据合法):

tr.erase(tr.upper_bound({x,0}));

排名

下一节再说。

前驱

auto it=tr.lower_bound({x,0});
if(it==tr.begin()) return -INF;
it--;
return it->first;

压行(保证数据合法):

return (*--tr.upper_bound({x, 0})).first;

后继

auto it=tr.upper_bound({x, INF}); //INF 赋极大值即可。
if(it==tr.end()) return INF;
return it->first;

压行(保证数据合法):

return (*tr.upper_bound({x, INF})).first;

进阶——排名

建立与操作

tree 类是这样定义的:

template<typename Key, typename Mapped, typename Cmp_Fn = std::less<Key>,
typename Tag = rb_tree_tag,
template<typename Node_CItr, typename Node_Itr,
typename Cmp_Fn_, typename _Alloc_>
class Node_Update = null_node_update,
typename _Alloc = std::allocator<char> > class tree

如果我们将其中的 Node_Update 类由 null_node_update 更改为 tree_order_statistics_node_update,那么它就能支持几个新的功能。

这时,它的定义应该长这样:(支持可重)

tree<pair<int, int>,
null_type,
less<pair<int, int> >,
rb_tree_tag,
tree_order_statistics_node_update> tr;

有以下的新操作:

操作 作用 效率
order_of_key(x) tr 中查找 x 的排名。 O(logn)
find_by_order(k) tr 中查找排名为 k 的元素。 O(logn)

注意:更改 Node_Update 类后会有子树大小的统计,所以会导致常数增大,效率降低。

实例

查找排名

tree 中的元素的排名是从 0 开始的。务必注意。

所以代码有所更改:

return tr.order_of_key({x,0})+1;

由排名查找元素

注意事项同上。

auto it=tr.find_by_order(k-1);
if(it==tr.end()) return INF;
return it->first;

压行(保证数据合法):

return (*tr.find_by_order(x-1)).first;

现在我们已经完成了平衡树的基本操作。

但是我们考虑如果一次性插入大量元素,比如一次加入 1145141919810

这就会导致时空超限。

入土——自定义

你都自定义类了为什么不写一棵 Treap 呢

前置知识:除了旋转之外的平衡树操作。

其实采用 tree 对我而言主要是白嫖 RBT 的平衡操作和它优秀的内存控制。

不然我为什么不手写

我们还是以 洛谷 P3369 【模板】普通平衡树 为例。

迭代器

我们通过 lower_bound() 等获得的迭代器是指向值的。

但是如果我们要得到指向节点的迭代器怎么办?

auto itn=it.m_p_nd;

itn 就是指向节点的迭代器。

之后对树结构的操作就与 itn 相关。

自定义节点

首先我们定义节点类型 st

struct st
{
int num;
mutable int cnt;
bool operator<(const st &b) const {return num<b.num;}
};

num 为节点的值,cnt 为该值的数目。

这里采用了和珂朵莉树差不多的处理方式,multable 以便于修改 cnt 的值。

重载 operator<() 以用于平衡树的更新。

自定义 Node_Update

模板

然后我们可以写一个 Node_Update 类,这个类的模板长这样:

template<class Node_CItr,class Node_Itr,class Cmp_Fn,class _Alloc>
struct my_node_update {};

这样就定义了一个 my_node_update 类。

基本内容

但是这样的一个更新类还不能使用,需要再经过调整。

该类至少有以下内容:

template<class Node_CItr,class Node_Itr,class Cmp_Fn,class _Alloc>
struct my_node_update
{
typedef int metadata_type;
void operator()(Node_Itr it, Node_CItr end_it)
{
;
}
virtual Node_CItr node_begin() const = 0;
virtual Node_CItr node_end() const = 0;
};

typedef int metadata_type; 声明了节点维护的额外信息,在这里维护的是子树的大小,所以为 int。如果有像 Treap 维护序列用到的懒标记这类也可以放在这里。

operator() 更新子树

operator() 是平衡树的 push_up 操作,如果我们要更新子树大小,就会调用 operator()

对于我们的操作,就像这样:

void operator()(Node_Itr it, Node_CItr end_it)
{
Node_Itr itl=it.get_l_child();
Node_Itr itr=it.get_r_child();
int l=0,r=0;
if(itl!=end_it) l=itl.get_metadata();
if(itr!=end_it) r=itr.get_metadata();
const_cast<int&>(it.get_metadata())=l+r+(*it)->cnt;
}

get_l_child() 返回该迭代器指向的节点的左儿子的迭代器。

get_r_child() 同理。

end_it 是结尾迭代器,如果指向某节点的迭代器 it 等于 end_it,那么证明该节点不存在。

const_cast<int&>(it.get_metadata()) 修改节点维护的额外信息。在这里就是左右子树大小之和加上该节点元素个数。

自定义操作

为了防止迭代器引用访问到非法内存,先定义一个 get() 函数,获取节点维护的额外信息。

int get(Node_CItr it) {return it==node_end()?0:it.get_metadata();}

查找排名

平衡树基本操作。

注意此时迭代器类型为 Node_CItr并且迭代器指向的是节点,节点指向的才是值。

int Rank(int x)
{
int ans=1;
Node_CItr it=node_begin();
while(it!=node_end())
{
Node_CItr itl=it.get_l_child();
Node_CItr itr=it.get_r_child();
if(x<=(*it)->num) it=itl;
else ans+=(*it)->cnt+get(itl), it=itr;
}
return ans;
}

由排名查找元素

平衡树基本操作。

注意事项同上。

int Find(int k)
{
Node_CItr it=node_begin();
while(it!=node_end())
{
Node_CItr itl=it.get_l_child();
Node_CItr itr=it.get_r_child();
int lsiz=get(itl);
if(k<=lsiz) it=itl;
else if(k<=lsiz+(*it)->cnt) return (*it)->num;
else {k-=lsiz+(*it)->cnt; it=itr;}
}
return -1;
}

自此,自定义 Node_Update 类已经完成了,该考虑其他部分了。

自定义 Tree

构建

我选择直接继承 Tree 类,然后再搞事情。

struct RBT:tree<st,null_type,less<st>,rb_tree_tag,my_node_update> {};

自定义函数

更新额外信息

最抽象和恶心的地方来了。

由于每次修改都需要重新统计子树大小,但是cnt 的修改不会触发 push_up(),也就导致了下面这份代码挂了。

void Insert(int x, int v=1)
{
auto it=lower_bound({x, 0});
if(it==end()||it->num!=x) insert({x, 1});
else it->cnt+=v;
}

Insert() 过后父亲节点没有更新子树大小,所以排名相关的操作会挂掉。

每次 insert() 会调用 update_to_top() 操作来更新。

其实可以先 erase()insert() 的,但是常数巨大

然后我上网找了很久找不到用法,文档里也没有...

然后自己试出来的。

void update(iterator x) {update_to_top(x.m_p_nd, (node_update*)this);}

这样就能正常更新子树大小了。

插入

能够插入任意数量的元素。

void Insert(int x, int v=1)
{
auto it=lower_bound({x, 0});
if(it==end()||it->num!=x) insert({x, 1});
else it->cnt+=v, update(it);
}

原来的方式插入 v 个值时间复杂度为 O(vlogn),空间复杂度为 O(v)

现在时间复杂度为 O(logn),空间复杂度为 O(1)

删除

与插入基本相同。

void Erase(int x, int v=1)
{
auto it=lower_bound({x, 0});
if(it==end()||it->num!=x) return;
else if(it->cnt<=v) erase(it);
else it->cnt-=v, update(it);
}

前驱

int Pre(int x)
{
auto it=lower_bound({x, 0}); it--;
return it->num;
}

后继

int Aft(int x)
{
auto it=upper_bound({x, 0});
return it->num;
}

效率

洛谷 P6136 【模板】普通平衡树(数据加强版) 中测试。

均使用同样的快读快写,选择 C++20 O2

tree_order_statistics_node_updatetree:1252ms 67.63MB

自定义版的 tree:1135ms 50.78MB

这可是红黑树空间常数大点怎么了

但是前者(1.38KB)明显短于后者(3.29KB)。

自行取舍。

额外操作

这些操作不建议手动调用。

否则会破坏 RBT 的性质导致时间复杂度退化。


itn 是指向节点的迭代器。

旋转

rotate_left(itn):将左儿子旋到自己的位置。

rotate_right(itn):将右儿子旋到自己的位置。

rotate_parent(itn):将父亲节点旋到自己的位置。

本文作者:Jimmy-LEEE

本文链接:https://www.cnblogs.com/redacted-area/p/18063265

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   Jimmy-LEEE  阅读(662)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起