【学习笔记】树形 DP

CF1032F Vasya and Maximum Matching

首先要看出来一连通块存在唯一的最大匹配等价于该最大匹配时完美匹配(没有闲置点)。

状态就与直接求最大匹配类似了:\(f_{u,0/1/2}\) 表示 \(\text{subtree}(u)\) 内,\(i\) 是单点(与儿子、父亲均不连边)、\(i\) 与某个儿子匹配、\(i\) 与父亲匹配的方案数,转移如下:

\(f_{u,0} = \prod_{v}f_{v,0/1}, f_{u, 1} = \prod_{v} (f_{v,0}+2f_{v,1}) \times \sum_{v}\frac{f_{v,2}}{f_{v,0}+2f_{v,1}}, f_{u, 2} = \sum_{v}(f_{v,0}+2f_{v,1})\)

P10053 [CCO 2022] Bi-ing Lottery Treekets

树形 DP 好题,调了好久。

暴力

全排列放置先后顺序,dfs 暴搜出所有的左右情况。\(O(k ! 2^k)\)

链(所有节点都没有左子节点)

\(k\) 个球必然放到末端的 \(k\) 个位置。发现每一种放置方案放置顺序都是确定的。再加上每一个球都是放在 \(\ge (s_i = max(n-k+1, dep_{i}))\) 的位置。那么我们按照限制从严到宽地放:那么答案就是 \(\prod_{i=1}^n (n-s_i+1-(i-1))\)

正解

\(siz_i\) 表示 \(i\) 子树大小,\(cnt_i\) 表示子树内释放的球个数之和。

\(f_{i,j}\) 表示 \(substree(i)\) 从父亲方向落入了 \(j\) 个球的方案数。其中 \(j \le min(K - cnt_i,siz_i-cnt_i)\)

这样还是比较 ambiguous,这 \(j\) 个球的顺序,与子树内的球的相对顺序亟待解决。

KEY:我们在处理 \(f_i\) 的时候确定 \(i\) 处释放球的先后顺序(包含与 \(j\) 的相对顺序,但是这 \(j\) 个被我们看做相同的)。这样的话,\(f_{i,j}\) 意在只考虑 \(j\) 个最终位置点集的方案数。

即先确定每一组所占的位置集合,然后再在组内排列确定顺序。

开始考虑转移:

\(j\) 落入 \(i\) 时,优先走的方向已经确定(即 \(i\) 是左儿子还是右儿子)。

设进来到 \(i\),下一个优先走的点是 \(t\),另一个为 \(p\),没有就是 \(0\)

开始枚举:释放的 \(a_i\) 个球的方向:枚举到 \(t\) 的个数 \(k\) 个,\(k \le min(a_i, siz_t-cnt_t)\),剩下 \(a_i - k\) 落到 \(p\) 里。

我们现在在做的事情在决定这些点最终会在哪个子树内,即我们在枚举最终状态,确定状态后再去确定顺序。即 KEY 所要表达的。

接续的是 \(j\) 个球的去向:落入 \(t\) 的个数为 \(y = min(j, siz_t-cnt_t-k)\)\(t\) 可能满),落入 \(p\)\(j - y\) 个。

由于这 \(j\) 个没有区别,选出 \(y\) 的方案数为 \(1\)

\(a_i\) 中选择 \(k\) 个落入 \(t\)\(p\) 的就确定了);从 \(k+y\) 个中区分出 \(k\) 个是从 \(i\) 释放的(同时确定先后顺序);从落到 \(p\) 中的 \((a_i-k+j-y)\) 个中选择 \((a_i - k)\)

枚举出的 \(j,k\) 对应的方案数:\(C_{a_i}^kA_{k+y}^kA_{a_i-k+j-y}^{a_i-k}f_{t,k+y}f_{p,a_i-k+j-y-\operatorname{if\_special}}\)。这里
\(\operatorname{if\_special}\) 即如果 \(t\) 满了,会有一个留在 \(i\),剩下的才会再流到 \(p\) 里。

