Lec4 Heaps
Lec4: Heaps
https://oi-wiki.org/ds/heap/
左偏树
性质
dist的定义
- 如果一个结点的左孩子或右孩子为空结点,则该结点的 dist 为 0,这种结点被称为外结点
- 如果一个结点的左孩子和右孩子都不为空,则该结点的 \(dist(x)=\min(dist(lson_x),dist(rson_x))+1\)
对于左偏树 \(\forall x,dist(lson_x)\geq dist(rson_x)\),因此
\(dist(x)=dist(rson_x)+1\)
dist不是深度(因为外节点可能在链的中间),左偏树的深度可能是O(n)的,一条向左的链也是左偏树。
操作
合并
将两个堆合并,但保持堆的大小关系 。合并是最关键的操作,因为插入=合并原树和只有一个点的树。删除根=合并左右儿子。
- 比较当前两个子树根节点的值,选择较小的为根(记为x), 另一个为y
- 递归合并x的右儿子与y
- 检查是否满足左偏性质,如果不满足,则交换子树。
感性理解,时间复杂度取决于根节点一直向右儿子走的路径("右侧路径")长度, 这个长度越短越好.
int merge(int x,int y){
if(!x||!y) return x+y;
if(tree[x].val>tree[y].val) swap(x,y);//根是小的
rson(x)=merge(rson(x),y);//合并右儿子
if(tree[lson(x)].dist<tree[rson(x)].dist)
swap(lson(x),rson(x));
tree[x].dist=tree[rson(x)].dist+1;//更新dist
return x;
}
Code
值得注意的是,左偏树的深度可能是O(n)的,只是dist的范围是O(logn) 所以为某一个节点找对应的根节点时,不能直接暴力向上跳,而是要用并查集维护每个节点根节点。
#include<bits/stdc++.h>
#define MAXN 100000
using namespace std;
struct Leftest{
private:
struct node{
int ls,rs;
int val;
int dist;
}tree[MAXN+5];
//一个并查集的根对应一棵左偏树的根
int root[MAXN+5];
#define lson(x) (tree[x].ls)
#define rson(x) (tree[x].rs)
int deleted[MAXN+5];
int findrt(int x){
if(root[x]==x) return x;
else return root[x]=findrt(root[x]);
}
int merge(int x,int y){
if(!x||!y) return x+y;
if(tree[x].val>tree[y].val) swap(x,y);
rson(x)=merge(rson(x),y);
if(tree[lson(x)].dist<tree[rson(x)].dist)
swap(lson(x),rson(x));
tree[x].dist=tree[rson(x)].dist+1;
return x;
}
public:
void merge2(int x,int y){
if(deleted[x]||deleted[y]) return;
int rx=findrt(x),ry=findrt(y);
if(rx==ry) return;
int now=merge(rx,ry);
root[rx]=root[ry]=now;
}
int deleteMin(int x){
if(deleted[x]) return -1;
x=findrt(x);//找到x所在堆的根
int val=tree[x].val;
deleted[x]=1; //删除根对应的节点
int now=merge(lson(x),rson(x));//合并
root[lson(x)]=root[rson(x)]=root[x]=now;
//因为并查集的路径压缩,很多点的父亲已经被压缩到x,所以要root[x]=now
return val;
}
void ini(int *a,int n){
for(int i=1;i<=n;i++){
tree[i].val=a[i];
root[i]=i;
deleted[i]=0;
}
}
}T;
int n,m;
int a[MAXN+5];
int main(){
int x,y,cmd;
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
T.ini(a,n);
for(int i=1;i<=m;i++){
scanf("%d",&cmd);
if(cmd==1){
scanf("%d %d",&x,&y);
T.merge2(x,y);
}else{
scanf("%d",&x);
printf("%d\n",T.deleteMin(x));
}
}
}
时间复杂度
一棵有 n个节点的二叉树,根的 dist 不超过\(\log(n)+1\) ,因为一棵根的dist为x的二叉树至少有x-1层是满二叉树,那么就至少有 \(2^x-1\)个节点。注意这个性质是所有二叉树都具有的,并不是左偏树所特有的。
对于左偏树,每次递归dist会-1 因为\(dist(x)=dist(rson(x))+1\) 所以合并的复杂度是\(O(\log n)\)
斜堆
不需要dist的限制
操作
跟左偏树一样先比较根 .每次合并后回溯,交换左右儿子 (个人感觉还是递归从下到上好理解,虽然画图的工作量大) (也有不递归的写法)
skew heap合并时总是交换当前节点的左右儿子,但当当前节点是右路径上的最大节点时,不交换左右儿子。这个问题主要会影响合并的最后一步,最后一步的根节点是右路径上最大的(或者说它没有右子树)则不交换。
int merge(int x,int y){
if(!x||!y) return x+y;
if(tree[x].val>tree[y].val) swap(x,y);
rson(x)=merge(rson(x),y);//优先合并右儿子
swap(lson(x),rson(x));
return x;
}
时间复杂度
对于节点p,如果p的右子树的大小>=p子树大小/2, 就定义p为heavy node. 否则为light node
当p的右子树发生合并(包括交换)
- 如果p是heavy, 那么p一定变成light.(因为右子树变的更大,交换后到了左边)
- 如果p是light,那么p可能变成heavy
- 合并过程中,如果一个节点的 heavy/light 发生变化,那么它原先一定在堆的最右侧路径上 (因为我们是沿着根节点一直往右走的, 不管是x还是y)
设在右侧路径上的heavy node数量为\(h_x,h_y\), light node为\(l_x,l_y\) 整棵树不在右侧路径的heavy node数量为\(h^0_x,h^0_y\)
定义势函数\(\Phi\)为当前树中 heavy node的总数量
\(c=t_{worst}+\Phi(H_{new})-\Phi(H_x)-\Phi(H_y)\)
- \(t_{worst}\)为真实的cost ( 就是模板中的\(a_i\) ) 最坏情况下是两树的右侧路径长度 \(h_x+l_x+h_y+l_y\)
- \(\Phi(H_{new})=h_x^0+h_y^0+h'_x+h'_y \leq h_x^0+h_y^0+l_x+l_y\). 因为原来的\(h_x,h_y\)全部变成light了,而\(l_x,l_y\)有可能变成heavy