树形 DP:答案与根无关,任取度数大于 1 的节点为根,状态记录根到该点路径上最后一个有色节点的颜色(黑/白/尚无),自底向上在给该点着色与沿用上方颜色之间取最小值。
OJ: roj
题目 ID: 1583
难度:提高
标签:动态规划树形 DP
创建: 2026-09-30 20:18
更新: 2026-10-07 13:50
形式化题目
给定一棵无根树
选择一个度数大于 1 的节点作为根
- 根到
的简单路径 上至少有一个有色节点(叶子自身着色也算); 上最后一个有色节点的颜色恰好等于 。
求满足上述要求的最少着色节点数
样例:
graph TD
n5(("5(根,黑)")) --- n4["4"]
n5 --- n2["2(白)"]
n5 --- n3["3"]
n4 --- n1["1"]
观察三条根到叶路径:路径 2。
正解
思路
从判定模型说起。 着色方案是否合法,对每片叶子只看一件事:根到它的路径上最后一个有色节点(下称"责任节点")是否存在、颜色是否为
把"最后颜色"做成状态。 树上"向上最近的特殊点"类问题的标准套路是自底向上树形 DP。固定节点
其中
转移。 对叶子(要求责任节点颜色为
对内部节点
- 不给
着色:子树入状态原样沿用,代价 ; - 给
着色:付出 1 个点, 成为子树内一切叶子的"上方最后色点",孩子的状态全部换成 的颜色,代价 , 枚举黑、白两种取值。
取三者最小即得
根的选取。 题目只允许度数大于 1 的节点作根,而最优值与根的选取无关。直观上,着色集合本身与根无关,换根只是改变每片叶子"最后一个有色节点"落在谁头上;我们用对照实验确认了这一性质:
答案为
| 节点 | 结构 | 继承黑 | 继承白 | 尚无色 |
|---|---|---|---|---|
| 1(叶, |
匹配则 0 | 0 | 1 | 1 |
| 2(叶, |
匹配则 0 | 1 | 0 | 1 |
| 3(叶, |
匹配则 0 | 0 | 1 | 1 |
| 4 | 子:1 | 0 | 1 | 1 |
| 5 | 子:2, 3, 4 | 1 | 2 | 2 |
观察两处关键:节点 4 在"继承黑"列取 0——不给 4 着色、沿用上方黑色即可满足
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 20:18
# update_at: 2026-09-30 20:25
from collections import deque
import sys
BLACK, WHITE, NONE = 0, 1, 2 # dp 状态:根到 u 路径上最后一个有色节点的颜色,NONE = 上方还没有有色节点
def min_coloring(adj: list[list[int]], color: list[int], root: int) -> int:
"""以 root 为根做树形 DP,返回让所有叶子 c_u 都成立的最少着色数。"""
parent = [0] * len(adj)
parent[root] = -1
order = [root]
q = deque([root])
while q: # 迭代建先父后子的顺序:深链也不爆栈
u = q.popleft()
for v in adj[u]:
if not parent[v]:
parent[v] = u
q.append(v)
order.append(v)
dp: dict[int, tuple[int, int, int]] = {}
for u in reversed(order):
kids = [v for v in adj[u] if v != parent[u]]
if not kids: # 叶子:上方颜色不合要求就只能自己着色,上方没色更得着色
dp[u] = (0 if color[u] == BLACK else 1,
0 if color[u] == WHITE else 1,
1)
continue
# u 不着色:子树各自沿用上方状态 u;u 着色:付 1 个代价,子树状态全换成 u 的颜色
inherit = [sum(dp[v][s] for v in kids) for s in (BLACK, WHITE, NONE)]
paint = (1 + sum(dp[v][BLACK] for v in kids),
1 + sum(dp[v][WHITE] for v in kids))
dp[u] = tuple(min(paint[0], paint[1], inherit[s]) for s in (BLACK, WHITE, NONE))
return dp[root][NONE]
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
m, n = next(data), next(data)
color = [0] * (m + 1)
for leaf in range(1, n + 1): # 前 n 个节点恰好是全部叶子,依次给出 c_u
color[leaf] = next(data)
adj: list[list[int]] = [[] for _ in range(m + 1)]
for _ in range(m - 1):
a, b = next(data), next(data)
adj[a].append(b)
adj[b].append(a)
# 题目要求根的度数 > 1;答案与根的选取无关,任取一个满足条件的节点即可
root = next(u for u in range(1, m + 1) if len(adj[u]) > 1)
print(min_coloring(adj, color, root))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-05 10:10
* update_at: 2026-10-05 10:10
*/
#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
typedef long long ll;
const int MAXN = 10005; // m <= 10^4
int m, n; // m 节点总数,n 叶子个数(节点 1..n 恰好是全部叶子)
int color[MAXN]; // color[u]:叶子 u 的 c_u,0 表示黑,1 表示白,仅 1..n 有效
vector<int> g[MAXN]; // 树的邻接表
int fa[MAXN]; // fa[u]:以 root 为根时 u 的父亲,根的 fa 为 0
int order[MAXN]; // BFS 顺序,逆序即为自底向上的树形 DP 计算顺序
// dp[u][s]:根到 u 的路径上最后一个有色节点的颜色为 s 时,u 子树全部合法所需的最少着色数
// s = 0 表示黑,s = 1 表示白,s = 2 表示路径上尚无有色节点
ll dp[MAXN][3];
// 以 root 为根建立父子关系与 BFS 顺序(迭代写法,深链也不会爆栈)
void build_order(int root) {
int head = 0;
int tail = 0;
fa[root] = 0;
order[tail] = root;
tail++;
while (head < tail) {
int u = order[head];
head++;
for (int i = 0; i < (int)g[u].size(); i++) {
int v = g[u][i];
if (v == fa[u]) continue; // 只有父亲这一个邻居已经访问过
fa[v] = u;
order[tail] = v;
tail++;
}
}
}
void solve() {
cin >> m >> n;
for (int u = 1; u <= n; u++) {
cin >> color[u];
}
for (int i = 1; i <= m - 1; i++) {
int a, b;
cin >> a >> b;
g[a].push_back(b);
g[b].push_back(a);
}
// 题目要求根节点的度数大于 1;答案与根的选取无关,任取一个满足条件的节点作为根
int root = 1;
for (int u = 1; u <= m; u++) {
if (g[u].size() > 1) {
root = u;
break;
}
}
build_order(root);
for (int idx = m - 1; idx >= 0; idx--) { // 逆 BFS 序:保证孩子先于父亲算完
int u = order[idx];
// 累加"u 不着色时,子树沿用上方颜色 s"的代价
ll sum_black = 0;
ll sum_white = 0;
ll sum_none = 0;
int child_cnt = 0; // u 的孩子个数,为 0 说明 u 是叶子
for (int i = 0; i < (int)g[u].size(); i++) {
int v = g[u][i];
if (v == fa[u]) continue;
sum_black += dp[v][0];
sum_white += dp[v][1];
sum_none += dp[v][2];
child_cnt++;
}
if (child_cnt == 0) {
// 叶子:上方颜色已经是 c_u 就不用着色;上方是另一种颜色或尚无颜色,都必须给它着色
dp[u][0] = (color[u] == 0) ? 0 : 1;
dp[u][1] = (color[u] == 1) ? 0 : 1;
dp[u][2] = 1;
} else {
// 内部节点:不着色则沿用上方状态 s;着色则付 1 个代价,孩子的状态全部换成该颜色
ll paint_black = 1 + sum_black;
ll paint_white = 1 + sum_white;
dp[u][0] = min(sum_black, min(paint_black, paint_white));
dp[u][1] = min(sum_white, min(paint_black, paint_white));
dp[u][2] = min(sum_none, min(paint_black, paint_white));
}
}
cout << dp[root][2] << endl; // 根上方没有任何有色节点,入状态是"尚无"
}
int main() {
solve();
return 0;
}复杂度
- 时间复杂度:
。BFS 建序与逆序 DP 各遍历每条边常数次,三状态的代价均为线性累加; 远低于 限制(真实数据实测最快 0.017s、最慢 0.031s)。 - 空间复杂度:
。邻接表、父子数组、访问顺序与每点一个三元组均为线性规模,实测峰值 ,远低于 。
总结
- "最后一个有色节点"就是状态:路径上的历史只有"最后颜色"会影响后续叶子,把它压缩进状态后子树问题立刻满足无后效性与最优子结构。
- "尚无色"必须独立成状态:它编码了"根到叶路径必须含色点"这一硬约束——路径一路无色时,叶子会被强制着色。
- 转移只有两个动作:给当前点着色(付 1,孩子的状态全部换成它的颜色)或不着色(沿用上方状态),配合叶子的三段式即可覆盖全部方案。
- 答案与根的选取无关,任取度数大于 1 的节点作根即可,复杂度
。