左偏树是一种可以合并的“堆”。这里打了引号,是因为左偏树并不是堆,但是能完成与堆类似的功能。而且还能支持可持久化

在可合并对中,左偏树是最常用的。虽然它的效率不及斐波那契堆与配对堆,但是复杂度是同一个级别,单次操作最坏情况下都是\(O(log_2n)\)的。而且不像斐波那契堆,码量大,难理解,在竞赛中用太不合算了,配对堆不资瓷可持久化。。。。。

放张表格,看看你打算学哪种?这里(目前)只教左偏树。其他的除了二叉堆博主都不会

指标 二叉堆 左偏树/斜堆 二项堆 斐波那契堆 配对堆
建堆 \(O(n)\) \(O(n)\) \(O(n)\) \(O(n)\) \(O(n)\)
插入节点 \(O(log_2n)\) \(O(log_2n)\) \(O(log_2n)\) \(O(1)\) \(O(1)\)
取最值节点 \(O(1)\) \(O(1)\) \(O(log_2n)\) \(O(1)\) \(O(1)\)
删除最值节点 \(O(log_2n)\) \(O(log_2n)\) \(O(log_2n)\) \(O(log_2n)\) \(O(log_2n)\)
删除任意已知节点 \(O(log_2n)\) \(O(log_2n)\) \(O(log_2n)\) \(O(log_2n)\) \(O(log_2n)\)
合并 \(O(nlog_2n)\) \(O(log_2n)\) \(O(log_2n)\) \(O(1)\) \(O(1)\)
空间 \(--\) \(-\) \(+\) \(++\) \(-\)
代码复杂度 \(--\) \(-\) \(++\) \(+++\) \(-\)
  • 已知:表示知道编号,而不是值。
  • \(--\)表示很小,\(-\)表示较小,\(+\)表示一般,\(++\)表示较大,\(+++\)表示很大。

左偏树的基本结构与堆有许多相似之处。它是一棵二叉树,对于一个节点\(x\),设其左儿子为\(ls\),右儿子为\(rs\),值为\(v_x\)总是满足\(v_x \le v_{ls},v_x\le v_{rs}\)(如果存在左右儿子)。

再次明确一遍,左偏树不是堆,它不一定是完全二叉树。“左偏”的意义就在于保证时间复杂度(否则一条链就能把复杂度卡到\(O(n^2)\))。来看看何为“左偏”。

先讲一讲外节点。如果一个节点没有左儿子或没有右儿子(或者都没有),那么这个节点被称为外节点。我们设\(dis_{x}\)表示\(x\)与最近的外节点之间的距离。(一条边的长度为1)。特别地,空节点0的\(dis\)\(-1\)。(这样就可以自然地完成外节点的\(dis\)为0)

