【Coel.学习笔记】左偏树(可并堆)

引入

左偏树是一种堆形数据结构,能够实现以下功能:

  1. 插入一个数字;
  2. 求最小/最大值(只能维护其中一个);
  3. 删除最小值/删除任意一个值;
  4. 合并两棵左偏树。

其中求最小值的时间复杂度为 \(O(1)\),其余操作时间复杂度为 \(O(\log n)\)
虽然配对堆也支持以上操作且实际使用中效率略高,但左偏树支持可持久化等扩展功能,是最为常用的可并堆。此外,配对堆可以直接调用 pb_ds 的 pairing_heap_tag 实现,而左偏树不能。

具体操作

先看模板。
洛谷传送门
一开始有 \(n\) 个小根堆,每个堆包含且仅包含一个数。接下来需要支持两种操作:

  1. 1 x y:将第 \(x\) 个数和第 \(y\) 个数所在的小根堆合并(若第 \(x\) 或第 \(y\) 个数已经被删除或第 \(x\) 和第 \(y\) 个数在用一个堆内,则无视此操作)。
  2. 2 x:输出第 \(x\) 个数所在的堆最小数,并将这个最小数删除(若有多个最小数,优先删除先输入的;若第 \(x\) 个数已经被删除,则输出 \(-1\) 并无视删除操作)。

解析:左偏树除了普通堆要维护的权值 \(val\) 以外,还要维护一个值 \(dis\),表示该节点到离它最近的空节点的距离。叶子节点的 \(dis\) 定为 \(1\),其余节点依次递增。
左偏树要求对于每棵子树都有左儿子的 \(dis\) 大于右儿子,这也是“左偏树”一词的由来。当然让右儿子大于左儿子,造个“右偏树”也行,本质上是一样的。


\(\text{merge}\) 是左偏树的核心操作。这个函数传入两个节点 \(a,b\),将子树合并并返回根节点。

假设 \(a\) 权值小于等于 \(b\),根据小根堆的性质可以知道根节点为 \(a\)。那么 \(a\) 与它的左子树不需要变化,把 \(b\)\(a\) 右子树合并。显然,这个操作可以递归进行。

此外合并过程中还要注意左右子树的 \(dis\) 关系,从而保证左偏树的性质。

剩下的操作只要合理利用 \(\text{merge}\) 操作加以运用就好了,顺便可以利用并查集的路径压缩加快速度。

这道题的操作比较复合(查找和删除放一起,插入变成初始化操作),所以不用类封装了。

#include <algorithm>
#include <cstring>
#include <iostream>

using namespace std;

const int maxn = 2e5 + 10, inf = 1e9;

int n, m;

int dis[maxn], val[maxn], l[maxn], r[maxn], idx;
int f[maxn];
bool vis[maxn];

int find(int x) { return x == f[x] ? x : f[x] = find(f[x]); }  //并查集路径压缩

bool cmp(int x, int y) {  //比较节点权值大小
    if (val[x] != val[y]) return val[x] < val[y];
    return x < y;
}

int merge(int x, int y) {
    if (x == 0 || y == 0) return x + y;
    if (!cmp(x, y)) swap(x, y);
    /*对应到并查集是启发式合并,在左偏树就是正常操作了*/
    r[x] = merge(r[x], y);
    if (dis[r[x]] > dis[l[x]]) swap(l[x], r[x]);  //保持左偏树性质
    dis[x] = dis[r[x]] + 1;
    return x;
}

int main(void) {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    val[0] = inf;
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        cin >> val[i];
        f[i] = i;
    }
    while (m--) {
        int op, x, y;
        cin >> op >> x;
        if (op == 1) {
            cin >> y;
            if (vis[x] || vis[y]) continue;
            x = find(x), y = find(y);
            if (x != y) {
                if (!cmp(x, y)) swap(x, y);
                f[y] = x;
                merge(x, y);
            }
        } else if (op == 2) {
            if (vis[x]) {
                cout << -1 << '\n';
                continue;
            }
            x = find(x);
            cout << val[x] << '\n';
            vis[x] = true;
            if (!cmp(l[x], r[x])) swap(l[x], r[x]);
            f[x] = l[x], f[l[x]] = l[x];
            merge(l[x], r[x]);
        }
    }
    return 0;
}

例题讲解

左偏树的例题并不多,所以只放一道比较典型的题。

[BOI2004] Sequence

洛谷传送门
给定一个整数序列 \(a\),求出一个递增序列 \(b\),使得两个序列各项差绝对值的和最小。

解析:本题也出自黄源河的论文《左偏树的特点及其应用》,可以看一看。

我们先把每个 \(a\)\(b\) 变换成 \(A=a_i-i,B=b_i-i\)。这时序列 \(B\)\(B_1\leq B_2\leq...\leq B_n\),并且这 \(b\)\(B\) 可以一一对应; \(a\)\(A\) 同理。这样,我们可以把答案变成求非下降序列 \(B\)

这样转换有什么用呢?我们把整个序列 \(A\) 分成相互衔接的两段 \(A_1,A_2,...A_m\)\(A_{m+1},A_{m+2},...A_n\)。考虑第一段最优解为 \(b_1=b_2=...=b_m=u\),第二段最优解为 \(b_{m+1}=b_{m+2}=...=b_{n}=v\) 的情况。假如 \(u\leq v\),那么直接合并两个答案不会影响序列的递增性,并且答案保证最优;但如果 \(u>v\),我们就不能直接合并,而是有结论:合并起来的答案是整个序列的中位数(证明比较复杂,此处略过)。显然,这个操作是可以分治进行的。

为了实现这个“不断分治寻找中位数”的过程,我们要用堆来维护。如果一个个添加到堆里面,我们很容易想到用对顶堆;但这题数据的添加没有顺序,所以要用可以合并的堆,也就用左偏树维护了。

#include <algorithm>
#include <iostream>

using namespace std;

const int maxn = 1e6 + 10;

int n, top, ans[maxn];
long long res;

struct node {//每个区间都相当于一个堆
    int ed, root, sz;  //储存区间末尾,堆的根和区间长度
} stk[maxn]; //递归常数大,用手写栈代替
int val[maxn], dis[maxn], l[maxn], r[maxn];

int merge(int x, int y) {
    if (x == 0 || y == 0) return x + y;
    if (val[x] < val[y]) swap(x, y);
    r[x] = merge(r[x], y);
    if (dis[r[x]] > dis[l[x]]) swap(r[x], l[x]);
    dis[x] = dis[r[x]] + 1;
    return x;
}

int mid(int x) { return merge(l[x], r[x]); }

int main(void) {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> val[i];
        val[i] -= i;
    }
    for (int i = 1; i <= n; i++) {
        node cur = {i, i, 1};
        dis[i] = 1;
        while (top && val[cur.root] < val[stk[top].root]) {
            cur.root = merge(cur.root, stk[top].root);
            if (cur.sz % 2 && stk[top].sz % 2) cur.root = mid(cur.root);
            cur.sz += stk[top].sz;
            top--;
        }
        stk[++top] = cur;
    }
    for (int i = 1, j = 1; i <= top; i++)
        while (j <= stk[i].ed) ans[j++] = val[stk[i].root];
    for (int i = 1; i <= n; i++) res += abs(val[i] - ans[i]);
    cout << res << '\n';
    for (int i = 1; i <= n; i++) cout << ans[i] + i << ' ';
    return 0;
}
posted @ 2022-08-11 21:01  秋泉こあい  阅读(38)  评论(0编辑  收藏  举报