\(ans = f_{1, 0}\)。边界:空叶子 \(f_{0,0} = 1\),非法 \(f_{i, j} = 0\)

反思

这题为不重不漏的思考方式提供了很好的例子,只有我们明确了这点才好明确状态进行转移。

P9021 [USACO23JAN] Subtree Activation P

整体上有个欧拉回路构造大的基础。太妙的 DP。
将每个结点当做一个状态:子树全亮,子树外全不亮
初始状态是 \(S\)
\(w(u, fa[u]) = siz[fa[u]] - siz[u]\)
\(w(u, S) = siz[u]\)

那就是找到一个从 \(S\) 开始到 \(S\) 结束,经过所有结点的最短欧拉路径

欧拉路径->度数为偶数
\(S\)\(S\) 末->与 \(S\) 联通
每条边选择 \(0/1/2\)
(走出去后子树内可能有些留着,从子树外某个地方通过 S 进去可能更优,所以不会走 3 次)

\(f[u][1/0][1/0]\) 表示当前在 \(u\) 状态,是/否与 \(S\) 联通,是/否度数为奇数

初始:
\(f[u][1][0] = 2 * siz[u]\)
\(f[u][1][1] = siz[u]\)
\(f[u][0][0] = 0\)
转移:
\(u\) 的一个儿子 \(v\)
\((u, v)\) 选择 \(0\)\(f[u][i][j] + f[v][1][0] \to f[u][i][j]\)
\(1\)\(f[u][i][j] + f[v][o][1] + w \to f[u][i|o][j \oplus 1]\)
\(2\)\(f[u][i][j] + f[v][o][0] + 2w \to f[u][i|o][j]\)
\(ans = f[1][1][0]\)
时间复杂度 $O(n) $

[JSOI2018] 潜入行动

tag:树上背包

考虑状态的设计:考虑树上的关系。在不考虑父亲节点的前提下,一个点关键在于是否放监听器和是否被监听。
简陋的转移:

\(f[i][j][0/1][0/1]\)
表示 i 这棵子树内选了 j 个,不考虑父亲节点
这个点有没有放,这个点有没有被覆盖
\(f[i][j][0][0] \gets f[i][j-siz][0][0] * f[v][siz][0][1]\)
\(f[i][j][0][1] \gets f[i][j-siz][0][0] * f[v][siz][1][1]\)
\(f[i][j][0][1] \gets f[i][j-siz][0][1] * f[v][siz][0][0/1]\)
\(f[i][j][1][0] \gets f[i][j-siz][1][0] * f[v][siz][0][0/1]\)
\(f[i][j][1][1] \gets f[i][j-siz][1][0] * f[v][siz][1][0/1]\)
\(f[i][j][1][1] \gets f[i][j-siz][1][1] * f[v][siz][0/1][0/1]\)

注意当 \(j= 0\) 时直接赋值 \(f_{i,1,1,0},f_{i,0,0,0} \gets 1\)
树上背包给我们一种感觉,就是每次当前 \(f_i\) 的信息,是已加入的所有儿子 \(v\) 以及 \(i\) 共同构成的。进而利用这个状态信息加入下一个儿子进行更新。这就与 [NOIP2022 建造军营] 类似了。

[NOIP2022]建造军营

