左偏树

Posted on 2026-10-14 15:37  暮光落阳  阅读(4)  评论(0)    收藏  举报

左偏树

是一种可并堆,支持 \(O(\log n)\) 插入,删除,合并,\(O(1)\) 访问最小值。

堆是一棵树,其每个节点都有一个键值,且每个节点的键值都大于等于/小于等于其父亲的键值.

左偏树是一棵二叉树,

定义:

  1. 左偏树的外节点:有子节点为空的节点。
  2. \(dist_u\) :节点 \(u\) 到其子树内最近的外节点的距离(空节点为 \(-1\))。

\(dist\) 可以理解为与树高(到其子树内最远的外节点的距离)相对的概念。


左偏

左偏树具有左偏的性质,即:

  • 对于每个节点,其左子节点的 \(dist\) 均大于等于右子节点。

这一性质保证了从任一节点出发,连续移向右子节点至边缘,经过的节点数不超过 \(\log(\text总节点数)\)(因为选择左子节点的路径长度不小于右子节点);同时也使 \(dist_u=dist_{rson[u]}+1\) 。

其维护只需在右偏时,交换左右节点即可。


基本操作

合并两个左偏树

以大根堆为例,将两个左偏树中根节点键值更大的作为结果的根节点和左子树,再将较小的和右子树递归合并作为右子树。递归过程中更新 \(dist\) 并维护左偏性质。

删除节点

将其左右子树合并,替代原节点的位置,从原位置向上更新 \(dist\) 。

插入节点

将待插入节点视为一棵左偏树进行合并。


总结

左偏树通过左偏性质保证合并的时间复杂度,基于合并实现插入,删除等操作。


实现

手写还是太难了,代码明明不长,却硬调一个上午。

枚举注意事项:

  1. 空节点一定要特判!不然就会遇到包括但不限于 WA,MLE,RE 的情况。
  2. 实际应用时找根节点需要并查集,由于时间限制要路径压缩,导致被删除的节点也可能被指向,所以其top 应指向新的根节点(而非设为 自己\(/\)0\(/\)-2147483648)。
  3. 还是由于路径压缩,如果要找直接父节点需要另开一个 fa 数组,更新除路径压缩外与 top 相同。
  4. 在删除节点时,应使被删除节点的子结点的上溯数组 fa top 指向自己。
  5. 左偏树似乎不支持在 \(O(\log n)\) 时间内直接修改元素值,只能先删除,再插入。

以下是一个模板:

Code

merge: 合并

del: 删除顶节点

struct Node{
	int lc,rc;
	int val,dist;
};

Node lt[100010];
int top[100010];

int find(int x){
	return x==top[x]?x:top[x]=find(top[x]);
}

int merge(int u,int v){
	if(!u)return v;
	if(!v)return u;
	if(lt[u].val>lt[v].val)swap(u,v);
	top[v]=u;
	lt[u].rc=merge(lt[u].rc,v);
	top[lt[u].rc]=u;
	if(lt[lt[u].lc].dist<lt[lt[u].rc].dist)swap(lt[u].lc,lt[u].rc);
	lt[u].dist=lt[lt[u].lc].dist+1;
	return u;
}

int del(int u){
	if(lt[u].dist)top[u]=merge(lt[u].lc,lt[u].rc),top[top[u]]=top[u];
	lt[u].dist=-1;
	return lt[u].val;
}

发现合并操作是可以递推实现的,于是:

Code

merge: 合并

del: 删除

struct Node{
    int lc,rc;
    int fa,val,dist;
};

Node lt[1000010];
int top[1000010];

int find(int x){
    return x==top[x]?x:top[x]=find(top[x]);
}

int merge(int u,int v){
    if(!(u&&v))return u|v;
    if(lt[u].val>lt[v].val)swap(u,v);
    top[v]=u;
    while(v){
        if(lt[lt[u].rc].val>lt[v].val)lt[v].fa=u,swap(v,lt[u].rc);
        u=lt[u].rc;
    }
    while(u!=lt[u].fa){
        if(lt[lt[lt[u].fa].lc].dist<lt[u].dist)swap(lt[lt[u].fa].lc,lt[lt[u].fa].rc);
        lt[lt[u].fa].dist=lt[lt[u].rc].dist+1;
        u=lt[u].fa;
    }
    return u;
}

int del(int u){
    if(lt[u].lc||lt[u].rc){
        if(lt[u].fa==u){
            lt[lt[u].lc].fa=top[lt[u].lc]=lt[u].lc;
            lt[lt[u].rc].fa=top[lt[u].rc]=lt[u].rc;
        }else{
            lt[lt[u].lc].fa=top[lt[u].lc]=lt[u].fa;
            lt[lt[u].rc].fa=top[lt[u].rc]=lt[u].fa;
        }
        int st=merge(lt[u].lc,lt[u].rc);
        if(lt[u].fa!=u){
            if(lt[lt[u].fa].lc==u)lt[lt[u].fa].lc=st;
            else lt[lt[u].fa].rc=st;
        }
        top[u]=st;
    }
    lt[u].dist=-1;
    return lt[u].val;
}

时间上应该有优势,但是调试上....死循环似乎比无限递归难调的多。