堆 学习笔记
概述
堆是一种数据结构。一般支持插入、删除最值、查询最值等操作,有小根堆和大根堆,分别维护最小值、最大值。
下文以小根堆为例。
二叉堆
定义
二叉堆是完全二叉树,同时满足堆的性质,不可并(复杂度过高)。
查询最值
返回堆顶即可。
T top(){
return h[1];
}
上浮、下沉
对于单独的一个破坏堆的性质的节点,将其不断向上交换或向下交换。
如果节点在当前位置过小,那么不断与父节点交换,直到大于等于父节点或成为根节点。
如果过大,就不断与叶节点中更小的交换,直到比子节点小或成为叶节点。
void pushup(int pos){
for(;pos>1&&h[pos]<h[pos>>1];pos>>=1)swap(h[pos],h[pos>>1]);
}
int get(int pos){
return pos<<1==cnt||h[pos<<1]<h[pos<<1|1]?pos<<1:pos<<1|1;
}
void pushdown(int pos){
for(int t=get(pos);t<=cnt&&h[t]<h[pos];pos=t,t=get(pos))swap(h[pos],h[t]);
}
插入
先放到堆底,然后向上调整。
void push(T x){
h[++cnt]=x,pushup(cnt);
}
删除
把堆顶和堆底交换,向下调整堆底。
void pop(){
swap(h[1],h[cnt--]),pushdown(1);
}
建堆
有一种 \(O(n)\) 建堆的方式:先直接插入所有数,再自底向上对每个节点下沉。
void build(int n,T a[]){
for(int i=1;i<=n;i++)h[++cnt]=a[i];
for(int i=n>>1;i>=1;i--)pushdown(i);
}
复杂度证明:考虑最坏情况,假设每个元素都从根开始下沉,复杂度 \(n\log n-\sum_{i=1}^n\log n=O(n)\)。
STL
如果只需要用上面几种简单的操作,没有必要手写,使用 STL 的 priority_queue 即可。priority_queue<int> 是大根堆,priority_queue<int,vector<int>,greater<int>> 是小根堆。
对顶堆
对顶堆是一个大根堆、一个小根堆的组合,可以动态维护第 \(k\) 大值。
用小根堆存前 \(k\) 大值,大根堆存剩下的值。显然第 \(k\) 大就是小根堆顶。
插入元素时与小根堆顶比较,大则插入小根堆。删除时直接删除小根堆顶,然后调整堆。
当小根堆堆的元素个数不为 \(k\) 时,取出元素多的堆中的堆顶放到另一个堆中。
维护第 \(k\) 小同理,大根堆存前 \(k\) 小值,小根堆存剩下的值。
对顶堆例题,按题意模拟即可。
由于插入后会立即调整,不用与小根堆顶比较。
#include<bits/stdc++.h>
using namespace std;
int m,n,a[200005],u[200005],p;
priority_queue<int>q1;
priority_queue<int,vector<int>,greater<int>>q2;
int main(){
cin>>m>>n;
for(int i=1;i<=m;i++)cin>>a[i];
for(int i=1;i<=n;i++)cin>>u[i];
for(int i=1;i<=n;i++){
while(p<u[i])q1.push(a[++p]),q2.push(q1.top()),q1.pop();
cout<<q2.top()<<'\n',q1.push(q2.top()),q2.pop();
}
return 0;
}
配对堆
定义
配对堆是一种可并堆。只要满足堆性质的树都是配对堆。
配对堆的每个节点需要维护权值,第一个儿子和下一个兄弟。有时也维护上一个兄弟,如果没有则指向父亲。
template<typename T>struct node{
T v;
int son,nxt;
//int f;
};
合并
由于配对堆的性质很松,只要在两个堆之间连边即可。
int merge(int a,int b){
if(!a||!b)return a^b;
if(h[a].v>h[b].v)swap(a,b);
return /*h[h[a].son].f=b,h[b].f=a,*/h[b].nxt=h[a].son,h[a].son=b,a;
}
插入
把新开的节点当成一个堆与原堆合并。
void push(int &rt,T k){
h[++cnt].v=k,rt=merge(rt,cnt);
}
删除
删除等于合并根的所有子节点。配对堆通过一个特定的合并顺序保证了复杂度,先将相邻的堆两两配对合并,再从右至左合并。
int merges(int pos){
//h[pos].f=h[h[pos].nxt].f=0;
if(!pos||!h[pos].nxt)return pos;
int x=h[pos].nxt,y=h[x].nxt;
return h[pos].nxt=h[x].nxt=0,merge(merges(y),merge(pos,x));
}
void pop(int &rt){
rt=merges(h[rt].son);
}
减少一个元素的值
此时这个节点的子树满足堆性质,但这个节点和父亲可能不满足,把子树与剩余部分断开再合并即可。
void decrease_key(int &rt,int pos,T k){
h[pos].v=k;
if(pos==rt)return;
if(pos==h[h[pos].f].son)h[h[pos].f].son=h[pos].nxt;
else h[h[pos].f].nxt=h[pos].nxt;
return h[h[pos].nxt].f=h[pos].f,h[pos].nxt=h[pos].f=0,rt=merge(rt,pos);
}
关于时间复杂度:
如果按顺序合并,容易形成一个菊花的结构,而这样配对就先把一定数量的节点放到第二层,第二次合并时根的儿子就少了。
已经证明每个操作的复杂度至多 \(O(\log n)\)。目前关于进一步的下界有争议,有 \(O(1)\) 合并 \(O(\log n)\) 减少元素值,还有说法 \(O(2^{2\sqrt{\log\log n}})\) 合并和减少元素值。
左偏树
定义
左偏树是 OI 中最常用的可并堆。左偏树是二叉树,满足堆性质,也满足左偏性质。
定义 dist 为一个节点向下走几步能到达空节点。也就是说,如果定义一个外节点为没有左儿子或右儿子,则 dist 为一,否则为这个节点到最近外节点的距离加一。左偏性质为左儿子的 dist 小于右儿子的 dist。
template<typename T>struct node{
T v;
int d,ls,rs;
};
合并
既然左边的规模比右边要大,那么就尽量向右递归。
将两个堆顶中权值小的作为根,不动这个堆的左子树,把另一个堆合并进右子树。如果不再满足左偏性质,就交换左右儿子。最后用右子树更新 dist。
int merge(int a,int b){
if(!a||!b)return a^b;
if(h[a].v>h[b].v)swap(a,b);
h[a].rs=merge(h[a].rs,b);
/*h[h[a].rs].f=a*/
if(h[h[a].ls].d<h[h[a].rs].d)swap(h[a].ls,h[a].rs);
return h[a].d=h[h[a].rs].d+1,a;
}
复杂度证明:
当前作为根的堆递归一层后会变成右儿子,而右儿子的 dist 少一。而一个节点的 dist 说明至少有 dist 层儿子为满二叉树,因此 dist 是 \(\log\) 级别的,复杂的也是 \(O(\log n+\log m)\),\(n,m\) 是两个堆的节点数。
另外还有两种左偏树的变种:斜堆,在合并时必须交换左右儿子,这样节点会轮流插入左右子树,均摊 \(O(\log n)\);随机堆,在合并时有一半概率交换左右儿子,期望时间复杂度 \(O(\log n)\)。
插入
合并原堆和新点。
void push(int &rt,T k){
h[++cnt].v=k,rt=merge(rt,cnt);
}
删除
合并根的左右儿子。
void pop(int &rt){
rt=merge(h[rt].ls,h[rt].rs);
}
删除任意节点
合并节点的左右儿子接到这个节点的父亲上。此时这个节点以上的部分的 dist 变化了,需要自底向上更新。如果一个位置已经满足左偏性质,就不用向上更新。
void pushup(int pos){
if(!pos)return;
if(h[h[a].ls].d<h[h[a].rs].d)swap(h[a].ls,h[a].rs);
if(h[a].d!=h[h[a].rs].d+1)h[a].d=h[h[a].rs].d+1,pushup(h[pos].f);
}
int merge(int a,int b){
if(!a||!b)return a^b;
if(h[a].v<h[b].v||(h[a].v==h[b].v&&a>b))swap(a,b);
return h[a].rs=merge(h[a].rs,b),h[h[a].rs].f=a,pushup(a),a;
}
void erase(int &rt,int pos){
int f=h[pos].f,t=merge(h[pos].ls,h[pos].rs);
h[t].f=f,h[pos].d=h[pos].f=h[pos].ls=h[pos].rs=0;
if(h[f].ls==pos)h[f].ls=t;
else h[f].rs=t;
pushup(f);
if(rt==pos)rt=t;
}
复杂度证明:若当前节点是父节点的右儿子,原来父节点的 dist 比当前节点的 dist 大一;否则,由于删除一个点使 dist 最多少一,当前节点的 dist 应该与兄弟节点的 dist 一样,否则到父节点就不用更新了。所以更新次数与 dist 同级,\(O(\log n)\)。
打标记
如果要对整个堆修改但不改变相对大小,可以采用懒惰标记,等到使用这个点的左右儿子再下传。
int merge(int a,int b){
if(!a||!b)return a^b;
if(h[a].v>h[b].v)swap(a,b);
pushdown(a),h[a].rs=merge(h[a].rs,b);
if(h[h[a].ls].d<h[h[a].rs].d)swap(h[a].ls,h[a].rs);
return h[a].d=h[h[a].rs].d+1,a;
}
void push(int &rt,T k){
h[++cnt].v=k,/*init tag*/rt=merge(rt,cnt);
}
void pop(int &rt){
pushdown(rt),rt=merge(h[rt].ls,h[rt].rs);
}
[[数据结构]]

浙公网安备 33010602011771号