零、写在前面

不想跑模型,水了下群友扔的题。发现是一个很裸的树上背包,大概分析下复杂度就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 纯数学视角:

假设节点 ukkk 个子节点,它们的子树大小分别为 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++sm1)

我们把节点 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=1k(si×j=0i1sj)<size[u]2
因为 ∑u=0N−1size[u]=N\sum_{u=0}^{N-1}size[u] = Nu=0N1size[u]=N

所以 O(∑u=0N−1size[u]2)=O(N2)O(\sum_{u=0}^{N-1}size[u]^2)=O(N^2)O(u=0N1size[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)

嵌套循环每执行一次(即 ij 的某一次组合),本质上就相当于把节点 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(N1)
既然每一对只会被计算一次,那么整个程序中这两个嵌套 for 循环内部代码的全局总执行次数,严格等于 N(N−1)2\frac{N(N-1)}{2}2N(N1)

所以,该代码的整体复杂度为O(N2)O(N^2)O(N2)

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