pbds食用教程
平板电视:比 STL 还 STL
——来自 luogu 某佬
Intro 前言
pb_ds
的主体部分在__gnu_pb_ds
命名空间下,使用前需要using namespace __gnu_pbds
WARNING: NOIP 评测的时候可能不允许使用
using namespace __gnu_pbds
。
头文件如下:
1 |
或者
1 |
|
WARNING: pb_ds 是以 libstdc++为标准库的编译器中专属的库 仅 gcc 可用
为什么要pb_ds
?
它封装了可并堆,字典树,平衡树等一堆常用且难搞的东西,极大程度减少码量,甚至可能可以降低时间复杂度。
而且,就算考场上不敢使用 pbds,我们也可以用它来进行对拍,或者更加硬核,直接把 pbds 中用到的部分的源码复制到提交文件中(但是要注意提交文件大小),更可以参照着 pbds 自己重写一遍里面的数据结构。总而言之,就算不敢直接调用 pbds 库,它依然是十分有用的。
hash 哈希表
pbds 中有两个哈希表:
1 | cc_hash_table<Key, Value>; |
其中cc_hash_table
是拉链法,gp_hash_table
是探测法。探测法相对来说会快一些。
怎么用?他们的用法和std::map
完全相同。
那他们有什么作用呢?
注意到,std::map
是依靠平衡树实现的,总复杂度是$\Theta(n\log n)$,而哈希表的总复杂度是$\Theta(n)$。
C++11 开始引入了
std::unordered_map
,同样是基于哈希表实现的。实测下来,开了 O2 的情况下,STL 相对会较快一些。所以如果比赛开了 C++11 和 O2 的话并没有必要使用 pbds hash
例题:P1333 瑞瑞的木棍
这个题是一个图论题,基本上就是欧拉回路的模板题。问题在于它给边的时候并不是给两端的编号而是两个字符串。
一种做法是按照正常的字符串哈希让每个颜色映射到一个整数上然后做。事实上在C++11后STL里面也封装了哈希函数,但是它的值域很大,所以一般在OI中不会用他。
另外就是用trie
或哈希表,然后正常图论该怎么做怎么做就完了。
代码来自 https://www.luogu.com.cn/blog/Chanis/gnu-pbds 。略有修改。
1 |
|
trie 字典树
模板定义:
1 | template < |
容易发现,trie
和tree
的模板几乎是一模一样的(除了E_Access_Traits
)。当然,由于比赛中实际使用trie
远远少于tree
,因此我们并不会像平衡树那样细致地讲解使用和自定义。事实上,trie
和tree
自定义的操作几乎是一样的。
通常我们使用的trie
是这样的:
1 | using Trie = __gnu_pbds::trie<string, null_type, |
然后我们可以有这些操作:
1 | trie.insert(str); |
由于trie
的实际应用很少,并且实现并不困难,因此我们就不过多阐述它了。
tree 平衡树
平衡树的实现有三个:rb_tree_tag
,splay_tree_tag
和ov_tree_tag
。除了红黑树都不建议使用,除非是一道 Splay 维护区间的问题而又不会写 Splay。
模板参数如下:
1 | template < |
其中:
Key
是键类型,也就是平衡树中存储的类型Mapped
映射类型,通常为null_type
表示无映射Cmp_Fn
比较函数Tag
实现方式Node_Update
节点更新方式 通常使用tree_order_statistics_node_update
Alloc
内存分配器
其中Node_Update
是 pbds tree 最重要的一部分,也是自定义 pbds 平衡树行为的最重要的手段
在讲解 RB-Tree 和 Splay-Tree 的用法前,我们看一下tree
的成员函数:
find
查找权值对应节点insert
插入新节点erase
删除迭代器所指向的节点lower_bound
第一个大于等于某元素的迭代器upper_bound
第一个大于某元素的迭代器join
合并 合并后另一棵树会变成空split(const Key &r, RBTree &other)
分裂,将大于r的元素放进other里(如果使用的是greater则是小于r)node_begin
得到根节点的迭代器node_end
得到最后一个叶节点后的一个空节点,一般表示不存在
RB-Tree 红黑树
红黑树的基本原理超出了我们的范围。我们所需要知道的只是:这是一种跑的飞快且极为难写难调的平衡二叉树。
平衡树的用途通常比较固定(除了 Splay),一般就这么几种:
插入一个数
删除一个数
查询一个数的排名
查询排名为 k 的数
求一个数的前驱后继
代码:
1 | // https://www.luogu.com.cn/problem/P3369 |
Node_Update 的实现
WARNING: 前方需要一些 C++高级特性 看不懂可以暂时跳过
首先我们要知道:Node_Update
是tree
的公有基类。也就是说,Node_Update
中的所有成员方法都会在tree
中公开。Node_Update
是这样的一个模板类:
1 | template < |
在 pbds 平衡树的每一个节点中,都带着一些附加的元数据,而这些数据的类型则是在Node_Update
中定义的。在一个自定义的Node_Update
中,我们至少需要定义两个东西:
metadata_type
元数据的类型void operator()(Node_Itr it, Node_CItr end_it)
修改树的时候调用的函数
其中Node_Itr
是指向树中结点的一个迭代器,有这些操作:
get_l_child()
返回左子,没有左子则返回node_end()
get_r_child()
同上get_metadata()
获取元数据,类型是我们定义的metadata_type
**it
访问迭代器所指向的节点中的值
我们来举个例子。假如说,我现在想要维护一棵红黑树,同时维护每一颗子树的大小。那么我们可以这样:
1 | struct NodeUpdateSize { |
注意it.get_metadata()
返回的是一个const reference
,是不可以直接赋值的,需要用const_cast<metadata_type &>
来去掉const
修饰。
更复杂一些,我们还可以实现查找某一个值的排名。要在平衡树上查找排名,我们得要先从树根开始往下找。很不幸的是,在Node_Update
里我们还不能直接访问tree
里的方法,但是我们可以通过virtual
的方式先生成虚函数从而实现调用tree
中方法的目的。
1 | struct MyNodeUpdate { |
不过,order_of_key
在tree_order_statistics_node_update
中已经提供了,我们能不能通过继承这个类来实现复用呢?很遗憾,不行。因为tree_order_statistics_node_update
中实现order_of_key
和find_by_order
需要使用metadata
,而我们修改metadata
后就无法继续维护原来的metadata
导致失效。
例题:NOI2004郁闷的出纳员
这个题目很容易让人想到平衡树,但是显然平衡树不能实现所有人的加和减操作。这个时候不妨换一个角度考虑:不直接修改所有人的工资,而是修改工资下界,然后记录当前工资下界与原始值的差,每次查询的时候减去这个差即可。
要注意的是,在平衡树里面放的类型不是int
而是pair<int, int>
,目的是为了防止重复的元素。这在平衡树的题目里是很常见的技巧。当然我们也可以维护metadata
记录同样的数据出现的次数,但这显然会麻烦不少。
代码如下:
1 |
|
priority_queue 堆
注意:如果用
__gnu_pbds::priority_queue
那么建议不要引用万能头或者using namespace __gnu_pbds
以免与std::priority_queue
发生冲突导致 CE。
前置知识:如何自定义priority_queue
的排序方式
其实这个前置知识并不会影响 pbds 堆的使用
我们都知道,对于一个结构体,我们不能直接把它丢进priority_queue
,无论是 STL 还是 pbds。那么我们有两种方案:重载小于号,或者写一个仿函数 functor
仿函数听着玄乎,其实很简单,只是一个重载了括号运算符的结构体而已。
比方说,众所周知,有个东西叫std::less
,它其实就是一个仿函数,实现也很简单:
1 | template<typename T> |
那么,我们同样也可以自己写一个comp
,用来比较,就像这样:
1 | struct comp{ |
使用的时候则这么用:
1 | std::priority_queue<int,std::vector<int>,comp> q; |
于是就可以得到堆里面装了数组下标,以下标对应的dis
值为关键字的小根堆
是不是非常简单
模板参数 & 成员函数
1 | template< |
其中:
ValueType
是堆里面的元素类型Comp
是比较器,是一个 functor。注意 pbds 的堆和 STL 一样 传入 less 会得到大根堆Tag
是堆的实现,总共有以下几种:pairing_heap_tag
配对堆 最有用binary_heap_tag
二叉堆binomial_heap_tag
二项堆rc_binomial_heap_tag
冗余计数二项堆thin_heap_tag
Tarjan 老爷子发明的一种 除了合并以外复杂度都和 Fibonacci 堆一样的堆
Alloc
是空间分配器,不用管他,省略即可
除了pairing_heap_tag
以外,其他四个在 OI 中都不如std::pirority_queue
。配对堆不仅优于 STL 的二叉堆,同时也优于algorithm
头文件中的make_heap
系列函数。因此,如果不做说明,下文所指的 pbds 堆都指配对堆。
五种 tag 都支持以下的操作:
push
压入一个元素pop
弹出一个元素top
获得堆顶size
获得大小empty
获得是否为空modify
修改某一个位置的元素 并且重新调整结构erase
删除某一个位置的元素join
合并两个堆
复杂度
push | pop | modify | erase | join | |
---|---|---|---|---|---|
std::priority_queue |
最坏$\Theta(n)$均摊$\Theta(\log n)$ | $\Theta(\log n)$ | $\Theta(n\log n)$ | $\Theta(n\log n)$ | $\Theta(n\log n)$ |
pairing_heap_tag |
$O(1)$ | 最坏 $\Theta(n)$ 均摊 $\Theta(\log(n))$ | 最坏 $\Theta(n)$ 均摊 $\Theta(\log(n))$ | 最坏 $\Theta(n)$ 均摊 $\Theta(\log(n))$ | $O(1)$ |
binary_heap_tag |
最坏 $\Theta(n)$ 均摊 $\Theta(\log(n))$ | 最坏 $\Theta(n)$ 均摊 $\Theta(\log(n))$ | $\Theta(n)$ | $\Theta(n)$ | $\Theta(n)$ |
binomial_heap_tag |
最坏 $\Theta(\log(n))$ 均摊 $O(1)$ | $\Theta(\log(n))$ | $\Theta(\log(n))$ | $\Theta(\log(n))$ | $\Theta(\log(n))$ |
rc_binomial_heap_tag |
$O(1)$ | $\Theta(\log(n))$ | $\Theta(\log(n))$ | $\Theta(\log(n))$ | $\Theta(\log(n))$ |
thin_heap_tag |
$O(1)$ | 最坏 $\Theta(n)$ 均摊 $\Theta(\log(n))$ | 最坏 $\Theta(\log(n))$ 均摊 $O(1)$或者$\Theta(\log n)$ 这取决于修改是增加还是减少 | 最坏 $\Theta(n)$ 均摊 $\Theta(\log(n))$ | $\Theta(n)$ |
应用
Dijkstra & Prim
OI 中堆最常见的应用之一就是图论里最短路和最小生成树。
我们知道,对于非负权图的最短路,有两大经典算法:Dijkstra 和已死的SPFA。其中,std::priority_queue
优化的 Dijkstra 复杂度是$\Theta(m\log m)$的,而手搓堆和线段树则是$\Theta(m\log n)$,对于 Fibonacci 堆则是吓人的$\Theta(m+n\log n)$。对于很稠密的图来说,我们有 $m=O(n^2)$,此时 dijkstra 的常数会涨得飞快,而在考场上手搓 Fib 堆又并不现实,因此我们需要一种简单的方式对 dijkstra 进一步提速。
同样,面对最小生成树,我们也有一样的问题:Kruskal 在稠密图上的表现不尽人意,而单独std::priority_queue
优化的 Prim 算法复杂度和 Kruskal 相同,常数还更大,因此面对稠密图我们需要更快的最小生成树算法,典型例子就是Moo Network G。
WARINING: 事实上,如果 pbds 堆优化 dij 是标算的一部分,那么它不应该给出
std::priority_queue
不能通过的测试点。因此,pbds 堆优化更多可以看作是一种从标算不是最短路但是可以用最短路解的问题中骗分的方式。GCC 认为
thin_heap_tag
在 Dijkstra 上表现会优于pairing_heap_tag
,并且单从复杂度分析它也确实更优(等同于 Fib 堆),但是由于常数比较大,实测下来它的性能并没有配对堆优秀。
那么为什么 pbds 堆会比 STL 堆跑的快呢?究其原因有两点:
pbds 堆支持
modify
操作pbds 堆
modify
操作时,如果堆中元素是减少的,那么复杂度是$o(\log n)$的(注意这里不是上确界)。事实上配对堆的modify
复杂度在学术界还无法给出精确解(就连 Tarjan 老爷子都没算出来)。目前认为的下界是$\Omega(\log\log n)$,上界是$O(2^{\sqrt{\log\log n}})$。
事实上,Fib 堆跑 dijkstra 和 prim 快的原因也在于此:Fib 堆中元素减小时修改的复杂度是$\Theta(1)$的,这也是为什么thin_heap_tag
有更优的理论复杂度。而 dijkstra 和 prim 中,每次松弛后,dis 显然是减小的。
有一个很奇怪的事情,pbds 的 dij 在 luogu P4779 里比 stl 堆快得多,但是我本机 benchmark 测无论是随机图还是网格套菊花两者速度都不相上下,开 O2 后 STL 堆甚至远远快过了手打堆和线段树。个人猜测是由于配对堆常数较大以及 pbds 堆底层数据结构内存不连续导致的,但是这并不能解释为什么 STL 堆能比手打堆和线段树快。
代码:
1 |
|
可并堆
相比起优化 dijkstra 和 prim,pbds 堆在这一方面应用更广——毕竟 STL 中并没有数据结构能直接实现可并堆,而配对堆和左偏树都不是很好写。
注意:a.join(b)
后,b
这个堆中的元素将会被清空。
代码:
1 |
|
可并堆可以解决很多类型的问题,比如某些树上问题。这些题目一般是一个节点维护一个堆,然后儿子的堆不断向父亲合并,过程中进行计算。这些题虽然常常有省选及以上的难度,但主要原因在于可并堆并不好实现。另外,可并堆通常会与并查集一块出现(比如上面的模板题)
rope
(我也不知道他算什么数据结构)
但是它可以实现可持久化数组。它的底层结构是树状的。常数很大但复杂度并不高。
注意:严格来讲rope
并不属于pbds。事实上,rope
本身跟pbds是同级的。它的头文件是rope
,命名空间则是__gnu_cxx
而不是pbds。
__gnu_cxx::rope
支持以下操作:
at(x)
&operator[]
同vector
,复杂度$\Theta(1)$push_back
&append
同vector
,复杂度$\Theta(\log n)$insert(x, other)
在x
的位置插入另一个串 复杂度最好$\Theta(\log n)$最坏$\Theta(n)$erase(x, len)
删除x
开始的len
个元素。复杂度$\Theta(\log n)$replace(x, len, other)
从第x
位开始的len
个元素替换为other
。也可以省略len
。复杂度$\Theta(\log n)`substr(x, len)
从x
开始len
位,复杂度$\Theta(\log n)$operator+
连接两个rope
我们来讲一讲rope
一种常见的应用:可持久化数组。
WARNING: 由于
rope
的常数较大,而这个题目的数据比较凶悍,因此不保证rope
能AC
首先我们定义一个历史记录数组,里面存放了所有版本的rope
的指针。每次创建一个新版本的时候,就从历史记录中取出那个版本创建一个新的rope
。这看上去复杂度很高,但是由于rope
底层的实现,实际上这个操作只是换了一个新的根节点,复杂度是$\Theta(1)$的。
接下来的事情就很简单了:读入,判断类型,修改或者输出即可。注意一下下标从0开始即可
代码:
1 |
|
例题
平衡树
可并堆
rope
END
这里有我自己测的hash和priority_queue的性能数据 https://github.com/AI1379/pbds_benchmark
参考: