左偏树——一种可并堆
前言——优先队列(Priority Queue)
在此引用黄前辈的论文素材:
优先队列(Priority Queue)是一种抽象数据类型(ADT),它是一种容器,里面有一些元素,这些元素也称为队列中的节点(node)。优先队列的节点至少要包含一种性质:有序性,也就是说任意两个节点可以比较大小。为了具体起见我们假设这些节点中都包含一个键值(key),节点的大小通过比较它们的键值而定。优先队列有三个基本的操作:插入节点(Insert),取得最小节点(Minimum) 和删除最小节点(Delete-Min)。
简介
左偏树,一种可以合并的堆状结构,支持插入,变换,合并 等操作。稳定的时间复杂度在Θ(logn) 的级别。对于一个左偏树中的节点,需要维护的值有dist 和value 。其中value 不必多说,dist 记录这个节点到它子树里面最近的叶子节点的距离,叶子节点距离为0。
相关定义
- 外节点:只有一个儿子或没有儿子的节点,即左右儿子至少有一个为空节点的节点
- 距离:一个节点到离它最近的外节点的距离,即两节点之间路径的权值和。特别地,外节点的距离为0,空节点的距离为−1
- 左偏树:一种满足左偏性质的堆有序二叉树(左偏树的左偏性质体现在左儿子的距离大于右儿子的距离)
- 左偏树的距离:我们将一棵左偏树根节点的距离作为该树的距离
节点信息
左偏树一般存储以下几个节点信息,这里先写出来,方便之后的讲述。(具体实现时还是要根据题目需求来存储信息,这里给出几个基本的)
- val:权值
- lson:左儿子
- rson:右儿子
- dist:距离
- father:父亲
性质:
- 一个节点的value 必定大于(或小于)左、右儿子的 value (堆性质)
- 一个节点的左儿子的 dist 不小于右儿子的 dist (左偏性质)
- 一个节点的距离始终等于右儿子+1
推论:
-
推论:任何时候,节点数为 n 的左偏树,距离最大为\(log_2(n+1)−1\)
-
证明:由性质2可知,当且仅当对于一棵左偏树中的每个节点i,都有 dist(left(i)) = dist(right(i)) 时,该左偏树的节点数最少。显然具有这样性质的二叉树是完全二叉树。
-
对于一棵距离为定值k的树,点数最少时,一定是一棵满二叉树。因为对于每个节点,如果想要有最少的儿子,那么起码要做到左儿子的数量等于右儿子的数量。那么对于他的逆命题也是成立的——若一棵左偏树的距离为k,则这棵左偏树至少有\(2^{k+1}-1\)个节点。
所以会有\[n≥2^{k+1}−1 \]\[log_2(n+1)≥k+1 \]
基本操作
1.合并 merge
将时间复杂度稳定在一个log,其主要思想就是不断把新的堆合并到新的根节点的右子树中——因为我们的右子树决定“距离”这个变量,而距离又一定保证在log的复杂度内,所以不断向右子树合并。
大体思路(以小根堆为例),首先我们假设两个节点x和y,x的根节点的权值小于等于y的根节点(否则swap(x,y)),把x的根节点作为新树Z的根节点,剩下的事就是合并x的右子树和y了。
合并了x的右子树和y之后,x当x的右子树的距离大于x的左子树的距离时,为了维护性质二,我们要交换x的右子树和左子树。顺便维护性质三,所以直接\(dist_x\)=\(dist_{rson(x)}+1\).
struct Tree{
int lson,rson,id;
int val,dis;
bool operator<(Tree x)const{return val==x.val?id<x.id:val<x.val;}
}a[100005];
inline int merge(int x,int y){
if (!x||!y)return x|y;
if (a[y]<a[x]) swap(x,y);
a[x].rson=merge(a[x].rson,y);
if (a[lc(x)].dis<a[rc(x)].dis) swap(lc(x),rc(x));
a[x].dis=a[rc(x)].dis+1;
return x;
}
我们观察,我们是不断交替拆分右子树,由推论可得我们的距离不会大于Θ(log\((n_x+1)\))+Θ(\(log(n_y+1)\))−2=O(\(logn_x\)+\(logn_y\))
2.插入 insert
单节点的树一定是左偏树,因此向左偏树插入一个节点可以看作是对两棵左偏树的合并。所以直接调用merge就可以了。
由于合并的其中一棵树只有一个节点,因此插入新节点操作的时间复杂度是O(logn)。
3.删除根节点(最大或最小)delete
由性质1,我们知道,左偏树的根节点是最小节点。在删除根节点后,剩下的两棵子树都是左偏树,需要把他们合并。
由于删除最小节点后只需进行一次合并,因此删除最小节点的时间复杂度也为O(logn)。
也是非常简单
if (de[x])
continue;
//已被删除就结束
de[x]=1;
//标记删除
fa[lc(x)]=fa[rc(x)]=fa[x]=merge(lc(x),rc(x));
//删除
lc(x)=rc(x)=a[x].dis=0;
//更新
4.构建左偏树
将n个节点构建成一棵左偏树,这也是一个常用的操作。
算法一 暴力算法——逐个节点插入,时间复杂度为O(nlogn)。
算法二 仿照二叉堆的构建算法,我们可以得到下面这种算法:
- 将n个节点(每个节点作为一棵左偏树)放入先进先出队列。
- 不断地从队首取出两棵左偏树,将它们合并之后加入队尾。
- 当队列中只剩下一棵左偏树时,算法结束。
时间复杂度为O(n)
在此,你是不是觉得一个merge就可以打天下了?no no no,太天真了,下面就不行了,因为:一个merge不够,那就多来几个吧。
5.删除任意已知节点(delete_each)
之所以强调“已知”,是因为这里所说的任意节点并不是根据它的键值找出来的,左偏树本身(堆这类结构大体都是这样)只能找到最小节点,不能有效的搜索指定键值的节点。故此,我们不能要求:请删除所有键值为x的节点。
前面提到过,优先队列是一种容器。对于通常的容器来说,一旦节点被放进去以后,容器就完全拥有了这个节点,每个容器中的节点具有唯一的对象掌握它的拥有权(ownership)。对于这种容器的应用,优先队列只能删除最小节点,因为你根本无从知道它的其它节点是什么。
但是优先队列还有另一个作用,就是可以找到最小节点。很多应用是针对这个功能的,拥有权并未完全转移给优先队列,而是把优先队列作为一个最小节点的选择器,从一堆节点中依次将它们选出来。这样一来节点的拥有权就可能同时被其它对象掌握。也就是说某个节点虽不是最小节点,不能从优先队列那里“已知”,但却可以从其它的拥有者那里“已知”。
我们的这种删除操作需要指定被删除的节点,这和原来的删除根节点的操作是兼容的,因为根节点肯定是已知的。上面已经提过,在删除一个节点以后,合并其子树。
(merge(left(x),right(x),然后将其连向p)
1.现在p指向了这颗新的左偏树,如果删除的是根节点,结束。如果被删除节点x不是根节点。
2.这时p指向的新树的距离有可能比原来x的距离要大或小,这势必有可能影响原来x的父节点q的距离,因为q现在成为新树p的父节点了。于是就要仿照合并操作里面的做法,对q的左右子树作出调整,并更新q的距离。这一过程引起了连锁反应,我们要顺着q的父节点链一直往上进行调整。
3.新树p的距离为dist(p),如果dist(p)+1等于q的原有距离dist(q),那么不管p是q的左子树还是右子树,我们都不需要对q进行任何调整,此时删除操作也就完成了。
4.如果dist(p)+1小于q的原有距离dist(q),那么q的距离必须调整为dist(p)+1,而且如果p是左子树的话,说明q的左子树距离比右子树小,必须交换子树。由于q的距离减少了,所以q的父节点也要做出同样的处理。
5.最后,如果p的距离增大了,使得dist(p)+1大于q的原有距离dist(q)。如果p是左子树,那么q的距离不会改变,此时删除操作结束。
如果p是右子树,这时有两种可能:一种是p的距离仍小于等于q的左子树距离,这时我们直接调整q的距离就行了;
另一种是p的距离大于q的左子树距离,这时我们需要交换q的左右子树并调整q的距离,交换完了以后q的右子树是原来的左子树,它的距离加1只能等于或大于q的原有距离,如果等于成立,删除操作可以结束了,否则q的距离将增大,我们还要对q的父节点做出相同的处理。
所以我们可以发现:删除任意节点的操作实质就是沿着到根的链进行操作,而为什么删除根不需要这么麻烦呢?
因为它没有parent.
(PS:内容部分均摘自《左偏树的特点及其应用》黄源河)
注:本代码并未涉及任意已知节点删除。(题目见Luogu P3377)
#include <iostream>
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <cmath>
using namespace std;
#define lc(x) a[x].lson
#define rc(x) a[x].rson
bool de[100005];
int fa[100005];
struct Tree{
int lson,rson,id;
int val,dis;
bool operator<(Tree x)const{return val==x.val?id<x.id:val<x.val;}
}a[100005];
inline int find(int x){
return x==fa[x]?x:fa[x]=find(fa[x]);
}
inline int merge(int x,int y){
if (!x||!y)return x|y;
if (a[y]<a[x]) swap(x,y);
a[x].rson=merge(a[x].rson,y);
if (a[lc(x)].dis<a[rc(x)].dis) swap(lc(x),rc(x));
a[x].dis=a[rc(x)].dis+1;
return x;
}
int main(){
// freopen ("P3377.in","r",stdin);
// freopen ("P3377.out","w",stdout);
a[0].dis=-1;
int n,m;
scanf ("%d%d",&n,&m);
for (int i=1;i<=n;i++){
scanf ("%d",&a[i].val);
a[i].id=fa[i]=i;
a[i].dis=0;
}
int c,x,y;
for (int i=1;i<=m;i++){
scanf ("%d%d",&c,&x);
if (c==1){
scanf ("%d",&y);
if (de[x]||de[y]) continue;
x=find(x),y=find(y);
if (x!=y)
fa[x]=fa[y]=merge(x,y);
}
else{
if (de[x]){
printf ("-1\n");
continue;
}
x=find(x);
printf ("%d\n",a[x].val);
de[x]=1;
fa[lc(x)]=fa[rc(x)]=fa[x]=merge(lc(x),rc(x));
lc(x)=rc(x)=a[x].dis=0;
}
}
return 0;
}
删除任意编号的点:
void Delete——each(int x){
int fx=f(x),p=merge(l(x),r(x));
int &ls=l(fx),&rs=r(fx);
f(p)=fx;
ls==x?ls=p:rs=p;
/*
q ← parent(x)
p ← Merge(left(x), right(x))
parent(p) ← q
If q ≠ NULL and left(q) = x Then
left(q) ← p
If q ≠ NULL and right(q) = x Then
right(q) ← p
*/
while(p){
if(a[ls].dis==a[rs].dis)
swap(ls,rs);
if(a[fx].dis==a[rs].dis+1)
return ;
a[fx].dis=a[rs].dis+1
p=fx,fx=f(x);
ls=l(fx),rs=r(fx);
}
//f()表示father,d()表示距离,l(),r(),表示左右子
}