可合并堆之左偏树的简介与应用
前言
在之前的这篇文章里,笔者详细讲解了二叉堆这种简单有效的数据结构,并且讨论了优先队列的实现方式。现在有个新的问题:
给定两个优先队列,请尽量高效地将它们合并成一个优先队列。
很显然,如果我们使用二叉堆实现,只能先出队再入队,而线性复杂度显然不能令人满意。所以我们需要考虑可合并堆(mergeable heap),常见的可合并堆有如下四种:
- 左偏树(leftist tree)
- 二项堆(binomial heap)
- 斐波那契堆(Fibonacci heap)
- 配对堆(pairing heap)
其中前两者可以实现O(logn)的合并,而后两者更是可以达到O(1)。限于篇幅,本文只讨论左偏树,因为它是可合并堆中最简单的一种,容易编码实现,“性价比”比较高,应用也较广泛。
左偏树
定义与性质
顾名思义,左偏树就是一棵往左偏的树(废话
左偏树是一棵二叉树,但是它的每个节点上除了记录键值之外,还会记录距离值(dist值,又称npl或者s-value)。所谓距离值,就是指树中某个节点与其距离最近的外节点之间的路径长度。而外节点(external node)就是那些没有子节点或者只有1个子节点的节点,特别地,规定外节点的距离值为0,而NULL节点的距离值为-1。
下图来自百度百科,是一个用左偏树表示的最小堆,蓝色数字就是距离值。
以下都以最小堆为例,左偏树具有以下两个性质:
- 堆性质:每个节点存储的值都小于或等于其子节点存储的值。
- 左偏性质:每个节点的左子节点的距离值总是不小于右子节点的距离值。
看官可以再观察一下上面的图,以方便理解这两个性质。可见,左偏树的子树也都是左偏树。
注意:左偏树并不一定像二叉堆那样是一棵完全二叉树。并且判断左偏的标准是距离值,所以左子树的深度或者节点数未必大于右子树。
根据左偏性质和距离值的定义,容易得出推论:
左偏树中一个节点的距离值等于它的右子节点距离值加上1。
另外再根据二叉树的性质,容易得出以下推论:
如果一棵左偏树根节点的距离值为d,那么该左偏树最少可以有2d+1-1个节点,此时它才是完全二叉树。
换言之,一棵有n个节点的左偏树,其根节点的距离值最大为⌊log(n+1)⌋-1。
接下来具体看看左偏树最重要的合并操作的流程。
合并操作
合并两棵左偏树A和B之前,先检查它们两个的根节点的值,较小的那个作为合并结果树的根节点。现假设A的根节点较小,那么我们把A的左子树放置不动,把A的右子树递归地与B进行合并。递归返回时,需要更新结果树的根节点距离值。另外,如果结果树不满足左偏性质,就将其左右子树交换,以使其再度满足左偏性质。简单的示意图如下所示。
上图可能不太直观,接下来举一个更实际一些的例子。
由此可见,左偏性质使得我们每次都可以只在右子树上进行合并操作。最后用伪码来表达其流程如下。
function merge(a, b) {
if (a == null)
return b;
if (b == null)
return a;
if (a.val > b.val)
swap(a, b);
a.right = merge(a.right, b);
if (a.left.dist < a.right.dist)
swap(a.left, a.right);
if (a.right == null)
a.dist = 0;
else
a.dist = a.right.dist + 1;
return a;
}
整个合并操作的实现是很简洁的。根据上一节的推论,可以得出合并操作的最坏时间复杂度为O(log|A|+log|B|)。
插入&构造操作
有了合并操作做基础,插入操作实际上就是合并一棵已有的左偏树和一棵单节点的左偏树,即:
function insert(a, x) {
b = make_leftist_tree(x);
return merge(a, b);
}
显然,如果我们用插入操作从零开始构造n个节点的左偏树,时间复杂度为O(nlogn)。一种优化方法是采用普通的FIFO队列来辅助构造,每次从队头取出两棵左偏树合并后放入队尾,直到队列中只剩一棵树为止,可以达到O(n)。
取堆顶操作
取堆顶也就是弹出左偏树中的最小元素。将其根节点移除后,剩下的两棵左右子树也都是左偏树,再把它们合并到一起就OK了。
function pop(a) {
v = a.val;
a = merge(a.left, a.right);
return v;
}
说了这么多,看一道简单的例题吧。
例题:HDOJ 1512
传送门在这里。
题目大意:有N只猴子,每只猴子起初互相都不认识。猴子之间经常发生冲突,每次会有两只猴子(a和b)约架,并且a和b各自会邀请自己的朋友中战斗力最强的那只(包括它自己)上场。每打完一场后,上场的两只猴子战斗力减半,并且它们和它们的朋友都会互相成为朋友,成为朋友的猴子就不会再约架。输入猴子总数、战斗力值和每次约架的猴子编号,要求输出每次打完后最高的战斗力值。
显然,判断是否为朋友可以用并查集实现(之前已经讲过),找最大值可以用最大堆实现。但是并查集每次合并之后,堆也要随着更新,普通二叉堆自然是不行的,左偏树就是解决这种问题的利器了。
直接放出AC代码如下。注意并查集的合并和左偏树的合并逻辑一起放在了merge()方法中。
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
const int MAXN = 100002;
struct LTNode {
int left, right, dist, str;
}ltree[MAXN];
int parent[MAXN];
int n, m, a, b;
int find(int x) {
int r = x;
while (parent[r] != r) {
r = parent[r];
}
int p = parent[x];
while (p != r) {
parent[x] = r;
x = p;
p = parent[x];
}
return r;
}
int merge(int x, int y) {
if (x == 0) {
return y;
}
if (y == 0) {
return x;
}
if (ltree[x].str < ltree[y].str) {
swap(x, y);
}
ltree[x].right = merge(ltree[x].right, y);
parent[ltree[x].right] = x;
if (ltree[ltree[x].left].dist < ltree[ltree[x].right].dist) {
swap(ltree[x].left, ltree[x].right);
}
ltree[x].dist = ltree[x].right == 0 ? 0 : ltree[ltree[x].right].dist + 1;
return x;
}
int pop(int x) {
int l = ltree[x].left, r = ltree[x].right;
parent[l] = l;
parent[r] = r;
ltree[x].left = ltree[x].right = ltree[x].dist = 0;
return merge(l, r);
}
int main() {
while (~scanf("%d", &n)) {
memset(ltree, 0, sizeof(ltree));
for (int i = 1; i <= n; i++) {
scanf("%d", <ree[i].str);
parent[i] = i;
}
scanf("%d", &m);
while (m--) {
scanf("%d%d", &a, &b);
int ra = find(a), rb = find(b);
if (ra == rb) {
printf("-1\n");
continue;
} else {
int pa = pop(ra), pb = pop(rb);
ltree[ra].str /= 2;
ltree[rb].str /= 2;
pa = merge(pa, ra);
pb = merge(pb, rb);
printf("%d\n", ltree[merge(pa, pb)].str);
}
}
}
return 0;
}
民那晚安。