经典树上最小点覆盖:设 dp[u][0/1] 表示节点 u 不放/放士兵时覆盖子树所有边的最少士兵数,通过树形 DP 自底向上转移。
OJ: roj
题目 ID: 1578
难度:提高
标签:树形 DP动态规划最小点覆盖
创建: 2026-09-30 19:44
更新: 2026-10-07 13:50
形式化题目
给定一棵包含
求最少需要放置多少个士兵,才能使得树上的所有边都被至少一个士兵瞭望到。即求树的最小点覆盖(Minimum Vertex Cover)。
样例说明
输入样例:
4
0 1 1
1 2 2 3
2 0
3 0对应一棵以节点
- 节点
连接 - 节点
连接 - 节点
没有子节点
树的边集为
graph TD
0((0)) --- 1((1 [士兵]))
1 --- 2((2))
1 --- 3((3))
正解
思路
本题是树上经典的最小点覆盖问题。根据题意,对于树上的任意一条边
我们可以任选一个节点(如输入中入度为 0 的节点,或任意指定根)作为树根,将无向树转化为有向有根树。
1. 朴素思考与瓶颈
若采用搜索回溯枚举每个节点选或不选,共有
观察树的局部无后效性:对于以
2. 状态定义
对于以
- 定义
表示:节点 不放置士兵时,覆盖以 为根的子树内所有边所需的最少士兵数。 - 定义
表示:节点 放置士兵时,覆盖以 为根的子树内所有边所需的最少士兵数。
3. 状态转移方程
对于节点
-
当
不放置士兵( )时: 连接边 必须被覆盖,由于端点 没有士兵,端点 处必须放置士兵! 此时子节点 只能取状态 。累加所有子节点贡献: -
当
放置士兵( )时: 边 已经由端点 处的士兵覆盖。此时子节点 处放不放士兵均可覆盖边 ,为了让子树所需总士兵数最少,子节点 应取两种情况的较小值: (初始时,表示在节点 放置了 1 名士兵)。
4. 计算顺序与实现细节
由于树形 DP 依赖于子节点的结果,我们需要按照自底向上(即树的后序遍历或拓扑逆序)依次转移。 在 Python 中,如果树深度较大,使用深递归容易导致爆栈。可以使用栈进行自顶向下收集遍历序列(模拟 DFS),随后将遍历序列反转,得到逆拓扑序,直接做自底向上的迭代循环。
最终整棵树的答案为:
代码
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-04-19 20:00
# update_at: 2026-04-19 20:00
import sys
from collections.abc import Iterator
def tree_min_vertex_cover(n: int, adj: list[list[int]], root: int = 0) -> int:
"""求以 root 为根的树的最小点覆盖大小。
dp[u][0]: u 处不放士兵时,覆盖 u 为根的子树所有边所需的最少士兵数。
此时 u 的所有子节点 v 处必须都放士兵。
dp[u][1]: u 处放士兵时,覆盖 u 为根的子树所有边所需的最少士兵数。
此时每个子节点 v 可以放也可以不放,取 min(dp[v][0], dp[v][1])。
使用拓扑/DFS 后序模拟树形 DP,避免深递归爆栈。
"""
order: list[int] = []
parent = [-1] * n
stack = [root]
# BFS/DFS 收集自顶向下访问顺序
while stack:
u = stack.pop()
order.append(u)
for v in adj[u]:
if v != parent[u]:
parent[v] = u
stack.append(v)
# dp[u] = [不选 u, 选 u]
dp0 = [0] * n
dp1 = [1] * n
# 逆拓扑序(自底向上)转移
for u in reversed(order):
for v in adj[u]:
if v != parent[u]:
dp0[u] += dp1[v]
dp1[u] += min(dp0[v], dp1[v])
return min(dp0[root], dp1[root])
def solve() -> None:
tokens: Iterator[int] = map(int, sys.stdin.buffer.read().split())
try:
n = next(tokens)
except StopIteration:
return
adj: list[list[int]] = [[] for _ in range(n)]
in_degree = [0] * n
for _ in range(n):
u = next(tokens)
k = next(tokens)
children = [next(tokens) for _ in range(k)]
adj[u].extend(children)
for v in children:
in_degree[v] += 1
adj[v].append(u)
# 找到树的根节点(输入中给出的父指向子,入度为 0 的即为树根)
# 若有多棵树或全双向,任意找一个作为根;这里输入给的是有向树边,入度为 0 的节点即为根
root = 0
for i in range(n):
if in_degree[i] == 0:
root = i
break
ans = tree_min_vertex_cover(n, adj, root)
print(ans)
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 09:49
* update_at: 2026-10-05 09:49
*/
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 100005; // 节点数上限(本题 N 远小于此,留足余量)
vector<int> g[MAXN]; // g[u] 存与节点 u 相邻的所有节点,无向树双向加边
int parent_node[MAXN]; // parent_node[u]:以 root 为根时 u 的父亲,根的父亲记为 -1
int order[MAXN]; // 自顶向下的遍历顺序(正序为拓扑序,逆序即自底向上)
int stk[MAXN]; // 手动栈,替代递归 DFS,避免深树爆栈
int in_degree[MAXN]; // 输入中每条边只出现一次,入度为 0 的节点即为树根
ll dp0[MAXN]; // dp0[u]:u 不放士兵时,覆盖 u 子树内所有边的最少士兵数
ll dp1[MAXN]; // dp1[u]:u 放士兵时,覆盖 u 子树内所有边的最少士兵数
int main() {
int n;
if (scanf("%d", &n) != 1) {
return 0;
}
for (int i = 0; i < n; i++) {
int u, k;
scanf("%d %d", &u, &k);
for (int j = 0; j < k; j++) {
int v;
scanf("%d", &v);
g[u].push_back(v);
g[v].push_back(u);
in_degree[v]++;
}
}
// 输入给出的边由父指向子且每条边只出现一次,入度为 0 的节点就是树根
int root = 0;
for (int i = 0; i < n; i++) {
if (in_degree[i] == 0) {
root = i;
break;
}
}
// 显式栈做一遍 DFS,得到自顶向下的访问顺序
for (int i = 0; i < n; i++) {
parent_node[i] = -1;
}
int top = 0;
stk[top++] = root;
int order_cnt = 0;
while (top > 0) {
int u = stk[--top];
order[order_cnt++] = u;
for (int i = 0; i < (int)g[u].size(); i++) {
int v = g[u][i];
if (v == parent_node[u]) {
continue;
}
parent_node[v] = u;
stk[top++] = v;
}
}
// dp 初值:不选 u 至少要 0 人;选 u 则先记上 u 自己这 1 名士兵
for (int i = 0; i < n; i++) {
dp0[i] = 0;
dp1[i] = 1;
}
// 逆拓扑序自底向上转移:
// u 不选时,边 (u, v) 只能靠 v 覆盖,故子节点必须全选,取 dp1[v];
// u 选时,子节点 v 可选可不选,取 min(dp0[v], dp1[v])。
for (int idx = order_cnt - 1; idx >= 0; idx--) {
int u = order[idx];
for (int i = 0; i < (int)g[u].size(); i++) {
int v = g[u][i];
if (v == parent_node[u]) {
continue;
}
dp0[u] += dp1[v];
dp1[u] += min(dp0[v], dp1[v]);
}
}
printf("%lld\n", min(dp0[root], dp1[root]));
return 0;
}复杂度
- 时间复杂度:
。每个节点和每条树边在建树与自底向上的动态规划中仅被遍历常数次。 - 空间复杂度:
。邻接表、DP 数组及遍历栈的空间占用均为 。
总结
- 模型转化:将“瞭望所有路”精确刻画为图论中的“最小点覆盖”。在树形结构上,每条边的两个端点必须至少选一个。
- 两状态转移:当前节点不选时,所有子节点必须全选;当前节点选时,每个子节点取放或不放的极小值。
- 实现注意:树形 DP 使用显式栈收集逆拓扑序做迭代转移,可以彻底消除递归爆栈风险,在 Python 下运行效率高且代码精简。