树形背包复杂度+P3177 [HAOI2015]树上染色

惊奇的发现躺在任务计划里几个月的“P3177树上染色”变成蓝题了,既然前几天清北学堂提到这题了,那就看一眼。(众所周知,洛谷任务计划是个栈)

本人十分不擅长DP,尤其像什么树形背包题,尤其是为啥看上去 n3 的复杂度都说是 n2 的,让我十分害怕,因为别人说的经典句子什么"两个点只会在 LCA 处合并",这 nm 我怎么总感觉是错的啊。

我曾经以为的优化就是指枚举到子树大小与父亲的 min 即可,但很长时间以来都被这个玄学的复杂度所困扰,因为这明显一条链可以卡的掉啊,换上链的复杂度是 n(n1)+(n1)(n2)+(n2)(n3)...+21,难道这东西确实是 n2 的?(雾。

我信你个鬼,很明显不是啊~。。我个大彩笔。

那么产生分歧的地方很明显:

我做的时候是将整个树的 siz[rt] 去与子树的 siz[v] 进行合并,它张这样:

大家都说只会在LCA处合并,相互产生贡献的肯定是像下面这样的:

每个子树都不交叉,它们的LCA都是这棵子树的根,设这个子树的第 i 个儿子大小为 siz[i],那么每次的复杂度就是

siz[2]siz[1]+siz[3](siz[2]+siz[1])+siz[4](siz[3]+siz[2]+siz[1])+...+siz[n](siz[n1]+siz[n2]+...+siz[1])

=i=1nj=1nsiz[i]siz[j]  (i!=j)

就是两两子树相乘。既然每个点当根的复杂度不好证,那么就考虑将每个 siz 看做这个子树中的每个点,看整棵树中所有的点对复杂度的贡献。

考虑到子树不交叉,那么这棵树的任意两个点对背包进行更新答案时,肯定要保证这两个点:1、都在当前根的子树中;2、不在当前根的同一个儿子子树中。那么显然,这个根是它俩的 LCA,是这个意思。写的好的话复杂度基本 n2 不多不少。

所以,为了保证 n2 ,我们不能提前处理出所有子树的大小,而是边合并边做背包。


关于树形背包的心路历程呢还得看这道题,就是刚刚从任务计划里出来的(从栈底开了个洞钻出来的)这道P3177

有些树形背包题数据范围是 n3 的,不用考虑这些。这题是 n2,而且最近洛谷放进去一个加强数据,果然就是一条链,卡掉了一部分题解。

由于数据加强了,过会儿考虑发给洛谷一个题解,咕值快要掉出前100了,qwq。


题意:

洛谷链接

给你一颗 n 个点带边权的树,要你将 k 个点涂成黑色,其余点为白色,整棵树的价值为任意两个黑点的距离之和,加上任意两个白点的距离之和。(0kn2000)


一看数据范围就很友善(雾,显然没有 O(n) 的算法或者其他神奇的乱搞,所以考虑 dpf[i][j]表示 i 子树中选了 j 个黑点的最。。。(愣住)。发现子树最优解并不独立,它还会与其他点产生贡献,我们需要考虑全局最优。

既然任意两点之间的贡献很难处理,那我们将所有的贡献转移到边上,突然就发现简单轻松了许多:一条边的贡献为两边的黑点数相乘加上白点数相乘。

而且恰好,在枚举子树内有多少黑点时,我们同样知道子树外有多少黑点(因为整棵树正好要选 k 个),还知道子树内白点个数,以及子树外白点个数,这条连接当前根与儿子的边的贡献就可以算出来了。

考虑这样做的可行性,因为一条边我们已经固定了这棵子树内黑点的个数,而外面的黑点无论在什么位置,这条边的价值是不变的。所以我们放心地让每棵子树内的边权和最大,最后一定会是最优解。

于是来到了树形背包的环节,接下来是几分钟就能写好的代码:

#include <bits/stdc++.h> using namespace std; const int inf=0x3f3f3f3f; int n,m; struct E{ int to,we; }; vector <E> e[2333]; int siz[2333]; int f[2333][2333]; inline int read() { int sum = 0, f = 1; char c = getchar(); while(c<'0' || c>'9') { if(c=='-') f = -1; c = getchar(); } while(c>='0'&&c<='9') { sum = sum * 10 + c - '0'; c = getchar(); } return sum * f; } inline int MIN(int aa,int bb) { return aa<bb?aa:bb; } void DFS(int u,int fa) { siz[u] = 1; for(int i=0;i<e[u].size();i++) { int v = e[u][i].to; if(v==fa) continue; DFS(v,u); siz[u] += siz[v]; } f[u][0] = f[u][1] = 0; for(int i=0;i<e[u].size();i++) { int v = e[u][i].to, w = e[u][i].we; if(v==fa) continue; for(int j=MIN(siz[u],m); ~j; --j) { for(int k=0; k<=MIN(siz[v],j); k++) { int val = w * ( k*(m-k) + (siz[v]-k)*(n-m-siz[v]+k) ); f[u][j] = max( f[u][j], f[u][j-k]+f[v][k]+val ); } } } } int main () { memset(f,-0x3f,sizeof(f)); int x,y,z; n = read(); m = read(); for(int i=1;i<n;i++) { x = read(); y = read(); z = read(); e[x].push_back( (E){y,z} ); e[y].push_back( (E){x,z} ); } DFS(1,0); cout<<f[1][m]; return 0; }

如果树形背包不太熟练的人,像我,很有可能发现自己的代码交上去WA了或TLE了,然而这种题就算下载数据再小也是基本没法调试的,建议WA或TLE的同学看看下面这三条:

1、由于树形背包是已经滚掉一维的(枚举到当前第几个儿子),我们枚举j要倒着枚举,因为我们一直用小的 j 更新大的 j ,如果先更新了小的 j ,可能会出现重复选取的情况。

2、再个就是枚举 v 中的黑点 k ,直觉上来看按说看似这层正着倒着都行,因为 j 是倒着枚举的,比 j 大的状态是已经合并了 v 子树的(上一层状态),比 j 小的状态一定是没有合并 v 子树的(本层状态),用 f[u][jk] 去更新 f[u][j],肯定是用没有合并的状态去更新合并后的状态。但是,这题得考虑个东西:当 k=0 的情况,会出现这么一种转移:

f[u][j]=max(f[u][j],f[u][j]+f[v][k]+val)

你以为你的 f[u][jk] 是上一层的状态?它可能已经被更新成这一层了!所以 f[u][j] 确实需要先转移一下,否则被新的一层更新掉之后,你就再也找不到这个状态了,而且不光找不到,还会把本层的 f[u][j] 进行重复转移,与上述第一条的原理类似,相当于重复选取,只不过看上去 k=0 没有选黑点,但是白点也是有贡献的啊!

所以,在做DP题尤其像为了这种省空间的背包题时,要滚掉一维的话细节是要仔细考虑的,否则丢掉上一层状态同层状态互相转移是经常会发生的,如果实在认怂,就开成 f[u][j][0/1] 这样就不用担心上述 12 的枚举顺序了,不过貌似也没人这么做。

3、洛谷新增加了一组数据,好多份题解都TLE了,但不代表他们代码就是错的,因为树形背包题随机数据上述写法是很快的。但是有一种数据可以卡掉:一条链。如果每次j都枚举到 siz[u] 的话,复杂度确实是有问题的,为了让复杂度成为树形背包中常说的那句:“任意两个点在其LCA处合并”,我们不能提前处理出子树的 siz,而是边做背包边合并,这样可以保证两个点会在不同的儿子子树之间合并。

这种写法由于是将做差转移改为了求和转移,我们也不用考虑 f[u][jk] 的状态不存在的情况了,就不用赋初值了。仿佛更好写了。

下面是AC代码:

#include <bits/stdc++.h> using namespace std; const int inf=0x3f3f3f3f; int n,m; struct E{ int to,we; }; vector <E> e[N]; int siz[N]; int f[2333][2333]; inline int read() { int sum = 0, f = 1; char c = getchar(); while(c<'0' || c>'9') { if(c=='-') f = -1; c = getchar(); } while(c>='0'&&c<='9') { sum = sum * 10 + c - '0'; c = getchar(); } return sum * f; } inline int MIN(int aa,int bb) { return aa<bb?aa:bb; } void DFS(int u,int fa) { siz[u] = 1; for(int i=0;i<e[u].size();i++) { int v = e[u][i].to, w = e[u][i].we; if(v==fa) continue; DFS(v,u); for(int j=siz[u]; ~j; --j) { if(m<j) continue; for(int k=MIN(m-j,siz[v]); ~k; --k) { int val = w * ( (n-m-siz[v]+k)*(siz[v]-k) + (m-k)*k ); f[u][j+k] = max( f[u][j+k], f[u][j] + f[v][k] + val ); } } siz[u] += siz[v]; } } int main () { int x,y,z; n = read(); m = read(); for(int i=1;i<n;i++) { x = read(); y = read(); z = read(); e[x].push_back( (E){y,z} ); e[y].push_back( (E){x,z} ); } DFS(1,0); cout<<f[1][m]; return 0; }

如果是像我这样树形背包不熟练的同学,建议将每次WA的原因找出来,而不是乱改了一番AC了之后就走了。否则如果理解不是很清晰的话(这种题细节还是蛮多的说),下次再遇到此类的题,一遍AC就像冒险一样。


__EOF__

本文作者枫叶晴
本文链接https://www.cnblogs.com/maple276/p/12870572.html
关于博主:菜菜菜
版权声明:呃呃呃
声援博主:呐呐呐
posted @   maple276  阅读(243)  评论(0编辑  收藏  举报
编辑推荐:
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
点击右上角即可分享
微信分享提示