【学习笔记】用算法题介绍C++的pb_ds库中的可并堆

写在前面

来自TRiddle,转载请注明出处,谢谢~

(最后一次更新: 2017/5/3

最近我在网上搜题解的时候无意中发现 C++ 中有 pb_ds(Policy-Based Data Structures)这个库(点击这里获取官方文档)。觉得挺新鲜的,就去谷歌去百度了下。然后就发现这个库里有很多好非常玩儿的东西(不是 NBA 里那个库里)。比如可并堆,平衡树和哈希表等等。怀着收获新玩具的喜悦,我赶紧咻咻咻地用它们写了一波题,感觉还是用得蛮舒服的~然而好东西不能光自己用是吧?于是就有了这篇文章。让我们先看看pb_ds中的可并堆吧~

(说明:以下内容在涉及数据结构的地方会用牺牲严谨性来换取更多的通俗性,请见谅^^)




什么是可并堆

(来自TRiddle,转载请注明出处,谢谢~)

首先我觉得有必要介绍一下什么是可并堆。可并堆就是可以合并的堆。在合并后堆中的元素依旧保持原来的“单调性”。(堆就是一个动态集合,能够插入删除其中的元素,同时能够迅速得知集合中最大或最小的元素。若想了解更多跟堆有关的东西,建议直接维基百科~想要了解跟堆的实现有关的东西,建议看《啊哈!算法》或《挑战程序设计竞赛》)

可并堆的题目

为了对可并堆有更加深入的了解,以及方便待会儿讨论可并堆的效率问题,在这里我打算引入一道算法题(HDUOJ 1512,点击这里获取原题)(如果只想了解用法和结论的话可以跳过这个题目,直接看结尾部分):

题目大意

N 只富有攻击性的猴子,第 i 只猴子的攻击力为 ai
猴子们之间会形成小团体。当猴子 A 和猴子 B 发生争执的时候,猴子 A 的小团体中攻击力最高的猴子 P 会和猴子 B B 的小团体中攻击力最高的猴子 Q Q 打一架。打架的结果是两个战士 P P Q Q 的攻击力减半,并且连个小团体合并为一个小团体。(初始的时候每个猴子只和自己形成小团体,“减半”是指除以 2 2 并向下取整)
现在已知有 M M 次争执即将发生,第 i 次争执的主角是猴子 Ai 和猴子 Bi ,问每次战斗过后合并的小团体中攻击力最高的猴子的攻击力是多少?( N,M105 , 猴子的攻击力在 int 的表示范围内)

使用可并堆的动机

因为 105 的数据量最大只够跑 O(nlogn) 左右的算法,所以对于每次争执,我们必须迅速地知道某只猴子属于哪个小团体,那个小团体中攻击力最高的猴子是谁。在争执结束后为了处理下次询问我们还得快速地合并这两个小团体。这可是个不小的任务。还好我们有两种优秀的数据结构可以胜任这个任务:

可并堆和并查集

并查集

维护若干个不相交的集合,每个集合由其中的某个元素表示。就像对于每个国家,最高元首可以作为他们的代表(因为一个最高元首不能同时执政两个国家,如果有的话请告诉我~)。例如,我们有数字集合 S1={1,2,3} S2={5,7,11} S3={256,1024} 。那么我们可以用元素 1,5,256 分别代表集合 S1,S2,S3 ,因为这些集合是没有交集的。我们不妨吧 1,5,256 这样的元素称为“代表元素”(在上面的例子中,我的代表元素是随机选的,并查集则不是)。这个数据结构为我们提供以下接口:

find(x): 返回x所在集合的“代表元素”。

Union(x, y): 合并x和y所在的集合。

于是我们可以用并查集来维护猴子小团体。

(点击这里获取更多与并查集有关的信息)

可并堆

能够在最多 O(logn) 的复杂度下快速合并两个堆。显然我们要动态查询猴子小团体的最大攻击力就得用堆来存储猴子小团体的攻击力。如果我们为每个猴子小团体分配一个堆的话,用可并堆是再合适不过的,因为要配合并查集的快速合并。这个数据结构提供以下接口:

push(x, y): 向第x个堆中插入y这个值

pop(x): 弹出第x个堆的堆顶

top(x): 返回第x个堆的堆顶的值

join(x, y): 将第x个堆和第y个堆合并

于是我们可以对每个小团体用可并堆来维护小团体中每只猴子的攻击力,以便快速地查询最高攻击力,以及快速地处理小团体合并。




pb_ds中的可并堆

好的,那么现在是介绍pb_ds库中的堆的最好时机啦。

声明及使用

在之前我们讲过,pb_ds库直接提供了这么个可合并的堆,这就是pb_ds中的 priority_queue(与queue库中的priority_queue不是一个东西)。虽然它是个优先队列,但是它实现的底层实现是堆(没听说过优先队列的话也没关系,两者其实是差不多的)。在用之前首先要在头文件部分来这么两句:

#include 
using namespace __gnu_pbds;

然后就可以声明我们的可并堆了:

__gnu_pbds::priority_queue <int> qs[maxn];

这里必须要解释一下,为什么已经用“using namespace __gnu_pbds;”了,还要用“__gnu_pbds::”呢?这是因为std这个命名空间里也有priority_queue,我在代码的其它部分中用了命名空间std里的内容的(using namespace std),建议尝试将“__gnu_pbds::”删除来感受一下会发生什么。

然后就是,为什么这个可并堆被声明成数组呢?因为上面的题目中每个猴子小团体都需要一个可并堆(第 i 只猴子的可并堆用 qs[i] 来表示)。

然后,这就搞定了。对,不用手写左偏树,这么一句话就可以搞定!是不是很开熏(不自觉地露出了痴汉般的笑容)?耶!(喂喂喂,题目的具体实现还没讲呢,开心个啥呀!)

底层实现

这个pb_ds库还对我们开放了一点底层实现,那就是我们可以自定义堆的排序策略,甚至还可以选择可并堆的实现方式。先看以下声明:

#define param int, less, binomial_heap_tag
__gnu_pbds::priority_queue qs[maxn];

为了不让代码太长我做了一点宏定义,希望没有影响各位看官们的理解。接下来又要做解释啦~

less<int> 表示这个堆的堆顶是堆中的最大元素(也就是定义了排序策略,想了解关于C++谓词的内容点击这里)

binomial_heap_tag表示这个堆的底层实现是二项堆,另外还有pairing_heap_tag,binary_heap_tag,thin_heap_tag等等。(想了解关于各种可并堆的实现请点击这里)

效率比较

让我们开看一下各种实现方式的效率对比(也就是用程序跑了一下上面的题目),这里除了比较各种pb_ds中priority_queue的各种实现方式外,还将pb_ds外的实现方式的效率列出以方便大家比较:

实现方式 Online Judge的反馈 时间 空间
pairing_heap_tag AC 1419ms 10.9MB
binary_heap_tag TLE
binomial_heap_tag AC 1388ms 11.7MB
rc_binomial_heap_tag MLE
thin_heap_tag MLE
std::priority_queue MLE
手写左偏树 AC 1263ms 5.4MB

这样看来,这个库的可并堆的效率还是不是特别高的(毕竟封装性好)。但是如果用对了tag的话可以做到效率几乎一样的效果。

我们再来看一下大连市第二十四中学的于纪平童鞋的论文《C++的pb_ds库在OI中的应用》中的结论:

【学习笔记】用算法题介绍C++的pb_ds库中的可并堆_第1张图片

用一句话总结就是:大胆地用带pairing_heap_tag和binomial_heap_tag的可并堆吧~




解题代码

题目具体的实现细节不想多讲了(抱歉^^),直接接附上AC代码吧,细节会在代码的注释中体现的,Enjoy Coding~:

#include 
#include 
using namespace std;
using namespace __gnu_pbds;

const int maxn = 1e5 + 5;

// 并查集的结构体
struct DisjointSet {
    int p[maxn];
    // 并查集的初始化
    void init(int n) {
        for(int i = 1; i <= n; i++) {
            p[i] = i;
        }
    }
    // 并查集的查找功能
    int find(int u) {
        return u == p[u] ? u : p[u] = find(p[u]);
    }
    // 判断两个元素是否在一个集合中
    bool isJoined(int u, int v) {
        return find(u) == find(v);
    }
    // 将两个元素合并
    // (猴子之间的政治阴谋)
    void Union(int u, int v) {
        u = find(u);
        v = find(v);
        p[v] = u;
    }
}s;

// 声明可并堆
#define param int, less, binomial_heap_tag
__gnu_pbds::priority_queue qs[maxn];
int T, n, m, u, v, val;

// 将两个可并堆合并的同时维护堆的信息
// (两个猴子正在打架,动物园的看客们正在围观)
void jointHeap(int u, int v) {
    u = s.find(u);
    v = s.find(v);
    int utop = qs[u].top();
    qs[u].pop();
    qs[u].push(utop / 2);
    int vtop = qs[v].top();
    qs[v].pop();
    qs[v].push(vtop / 2);
    qs[u].join(qs[v]);
}

int main() {
    while(scanf("%d", &n) == 1) {
        // 每组算法开始之前的初始化
        s.init(n);
        for(int i = 1; i <= n; i++) {
            qs[i].clear();
            scanf("%d", &val);
            qs[i].push(val);
        }
        scanf("%d", &m);
        while(m--) {
            scanf("%d%d", &u, &v);
            // 如果两个猴子已经在同一小团体中
            if(s.isJoined(u, v)) {
                cout << -1 << endl;
                continue;
            }
            // 合并两个小团体的堆
            jointHeap(u, v);
            // 查询新的小团体中的老大
            cout << qs[s.find(u)].top() << endl;
            // 合并两个小团体
            s.Union(u, v);
        }
    }
    return 0;
}

你可能感兴趣的:(Essay)