题目链接:E. Lomsat gelral
树上启发式合并,初见这个名字,我和大部分人一样望文生义觉得应该是子树信息的合并使用了一种“启发式”,相当于区间操作的线段树的Lzay标志一般,仅仅启发而不去真的合并。
然后我兴高采烈的学了一下发现其实就是一个暴力。一个优雅的暴力优化。
dsu on tree,dsu就是并查集,直译为树上并查集。(某L称该算法为静态链分治。
首先我们审视一下题目暴力的写法:
对于每个子树开一个桶或者对每个子树都清空一次桶,然后暴力跑得出空间为
N
2
N^2
N2 或者时间为
N
2
N^2
N2 。这明显是不可取的。然后考虑暴力的优化,那就是当k结点需要求解的时候,可以保留一个重儿子求解之后的桶不给他清空,然后合并其他轻儿子,也就是说
轻儿子会经历:求解,清空共2次
重儿子会经历:求解,共1次。
然后发现如果有轻儿子重儿子之分则树链不超过
l
o
g
2
N
log_2N
log2N高度,于是可以得出这个小小的优化竟然能够让
N
2
N^2
N2变成
N
l
o
g
2
N
Nlog_2N
Nlog2N
这个题写法可以有两种:
#define X first
#define Y second
const int maxn = 1e5+10;
ll a[maxn];
vector <int >g[maxn];
int tong[maxn] ,sz[maxn] ;
ll ans[maxn] ;
bool cmp(int a,int b){return sz[a]<sz[b];}
int dfs1(int k,int last)
{
sz[k]=1;
for(int i =0;i<g[k].size();++i)if(g[k][i]!=last)sz[k]+=dfs1(g[k][i],k);
return sz[k];
}
void add(int k,int last,int cut,pair<int ,ll>& rem)
{//把k子树所有结点存入桶中
//cut =1表示入桶,0表示出桶
tong[a[k] ]+=cut;
if(tong[a[k] ]==rem.X&&cut==1)
{
rem.Y+=a[k];
}
else if(tong[a[k] ]>rem.X&&cut==1){rem.X=tong[a[k] ];rem.Y=a[k];}
for(int i=0;i<g[k].size();++i)
if(g[k][i]!=last)add(g[k][i],k,cut,rem);
}
pair<int ,ll> dfs2(int k,int last)
{
//printf("to : %d\n",k);
//对于k结点子树求答案;
//函数返回值:_max 和 答案;对应rem.X和rem.Y
//先对k儿子子树求答案,然后对最重儿子最后处理;
sort(g[k].begin(),g[k].end(),cmp);
if(sz[k]==1){tong[a[k]]++;ans[k] = a[k];return (pii ){ 1,a[k] };}
pair<int ,ll> rem ={0,0};
for(int i=0;i<g[k].size()-1+(k==1);++i)
{
rem = dfs2(g[k][i] ,k);//除了最后一步都为轻儿子,最后一步不清除.则rem保证为最后一个重儿子的结果,则可以支持后续操作
if(i!=g[k].size()-2+(k==1))add(g[k][i],k,-1,rem);
}
//printf("%d %d %d\n",k,rem.X,rem.Y);
tong[a[k]]++;
if(tong[a[k] ]==rem.X)
{
rem.Y+=a[k];
}
else if(tong[a[k] ]>rem.X){rem.X=tong[ a[k] ];rem.Y=a[k];}
for(int i =0;i<g[k].size()-2+(k==1);++i)
{
add(g[k][i],k,1,rem);
}
ans[k] = rem.Y;
return rem;
}
int main()
{
int n;read(n);
for(int i= 1;i<=n;++i)read(a[i]);
for(int i =1;i<n;++i)
{
int u,v;read(u);read(v);
g[u].push_back(v);g[v].push_back(u);
}
//input finished
//树上启发式合并
dfs1(1,1);
dfs2(1,1);
for(int i =1;i<=n;++i)printf(" %lld"+(i==1),ans[i]);printf("\n");
}
和
#include<cstdio>
using namespace std;
typedef long long LL;
const int maxn=100000,maxe=maxn<<1;
int n,col[maxn+5],fa[maxn+5];
int E,lnk[maxn+5],son[maxe+5],nxt[maxe+5];
int si[maxn+5],SH[maxn+5];bool vis[maxn+5];
int MAX,num[maxn+5];LL sum,ans[maxn+5];
#define Add(x,y) son[++E]=y,nxt[E]=lnk[x],lnk[x]=E
void Dfs(int x,int pre=0)
{
si[x]=1;fa[x]=pre;
for (int j=lnk[x];j;j=nxt[j]) if (son[j]!=pre)
{
Dfs(son[j],x);si[x]+=si[son[j]];
if (si[son[j]]>si[SH[x]]) SH[x]=son[j];
}
}
void Update(int x,int f)
{
if ((num[col[x]]+=f)>=MAX) if (num[col[x]]>MAX)
MAX=num[col[x]],sum=col[x]; else sum+=col[x];
for (int j=lnk[x];j;j=nxt[j]) if (son[j]!=fa[x])
if (!vis[son[j]]) Update(son[j],f);
}
void Solve(int x,bool fl=true)
{
for (int j=lnk[x];j;j=nxt[j]) if (son[j]!=fa[x])
if (son[j]!=SH[x]) Solve(son[j],false);
if (SH[x]) Solve(SH[x],true),vis[SH[x]]=true;
Update(x,1);ans[x]=sum;if (SH[x]) vis[SH[x]]=false;
if (!fl) Update(x,-1),MAX=sum=0;
}
int main()
{
freopen("program.in","r",stdin);
freopen("program.out","w",stdout);
scanf("%d",&n);for (int i=1;i<=n;i++) scanf("%d",&col[i]);
for (int i=1,x,y;i<n;i++) scanf("%d%d",&x,&y),Add(x,y),Add(y,x);
Dfs(1);Solve(1);for (int i=1;i<=n;i++) printf("%I64d ",ans[i]);
return 0;
}
除去注释和下面的压行,下面仍然要多一点语句,主要是第一个代码使用了rem去处理合并轻儿子过程对父亲整个子树的答案更新情况,而下面的代码使用了一个数组记录每个节点的rem,使用额外的数组存储对于空间有一点占用,两边都可以的。下面空间多了N个pair,但是更加简短,推荐使用。
以第一个代码来说:
过程先使用dfs1求子树size。
然后add函数为增加整个子树信息进入桶中,或者从桶中删除整个子树信息。然后就是dfs2过程了
dfs2代码先:重载cmp函数然后sort排个序,先对轻儿子求答案,然后剩下最后一个重儿子求完答案并没有清空,也就是这一小段:
rem = dfs2(g[k][i] ,k);
//除了最后一步都为轻儿子,最后一步不清除.则rem保证为最后一个重儿子的结果,则可以支持后续操作
if(i!=g[k].size()-2+(k==1))add(g[k][i],k,-1,rem);
可以看到当出了这个循环,rem保存的是重儿子的答案,_max 和cnt都存在这个pair变量中了,然后桶中也仍保留重儿子的信息。
然后就是add轻儿子,并且add函数当cut为1,会更新通过引用传进去的rem,作为父亲节点子树的答案,然后返回父亲节点子树答案当做函数返回值。
至此,完成树上启发式合并这个名字听起来和算法过程没什么大关系的算法啦。