tag:树形DP
首先就是简单的缩点,这不是这里的重点。在缩点后,就是一个树上的统计。在打模拟赛时的想法:
方向1:枚举点的选择并统计,拿到部分分。
方向2:统计钦定边个数确定的点选择方案。这似乎很难做,我并没有从这里很好地挣脱出来。
方向3:树形 DP 带着价值统计。想的时候状态有问题,变成了 \(f_{i,j}\) 表示 \(i\) 这棵子数里选择连续的 \(j\) 个点。
其实我们不要刻意去关注这些点选出来的构型,我们关心的是点的选择与否以及边的确定与否。
那么设计状态 \(f_{i,0/1}\) 表示 \(i\) 这棵子树里是否有点的方案数。
考虑转移。类似上一题,我们每次加入一棵子树 \(v\),有转移:
\(f_{i,1} \gets f_{i,0} \times f_{v,1} + f_{i,1} \times (2 \times f_{v,0} + f_{v,1})\)
\(f_{i,0} \gets f_{i,0} \times (f_{v,0} \times 2)\)
初始化:
\(f_{i,0} \gets 2^{\text{边双联通分量 i 中边的数量}}\)
\(f_{i,1} \gets 2^{\text{边双联通分量 i 中边的数量}} \times (2^{\text{边双联通分量 i 中点的数量}}-1)\)
所以对于树上选择点的问题,可以考虑树形 DP。
同时可以利用这种加入子树的方式转移。
同时注意记进答案的细节:\(f_{u,1}\)\(ans\) 的贡献,要钦定往父亲的那边不选,否则会重复统计。

[SHOI2015] 聚变反应炉

tag:树上背包

从部分分开始。对于 \(c\) 等于 \(1\)\(0\),通过手磨数据发现无论顺序如何,先处理 \(c\)\(1\) 的都是最优的。
故这 \(50\) 分直接贪心地先取 \(c\)\(1\) 的。

受贪心的启发,我们考虑点亮顺序。

定义 \(dp[u][0/1]\) 表示自己比父亲激发早还是晚,并将 \(u\) 这棵子树染完的最小花费;

定义辅助数组 \(g[s]\) 表示父节点 \(u\) 接受儿子们值为 \(s\) 的能量的最小花费。

那么我们就有转移:

点我展开看代码
void dfs(int u,int fa){
    for(int v : e[u])if(v ^ fa)dfs(v,u);
	int sum = 0,now = 0;
	memset(g,0x3f,sizeof g);
	g[0] = 0;
	for(int v : e[u])if(v ^ fa){
		for(int i = sum;~i;--i)
			g[i+c[v]] = min(g[i+c[v]],g[i]+f[v][0]),g[i] += f[v][1];
	    sum += c[v];
	}
	f[u][1] = f[u][0] = inf;
	for(int i = 0;i<=sum;++i)
		f[u][0] = min(f[u][0],max(g[i],g[i]-i+d[u])),
		f[u][1] = min(f[u][1],max(g[i],g[i]-i+d[u]-c[fa]));
}

[POI 2016] Hydrorozgrywka

若果设计 \(f_{u}=0/1\) 表示先手必败/先手必胜,那么在该点的决策被忽略了:是否走进这棵子仙人掌。

\(f[u] = 0/1/2\)

0: 进入到子仙人掌后先手必败;

1: 能够以进入子仙人掌中的方式改变先后手,达到必胜目的;

2: 能够以选择是否进入子仙人掌的方式改变先后手,达到必胜目的 。

考虑转移:

方点(从根出发在环上走一圈):

\(f[v] = 0\),忽略,直接不走进去走下一步;

\(f[v] = 1\),当前先手必胜;

\(f[v] = 2\),当前先手必胜。

  • 在环上往左/右走的第一个 \(f[v] != 0\)\(v\),根据奇偶性判断是否必胜
    ,若满足:\(f[u] = 1\),否则 \(f[u] = 0\)

  • 剩余情况,若所有的 \(f[v] = 0\), 奇环:\(f[u] = 2\), 偶环:\(f[u] = 0\)

圆点

  • 若存在 f[v] = 1,f[u] = 1

  • 否则 \(sum = \Sigma_v [f[v] = 2]\)\(sum\) 为奇数 \(f[u] = 2\)\(sum\) 为偶数 \(f[u] = 0\)

