堆 学习笔记

概述

堆是一种数据结构。一般支持插入、删除最值、查询最值等操作,有小根堆和大根堆,分别维护最小值、最大值。

下文以小根堆为例。

二叉堆

定义

二叉堆是完全二叉树,同时满足堆的性质,不可并(复杂度过高)。

查询最值

返回堆顶即可。

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\) 小值,小根堆存剩下的值。

P1801

对顶堆例题,按题意模拟即可。

由于插入后会立即调整,不用与小根堆顶比较。

#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);
}

[[数据结构]]

posted @ 2024-03-01 09:29  lgh_2009  阅读(15)  评论(0)    收藏  举报