左偏即为对于任意节点\(x\),满足\(dis_{ls}\ge dis_{rs}\)。(这样就可以得到\(dis_x=dis_{rs}+1\)

为什么这样就可以保证复杂度呢?

很明显,一般地,节点总是集中于左子树,我们就可以把操作集中于右子树来减少操作。

来看看更严谨的证明。

  • 引理:最大dis为k的左偏树节点个数至少为\(2^{k+1}-1\)(满二叉树)。

我们运用反证法。我们将深度大于k的节点去除,得到树T,如果树T不是满二叉树,则树T中必定有一个外节点\(x\)深度小于k,与根节点dis为k矛盾,原命题成立。

  • 定理: 由对于一个有\(n\)个节点的左偏树,最大距离不超过 \(\log_2(n+1)-1\)
    证明: 设 \(max\{dist\}=k\) ,由引理得\(n\geq 2^{k+1}-1\) ,移项得: \(k\leq \log_2(n+1)-1\)

这就保证了\(O(log_2n)\)的复杂度。

如何实现

合并

合并操作与FHQ Treap十分类似,如果你会FHQ Treap,这将很好理解。

int Merge( int x, int y ){
    if ( !x || !y ){ return x | y; }//如果有一个堆为0,直接返回这个堆
    if ( val[x] > val[y] || ( val[x] == val[y] && x > y  ) ) swap( x, y );//使x的值小于y(将x做为根节点)
    R[x] = Merge( R[x], y ); fa[R[x]] = x;//R[x]可能已被更改,更新R[x]的父亲
    if ( dis[L[x]] < dis[R[x]] ) swap( L[x], R[x] );//维持左偏性质
    dis[x] = dis[R[x]] + 1;//更新dis
    return x; 
}

val[x]记录该节点保存的值,L[x]表示\(x\)的左儿子(没有的话就为0),R[x]表示\(x\)的右儿子(没有的话就为0)。fa[x]记录该节点的父亲。

Merge的作用就是将两个堆(已知根节点)合并成一个并返回其根节点。由于要保持堆的性质,根节点的值肯定要是最小的。要合并的两个堆中,值最小的肯定是\(x\)\(y\),如果\(y\)的值比\(x\)小,那\(x\)肯定不能作为根节点,所以这时候\(x\)不能作为根节点,只能把\(y\)作为根节点了。为了方便处理,我们直接交换x与y(注意:不是val,交换的是两个堆的位置)。这样\(x\)就肯定为根节点了。我们不改变\(x\)的左子树(因为是左偏的嘛,左子树太费时),将原来\(x\)的右子树与\(y\)合并作为\(x\)的右子树。注意要更新\(dis\)\(fa\)的值。

还有一个前面提到过的小细节:dis[0]在主程序中必须赋为-1


删除

删除根节点操作就更简单了,直接合并左右儿子即可。

#define Del(x) val[x] = -1, fa[L[x]] = fa[R[x]] = 0, Merge( L[x], R[x] )
//注意先把父亲赋为0

会了这两个操作,就可以完成左偏树模板啦。。。

#include<bits/stdc++.h>
using namespace std;
#define MAXN 100005

int N, M;
int L[MAXN], R[MAXN];
int fa[MAXN], val[MAXN], dis[MAXN];

int Merge( int x, int y ){
    if ( !x || !y ){ return x | y; }
    if ( val[x] > val[y] || ( val[x] == val[y] && x > y  ) ) swap( x, y );
    R[x] = Merge( R[x], y ); fa[R[x]] = x;
    if ( dis[L[x]] < dis[R[x]] ) swap( L[x], R[x] );
    dis[x] = dis[R[x]] + 1;
    return x; 
}

#define Del(x) val[x] = -1, fa[L[x]] = fa[R[x]] = 0, Merge( L[x], R[x] )
int find( int x ){
    while( fa[x] ) x = fa[x];
    return x;
}//fa表示直接父亲!这不是并查集!不要想着去路径压缩!

int main(){
    scanf( "%d%d", &N, &M );
    for ( int i = 1; i <= N; ++i ) scanf( "%d", &val[i] );
    dis[0] = -1;//重要!
    for ( int i = 1; i <= M; ++i ){
        int opt, x, y;
        scanf( "%d", &opt );
        if ( opt == 1 ){
            scanf( "%d%d", &x, &y );
            if ( val[x] == -1 || val[y] == -1 ) continue;//有一个点被删除就不要操作了
            x = find(x); y = find(y);//记得先找到根节点再合并
            if ( x != y ) Merge( x, y );
        }
        else{
            scanf( "%d", &x );
            if ( val[x] == -1 ) printf( "-1\n" );//用val=-1标记已被删除的节点(输入全是正整数,如果有负数可以用INT_MIN,反正视数据范围而定)
            else printf( "%d\n", val[x = find(x)] ), Del(x);
        }
    }
    return 0;
}

当然,操作远远不止这些~慢慢更


修改

洛谷P1456 Monkey King

这题要修改值。。。其实暴力一点,直接删去,再合并就OK。。。

具体一点:

  1. 删去两个左偏树的根节点(用x、y保存编号,t1、t2保存删去x、y后的左偏树根节点编号)(删除方法同上)
  2. val[x]val[y]减半
  3. 初始化x、y(L[x]=R[x]=fa[x]=0,y也一样)
  4. 合并x、y、t1、t2
  5. 返回val[根节点]

不难理解吧?注意一个小优化,先合并x、y。因为x、y都只剩一个根节点,合并复杂度是\(O(1)\)的。这只是优化常数,不优化也能过。

核心代码:

int fight( int x, int y ){
    x = find(x); y = find(y);
    if ( x == y ) return -1;
    fa[L[x]] = fa[R[x]] = fa[L[y]] = fa[R[y]] = 0;
    int t1, t2;
    t1 = Merge( L[x], R[x] ); t2 = Merge( L[y], R[y] ); 
    val[x] >>= 1; val[y] >>= 1;
    L[x] = R[x] = L[y] = R[y] = dis[x] = dis[y] = 0;
    x = Merge( x, y ); x = Merge( x, t1 ); x = Merge( x, t2 );
    return val[x];
}

还有一个猥琐的坑点:多组数据!记得初始化。


更新中。。。。