【Coel.学习笔记】左偏树(可并堆)
引入
左偏树是一种堆形数据结构,能够实现以下功能:
- 插入一个数字;
- 求最小/最大值(只能维护其中一个);
- 删除最小值/删除任意一个值;
- 合并两棵左偏树。
其中求最小值的时间复杂度为 \(O(1)\),其余操作时间复杂度为 \(O(\log n)\)。
虽然配对堆也支持以上操作且实际使用中效率略高,但左偏树支持可持久化等扩展功能,是最为常用的可并堆。此外,配对堆可以直接调用 pb_ds 的 pairing_heap_tag 实现,而左偏树不能。
具体操作
先看模板。
洛谷传送门
一开始有 \(n\) 个小根堆,每个堆包含且仅包含一个数。接下来需要支持两种操作:
1 x y
:将第 \(x\) 个数和第 \(y\) 个数所在的小根堆合并(若第 \(x\) 或第 \(y\) 个数已经被删除或第 \(x\) 和第 \(y\) 个数在用一个堆内,则无视此操作)。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;
}