点我展开看代码
#define rep(i, l, r) for(int (i)=(l);(i)<=(r);++(i))
#define per(i, r, l) for(int (i)=(r);(i)>=(l);--(i))
using namespace std;
namespace IO{//读写
}using namespace IO;
const int N = 1e6 + 10;
int n,m;
vector<int> e[N],g[N];
int idx;
int dfn[N],low[N],tim;
int st[N],top;
void tarjan(int u){
	dfn[u] = low[u] = ++tim;
	st[++top] = u;
	for(int v : e[u]){
		if(!dfn[v]){
			tarjan(v);
			low[u] = min(low[u], low[v]);
			if(low[v] == dfn[u]){
				g[u].emplace_back(++idx);
				int t = 0;
				while(t ^ v){
					t = st[top--];
					g[idx].emplace_back(t);
				}
			}
		}else low[u] = min(low[u], dfn[v]);
	}
}
int pl[N],pr[N];
int F[N];//Down
int G[N];//Up
int dp[N];//all
int cnt[3][N];
void dfs(int u){
	for(int v : g[u])dfs(v);
	if(u <= n){
		for(int v : g[u])if(F[v])++cnt[F[v]][u];
		if(cnt[1][u])F[u] = 1;
		else F[u] = (cnt[2][u] & 1 ? 2 : 0);
	}else{
		pl[u] = find_if(g[u].begin(), g[u].end(), [](int x){ return F[x]; })-g[u].begin();
		if(pl[u] == (int)g[u].size())F[u] = (g[u].size() & 1 ? 0 : 2);
		else{
			pr[u] = find_if(g[u].rbegin(), g[u].rend(), [](int x){ return F[x]; })-g[u].rbegin();
			F[u] = (pr[u] & 1) || (pl[u] & 1);
		}
	}
}
int nxt[N];
void redfs(int u){
	if(u <= n){
		if(cnt[1][u] + (G[u] == 1))dp[u] = 1;
		else dp[u] = ((cnt[2][u] + (G[u] == 2)) & 1 ? 2 : 0);
		for(int v : g[u]){
			if(cnt[1][u] + (G[u] == 1) - (F[v] == 1))G[v] = 1;
			else G[v] = ((cnt[2][u] + (G[u] == 2) - (F[v] == 2)) & 1 ? 2 : 0);
			redfs(v);
		}
	}
	else{
		//把 father 的信息放到 u 上,算出 G[v],为换根提供数据
		for(int i = (int)g[u].size()-1, pre = (int)g[u].size();~i;--i){
			nxt[i] = pre;
			if(F[g[u][i]])pre = i;
		}
		if(G[u])pl[u] = -1;//在下方第 3 种情况种算两点之间距离奇偶用 
		/*
		pre 为上一个 F 不为 0 的位置 
		nxt 为下一个 F 不为 0 的位置 
		*/
		for(int i = 0, pre = (G[u] ? -1 : -2);i<(int)g[u].size();++i){
			int v = g[u][i];
			if(pre == -2 && nxt[i] == (int)g[u].size()){
				G[v] = (g[u].size() & 1 ? 0 : 2);//方点周围全为 0
			}else if(pre == -2){
				G[v] = !((pr[u] + i) & 1) || !((nxt[i] - i) & 1);
				//不全为 0 
				//i 到 pr/nxt 长度为偶数(走到那里时先后手不变) 
			}else if(nxt[i] == (int)g[u].size()){
				G[v] = !((i - pre) & 1) || ((g[u].size() - i + pl[u]) & 1);
			}else{
				G[v] = !((i - pre) & 1) || !((nxt[i] - i) & 1);
			}
			if(F[v])pre = i;
		}
		for(int v : g[u])redfs(v);
	}
}
int main(){
	read(n, m);
	rep(i, 1, m){
		int u, v; read(u, v);
		e[u].emplace_back(v);
		e[v].emplace_back(u);
	}
	idx = n;
	tarjan(1);
	dfs(1), redfs(1);
	rep(i, 1, n)puts(dp[i] ? "1" : "2");
	return 0;
}

[SDOI2010] 城市规划

仙人掌 DP 树上独立集:

这篇题解讲得很详细(来源: q779)。

