左偏树是一种可并堆,除了堆的基本功能,最大的特点就是支持合并堆,甚至比普通堆好写。
下面叙述以小根堆为例,大根堆对称。
支持的功能:
- \(O(\log n)\) 求一个数所在堆的根
- \(O(1)\) 求最小值
- \(O(\log n)\) 合并两个堆
- \(O(\log n)\) 删除最小值
- \(O(\log n)\) 插入一个数
\(n\) 是插入的总节点数(或当前堆的节点数)。
维护的信息:
struct T{
int l, r, v, d, f;
// l, r 表示左右儿子, v 表示值
// d 表示从当前节点到其子树中最近叶子节点的距离 + 1, f 表示当前节点的父亲
} t[N];
基本的结构还是堆,即对于任意节点,它的权值小于等于其子树中任意权值,因此查询最小值只需 \(O(1)\) 访问根即可。
左偏的意义就是:对于任意一个节点的左儿子 \(ls\) 和右儿子 \(rs\),都有 \(t[ls].d \ge t[rs].d\),感性理解就是左子树深的更长。
性质:对于一棵根节点 \(rt\) 满足 \(t[rt].d = k\) 的堆而言,至少有 \(2^k - 1\) 个节点,即一个高度为 \(k\) 的满二叉树的节点树,因为这些节点必不可少,否则 \(d\) 就小于 \(k\) 了。因此对于一个有 \(n\) 个节点的堆,根节点的 \(d\) 就是 \(\log n\) 级别的。
操作:
求一个数所在堆的根
朴素上我们可以一个个跳 \(t[x].f\)。不过我们可以把 \(t[x].f\) 看做一个并查集 \(fa\) 数组,路径压缩一下:
int find(int x) {
return t[x].f == x ? x : t[x].f = find(t[x].f);
}
这样只要保证我们之后的赋值 \(fa\) 操作都是类似并查集的合并操作,那么复杂度就是 \(O(\log n)\) 的。
求最小值:
找到一个数所在根,直接访问根节点值即可。
合并两个堆
合并 \(merge(x, y)\) 分别以 \(x, y\) 为根的两个小根堆,并返回合并完的根编号:不妨设 \(t[x].v < t[y].v\)(若不满足 \(\text{swap}\) ) ,接着只需递归 \(merge(t[x].r, y)\)。回溯时检查 \(x\) 左右儿子的 \(d\),若不满足左偏树关系交换,返回 \(x\) 即可。
时间复杂度,每次递归,\(x, y\) 之一的 \(d\) 必然减少 \(1\),做多减少到 \(0\),而 \(d\) 是 \(\log n\) 量级的,所以复杂度是 \(O(\log n)\)。
貌似网上的复杂度都不是很对,不能每次都赋 \(t[x].fa = x\),这样复杂度就假了,而是函数调用之前把 \(t[y].f = x\),然后内部合并不改变 \(f\) 的值,这样相当于合并两个联通块,复杂度是对的。
int merge(int x, int y) { // 递归合并函数
if (!x || !y) return x + y;
if (t[x].v > t[y].v || (t[x].v == t[y].v && x > y)) swap(x, y);
rs = merge(rs, y);
if (t[ls].d < t[rs].d) swap(ls, rs);
t[x].d = t[rs].d + 1;
return x;
}
int work(int x, int y) { // 合并 x, y 两个堆。
if (t[x].v > t[y].v || (t[x].v == t[y].v && x > y)) swap(x, y);
t[x].f = t[y].f = x;
merge(x, y); return x;
}
删除最小值
找到根节点后,合并两个子树。
void del(int x) {
t[x].f = work(ls, rs);
}
插入一个数
直接单开一个节点,合并就好了。