左偏树——一种可并堆

前言——优先队列(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 \]

\[log_2(n+1)−1≥k \]

基本操作

1.合并 merge

将时间复杂度稳定在一个log,其主要思想就是不断把新的堆合并到新的根节点的右子树中——因为我们的右子树决定“距离”这个变量,而距离又一定保证在log⁡的复杂度内,所以不断向右子树合并。

大体思路(以小根堆为例),首先我们假设两个节点x和y,x的根节点的权值小于等于y的根节点(否则swap(x,y)),把x的根节点作为新树Z的根节点,剩下的事就是合并x的右子树和y了。

合并了x的右子树和y之后,xx的右子树的距离大于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(),表示左右子
}
posted @ 2021-01-29 10:50  fallingdust  阅读(142)  评论(0编辑  收藏  举报