左偏树是一种可并堆,除了堆的基本功能,最大的特点就是支持合并堆,甚至比普通堆好写。
下面叙述以小根堆为例,大根堆对称。
支持的功能:
- (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)(若不满足 ( ext{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);
}
插入一个数
直接单开一个节点,合并就好了。