左偏树

抛出问题

\(n\)个人(一个人可以单独作为一个团体),每个人有一个分数且他们的初始位置是他们的编号,有\(m\)种操作,每次操作可能有两种情况:

  1. 合并两个团体;
  2. 找出某个编号的人所在团体的最小分数的人,输出其分数并杀掉他。

\(n\leq 10^6,m\leq 10^5\)
典例:罗马游戏

解决问题

直接办法

我们发现题目要求中集合了小根堆并查集两种数据结构,也许我们会想直接合并它们,但是细细想来,小根堆的性质有可能因此破坏。因此咱们有一种直接的方案,就是将其中一个小根堆中的节点(\(n\)个)一个一个地移到另一个小根堆(\(m\)个)中,这样的复杂度就是\(O(n\lg (n+m))\)
但是注意到数据范围,再加上操作有\(m\)次,所以最坏总复杂度就是\(O(mn\lg (n+m))\),绝对会超时。

新的方法——左偏树

为此,一个新的数据结构就诞生了,它叫"左偏树"。顾名思义,它是一个树状结构,而且是二叉树,而它的节点是向左偏移的(左子树节点可能会多些)。

左偏树的性质

  1. 左偏树的每个节点都有一个属性(attribution),到最近的叶子节点的距离(我们定义为\(p\)),并且我们认为叶子节点是空节点(它们有\(p=0\)),如图所示;
    图1
  2. 左偏树的每个节点的\(p\)恰比其右儿子的\(p\)\(1\)
  3. 左偏树的每个节点的左儿子的\(p\)不小于右儿子的\(p\)
  4. 左偏树满足堆的性质。

左偏树的推论

  1. 左偏树的根节点的\(p\)\(log\)级别的,也就是说它到最右边节点的距离是\(log\)级别的;
  2. 左偏树的子树是左偏树;
  3. 右链长度为\(d\)的左偏树至少包含\(2^{d}-1\)个内部节点,\(d\)实际上就是根的\(p\)值,如图。
    图2

左偏树的操作

合并

我们现在假设要合并两个左偏树(小根堆),它们的树根分别为\(a\)\(b\),如图所示。
图3
如何合并呢?我们先选出树根(堆顶元素),只需要比较\(a\)\(b\)即可。假设选出来的是\(a\)(如果选出来是\(b\),那么交换它们即可),注意到左偏树的性质2,我们递归合并\(a\)的右子树\(a_R\)\(b\)为根的树。这就是左偏树的精华所在。
为什么要这么做?
这样做可以花\(O(\lg n)\)的单次时间复杂度合并两个左偏树,而这恰恰就是利用了它左偏的性质!试想,如果一直这样递归下去,每次选择的都是右子树和另一个树合并,最多也不过就是\(a\)\(b\)\(p\)值之和。
但是这还没有结束,在递归合并完毕后要进行维护操作,也就是维护左偏树的性质3。如果\(a\)的左右子树违反了左偏树的性质,交换一下即可。
图4
细节方面:

  1. 注意底部空节点,作为递归结束标志;
  2. 记得更新父子关系;
  3. 最后记得更新\(a\)\(p\)值。

总结如下:

  1. 选根,递归合并;
  2. 维护左偏树性质;
  3. 注意细节。

插入与删除

插入其实很简单,就是调用一次合并函数,如图。
图5
删除也不难,因为这里删除的是最小元素,也就是树根,我们只需要将其左右子树再合并即可,如图。
图6
单次复杂度都是\(O(\lg n)\)

左偏树的实现

左偏树可以用数组实现,也可以用指针实现,但是指针实现在查询方面不太好做,所以一般是用数组实现的。在处理上也有一些小技巧,可能也不算什么,不过大家可以细看一下。这是左偏树的模板,点我去模板题,我的代码如下:

#include <cstdio>

struct TREE
{
    int val, lc, rc, npl, fa;
}t[100010];

inline int read()
{
    int x = 0;
    char ch = getchar();
    while(ch < '0' || ch > '9')
        ch = getchar();
    while(ch >= '0' && ch <= '9')
        x = (x<<3) + (x<<1) + (ch^48), ch = getchar();
    return x;
}

inline void swap(int &a, int &b)
{
    int t = a;
    a = b;
    b = t;
}

int merge(int a, int b)
{
    if(!a) return b;
    if(!b) return a;
    if(t[a].val > t[b].val || (t[a].val == t[b].val && a > b))
        swap(a, b);
    int &ar = t[a].rc, &al = t[a].lc;
    ar = merge(ar, b);
    t[ar].fa = a;
    if(t[al].npl < t[ar].npl)
        swap(al, ar);
    t[a].npl = t[ar].npl + 1;
    return a;
}

void del(int a)
{
    int al = t[a].lc, ar = t[a].rc;
    t[a].val = -1;
    t[al].fa = 0;
    t[ar].fa = 0;
    merge(al, ar);
}

inline int find(int a)
{
    while(t[a].fa)
        a = t[a].fa;
    return a;
}

int main()
{
    int n, m;
    int x, a, b;
    n = read(); m = read();
    t[0].val = -1;
    for(register int i = 1; i <= n; i += 1)
        t[i].val = read();
    for(register int i = 0; i < m; i += 1)
    {
        x = read();
        if(x == 1)
        {
            a = read(); b = read();
            if(t[a].val != -1 && t[b].val != -1)//一定要加上去,不然合并就会出问题
            {
                a = find(a), b = find(b);
                if(a != b)//注意判断
                    merge(a, b);
            }
        }
        else
        {
            a = read();
            if(t[a].val == -1)
                printf("-1\n");
            else
            {
                a = find(a);
                printf("%d\n", t[a].val);
                del(a);
            }
        }
    }
    return 0;
}

时间复杂度:\(O(m\lg n)\)。相应的,解决顶上的问题就不难了——其实就是稍微改改就行了。

尾注

不知道是哪个左撇子发明的这个数据结构。。。
左偏树这个数据结构还是非常棒的,这也提醒我们应该学会创造性思维。

  • 感谢LMH大佬的帮助;
  • 感谢洛谷平台的帮助;
  • 感谢那些写题解的大佬的帮助。

写在最后

感谢大家的关注和阅读。
本文章借鉴了少许思路,最后经过本人思考独立撰写此文章,如需转载,请注明出处。

posted @ 2018-05-23 21:42  孤独·粲泽  阅读(317)  评论(0编辑  收藏  举报