这篇题解讲得很好。

[CEOI2007] 树的匹配 Treasury

树上独立集

这篇题解讲得很详细。

注意状态的设定,同“聚变反应炉”类似,我们在关心父子关系时,将其记入状态。

[POI2008] MAF-Mafia

基环树 DP

其实这题正解是贪心,但是基环树的\(dp\)是可做的。

两种方法:

  • 对子树做完后再在环上做一遍\(dp\)
  • 拆环做\(dp\)
    具体实现:
点我展开看代码
// 基环树拆环 DP 
const int N = 1000010,inf = 0x3f3f3f3f;
int n,to[N],d[N];
vector<int> e[N];
int mx,mn;// 最大存活人数 最小存活人数 
int cir[N],len;
bool incir[N];
int f[N][2];
void dfs(int u){
	f[u][0] = 0, f[u][1] = 1;
	bool flag = 1;
	for(int v : e[u])if(!incir[v]){
		dfs(v); flag = 0;
		// 这里 f[u][0/1] 是已经扫过的子树得到的结果 
		f[u][0] = max(f[u][0],f[u][1] - 1) + max(f[v][0],f[v][1]);
		// 那么这里 f[u][1] - 1 是因为这一枪让当前的 v 来崩 
		if(f[v][0] != -inf)f[u][1] += f[v][0];
		else f[u][1] = -inf;// 若存在一个儿子,他必须存活,那么当前 u 不能存活 
	}
	if(flag)f[u][0] = -inf; // 叶子结点一定存活 
}
void dfs(int u,int del,int rt){
	if(u != del)f[u][0] = 0, f[u][1] = 1;
	bool flag = 1;
	for(int v : e[u]){
		if(u == del && v == rt)continue;
		dfs(v,del,rt), flag = 0;
		f[u][0] += max(f[v][1],f[v][0]);
		if(f[v][0] == -inf)f[u][1] = -inf;
		else f[u][1] += f[v][0];
	}
	if(flag && u != del)f[u][0] = -inf;
}
void solve(int s){
	len = 0;
	bool flag = 1;
	for(int now = s;d[now];now = to[now])
		cir[++len] = now, incir[now] = 1, d[now] = 0, flag &= ((int)e[now].size() == 1);
	if(flag){// 只有一个环 
		++mn;
		if(len == 1)--mn;// 只有一个自环 
		mx += len / 2;
		return ;
	}
	if(len == 1){// 以自环为根的一棵树 
		dfs(s);
		mx += max(f[s][1] - 1, f[s][0]);// 由于自环自己必死,故 -1 
		return ; 
	}
	// 删 s -> to[s] 这条边 
	// 这里令 to[s] 必死 
	int res = -inf;
	f[to[s]][1] = -inf;
	f[to[s]][0] = 0;
	dfs(s,to[s],s);
	res = max(res,f[s][0]);
	res = max(res,f[s][1]);
	// 令 to[s] 必活 
	f[to[s]][1] = 1;
	f[to[s]][0] = -inf;
	dfs(s,to[s],s);
	res = max(res,f[s][0]);
	mx += res; 
}
signed main(){
	scanf("%d",&n);
	for(int i = 1;i<=n;++i)scanf("%d",&to[i]),++d[to[i]],e[to[i]].push_back(i);
	queue<int> q;
	for(int i = 1;i<=n;++i)if(!d[i])q.push(i),++mn;
	while(!q.empty()){
		int now = q.front(); q.pop();
		if(--d[to[now]] == 0)q.push(to[now]);
	}
	for(int i = 1;i<=n;++i)if(d[i])solve(i);
	printf("%d %d",n-mx,n-mn);
	return 0;
}

反思

  1. 数据范围较小的,用暴力;对于具有特殊性质的数据,找规律
  2. 基环树 dp:① 拆环做树形dp;② 先树上再环上

Another Letter Tree - 暑期训练37

树形dp,差分

