通过树形 DP 求解树的最小顶点覆盖,根据结点放与不放两种状态在子树间转移。
OJ: luogu
题目 ID: P2016
难度:普及
标签:树形 DP图论动态规划
创建: 2026-10-23 23:05
更新: 2026-10-23 23:30
形式化题目
给定一棵包含
暴力解法
思路
树中共有
对于每一种选点方案,遍历树上的每条边
由于暴力的状态空间为
代码
cpp
/**
* 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-23 23:26
* update_at: 2026-10-23 23:26
*/
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 复杂度为 O(2^n * m) = O(2^n * n),通过指数级枚举所有结点的选/不选状态,
// 逐条检查树上的每条边是否至少有一个端点被选(即被士兵瞭望到),
// 只能用于验证极小规模数据(如 n <= 20)。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll maxn = 30;
ll n;
struct Edge {
ll u, v;
};
vector<Edge> edges;
ll b[maxn]; // 记录每个结点是否选士兵:1 选,0 不选
ll ans = 1e9;
// 递归枚举每个结点的选择状态
void dfs(ll dep) {
if (dep == n) {
// 检查所有边是否都被覆盖
bool ok = true;
for (const auto &e : edges) {
if (!b[e.u] && !b[e.v]) {
ok = false;
break;
}
}
if (ok) {
ll cnt = 0;
for (ll i = 0; i < n; ++i) {
if (b[i]) cnt++;
}
ans = min(ans, cnt);
}
return;
}
// 不选当前结点
b[dep] = 0;
dfs(dep + 1);
// 选当前结点
b[dep] = 1;
dfs(dep + 1);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n)) return 0;
for (ll i = 0; i < n; ++i) {
ll u, k;
cin >> u >> k;
for (ll j = 0; j < k; ++j) {
ll v;
cin >> v;
edges.push_back({u, v});
}
}
ans = n;
dfs(0);
cout << ans << "\n";
return 0;
}复杂度
- 时间复杂度:
。每个结点 种选择,每种状态需线性检查 条边。 - 空间复杂度:
。用于递归调用栈与边集存储。
瓶颈
暴力解法对整棵树进行了全局盲目搜索,完全忽略了树结构的层次特征与无后效性。在树中,一个子树内部的边覆盖方案仅与该子树的根节点是否被选有关,各子树之间互不干扰。盲目枚举所有结点的状态导致了大量的重复计算。
正解
思路
本题是经典的树上最小顶点覆盖问题,可以通过树形动态规划(Tree DP)在
任选一个结点作为根(例如结点
定义状态:
:表示在以 为根的子树中,且结点 不放置士兵时,覆盖该子树内部所有边所需的最小士兵数。 :表示在以 为根的子树中,且结点 放置士兵时,覆盖该子树内部所有边所需的最小士兵数。
考虑状态转移:
- 若
不放置士兵(计算 ): 自身花费为 。为使连接父子结点的边 能够被瞭望到,其每一个子结点 都必须放置士兵。因此: - 若
放置士兵(计算 ): 自身花费为 。此时边 已经被 处的士兵覆盖,子结点 处放不放士兵均可。为了让总花费最小,子结点 应当选择花费更小的状态。因此:
边界与实现细节:
- 当
为叶子结点时, ,自然有 。 - 输入数据每条边只出现一次且未指明父子顺序,建图时需建立无向双向边,通过 DFS 传入父结点参数
fa避免反向走回。 - 最终整棵树的最小士兵数为
。
代码
cpp
/**
* 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-23 23:26
* update_at: 2026-10-23 23:26
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll maxn = 1505;
ll n;
vector<ll> e[maxn];
ll dp[maxn][2];
bool vis[maxn];
// 树形 DP
// u: 当前节点,fa: 父节点
void dfs(ll u, ll fa) {
dp[u][0] = 0; // u 不放士兵
dp[u][1] = 1; // u 放士兵,花费为 1
for (ll v : e[u]) {
if (v == fa) continue;
dfs(v, u);
// 如果 u 不放士兵,则其子节点 v 必须放士兵,否则边 (u, v) 无法被瞭望
dp[u][0] += dp[v][1];
// 如果 u 放士兵,则子节点 v 可放可不放,取二者最小值
dp[u][1] += min(dp[v][0], dp[v][1]);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n)) return 0;
for (ll i = 0; i < n; ++i) {
ll u, k;
cin >> u >> k;
for (ll j = 0; j < k; ++j) {
ll v;
cin >> v;
e[u].push_back(v);
e[v].push_back(u);
}
}
// 任选 0 作为根节点进行树形 DP
dfs(0, -1);
cout << min(dp[0][0], dp[0][1]) << "\n";
return 0;
}复杂度
- 时间复杂度:
。建图耗时 ;DFS 遍历过程中,每个结点访问一次,每条边访问两次,每个结点状态转移的耗时与其度数成正比,总转移耗时为 。 - 空间复杂度:
。邻接表存储 条边占用 空间,DP 数组及递归栈深度均为 。
总结
本题是树形 DP 入门经典题(树的最小顶点覆盖)。核心突破口在于抓住树上每条边的覆盖约束:一条边的两端点至少选其一。通过把无根树定根,将全局约束转化为父子关系约束,利用子树间相互独立的特性设立选与不选两种状态,自底向上合并子树最优解,从而在线性复杂度内解决问题。