Trick 树上背包合并复杂度分析
零、写在前面
不想跑模型,水了下群友扔的题。发现是一个很裸的树上背包,大概分析下复杂度就ac了。
忽然想起来去年也写过类似的一道树上背包,当时对于复杂度是非常疑惑的,始终无法理解时间复杂度是O(N^2)。
今天反而一下就分析出来了,正好记录一下。
一、P1273 的复杂度分析
一眼树上背包:
-
当前递归到子树 u
-
定义 f[j] 为 子树 u 内选了 j 个用户的最大净收益,那么对于 u 的邻接点v,可做如下背包合并:
-
for (int i = siz[v]; i >= 0; --i) { for (int j = siz[u]; j >= 0; --j) { nf[i + j] = std::max(nf[i + j], f[j] + g[i] - (i > 0 ? w : 0)); } }g[i] 代表子树v 内选 i 个用户的最大净收益
-
所以不难写出这个解法:
#include <bits/stdc++.h>
namespace ranges = std::ranges;
namespace views = std::views;
using i64 = long long;
using u32 = unsigned;
using u64 = unsigned long long;
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int N, M;
std::cin >> N >> M;
std::vector<std::vector<std::array<int, 2>>> adj(N);
for (int i = 0; i < N - M; ++i) {
int m;
std::cin >> m;
while (m-- > 0) {
int v, w;
std::cin >> v >> w;
--v;
adj[i].push_back({v, w});
}
}
std::vector<int> val(M);
for (int i = 0; i < M; ++i) {
std::cin >> val[i];
}
std::vector<int> siz(N);
auto dp = [&](this auto &&self, int u, int p) -> std::vector<int> {
siz[u] = 1;
std::vector<int> f(2, -1E9);
f[0] = 0;
if (u >= N - M) {
f[1] = val[u - (N - M)];
}
for (auto &[v, w] : adj[u]) {
if (v == p) {
continue;
}
auto g = self(v, u);
std::vector<int> nf(siz[u] + siz[v] + 1, -1E9);
nf[0] = 0;
for (int i = siz[v]; i >= 0; --i) {
for (int j = siz[u]; j >= 0; --j) {
nf[i + j] = std::max(nf[i + j], f[j] + g[i] - (i > 0 ? w : 0));
}
}
siz[u] += siz[v];
f.swap(nf);
}
return f;
}(0, -1);
int ans = 0;
for (int i = 0; i < dp.size(); ++i) {
if (dp[i] >= 0) {
ans = i;
}
}
std::cout << ans << '\n';
return 0;
}
如何分析时间复杂度?
瓶颈在于遍历邻接点v时的二重循环:
for (auto &[v, w] : adj[u]) {
if (v == p) {
continue;
}
for (int i = siz[v]; i >= 0; --i) {
for (int j = siz[u]; j >= 0; --j) {
}
}
}
1.1 纯数学视角:
假设节点 u 有 kkk 个子节点,它们的子树大小分别为 s1,s2,s3,…,sks_1, s_2, s_3, \dots, s_ks1,s2,s3,…,sk。
代码在合并子节点时,是逐个合并的:
- 最开始,
siz[u]只有节点u自己,大小为 111。 - 合并第 1 个子节点时,执行次数是 s1×1s_1 \times 1s1×1。合并后
siz[u]变成了 1+s11 + s_11+s1。 - 合并第 2 个子节点时,执行次数是 s2×(1+s1)s_2 \times (1 + s_1)s2×(1+s1)。合并后
siz[u]变成了 1+s1+s21 + s_1 + s_21+s1+s2。 - 合并第 mmm 个子节点时,执行次数是 sm×(1+s1+⋯+sm−1)s_m \times (1 + s_1 + \dots + s_{m-1})sm×(1+s1+⋯+sm−1)。
我们把节点 u 本身看作 s0=1s_0 = 1s0=1。
那么,在节点 u 上为了合并所有子树,嵌套循环的总执行次数就是:
∑i=1k(si×∑j=0i−1sj)<size[u]2 \begin{align} &\sum_{i=1}^{k} \left( s_i \times \sum_{j=0}^{i-1} s_j \right) \\ &< size[u]^2 \end{align} i=1∑k(si×j=0∑i−1sj)<size[u]2
因为 ∑u=0N−1size[u]=N\sum_{u=0}^{N-1}size[u] = Nu=0∑N−1size[u]=N
所以 O(∑u=0N−1size[u]2)=O(N2)O(\sum_{u=0}^{N-1}size[u]^2)=O(N^2)O(u=0∑N−1size[u]2)=O(N2)
1.2 组合数学视角
for (int i = siz[v]; i >= 0; --i)
for (int j = siz[u]; j >= 0; --j)
siz[v]代表子节点 vvv 的子树中的节点个数。siz[u]代表在合并 vvv 之前,节点 uuu 已经合并好的节点个数(包括 uuu 自己和它前面的子树)。
siz[v] * siz[u] 的实际组合意义是:从 vvv 的子树中随便挑一个节点 xxx,再从 uuu 当前已经合并的子树中随便挑一个节点 yyy,把它们配成一对 (x,y)(x, y)(x,y)。
嵌套循环每执行一次(即 i 和 j 的某一次组合),本质上就相当于把节点 xxx 和节点 yyy 在这里“相遇”并处理了一次。
换句话说:
整棵树的所有节点构成的每一对配对 (x,y)(x, y)(x,y),在整个 DFS 的过程中,有且仅有一次会在这个嵌套的 for 循环中被计算到。
一共有 NNN 个节点,配对的总对数是组合数 CN2=N(N−1)2C_N^2 = \frac{N(N-1)}{2}CN2=2N(N−1)。
既然每一对只会被计算一次,那么整个程序中这两个嵌套 for 循环内部代码的全局总执行次数,严格等于 N(N−1)2\frac{N(N-1)}{2}2N(N−1)。
所以,该代码的整体复杂度为O(N2)O(N^2)O(N2)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)