浅谈基础数据结构-二叉堆
Dream__Sky · · 算法·理论
1、堆的概述:
对于一种可以查询最大或最小值,即优先级最高(也可通过重载运算符更改优先级),并且可以插入新的元素,可以删除最大最小值的集合容器,被称为优先队列。优先队列可以暴力维护,但通过二叉堆实现优先队列,可以较高效率完成插入和查询操作。
2、堆的性质
- 堆是一棵 完全二叉树。
- 堆的顶部优先级最高。例如大根堆堆顶数字最大,小根堆堆顶数字最小。为什么说是优先级最高,因为堆并不只能根据数字大小来维护堆内元素的顺序,还可以通过别的方式更改这些顺序(例如重载)。某些题就不是按数字的大小为优先级来进行堆的操作的。
- 当前节点的儿子优先级都比当前节点小,且左儿子小于右儿子。
3、堆的原理(操作)
以下演示的都为小根堆。
1、插入
我们先考虑插入在哪里。为了要满足堆的性质
此时并不满足性质
其实插入的过程中,不必考虑性质三的后半段,这会在删除时满足。查询时,也不用考虑,因为堆顶只有一个。
代码:
void modify(int x)
{
if(x==1||w[x]>w[x/2]) return ;//根节点或满足性质停止交换
swap(w[x],w[x/2]);
modify(x/2);//用递归的方式,往上交换
}
void push(int x)
{
w[++tot]=x;//插入堆底
modify(tot);
}
2、删除
假设我们要删除堆顶的
我们还是要保持堆的性质
堆顶向下交换过程中,在它的两个儿子里面,找一个比较小的,且是小于它的,和它交换一下。直到满足性质,即两个儿子都比它大。
代码:
void repair(int x)
{
if(x*2>tot)return ;//注意不能超过堆底
int tar=x*2;
if(x*2+1<=tot) tar=w[x*2]<w[x*2+1]?x*2:x*2+1;//找小的儿子
if(w[x]>w[tar])//如果不满足性质,则继续向下交换
{
swap(w[x],w[tar]);
repair(tar);
}
}
void pop()
{
swap(w[1],w[tot--]);//交换并删除
repair(1);
}
3、查询
没什么好讲,直接输出堆顶。
好,到目前为止讲的都没什么用,但是如果你们够牛,有实力学到左偏树的话,那还是有用的(不过笔者不会)。
4、堆的 STL 实现
定义一个优先队列:
#include<queue>//需要的头文件
priority_queue<int> q;//这是一个大根堆q
priority_queue<int,vector<int>,greater<int> >q;//这是一个小根堆q
//注意某些编译器在定义一个小根堆的时候greater<int>和后面的>要隔一个空格,不然会被编译器识别成位运算符号>>
优先队列的操作:
q.top()//取得堆顶元素,并不会弹出
q.pop()//弹出堆顶元素
q.push()//往堆里面插入一个元素
q.empty()//查询堆是否为空,为空则返回1否则返回0
q.size()//查询堆内元素数量
因为 STL 好写,下面堆的应用全部都会采用 STL 的代码实现。
5、关于堆的复杂度
由于是完全二叉树,插入删除复杂度为
6、堆的应用
1、基本操作
这一部分的二叉堆仅仅作为一个工具,主要还是思路。
P1177 将
输入时每次插入维护堆,最后不停输出堆顶,不停删除即可。
代码不给。
P1090 共
考虑贪心,每次合并最小两堆一定最优。
证明:
合并的过程可以看成一棵二叉树,总代价为
那么我们需要维护当前数字中的最小值,可以想到优先队列。每次弹出两个堆顶,并合并再次插入堆中。只剩一个数时,就是答最小贡献。
代码不给。
P2168
首先介绍一下哈夫曼树,哈夫曼树也叫最优二叉树,是一种带权路径(叶子节点权值,即题中的
。
最底下的叶子节点就为每个单词的出现次数,树上的 01 就是最后构造出来的哈夫曼编码。
这道题要求的,就是构造出的哈夫曼树的根节点的值,与树的高度。
与合并果子类似,考虑贪心。每次从堆中取出权重最小的
但是这里有一个问题,最后一次合并时可能不足
代码:
2、二叉堆维护对顶堆
这一部分主要可以求一些有关 topk 的题。
对顶堆像是这样,两个堆堆顶相对,上面是小根堆,下面大根堆,从上到下的数,分别是从大到小的(当然也可以反过来)。
对于第
考虑插入一个数如何继续维护这个 topk。
首先要保证对顶堆从上到下的数,分别是从大到小的。如果这个数大于下面这个堆的堆顶,那么往上插入,反之向下插入。
然后要保证大根堆的中有
但是用对顶堆来维护 topk,如果
P1168 给定一个数组,对于前奇数项求中位数。
那显然,我们对第
首先在每个数插入时,先保证大小之间的性质。到了每奇数项,再进行微调,把底下大根堆中把多的或者少的数调掉,每次输出它的堆顶。
代码(略有区别,我这里是每次都先拿出一个值当中位数,也就是两个堆里的数少了
for(int i=2,x;i<=n;i++)
{
cin>>x;
if(x>mid) p.push(x);
else q.push(x);
if(i&1)
{
while(q.size()!=p.size())
{
if(q.size()>p.size())
{
p.push(mid);
mid=q.top();
q.pop();
}
else
{
q.push(mid);
mid=p.top();
p.pop();
}
}
cout<<mid<<"\n";
}
}
P7072 根据题意每次维护第
注意维护的是第
代码:
void push(int s)
{
if(s>=ma.top()) mi.push(s);//插入
else ma.push(s);
if(mi.size()>now)
{
ma.push(mi.top());
mi.pop();
}
if(mi.size()<now)
{
mi.push(ma.top());
ma.pop();
}//进行微调,这道题中最多也只需要微调一次
}
P1801 不讲了,反正也是板子题。
3、用二叉堆维护一些合并问题
P1631 有两个长度为
首先排序。
我们考虑哪些数有机会参与贡献。假设我们当前数
代码:
for(int i=1;i<=n;i++) q.push(make_pair(a[i]+b[1],1));//先插入每个队列的第一个,这些还都有可能
for(int i=1;i<=n;i++)
{
pair<int,int> p=q.top();
q.pop();
cout<<p.first<<" ";//取出队头,并向后拓展有可能的位置
q.push(make_pair(p.first-b[p.second]+b[p.second+1],p.second+1));
}
P2085
我们先考虑暴力是怎么做的,对于每个函数算出其变量
这时候,很多后面的数实际上是参与不到贡献中的,也就是说这些数根本没必要算出来。
考虑优化。首先将
每次,如果一个数是可能出现在输出中的,那么它的前一个,即变量为
主要代码:
for(int i=1;i<=n;i++)
{
cin>>a[i]>>b[i]>>c[i];
l[i]=1;
pair<int,int> p;
p.first=f(a[i],b[i],c[i],l[i]);
p.second=i;
q.push(p);//首先预处理出哪些是有可能的
}
for(int i=1;i<=m;i++)
{
pair<int,int> p=q.top();
q.pop();
cout<<p.first<<" ";
int t=p.second;
l[t]++;
p.first=f(a[t],b[t],c[t],l[t]);//向后拓展
q.push(p);
}
4、二叉堆杂题
P2827 蚯蚓
这道题参考合并果子,每次从蚯蚓堆中找到最大的一条,把他切掉,再放回去。但是放回去时,其它的蚯蚓长度都要增加
代码:
for(register int i=1;i<=m;i++)
{
int now=qu.top()+dick;//dick为偏移量,这里算出真实长度
qu.pop();
int q1,q2;
q1=int(1.0*u/v*now);
q2=now-q1;//算出两条的长度
dick+=q;//偏移量累加
q1-=dick,q2-=dick;//再把这两条再偏移回去
qu.push(q1),qu.push(q2);//重新放入堆里
if(i%t==0) write(now),putchar(' ');
}
好,只有
满分思路与二叉堆无关,不讲了,自己看题解。反正就是每次切出来的蚯蚓长度也具有单调性,不需要二叉堆维护。
P4053 考虑贪心。
第一个很显然的贪心策略,我们要把它的截止时间从小到大排序。对于那些不紧急的任务,可以往后再做。
排序后,我们遍历所有建筑,如果它在当前时间能被修好,那就修;如果不能,那么此时必然要废除掉一个建筑。我们考虑废除之前修的哪一座最优,显然,肯定废除修理时间最长的建筑。
在这里每次找之前时间最长的建筑,就可以用二叉堆来维护。
代码:
for(int i=1;i<=n;i++)
{
if(time+a[i].x<=a[i].y)//如果可以修理,那就修理,并加入已修理的堆里。为后面反悔的操作考虑。
{
daan++;
time+=a[i].x;
q.push(a[i].x);
}
else
{
if(a[i].x<q.top())//需要废除
{
time=time-q.top()+a[i].x;
q.pop();
q.push(a[i].x);//废除最大的(即大根堆的堆顶)
}
}
}
之前绍兴市赛小学组 T4 也是反悔贪心。
P2278 大模拟
我们可以用堆来维护各进程的优先级关系。
每次先按优先级关系一直往下做,直到再做下去就比当前输入的进程时间要晚了。
剩下考虑两种情况。 1、当前输入的进程优先级是最高的,那么我们就先把正在做的这个进程做一半,重新放回堆里,再接着像之前那样考虑,按优先级关系往下做。
2、优先级比较低,那我们直接把这个放入堆中,也像之前一样考虑即可。
这样过于麻烦,所以我们需要一种通用解法:
可以发现,第一种情况的处理方法对于第二种情况同样适用,把当前进程执行一半,我们算出他能够执行的时间,更新这个进程并且重新放回堆中。
while(scanf("%d%d%d%d",&x.id,&x.st,&x.ti,&x.pr)!=EOF)
{
while(!q.empty()&&now+q.top().ti<=x.st)//先是普通情况。
{
info y=q.top();
q.pop();
cout<<y.id<<" "<<now+y.ti<<"\n";
now+=y.ti;
}
if(!q.empty())
{
info z=q.top();
q.pop();
z.ti=z.ti+now-x.st;
q.push(z);//考虑为当前进程执行一半
}
q.push(x);//把当前输入进程也放入堆中。
now=x.st;//在这里,我们已经把在输入当前进程的时间前的所有情况都统计完了
}
7、重载运算符
对于一些需要按,比较复杂的规则,在优先队列内排序的题,我们就需要重载运算符
具体写法:
struct info//写在结构体内
{
int x,y;
bool operator <(const info &a)const{//注意这里必须要是<
return a.x<x;//注意这里"a."不能与后面的交换,其他与自定义排序的写法一样。
}
};
priority_queue<info> q; //比如这里就是按结构体的x从小到大排序
struct info
{
int x,y;
bool operator <(const info &a)const{
return a.x<x||(a.x==x&&a.y<y);
}
};
priority_queue<info> q; //这里就是先按x从小到大排序,再按y从小到大排序
完结撒花。