通过拓扑排序剥除基环树外向树枝收缩至环上,再对简单环分类讨论破环为链进行动态规划,求基环树森林的最大权独立集。
OJ: roj
题目 ID: 1584
难度:提高+/省选-
标签:动态规划树形 DP基环树 DP拓扑排序
创建: 2026-04-19 10:00
更新: 2026-10-07 13:50
形式化题目
给定一个包含
如果视所有的有向边为无向边,求该图的一个最大权独立集:选出一个顶点子集
正解
思路
1. 问题建模与图的结构
每个骑士
一般图的最大权独立集是 NP-hard 的,但本题给出的图具有特殊结构:每个顶点恰有一条出边,因此在无向图意义下,每个连通块的边数等于顶点数(
2. 拓扑排序剪枝:收缩树枝
对于单一基环树连通块,它由一个核心的“简单环”以及挂在环上若干节点的“外向树枝”构成。
由于有向边是
- 设
表示不选节点 时, 子树内的最大点权和; 表示选节点 时, 子树内的最大点权和。初始 。 - 将所有入度为
的节点加入队列; - 每次取出队首节点
,转移给它的唯一出边邻居 : - 将
的入度减 。若 的入度变为 ,则将其加入队列继续剪枝。
当拓扑排序结束时:
- 所有树枝上的节点均已完成树形 DP 并被移除;
- 剩下的入度大于
的节点恰好构成该连通块的唯一简单环; - 环上每个节点
的 和 已经包含了挂在它上面的整棵树枝的最优贡献。
3. 破环为链:环上的两次 DP
设环上的节点按拓扑出边顺序依次为
-
方案 1:强制不选
- 初始化:
; - 沿环递推
: - 递推结束后,由于
未选, 可选可不选,该方案的最优值为 。
- 初始化:
-
方案 2:强制选
- 初始化:
; - 沿环同样规则递推到
; - 递推结束后,由于
已经被选, 绝对不能选,故该方案的最优值为 。
- 初始化:
该环连通块的最大权独立集即为
代码
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 10:00
# update_at: 2026-04-19 10:00
import sys
NEG_INF = -10**18 # 强制不选或非法状态的惩罚权值
def trim_trees(n: int, val: list[int], to: list[int], deg: list[int]) -> tuple[list[int], list[int]]:
"""用拓扑排序把基环树外向树枝收缩到环上节点,返回每个节点的 (f0, f1) 权值。"""
f0 = [0] * (n + 1)
f1 = val[:]
queue = [u for u in range(1, n + 1) if deg[u] == 0]
head = 0
while head < len(queue):
u = queue[head]
head += 1
p = to[u]
f0[p] += max(f0[u], f1[u])
f1[p] += f0[u]
deg[p] -= 1
if deg[p] == 0:
queue.append(p)
return f0, f1
def solve_cycle(cycle: list[int], f0: list[int], f1: list[int]) -> int:
"""在单个环上做破环 DP,返回该基环树连通块的最大权独立集。"""
# 方案 1: cycle[0] 强制不选
dp0, dp1 = f0[cycle[0]], NEG_INF
for u in cycle[1:]:
dp0, dp1 = max(dp0, dp1) + f0[u], dp0 + f1[u]
best_without_root = max(dp0, dp1)
# 方案 2: cycle[0] 强制选,则 cycle[-1] 强制不选
dp0, dp1 = NEG_INF, f1[cycle[0]]
for u in cycle[1:]:
dp0, dp1 = max(dp0, dp1) + f0[u], dp0 + f1[u]
best_with_root = dp0
return max(best_without_root, best_with_root)
def solve() -> None:
data = iter(map(int, sys.stdin.buffer.read().split()))
try:
n = next(data)
except StopIteration:
return
val = [0] * (n + 1)
to = [0] * (n + 1)
deg = [0] * (n + 1)
for i in range(1, n + 1):
val[i] = next(data)
h = next(data)
to[i] = h
deg[h] += 1
f0, f1 = trim_trees(n, val, to, deg)
ans = 0
vis = [False] * (n + 1)
for i in range(1, n + 1):
if deg[i] > 0 and not vis[i]:
cycle: list[int] = []
curr = i
while not vis[curr]:
vis[curr] = True
cycle.append(curr)
curr = to[curr]
ans += solve_cycle(cycle, f0, f1)
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 10:11
* update_at: 2026-10-05 10:11
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 1e6 + 5;
// 骑士编号 1..n,每个骑士恰好厌恶一个人,形成基环树森林
int val[MAXN]; // val[u]: 骑士 u 的战斗力
int to_[MAXN]; // to_[u]: u 最厌恶的骑士(u 有一条指向它的出边)
int deg[MAXN]; // deg[u]: u 的入度,拓扑排序剥叶子用
ll f0[MAXN]; // f0[u]: 不选 u 时,u 子树(含收缩贡献)内的最大权和
ll f1[MAXN]; // f1[u]: 选 u 时,u 子树(含收缩贡献)内的最大权和
int que[MAXN]; // 手写队列:拓扑排序中入度为 0 的节点
bool vis[MAXN]; // vis[u]: u 是否已属于某个处理过的环
int cyc[MAXN]; // 环上节点的收集数组
// 拓扑排序剥除所有树枝:树枝上的树形 DP 收缩到环上节点
// 返回剩余入度 > 0 的节点个数(即所有环上的节点数)
int trim_trees(int n) {
int head = 0, tail = 0;
for (int u = 1; u <= n; ++u)
if (deg[u] == 0) que[tail++] = u;
while (head < tail) {
int u = que[head++];
int p = to_[u];
// 树枝贡献转移给唯一出边邻居 p(与经典树形 DP 一致)
f0[p] += max(f0[u], f1[u]);
f1[p] += f0[u];
if (--deg[p] == 0) que[tail++] = p;
}
return n - tail; // 未入过队的节点入度仍 > 0,正好是环上节点
}
// 在环上破环为链做 DP,返回该基环树的最大权独立集
ll solve_cycle(int m) {
const ll NEG = -(1LL << 60);
// 方案 1:强制不选环首 cyc[0],则环尾可选可不选
ll dp0 = f0[cyc[0]], dp1 = NEG;
for (int i = 1; i < m; ++i) {
int u = cyc[i];
ll nd0 = max(dp0, dp1) + f0[u];
ll nd1 = dp0 + f1[u];
dp0 = nd0;
dp1 = nd1;
}
ll best1 = max(dp0, dp1);
// 方案 2:强制选环首 cyc[0],则环尾强制不选
dp0 = NEG;
dp1 = f1[cyc[0]];
for (int i = 1; i < m; ++i) {
int u = cyc[i];
ll nd0 = max(dp0, dp1) + f0[u];
ll nd1 = dp0 + f1[u];
dp0 = nd0;
dp1 = nd1;
}
ll best2 = dp0;
return max(best1, best2);
}
int main() {
// 关闭同步,N 最大 1e6,必须快读快写
ios::sync_with_stdio(false);
cin.tie(NULL);
int n;
if (!(cin >> n)) return 0;
for (int u = 1; u <= n; ++u) {
cin >> val[u] >> to_[u];
f1[u] = val[u]; // 初始树形 DP:选自己至少有自身权值
deg[to_[u]]++;
}
trim_trees(n);
ll ans = 0;
for (int i = 1; i <= n; ++i) {
if (deg[i] > 0 && !vis[i]) {
// 沿出边走一圈收集环上节点
int m = 0;
int u = i;
while (!vis[u]) {
vis[u] = true;
cyc[m++] = u;
u = to_[u];
}
ans += solve_cycle(m);
}
}
cout << ans << "\n";
return 0;
}复杂度
- 时间复杂度:
。每个节点在拓扑排序中入队与出队至多一次;所有环上的节点被遍历两次用于破环 DP,常数极小,能在线性时间内快速处理 。 - 空间复杂度:
。需要存储图的指向数组、度数数组及 DP 状态数组,占用线性内存。
总结
- 基环树标准套路:对于每个点恰好一条出边的函数图(基环树森林),利用拓扑排序“剥叶子”可以在避免显式建反向邻接表的同时,自然地完成外向树枝的自底向上树形 DP。
- 破环为链:环上的冲突限制只需针对一个关键基准点讨论其“选”与“不选”两种互斥状态,即可彻底拆解环的循环依赖,退化为两遍标准的线性序列 DP。
图示解析
这张图展示了基环树最大权独立集的求解脉络:
基环树森林 (每个节点一条出边)
|- 拓扑排序剪枝
| `- 从入度为 0 的叶子自底向上做树形 DP
| `- 树枝贡献完全收缩到环上节点 (f0, f1)
`- 环上破环两次 DP
|- 方案 1: 强制不选起点 c0 -> 终点 ck-1 可选可不选
|- 方案 2: 强制选起点 c0 -> 终点 ck-1 强制不选
`- 取两方案较大值,累加每个连通块的答案样例 1 结构示意
输入数据构建的关系如下:
graph LR
1((1: 权值10)) --> 2((2: 权值20))
2 --> 3((3: 权值30))
3 --> 1
- 若选 3,则 1 和 2 均不能选,收益为 30;
- 若选 2,则 3 和 1 均不能选,收益为 20;
- 若选 1,则 2 和 3 均不能选,收益为 10; 最优解为选 3,最大战斗力为 30。