树的最小权支配集:设 dp0/dp1/dp2 分别表示结点自己设看守、被儿子看到、只能指望父亲看到三种状态,自底向上树形 DP 求最小经费。
OJ: roj
题目 ID: 1579
难度:提高
标签:树形 DP动态规划最小支配集
创建: 2026-09-30 20:09
更新: 2026-10-07 13:50
形式化题目
给定一棵
要求选出结点的一个子集
; 的父亲 ; 的某个儿子 。
也就是每个结点要么自己设看守,要么与一个设了看守的相邻结点(父亲或儿子)相连。求
样例说明
输入给出的树结构如下,结点编号与安置费用为
graph TD
1((1, 30)) --- 2((2, 16))
1 --- 3((3, 5))
1 --- 4((4, 4))
2 --- 5((5, 11))
2 --- 6((6, 5))
在结点
若改成只选结点
正解
思路
1. 朴素做法与瓶颈
最直接的做法是枚举每个结点设不设看守,共
观察覆盖条件:一个结点
2. 为什么两个状态不够
如果只设"
已被儿子看到,子树已经付过钱了; 还没被覆盖,只是等着父亲来看它,这笔钱要留到父亲那一层再付。
这两种情况都表现为"
3. 状态定义
对以
: 自己设看守,子树内所有结点都被看守的最小费用; : 不设看守,但已被某个儿子看到,子树内所有结点都被看守的最小费用; : 不设看守,儿子也没看到它, 只能指望父亲来看它;此时子树内除 以外的所有结点必须已被看守的最小费用。
三种状态互斥且穷尽:
4. 状态转移
设
①
②
③
只需挑增量最小的那个儿子承担这个"设看守"的职责:
叶子没有儿子,无法被子结点看到,故
5. 小规模转移演示
对样例中结点
| 结点 | 角色 | 说明 | |||
|---|---|---|---|---|---|
| 5 | 叶子 | 11 | 0 | 无儿子, |
|
| 6 | 叶子 | 5 | 0 | 同上 | |
| 2 | 内部 |
表格中
6. 答案与实现
根结点没有父亲,
转移必须自底向上进行。这里不用递归(链状树会爆栈):输入本身给的就是"父亲 → 儿子"的有向描述,入度为
代码
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:09
# update_at: 2026-09-30 20:09
import sys
INF = 1 << 60 # 比任何合法经费和大得多,用来表示"这个状态不成立"
def min_guard_cost(n: int, cost: list[int], children: list[list[int]], root: int) -> int:
"""求树上最小权支配集的权值和:每个点要么自己设看守,要么被父亲或儿子看到。
dp0[u]:u 自己设看守,u 子树全部被看守的最小花费。
dp1[u]:u 不设看守,但 u 已被它的某个儿子看到,u 子树全部被看守的最小花费。
dp2[u]:u 不设看守、也没有任何儿子看到 u,此时 u 只能指望父亲来看它,
子树内(除 u 自己)必须已经全部被看守的最小花费。
三者互斥且覆盖全部情况,故答案就是根节点上 min(dp0, dp1)(根没有父亲)。
"""
# 输入已经是有根树的父子关系,按读入顺序展开即得到自顶向下的遍历序,
# 逆序就是自底向上的转移序,不需要显式 DFS。
order: list[int] = []
stack = [root]
while stack:
u = stack.pop()
order.append(u)
stack += children[u]
dp0 = [0] * (n + 1)
dp1 = [0] * (n + 1)
dp2 = [0] * (n + 1)
for u in reversed(order):
dp0[u] = cost[u] + sum(min(dp0[v], dp1[v], dp2[v]) for v in children[u])
# u 不设看守时,每个儿子 v 都不能指望 u 来看它,所以只能取 dp0[v] / dp1[v]。
dp2[u] = sum(min(dp0[v], dp1[v]) for v in children[u])
# 至少有一个儿子设了看守才能让 u 被看到:把其中一个儿子的 dp2 换成 dp0,
# 代价增量最小的那次替换就是最优选择。
extra = min(
(dp0[v] - min(dp0[v], dp1[v]) for v in children[u]),
default=INF, # 叶子没有儿子,dp1 不可能成立
)
dp1[u] = dp2[u] + extra
return min(dp0[root], dp1[root])
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
cost = [0] * (n + 1)
children: list[list[int]] = [[] for _ in range(n + 1)]
has_parent = [False] * (n + 1)
for _ in range(n):
u, k, m = next(data), next(data), next(data) # 结点标号、经费、儿子数
cost[u] = k
children[u] = [next(data) for _ in range(m)]
for v in children[u]:
has_parent[v] = True
# 题面给出的是"父亲 → 儿子"的有向描述,入度为 0 的结点就是皇宫的起点(根)。
root = next(u for u in range(1, n + 1) if not has_parent[u])
print(min_guard_cost(n, cost, children, 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 09:49
* update_at: 2026-10-05 09:49
*/
// 树的最小权支配集:三态树形 DP。
// dp0[u]:u 自己设看守,u 子树全部被看守的最小经费;
// dp1[u]:u 不设看守,但被某个儿子看到,u 子树全部被看守的最小经费;
// dp2[u]:u 不设看守、儿子也没看到它,只能指望父亲,u 子树内除 u 外
// 全部被看守的最小经费。
// 根没有父亲,答案为 min(dp0[root], dp1[root])。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1505;
const ll INF = 1e18;
ll n;
ll cost[MAXN]; // cost[u]:在 u 宫殿设看守的经费
int child_cnt[MAXN]; // child_cnt[u]:u 的儿子数
int child[MAXN][MAXN]; // child[u][j]:u 的第 j 个儿子(题面即按此给出,直接存矩阵)
bool has_parent[MAXN]; // has_parent[v]:v 是否出现过在某个父亲的儿子列表里
int order[MAXN]; // order:自顶向下的访问序,逆序即自底向上的转移序
ll dp0[MAXN], dp1[MAXN], dp2[MAXN];
// 从根出发用栈收集自顶向下遍历序;输入本身就是"父亲 -> 儿子"的有向描述,
// 入度为 0 的结点即根。
int find_root() {
for (int u = 1; u <= n; u++) {
if (!has_parent[u]) return u;
}
return 1; // 理论上不会走到这里
}
// 自底向上做三态转移。
void solve() {
int root = find_root();
// 用数组模拟栈:先得到自顶向下的访问序 order[0..cnt-1]。
int cnt = 0;
order[cnt++] = root;
for (int i = 0; i < cnt; i++) {
int u = order[i];
for (int j = 1; j <= child_cnt[u]; j++) {
order[cnt++] = child[u][j];
}
}
// 逆序迭代:处理 u 时它的所有儿子已经转移完毕。
for (int i = cnt - 1; i >= 0; i--) {
int u = order[i];
// dp0:u 自己设看守,每个儿子三态任选(父亲已看到它,dp2 也合法)。
// dp2:u 不设看守,每个儿子都不能指望 u,只能取 dp0 / dp1。
ll sum0 = cost[u], sum2 = 0;
// min_delta:把某个儿子从 min(dp0,dp1) 抬到 dp0 的最小代价增量,
// 用于计算"至少一个儿子设了看守"的 dp1。
ll min_delta = INF;
for (int j = 1; j <= child_cnt[u]; j++) {
int v = child[u][j];
sum0 += min(min(dp0[v], dp1[v]), dp2[v]);
sum2 += min(dp0[v], dp1[v]);
ll delta = dp0[v] - min(dp0[v], dp1[v]);
if (delta < min_delta) min_delta = delta;
}
dp0[u] = sum0;
dp2[u] = sum2;
if (child_cnt[u] == 0) {
dp1[u] = INF; // 叶子没有儿子,不可能"被儿子看到"
} else {
dp1[u] = sum2 + min_delta;
}
}
cout << min(dp0[root], dp1[root]) << endl;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
int u, m;
ll k;
cin >> u >> k >> m;
cost[u] = k;
child_cnt[u] = m;
for (int j = 1; j <= m; j++) {
cin >> child[u][j];
has_parent[child[u][j]] = true;
}
}
solve();
return 0;
}复杂度
- 时间复杂度:
。每个结点在自底向上的过程中只被转移一次,内部对儿子的三次求和合计只遍历该结点的每条父子边常数次。 - 空间复杂度:
。 cost、children邻接表、三个 DP 数组以及遍历序order均为。
总结
- 模型转化:把"看守所有宫殿"翻译成树上的最小权支配集 —— 每个点必须被自己或相邻的父亲/儿子覆盖,目标是最小化选中点的权值和。
- 状态设计的动机:一个点"已覆盖"与"未覆盖但等父亲"的代价不同,二态会丢失信息;按覆盖来源(自己 / 儿子 / 父亲)拆成
三态后,子树之间才互相独立。 - 转移的关键技巧:
用"在 基础上挑增量最小的儿子改成必选"来表达"至少一个儿子设看守"这一约束,避免了对子集再枚举。答案取 。 - 实现注意:树形 DP 依赖自底向上序,用显式栈 + 逆序迭代替代深度递归,既避免爆栈又保持
。

