bzoj4919 大根堆|线段树合并/multiset 启发式合并|题解
题面
给定一棵n个节点的有根树,编号依次为1到n,其中1号点为根节点。每个点有一个权值v_i。
你需要将这棵树转化成一个大根堆。确切地说,你需要选择尽可能多的节点,满足大根堆的性质:对于任意两个点i,j,如果i在树上是j的祖先,那么v_i>v_j。
请计算可选的最多的点数,注意这些点不必形成这棵树的一个连通子树。
输入格式
第一行包含一个正整数n(1<=n<=200000),表示节点的个数。
接下来n行,每行两个整数v_i,p_i(0<=v_i<=10^9,1<=p_i<i,p_1=0),表示每个节点的权值与父亲。
输出格式
输出一行一个正整数,即最多的点数。
样例
输入
6
3 0
1 1
2 1
3 1
4 1
5 1
输出
5
解析
题意是在一个树上选出最多的点使这些点严格小于其树上的父亲.
如果提出来部分选出点在树上所属的一条链,那就是一个上升序列。
先看数据范围,1e9,最好离散化,不然常数大时间上容易死。
然后考虑如何解决问题,其实它就是一个树形dp,但是我们应该如何维护最优解就是个问题了。
当时思考是建两个树,分别维护选根节点或者不选根节点的树中我选进来的节点的值域信息。
貌似选入与未选入单独处理相互转移即可得到正解(因为都是最优),后来发现我们线段树合并的时候并不好合并。
于是需要变换思路,我们可以考虑一点,我们当前要做的是树形dp,是尽可能去选取节点存储最优解。
如果我们抛弃线段树,从树形dp的角度去思考我们要如何做。
首先是dp中的最优信息,我们需要用一个数组来存储。
因为没有什么一维出最优的思路,我们把数组开成二维,一维是我们在哪个节点上,另一维是我们当前选入的最大节点范围(如\(f[1][9]\)表示我们在节点1的树上选出一些节点,节点大小<=9)对应的最优。
然后考虑每一次转移时的答案变动,可以发现我们如果只看根节点是否选入,
对于这个点,能得到的答案就是它子树小于当前节点大小的节点数+1。
就是\(f[fa][num[fa]]=max(f[fa][num[fa]-1]+1,f[fa][num[fa]])\).
考虑当我们的\(f[fa][num[fa]]\)可以被更新时,我们的\(f[fa][>=num[fa]]\)均可能被更新(参考f的定义)。
那么我们需要找到的更新答案区间是在\(f[fa][>=num[fa]]\)的部分中比当前结果\(f[fa][num[fa]-1]+1\)要小的部分。
如果\(f[fa][num[fa]]\)都比\(f[fa][num[fa]-1]+1\)大,明显不必更新。
那么,我们可以考虑用二分去寻找更改答案边界,将所有答案都更新上就可以了。
但是明显我们找到边界后,这个答案的更新如果是暴力更新\(O(n)\)明显要死,因为我们每一次合并之后的节点均需要一次\(O(n)\)。
且考虑答案继承时(由子树转来),显而易见的,\(f[fa][i]=\sum^i_{son的个数}f[son][i]\),这也是个\(O(n)\)。
这时间复杂度已经飚上天了。
怎么优化呢,考虑用值域线段树去维护答案。
但是也有问题,我们的值域线段树的更新如果用lazy标记实时pushdown,时间复杂度也不容乐观。
考虑优化,我们用永久化标记(在某个节点处的标记不pushdown,表示该节点所包含的值域范围全部加上标记,每次查询将路径上的所有标记都加上,就是我们的答案)。
这样我们更改答案时的时间复杂度锐减(虽然说查询的常数巨大),在此基础上疯狂卡常卡卡常数,就可以过了这道题。
#include<bits/stdc++.h>
#define qr qr()
using namespace std;
const int N=2e7+500,MN=2e5+500;
struct node{
int sum,ls,rs;
}tree[N];
struct nd{
int t,nx;
}edge[N];
int nd_rt[MN],head[MN],num[MN],cp[MN],cnt,tot,n;
inline int qr
{
int x=0;char ch=getchar();
while(ch>57||ch<48)ch=getchar();
while(ch>=48&&ch<=57)x=(x<<3)+(x<<1)+(ch^48),ch=getchar();
return x;
}
inline int bc(int x)
{
return lower_bound(cp+1,cp+cnt+1,x)-cp;
}
inline void update(int &rt,int st,int ed,int l,int r)
{
if(!rt)rt=++tot;
if(l>=st&&r<=ed)
{
++tree[rt].sum;
return ;
}
int md=(l+r)>>1;
if(md>=st)update(tree[rt].ls,st,ed,l,md);
if(md<ed) update(tree[rt].rs,st,ed,md+1,r);
}
inline void merge(int &fa,int son)
{
if(!fa||!son)
{
fa=fa^son;
return ;
}
tree[fa].sum+=tree[son].sum;
merge(tree[fa].ls,tree[son].ls);
merge(tree[fa].rs,tree[son].rs);
}
inline int ask(int rt,int l,int r,int st)
{
if(!rt||!st)return 0;
int md=(l+r)>>1;
if(st<=md)return ask(tree[rt].ls,l,md,st)+tree[rt].sum;
else return ask(tree[rt].rs,md+1,r,st)+tree[rt].sum;
}
inline void dfs(int now)
{
int st=bc(num[now]);
nd_rt[now]=++tot;
for(int i=head[now];i;i=edge[i].nx)
{
int to=edge[i].t;
dfs(to);
merge(nd_rt[now],nd_rt[to]);
}
int ans=ask(nd_rt[now],1,cnt,st-1)+1;
if(ans<=ask(nd_rt[now],1,cnt,st))return ;
int l=st,r=cnt;
while(l<=r)
{
int md=(l+r)/2;
if(ask(nd_rt[now],1,cnt,md)<ans)l=md+1;
else r=md-1;
}
update(nd_rt[now],st,r,1,cnt);
}
void add(int f,int t)
{
edge[t]={t,head[f]};
head[f]=t;
}
void init()
{
n=qr;
for(int i=1;i<=n;++i){
num[i]=qr;
cp[i]=num[i];
int fa=qr;
add(fa,i);
}
sort(cp+1,cp+n+1);
cnt=unique(cp+1,cp+n+1)-cp-1;
dfs(1);
printf("%d\n",ask(nd_rt[1],1,cnt,cnt));
}
int main()
{
init();
return 0;
}
但是我们的做法明显是最废解(至少从时间上来说)。
如果在保留我们的大致解题思路的前提下,是否还可优化呢?
考虑我们的时间主要耗在哪里了。
我们在二分查找答案更新右边界时,时间复杂度是二分\(O(log\ n)\)×查询\(O(log\ n)=O(log^2n)\)而且查询在永久化标记的作用下自带大常数。
加上我们在每一个节点处都要重复该操作时间复杂度\(O(n×log^2n)\)。
明显就是这个永久化标记的log+大常数的锅。
那现在我们再想想,在我们进行答案更新时,更新的区间一定是连续的。
那么我们可以用一个差分来维护信息,叶子节点是差分主体,上面的节点保存下面差分之和,即为该节点的右界处对应的答案。
(注:该方法是值域线段树内部差分,像这类树套树的问题,还可能用到外部树差分,如雨天的尾巴,一个板子题,但是用到了该方法,存个模板链接)
这样我们答案更新时可以通过两个单点修改来解决。
但是我们的答案查询怎么办?
简单,我们先在起始点(num[fa])处进行单点修改,保证我们后面查到的区间全部大于等于答案。
我们再在树上查询到第一个答案大于该点答案的点就可以了。
如何模拟这个过程呢,我们需要每一次看左子树边界的答案是否>查询答案。
大于在左子树找,反之在右子树。
最后我们定位到的点要么刚好大于我们查询的答案,要么等于。
我们判断它属于哪一种情况,即可对应更新答案区间右边界了。
#include<bits/stdc++.h>
#define qr qr()
using namespace std;
const int N=2e7+500,MN=2e5+500;
struct node{
int sum,ls,rs;
}tree[N];
struct nd{
int t,nx;
}edge[N];
int nd_rt[MN],head[MN],num[MN],cp[MN],cnt,tot,n;
inline int qr
{
int x=0;char ch=getchar();
while(ch>57||ch<48)ch=getchar();
while(ch>=48&&ch<=57)x=(x<<3)+(x<<1)+(ch^48),ch=getchar();
return x;
}
inline int bc(int x)
{
return lower_bound(cp+1,cp+cnt+1,x)-cp;
}
inline void update(int &rt,int l,int r,int pos,int val)
{
if(!rt)rt=++tot;
tree[rt].sum+=val;
if(l==r)return;
int md=(l+r)/2;
if(pos<=md)update(tree[rt].ls,l,md,pos,val);
else update(tree[rt].rs,md+1,r,pos,val);
}
inline int ask(int rt,int l,int r,int st,int ed)
{
if(!rt||st>ed)return 0;
if(l>=st&&r<=ed)return tree[rt].sum;
int md=(l+r)/2,ans=0;
if(st<=md)ans+=ask(tree[rt].ls,l,md,st,ed);
if(ed>md)ans+=ask(tree[rt].rs,md+1,r,st,ed);
return ans;
}
inline void merge(int &fa,int son)
{
if(!fa||!son)
{
fa=fa^son;
return ;
}
tree[fa].sum+=tree[son].sum;
merge(tree[fa].ls,tree[son].ls);
merge(tree[fa].rs,tree[son].rs);
}
inline int find(int rt,int l,int r,int pos)
{
if(!rt)return r;
if(l==r)return l;
int md=(l+r)/2;
if(pos<tree[tree[rt].ls].sum)return find(tree[rt].ls,l,md,pos);
else return find(tree[rt].rs,md+1,r,pos-tree[tree[rt].ls].sum);
}
inline void add(int f,int t)
{
edge[t]={t,head[f]};
head[f]=t;
}
inline void dfs(int now)
{
int st=bc(num[now]);
nd_rt[now]=++tot;
for(int i=head[now];i;i=edge[i].nx)
{
int to=edge[i].t;
dfs(to);
merge(nd_rt[now],nd_rt[to]);
}
int ans=ask(nd_rt[now],1,cnt,1,st-1)+1;
update(nd_rt[now],1,cnt,st,1);
int pos=find(nd_rt[now],1,cnt,ans);
if(ask(nd_rt[now],1,cnt,1,pos)>ans)--pos;
if(pos<cnt)update(nd_rt[now],1,cnt,pos+1,-1);
}
void init()
{
n=qr;
for(int i=1;i<=n;++i){
num[i]=qr;
cp[i]=num[i];
int fa=qr;
add(fa,i);
}
sort(cp+1,cp+n+1);
cnt=unique(cp+1,cp+n+1)-cp-1;
dfs(1);
printf("%d\n",ask(nd_rt[1],1,cnt,1,cnt));
}
int main()
{
init();
return 0;
}
然后还有第三种省事些的写法,我们不用线段树合并。
我们用multiset+启发式合并。
首先说一下multiset的性质:
- 允许重复元素出现
- 自动升序排列
然后来解释一下启发式合并。
其实就是对于两个集合来说,我们每一次都把集合元素少的并到集合元素多的集合中。
这样我们就能减去部分合并的时间复杂度。
比方说我们用同一个集合(n个元素)去并上一个比它大100倍的集合,我们就要O(100×n)的复杂度。
反之,则只用O(n)即可解决。
那么根据这两个东西,我们想个方法来将这个题解决。
先用multiset去维护节点集合。
接着我们思考一个事情,我们子树内部的答案只用一个最优来表示,那么它一定是一个不下降序列。
意思是我们根节点的答案是>=子树的答案的。
所以模拟选择最优答案的过程,我们如果要新加入我们这个父亲节点,只有两种情况。
一是我们的最优答案因其而更新,这种就是它的大小比子树选入的节点均大,我们直接将其插入选入的节点集合。
二是我们的最优答案无法因其更新,就是说当前最优选取的最大节点比父节点大。
结合一与二,我们可以想到,当我们想要让答案被更多次数的更新,我们需要让答案一致时,让答案所选取的节点大小更小。
这样下一次并入一个节点时若它是最大节点,就可以直接更新答案。
那在我们每一次并入父节点后,就看这个父节点是否在multiset的末尾,在末尾说明答案被更新。
不在结尾则删去第一个大于等于该节点大小的节点,相当于用这个节点替换了那个节点记录答案。
由于子树之间的答案统计互不影响,所以直接让它们启发式合并即可。
所以说我们的代码框架就有了。
不得不说,一些STL还是非常实用的,要认真学习。
#include<bits/stdc++.h>
#define ll long long
#define qr qr()
#define pa pair<int,int>
#define fr first
#define sc second
using namespace std;
const int N=2e5+200;
int n,num[N],tot,head[N];
struct node{
int t,nx;
}edge[N];
multiset <int> s[N];
#define sit multiset<int>::iterator
//这个玩意是迭代器,只能用这玩意去访问multiset内的下标.
inline int qr
{
int x=0;char ch=getchar();bool f=0;
while(ch>57||ch<48)
{
if(ch=='-')f=1;
ch=getchar();
}
while(ch<=57&&ch>=48)x=(x<<3)+(x<<1)+(ch^48),ch=getchar();
return f?-x:x;
}
void dfs(int now)
{
for(int i=head[now];i;i=edge[i].nx)
{
int t=edge[i].t;
dfs(t);
if(s[t].size()>s[now].size())swap(s[t],s[now]);
for(auto i:s[t])
s[now].insert(i);
}
sit st=s[now].lower_bound(num[now]);
if(st!=s[now].end())s[now].erase(st);
s[now].insert(num[now]);
}
inline void add(int f,int t)
{
edge[++tot]={t,head[f]};
head[f]=tot;
}
void init()
{
n=qr;
for(int i=1;i<=n;++i)
{
num[i]=qr;
int t=qr;
add(t,i);
}
dfs(1);
printf("%ld",s[1].size());
}
int main()
{
#ifndef ONLINE_JUDGE
freopen("in.in","r",stdin);
freopen("out.out","w",stdout);
#endif
init();
return 0;
}
愿你我皆若星辰