堆
堆是一种支持查询最值、删除最值的树形数据结构。
二叉堆
二叉堆是最简单、最常用的堆之一。
其形态是一棵完全二叉树。
实现
众所周知,二叉树有一种独特的表示节点编号的方法。
每个左子节点编号都是其父节点的 \(2\) 倍,每个右子节点编号都是其父节点的 \(2\) 倍 \(+1\),根节点编号为 \(1\)。
因此,我们可以用一个数组存储这个完全二叉树,并利用上述特征取出每个父节点与子节点的数值。
由于完全二叉树的性质,二叉堆的所有节点在数组中都是连续的,因此空间复杂度为 \(O(n)\)。
为了防止越界的情况,还需要保存堆内的节点个数。
int heap[N],n;
不妨保证根节点永远是二叉堆内部最大的数(即此堆为大根堆):
-
当插入新节点时更新上面的节点。
-
观察新节点的父节点(编号 \(\lfloor \frac{x}{2}\rfloor\))
-
若父节点小于子节点,则交换父节点与子节点的值:
-
void Up(int p){
while(p>1) {
if(heap[p]>heap[p>>1]) {
swap(heap[p],heap[p>>1]);
p>>=1;
}
else break;
}
}
void Insert(int x) {
heap[++n]=x;
Up(n);
}
-
当排出根节点时更新下面的节点。
-
观察左子节点与右子节点,取其较大者,与父节点比较,若父节点较小,则交换两数(左右子节点取较大值与父节点交换后必为三者中最大值)。
-
继续下调:
-
void Down(int p) {
int s=p<<1;
while(s<=n) {
if(s<n&&heap[s]<heap[s+1])s++;
if(heap[s]>heap[p]) {
swap(heap[s],heap[p]);
p=s;s<<=1;
}
else break;
}
}
void Pop(){
heap[1]=heap[n--];
Down(1);
}
通过上述操作维护堆,便可以使得根节点永远是堆中最大值。
int GetTop() {
return heap[1];
}
删除堆中任意节点。
像普通的删除根节点一样,将此节点换到最后一个位置即可。
注意:此时可能既需要下调,也可能上调,都需要执行一次:
void Remove(int k){
heap[k]=heap[n--];
Up(k);Down(k);
}
当然,万能的 \(STL\) 也为我们提供了一个与堆用法基本相同的优先队列:
priority_queue<int,vector<int>,less<int> >q;//大根堆
priority_queue<int,vector<int>,greater<int> >q;//小根堆
priority_queue<int>q;//默认大根堆
但是相较于手写堆来说,优先队列无法支持删除任意一个数的操作:
经典问题
例1.二叉堆
题意:给定一个数列,初始为空,请支持下面三种操作:
- 给定一个整数?\(x\) ,请将?\(x\)?加入到数列中。
- 输出数列中最小的数。
- 删除数列中最小的数(如果有多个数最小,只删除?\(1\)?个)。
此题是堆的板子,直接模拟即可,单次 \(1,3\) 操作复杂度 \(O(\log n)\),\(2\) 操作复杂度 \(O(1)\)。
int heap[1000010],n,m;
void Up(int p){
while(p>1){
if(heap[p]>heap[p>>=1]){
swap(heap[p],heap[p>>=1]);
p>>=1;
}
else break;
}
}
int GetTop(){
return heap[1];
}
void Insert(int x){
heap[++n]=x;
Up(n);
}
void Down(int p){
int s=p<<=1;
while(s<=n){
if(s<n&&heap[s]<heap[s+1]) s++;
if(heap[s]>heap[p]){
swap(heap[s],heap[p]);
p=s;
s<<=1;
}
else break;
}
}
void Pop(){
heap[1]=heap[n--];
Down(1);
}
int main(){
scanf("%d",&m);
while(m--){
int op,x;
scanf("%d",&op);
if(op==1){
scanf("%d",&x);
Insert(x);
}
if(op==2) printf("%d\n",GetTop());
if(op==3) Pop();
}
return 0;
}
左偏树
上述二叉堆,实现简单,用途广泛,但仅能支持一些基本操作。
当我们要合并两个堆时,二叉堆要枚举其中一个堆的所有值并插入另一个堆,时间复杂度高达惊人的 \(O(n\log n)\)。
由于二叉堆维护的信息过少,导致合并时间复杂度过高。
此时,我们有两种解决方案:
-
启发式合并,合并时间复杂度均摊 \(O(\log^2 n)\)。
-
可并堆。
这里主要介绍第二种方案。
左偏树,众多可并堆中的一种,仍然基于二叉树结构,但却不是完全二叉树。
相反的,左偏树,顾名思义,具有左倾性质,即对于任意节点 \(x\),有:左子树大小 \(\ge\) 右子树大小。
约定:
-
\(lc_x\),节点 \(x\) 左儿子。
-
\(rc_x\),节点 \(x\) 右儿子。
-
\(v_x\),节点 \(x\) 权值。
定义:
-
外节点:左儿子或右儿子为空的节点。
-
距离 :该节点与最近的外节点经过的边数。外节点的距离为 \(0\) ,空节点的距离为 \(-1\) ,空节点指不存在的节点。
左偏树对每个节点维护一个 \(dis\) 值,表示该节点的距离。
通过维护上述性质,左偏树合并两个堆的复杂度可做到优秀的 \(O(\log n+\log m)\),其中 \(n,m\) 分别是两个被合并堆的大小。
性质:对于任意节点 \(x\) 有:
-
\(v_x\ge \max(v_{lc_x},v_{rc_x})\) (这里是大根堆,若是小根堆则交换大于号两边式子)。
-
\(dis_{lc_x}\ge dis_{rc_x}\)(左偏性质)。
-
\(dis_x=dis_{rc_x}+1\) 。
对于根节点,有 \(dis_{根}\le \log(n+1)-1\) 。
注意:\(dis\) 并不代表整棵树最大深度,左偏树最大深度为 \(O(n)\)。
实现
合并
左偏树最重要的操作是 合并 ,即将两棵左偏树合并成一棵,基本上是左偏树以及大部分可并堆的灵魂所在。
合并流程大致如下:
-
维护堆的性质,选取值较小的根作为合并后的堆顶,然后递归合并其右儿子与另一个堆,作为合并后的堆的右儿子。
-
维护左偏性质,合并后若左儿子的 \(dis<\) 右儿子的 \(dis\) ,就交换两个儿子,并更新根的 \(dis\)。
int Merge(int x,int y){
if(!x||!y) return x+y;//其中一堆为空
if(v[x]<v[y]) swap(x,y);//选择较大值作为堆顶
rc[x]=Merge(rc[x],y);//递归合并右儿子
if(dis[lc[x]]<dis[rc[x]]) swap(lc[x],rc[x]);//交换两个儿子以维护左偏性质
dis[x]=dis[rc[x]]+1;//更新当前根节点dis值
return x;//返回根节点编号
}
可以证明,由于右半部分节点深度最大为 \(O(\log n)\),所以单次合并时间复杂度为 \(O(\log n)\)。
同时,由于我们在回溯时维护了左偏性质,故合并后的树仍然为左偏树。
插入:
将新插入节点当做单独的一个堆,合并即可。
删除:
将被删除节点的左、右儿子分别合并即可。
查询最值:
直接返回堆顶即可。
应用
例1.罗马游戏
显然是可并堆。
但是,由于左偏树深度最大 \(O(n)\)。暴力从 \(x\) 节点向上跳找根显然不是什么明智的选择。
可以使用并查集优化上述找根的过程。
const int N=1e6+10;
int a[N],n,q,fa[N],dis[N],ls[N],rs[N];
int Get(int x) {
return x==fa[x]?x:fa[x]=Get(fa[x]);
}
int Merge(int x,int y) {
if(!x||!y) return x+y;
if(a[x]>a[y]) swap(x,y);
rs[x]=Merge(rs[x],y);
if(dis[ls[x]]<dis[rs[x]]) swap(ls[x],rs[x]);
dis[x]=dis[rs[x]]+1;
return x;
}
main() {
cin>>n;dis[0]=-1;
FOR(i,1,n) a[i]=read(),fa[i]=i;
cin>>q;
while(q--) {
char c[2];scanf("%s",c);
int x,y;
if(c[0]=='M') {
x=read(),y=read();
if(!~a[x]||!~a[y]) continue;
int fx=Get(x),fy=Get(y);
if(fx==fy) continue;
fa[fx]=fa[fy]=Merge(fx,fy);
} else {
x=read();
if(!~a[x]) {
puts("0");
continue;
}
int fx=Get(x);
write(a[fx]),putchar('\n');
a[fx]=-1;
fa[fx]=fa[ls[fx]]=fa[rs[fx]]=Merge(ls[fx],rs[fx]);
//注意这里,如果不更新 fa[fx] 的值,由于并查集有路径压缩,所以一些点的父亲仍然是 fx,所以要让 fx 指向新根。
}
}
return 0;
}
整体修改堆中权值
有时候我们需要对一个堆中的所有数字整体 \(+d,-d,\times d\dots\)。
此时暴力地修改显然不是上策。
回想线段树区间修改的过程,我们使用懒标记,只有在查询用到该位置时才更新,利用懒标记快速维护区间信息,并在修改后将标记下传给儿子。
同样的,我们也可以在左偏树上运用懒标记思想,只有在查询时维护信息。
由于左偏树保存了左右儿子,所以下传标记是容易实现的。
//这里以全局加为例。
void push_down(int p) {
if(!add[p]) return ;
if(ls[p]) {
a[ls[p]]+=add[p];
add[ls[p]]+=add[p];
}
if(rs[p]) {
a[rs[p]]+=add[p];
add[rs[p]]+=add[p];
}
add[p]=0;
}
int Merge(int x,int y) {
if(!x||!y) return x+y;
if(!~x||!~y) return x+y+1;
push_down(x),push_down(y);
if(a[x]>a[y]) swap(x,y);
rs[x]=Merge(rs[x],y);
if(dis[ls[x]]<dis[rs[x]]) swap(ls[x],rs[x]);
dis[x]=dis[rs[x]]+1;
return x;
}
一个士兵攻占的城池事实上就是出生点的深度-死掉位置的深度(如果没死掉则减 \(0\))。
我们可以在树上进行 dfs,同时对每个节点维护一棵左偏树,用来统计到达该点的士兵。
可以通过不断弹出最小值的操作将死在该点的士兵清除并统计答案,所以要用小根堆。
这样就是一个支持整堆加、整堆乘的操作了。
我们可以内定运算优先级:先加再乘。这样就比较好维护了。
const int N=3e5+10;
int n,m,h[N],a[N],c[N],v[N],dep[N],die[N],ans[N],dis[N],ls[N],rs[N],rt[N],vis[N];
int add[N],mul[N];
vector<int>e[N];
void push_down(int p) {
if(mul[p]==1&&!add[p]) return ;
if(ls[p]) {
a[ls[p]]*=mul[p];
a[ls[p]]+=add[p];
mul[ls[p]]*=mul[p];
add[ls[p]]*=mul[p];
add[ls[p]]+=add[p];
}
if(rs[p]) {
a[rs[p]]*=mul[p];
a[rs[p]]+=add[p];
mul[rs[p]]*=mul[p];
add[rs[p]]*=mul[p];
add[rs[p]]+=add[p];
}
mul[p]=1,add[p]=0;
}
int Merge(int x,int y) {
if(!x||!y) return x+y;
if(!~x||!~y) return x+y+1;
push_down(x),push_down(y);
if(a[x]>a[y]) swap(x,y);
rs[x]=Merge(rs[x],y);
if(dis[ls[x]]<dis[rs[x]]) swap(ls[x],rs[x]);
dis[x]=dis[rs[x]]+1;
return x;
}
void dfs(int x,int fa) {
dep[x]=dep[fa]+1;
for(int y:e[x]) {
dfs(y,x);
if(!~rt[x]) rt[x]=rt[y];
else rt[x]=Merge(rt[x],rt[y]);
}
while(~rt[x]&&a[rt[x]]<h[x]) {
die[rt[x]]=x;
push_down(rt[x]);
if(!ls[rt[x]]) rt[x]=-1;
else rt[x]=Merge(ls[rt[x]],rs[rt[x]]);
}
if(!~rt[x]) return ;
if(x==1) return ;
if(vis[x]) mul[rt[x]]*=v[x],add[rt[x]]*=v[x],a[rt[x]]*=v[x];
else a[rt[x]]+=v[x],add[rt[x]]+=v[x];
push_down(rt[x]);
return ;
}
main() {
cin>>n>>m;dis[0]=-1;
fill(mul+1,mul+m+1,1);
FOR(i,1,n) h[i]=read();
rt[1]=-1;
FOR(i,2,n) {
int fa=read();vis[i]=read(),v[i]=read();
e[fa].pb(i);rt[i]=-1;
}
FOR(i,1,m) {
a[i]=read();c[i]=read();
if(!~rt[c[i]]) rt[c[i]]=i;
else rt[c[i]]=Merge(rt[c[i]],i);
}
dfs(1,0);
FOR(i,1,m) ans[die[i]]++;
FOR(i,1,n) cout<<ans[i]<<"\n";
FOR(i,1,m) cout<<dep[c[i]]-dep[die[i]]<<"\n";
return 0;
}
例3. [APIO2012] 派遣
原问题等价于:记一个节点的价值为从该节点子树中选出使得 \(b_i\) 之和不超过 \(m\) 的最大节点数量乘以该节点 \(c_i\),求 \(n\) 个节点中的最大价值。
有一个显然的贪心:一定是从小往大选,证明显然。
所以我们对每个节点维护一个左偏树(大根堆),并从叶子开始自底而上合并,若堆中节点 \(b_i\) 之和大于 \(m\) 则不断弹出堆顶。更新答案。
复杂度 \(O(n\log n)\)。
const int N=1e5+10;
int n,m,rt[N];
int ls[N],rs[N],dis[N],siz[N];
LL ans,sum[N];
struct Node {
int ld,cst;
}a[N];
int Merge(int l,int r) {
if(!l||!r) return l+r;
if(a[l].cst<a[r].cst) swap(l,r);
rs[l]=Merge(rs[l],r);
if(dis[ls[l]]<dis[rs[l]]) swap(ls[l],rs[l]);
dis[l]=dis[rs[l]]+1;
return l;
}
vector<int>e[N];
void dfs(int x) {
for(int y:e[x]) {
dfs(y);
sum[x]+=sum[y];siz[x]+=siz[y];
rt[x]=Merge(rt[x],rt[y]);
}
while(sum[x]>m) {
int nw=rt[x];sum[x]-=a[nw].cst;
rt[x]=Merge(ls[rt[x]],rs[rt[x]]);
siz[x]--;
}
cmax(ans,(LL)siz[x]*a[x].ld);
}
main() {
cin>>n>>m;dis[0]=-1;
int root=0;
FOR(i,1,n) {
int x=read();a[i].cst=read();a[i].ld=read();
if(x) e[x].eb(i);
else root=i;
sum[i]=a[i].cst;rt[i]=i;siz[i]=1;
}
dfs(root);
cout<<ans<<"\n";
return 0;
}