这题的关键在于看清了答案是可差分的。可以 \(O(nm^2)\) 地算出从根节点到点 \(u\),匹配模式串的范围为 \([l,r]\) 的方案数。答案统计:对于 \(a \to b\) 的路径询问,记录 \(l_i,r_i\) 表示前半段匹配了 \([1,l_i]\),后半段匹配了 \([r_i,m]\) 的方案数。\(l,r\) 的计算就是将起点在 \(lca\) 以上的部分减去,并将起点在 \(lca\) 下,终点在 \(lca\) 上的乘一下减去。
从模式串的长度出发,我们得到了一个较为完备的信息 \(f,g\),进而可以只记录到根节点的信息,可差分。

P5669 [POI 2016] Nadajniki

逆天大心脏选手,考试狂轰滥炸 1h30min 写了个比题解稍微复杂的 AC code。

点我展开看代码
#define rep(i, l, r) for(int (i)=(l);(i)<=(r);++(i))
#define per(i, r, l) for(int (i)=(r);(i)>=(l);--(i))
const int N = 2e5 + 10;
int n;
vector<int> e[N];
int f[N][3][3][4];
int g[3][3][4];
inline void upd(int &x,int y){ (x > y) && (x = y); }
void dfs(int u,int fa){
	memset(f[u], 0x3f, sizeof f[u]);
	rep(o, 0, 3)
		f[u][0][0][o] = 0, f[u][1][0][o] = 1, f[u][2][0][o] = 2;
	for(int v : e[u])if(v ^ fa){
		dfs(v, u);
		rep(i, 0, 2)rep(j, 0, 2)rep(k, 0, 3)g[i][j][k] = f[u][i][j][k];
		memset(f[u], 0x3f, sizeof f[u]);
		rep(x, 0, 2){
			rep(i, 0, 2)rep(j, 0, 2)rep(k, 0, 3)
				upd(f[u][2][min(2,x+i)][0], g[2][x][0] + f[v][i][j][k]);
			rep(i, 1, 2)rep(j, 0, 2)
				upd(f[u][1][min(2,x+i)][0], g[1][x][0] + f[v][i][j][0]);
			rep(j, 1, 2)rep(k, 0, 3)
				upd(f[u][1][x][0], g[1][x][0] + f[v][0][j][k]);
			rep(k, 1, 2)
				upd(f[u][1][x][0], g[1][x][0] + f[v][0][0][k]);
			rep(j, 0, 2)rep(k, 0, 3){
				upd(f[u][0][min(2, x+2)][k], g[0][x][k] + f[v][2][j][0]);
				upd(f[u][0][min(2, x+1)][k], g[0][x][k] + f[v][1][j][0]);
			}
			rep(k, 0, 3)rep(any, 0, 3)
				upd(f[u][0][x][min(k, 2)], g[0][x][k] + f[v][0][2][any]);
			rep(k, 0, 3){
				upd(f[u][0][x][min(k, 1)], g[0][x][k] + f[v][0][1][1]);
				upd(f[u][0][x][min(k, 1)], g[0][x][k] + f[v][0][1][2]);
			}
			rep(k, 0, 3)
				upd(f[u][0][x][0], g[0][x][k] + f[v][0][0][2]);
		}
	}
}
int main(){
	read(n);
	rep(i, 1, n-1){
		int u, v; read(u, v);
		e[u].emplace_back(v),e[v].emplace_back(u);
	}
	int st = 1;
	dfs(st, 0);
	int ans = 1e9;
	rep(a, 0, 2)rep(b, 0, 3){
		upd(ans, f[st][1][a][b]);
		upd(ans, f[st][2][a][b]);
		upd(ans, f[st][0][2][b]);
	}
	upd(ans, f[st][0][1][1]);
	upd(ans, f[st][0][1][2]);
	upd(ans, f[st][0][0][2]);
	cout << ans << endl;
	return 0;
}
posted @ 2025-05-13 21:26  Luzexxi  阅读(31)  评论(0)    收藏  举报