通过 Tarjan 缩点将软件循环依赖转化为不可分割的超点,再连虚拟根构成树形结构,进行树上有依赖的背包 DP 求解。
OJ: luogu
题目 ID: P2515
难度:提高
标签:scc树形dp背包dp
创建: 2025-12-30 17:19
更新: 2026-10-23 23:35
形式化题目
给定
- 若
,表示该软件无依赖,安装后即可发挥价值; - 若
,表示必须在软件 及其全部直接或间接依赖均被安装的情况下,软件 才能发挥价值。
求在总占用空间不超过
暴力解法
思路
每个软件有安装和不安装两种选择。可以枚举所有
- 用二进制状态掩码枚举每个软件是否被选中;
- 对选中的软件集合,检查其依赖闭包是否合法:若选中了软件
且 ,则 必须也已被选中; - 若合法且所选软件的总体积不超过
,计算所选软件的总价值并更新全局最优解。
代码
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:30
* update_at: 2026-10-23 23:30
*/
// brute.cpp:小数据指数级枚举暴力解
// 枚举全部 2^N 种选择方案,逐一检查前置依赖是否全部满足且总空间不超过 M
// 时间复杂度 O(2^N * N),仅适用于 N <= 20 的小规模数据对拍与验证
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 105;
ll n, m;
ll w[MAXN], v[MAXN], d[MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; ++i) cin >> w[i];
for (int i = 1; i <= n; ++i) cin >> v[i];
for (int i = 1; i <= n; ++i) cin >> d[i];
ll ans = 0;
int limit = 1 << n;
for (int mask = 0; mask < limit; ++mask) {
bool ok = true;
ll total_w = 0;
ll total_v = 0;
for (int i = 1; i <= n; ++i) {
if ((mask >> (i - 1)) & 1) {
total_w += w[i];
total_v += v[i];
if (d[i] != 0) {
if (!((mask >> (d[i] - 1)) & 1)) {
ok = false;
break;
}
}
}
}
if (ok && total_w <= m) {
if (total_v > ans) {
ans = total_v;
}
}
}
cout << ans << "\n";
return 0;
}复杂度
- 时间复杂度:
。外层枚举所有 种选择子集,内层遍历 个软件验证依赖合法性并统计体积与价值。 - 空间复杂度:
。仅需存储每个软件的重量、价值和前置依赖数组。
瓶颈
当
正解
思路
1. 强连通分量与缩点
将依赖关系抽象为有向图:
- 若软件
依赖于 ( ),建立一条有向边 (表示“必须先装 ,才能装 ”)。 - 如果依赖关系出现环(例如
),由于选其中任何一个都需要前置已被安装,推导可得:环上的所有软件要么全部安装,要么都不安装。 - 因此,使用 Tarjan 算法求出图中所有的强连通分量(SCC),将每个 SCC 缩为一个“超点”:
- 超点空间
; - 超点价值
。
- 超点空间
2. 重建树形结构与虚拟根
缩点后原图转化为了有向无环图(DAG):
- 在缩点后的新图里,每个超点最多只有一条入边(因为原图中每个点最多只有一个前置依赖)。
- 为了处理无依赖的节点以及孤立的闭合依赖环,建立一个虚拟根节点
(其空间为 ,价值为 )。 - 统计所有 SCC 的入度,将所有入度为
的 SCC 均连接到虚拟根 的后继(即 )。 - 此时,整张图变成以
为根节点的树形依赖结构(选子节点必须先选父节点)。
3. 树上背包 DP
在建立的树上进行树形分组背包 DP(同经典模型 P2014 选课):
- 设
表示在以 为根的子树中选择,花费容量不超过 所能获得的最大总价值。 - 初始化:选
的子树必须先选 本身,因此当容量 时, ,其余为 。 - 转移:遍历
的每个子节点 ,先递归计算子树 的 DP 值,再采用分组背包思想倒序更新 的容量: 其中外层倒序枚举,内层枚举分配给子节点 的容量 。 - 最终答案:从虚拟根
开始 DFS,结果为 。
代码
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:30
* update_at: 2026-10-23 23:30
*/
#include <iostream>
#include <vector>
#include <algorithm>
#include <cstring>
using namespace std;
typedef long long ll;
const int MAXN = 105;
const int MAXM = 505;
ll n, m;
ll w[MAXN], v[MAXN], d[MAXN];
// 原图邻接表(d[i] -> i,表示选了 d[i] 才能选 i)
int head[MAXN], to_e[MAXN], nxt[MAXN], edge_cnt;
void add_edge(int u, int to) {
edge_cnt++;
to_e[edge_cnt] = to;
nxt[edge_cnt] = head[u];
head[u] = edge_cnt;
}
// Tarjan 相关
int dfn[MAXN], low[MAXN], timer;
int stk[MAXN], top;
bool in_stack[MAXN];
int scc_id[MAXN], scc_cnt;
ll scc_w[MAXN], scc_v[MAXN];
void tarjan(int u) {
dfn[u] = low[u] = ++timer;
stk[++top] = u;
in_stack[u] = true;
for (int e = head[u]; e; e = nxt[e]) {
int v_node = to_e[e];
if (!dfn[v_node]) {
tarjan(v_node);
low[u] = min(low[u], low[v_node]);
} else if (in_stack[v_node]) {
low[u] = min(low[u], dfn[v_node]);
}
}
if (low[u] == dfn[u]) {
scc_cnt++;
while (true) {
int cur = stk[top--];
in_stack[cur] = false;
scc_id[cur] = scc_cnt;
scc_w[scc_cnt] += w[cur];
scc_v[scc_cnt] += v[cur];
if (cur == u) break;
}
}
}
// 缩点后新图的树边(从父亲到儿子)
int head_tree[MAXN], to_tree[MAXN * MAXN], nxt_tree[MAXN * MAXN], tree_edge_cnt;
int in_deg[MAXN];
bool has_edge[MAXN][MAXN];
void add_tree_edge(int u, int to) {
tree_edge_cnt++;
to_tree[tree_edge_cnt] = to;
nxt_tree[tree_edge_cnt] = head_tree[u];
head_tree[u] = tree_edge_cnt;
}
// 树上背包 DP
// dp[u][j] 表示在以 u 为根的子树中选择,占用容量不超过 j 获得的最大价值
ll dp[MAXN][MAXM];
void dfs_dp(int u) {
// 必须选 u 才能选其子树
for (int j = scc_w[u]; j <= m; ++j) {
dp[u][j] = scc_v[u];
}
for (int e = head_tree[u]; e; e = nxt_tree[e]) {
int to = to_tree[e];
dfs_dp(to);
// 分组背包:倒序枚举背包容量,防止重复使用子树
for (int j = m; j >= scc_w[u]; --j) {
for (int k = 0; k <= j - scc_w[u]; ++k) {
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[to][k]);
}
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> m)) return 0;
for (int i = 1; i <= n; ++i) cin >> w[i];
for (int i = 1; i <= n; ++i) cin >> v[i];
for (int i = 1; i <= n; ++i) {
cin >> d[i];
if (d[i] != 0) {
// 建原图依赖边:i 依赖 d[i],即 d[i] -> i,表示前置为 d[i]
// 为了找强连通分量,有向环在 d[i] -> i 或 i -> d[i] 下是等价的(只是环的方向反转)
add_edge(d[i], i);
}
}
// 1. Tarjan 缩点求强连通分量
for (int i = 1; i <= n; ++i) {
if (!dfn[i]) {
tarjan(i);
}
}
// 2. 建立缩点后的 DAG,并接入虚拟根 0
for (int i = 1; i <= n; ++i) {
if (d[i] != 0) {
int u_scc = scc_id[d[i]];
int v_scc = scc_id[i];
if (u_scc != v_scc && !has_edge[u_scc][v_scc]) {
has_edge[u_scc][v_scc] = true;
add_tree_edge(u_scc, v_scc);
in_deg[v_scc]++;
}
}
}
// 入度为 0 的 SCC 挂在虚拟根 0 下(包括孤立环与依赖 0 的点)
for (int i = 1; i <= scc_cnt; ++i) {
if (in_deg[i] == 0) {
add_tree_edge(0, i);
}
}
// 虚拟根 0 本身体积 0,价值 0
scc_w[0] = 0;
scc_v[0] = 0;
// 3. 树上背包 DP
dfs_dp(0);
cout << dp[0][m] << "\n";
return 0;
}复杂度
- 时间复杂度:
。 - Tarjan 算法和新图构建阶段点数和边数均为
,耗时 ; - 树上分组背包中,节点数
。树上每个节点合并子树时,容量枚举上限为 ,总运算次数为 。当 时, 次计算,可在 1 秒时限内轻松通过。
- Tarjan 算法和新图构建阶段点数和边数均为
- 空间复杂度:
。链式前向星和 Tarjan 辅助数组空间为 ,DP 数组状态数为 ,总内存占用小于 1 MB。
总结
本题的关键在于利用依赖关系的单前置约束识别出基环森林结构,通过 Tarjan 缩点将相互循环依赖的软件分量打包为不可分割的整体物品,从而将图简化为有向树;再借助超级虚拟根将森林统一为单一单根树,最终套用树上背包 DP 求解。