树形 DP 求树的最小点覆盖:按 BFS 序自底向上递推 f[u][0/1],每个点选放或不放士兵,保证每条边至少一端被覆盖。
OJ: roj
题目 ID: 3174
难度:普及+/提高-
标签:动态规划树形 DP树最小点覆盖python
创建: 2026-10-01 23:08
更新: 2026-10-10 13:00
形式化题目
给定一棵
正解
思路
朴素做法为什么不行。 每个节点只有"放 / 不放"两种选择,若直接枚举所有
利用树的结构做 DP。 树的关键性质是:以根为界,整棵树可以分解成一棵棵互不相交的子树,一条边
: 不放士兵时, 的子树内所有边被覆盖的最少士兵数; : 放士兵时, 的子树内所有边被覆盖的最少士兵数(先计入 自己的 个)。
叶子节点没有子边,所以
- 若
不放(状态 ),边 只能靠 覆盖, 必须放: ; - 若
放(状态 ),这条边已经被 覆盖, 放不放都行,取较小者: 。
儿子之间的选择互相独立(每条父子边只涉及这两个节点),所以直接对儿子求和即可。答案为
以样例 1(
graph TD
A["节点 0<br/>✅ 放士兵"] --> B["节点 1"]
B --> C["节点 2"]
B --> D["节点 3"]
style A fill:#ffd6e7,stroke:#d63384
节点 0 上的一个士兵同时观察到
| 节点 | 计算依据 | ||
|---|---|---|---|
| 2(叶) | 叶子初始化 | ||
| 3(叶) | 叶子初始化 | ||
| 1 | 不放则两个儿子必须放;放则儿子随意 | ||
| 0(根) | 同上 |
根节点取
代码
/**
* 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-10 13:00
* update_at: 2026-10-10 13:00
*/
// 战略游戏:树形 DP 求最小点覆盖(多组数据,读到 EOF)
#include <cstdio>
#include <vector>
using namespace std;
typedef long long ll;
void solve_one(int n) {
vector<vector<int> > children(n);
char tok[64];
int root = -1;
for (int line = 0; line < n; line++) {
int u, c;
scanf("%63s", tok);
sscanf(tok, "%d:(%d)", &u, &c);
if (root < 0) {
root = u; // 第一行描述的就是树根
}
for (int i = 0; i < c; i++) {
int v;
scanf("%d", &v);
children[u].push_back(v);
}
}
// 先做一次 BFS 得到「父先于子」的访问顺序
vector<int> order;
order.push_back(root);
for (int head = 0; head < (int)order.size(); head++) {
int u = order[head];
for (int i = 0; i < (int)children[u].size(); i++) {
order.push_back(children[u][i]);
}
}
vector<ll> dp0(n, 0), dp1(n, 1); // dp0:u 不放士兵;dp1:u 放士兵
for (int t = (int)order.size() - 1; t >= 0; t--) {
int u = order[t];
for (int i = 0; i < (int)children[u].size(); i++) {
int c = children[u][i];
dp0[u] += dp1[c]; // u 不放,每条 (u,c) 只能靠 c 覆盖
dp1[u] += dp0[c] < dp1[c] ? dp0[c] : dp1[c];
}
}
ll ans = dp0[root] < dp1[root] ? dp0[root] : dp1[root];
printf("%lld\n", ans);
}
int main() {
int n;
bool any = false;
while (scanf("%d", &n) == 1) {
solve_one(n);
any = true;
}
(void)any;
return 0;
}#!/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-07-05 21:47
# update_at: 2026-07-05 21:47
import sys
def min_soldiers(children: list[list[int]], root: int) -> int:
"""树形 DP:返回覆盖所有边的最少士兵数(即树的最小点覆盖)。"""
order = [root]
for u in order: # 队列在遍历中增长,得到从根出发的 BFS 序
order += children[u]
# dp0[u]:u 不放士兵时子树的最小代价,此时每条 (u,c) 只能靠 c 覆盖
# dp1[u]:u 放士兵时子树的最小代价,先记上自己这 1 个
dp0 = [0] * len(order)
dp1 = [1] * len(order)
for u in reversed(order): # 逆 BFS 序保证算 u 时所有儿子已算完
for c in children[u]:
dp0[u] += dp1[c]
dp1[u] += min(dp0[c], dp1[c])
return min(dp0[root], dp1[root])
def solve() -> None:
data = iter(sys.stdin.buffer.read().split())
out: list[str] = []
while (n_tok := next(data, None)) is not None: # 多组数据,读到 EOF 为止
n = int(n_tok)
children: list[list[int]] = [[] for _ in range(n)]
root = -1
for _ in range(n): # 每行:节点编号:(子节点数) 子节点…,首 token 形如 0:(2)
head = next(data) # bytes
u, cnt = map(int, head[:-1].split(b':('))
root = u if root < 0 else root # 第一行描述的就是树根
children[u] = [int(next(data)) for _ in range(cnt)]
out.append(str(min_soldiers(children, root)))
print('\n'.join(out))
if __name__ == "__main__":
solve()代码里有两个值得注意的实现细节:
- 输入的节点行是
0:(2) 1 2这种格式,第一个 token 形如0:(2),同时携带节点编号和子节点数,用head[:-1].split(b':(')一次拆出,再逐个读入子节点。 order列表既当队列又当结果:遍历它的同时往尾部追加新节点,得到 BFS 序;随后reversed(order)自底向上递推,整个过程只有次循环。
复杂度
- 时间复杂度:
。每个节点在 BFS 序中恰好出现一次,递推时每条父子边被合并一次;多组数据之间各自独立,总和不超过数据规模上限。 - 空间复杂度:
。 children邻接表、BFS 序与两个 DP 数组都是线性大小。
同样的算法在 C++ 中的落地方式几乎一样:链式前向星存树、数组代替 children、手写队列求 BFS 序后逆序递推,没有任何 Python 特有的瓶颈,
总结
- 本题是树的最小点覆盖的经典模型:“每条边至少一端被选中”。
- 树形 DP 的固定套路:状态定义在"以
为根的子树"上,且把" 自己的选择"并入状态( ),转移时父子边的要求由这两个状态天然表达。 - 实现上用 BFS 序 + 逆序遍历代替递归,既得到自底向上的计算顺序,又避免深链在 Python 下爆递归栈。
- 与之孪生的问题是树的最大独立集(每条边至多一端被选中)和树的最大匹配,状态设计完全相同,只是转移的约束方向相反,可以对比练习。
图示解析
状态转移的方向
把样例 1 的 DP 递推画成自底向上的数据流,可以看清"儿子的两个状态如何汇入父亲":
graph BT
C["节点 2:f = (0, 1)"] --> B["节点 1:f = (2, 1)"]
D["节点 3:f = (0, 1)"] --> B
B --> A["节点 0:min(1, 2) = 1"]
style A fill:#ffd6e7,stroke:#d63384
style B fill:#e7f5ff,stroke:#1c7ed6
自下而上看:叶子给出初始值;节点 1 汇总两个儿子——"不放"状态被迫接收两个儿子的"放"值之和
