← 目录 第 33 / 60 节 全部 60 节

33 · 区间 DP 与树形 DP

两种“没有下标顺序”的 DP:区间 DP 按长度从短到长,树形 DP 靠 DFS 后序自底向上。把石子合并的三层循环、破环成链,和“选/不选”这一维的树形状态设计一次讲透。
提高-/提高预计 70 分钟区间 DP树形 DP自底向上
本节出现的新函数 / 新语法(先看懂再用)

第 31 节的线性 DP 从左往右扫,第 32 节的背包按容量递增,顺序都由下标决定。这一节的两个结构没有现成的下标顺序可用:区间要从短往长算,树要等儿子算完再算父亲。规矩其实是同一条——递推顺序必须和状态依赖关系一致:依赖谁,就先算谁。

区间 DP 的循环顺序不是风格问题,是正确性问题。len 一旦挪到内层,程序不报错、不崩,答案直接变成错的。
区间 DP树形 DP
状态f[l][r]:把第 l..r 个元素处理完的最优值f[u][…]:以 u 为根的子树内部的最优值
依赖两个更短的区间 [l,k]、[k+1,r]所有儿子 v 的 f[v][…]
顺序区间长度 len 从 2 到 nDFS 后序:先递归儿子,回到 u 再合并
复杂度状态 O(n²) × 转移 O(n) = O(n³)每点算一次、每条边过一次 = O(n)
典型题石子合并、能量项链、最长回文子序列没有上司的舞会、最大子树和、树形背包

先看清三块骨头:区间 DP 的三层循环、树的存法、树的遍历顺序。前两块是写法,第三块是这一节真正的思想。

新函数拆解 · 区间 DP 的三层循环
for (int len = 2; len <= n; ++len) for (int l = 1, r = len; r <= n; ++l, ++r) for (int k = l; k < r; ++k) f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + cost(l, r));
组成含义 / 格式要求
len区间长度。必须是最外层:长度更小的区间先算完,长区间才拿得到子区间的值。
l, r左右端点,r 由 l + len − 1 推出。用一个变量加长度反推,别再单独写一层 r 的循环。
k分割点,范围 l ≤ k < r。它把 [l,r] 切成 [l,k] 与 [k+1,r] 两段,对应“最后一次操作”。
len 从 2 开始长度 1 的区间不用合并,直接 f[i][i] = 0。从 1 开始只是白跑一层,不算错。
f[l][k] 与 f[k+1][r]两者长度都严格小于 len,走到外层 len 时它们一定已经算好——这就是 len 必须在最外面的唯一理由。
cost(l, r)把两段并成一段的额外代价,最常见是区间和 s[r] − s[l−1],用前缀和 O(1) 取。
答案在哪线性题答 f[1][n];环形题先把数组复制成 2n,答所有长度 n 的窗口 f[l][l+n−1] 里的最优值。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

int n = 5, a[105], s[105];
int f[105][105];                 // f[l][r]:把 a[l..r] 合并成一堆的最小代价

int main() {
    for (int i = 1; i <= n; ++i) { a[i] = i; s[i] = s[i - 1] + a[i]; }
    memset(f, 0x3f, sizeof f);
    for (int i = 1; i <= n; ++i) f[i][i] = 0;        // 长度 1:本来就是一堆,不需要合并

    for (int len = 2; len <= n; ++len)               // ① 先短后长
        for (int l = 1, r = len; r <= n; ++l, ++r)   // ② 再扫左端点
            for (int k = l; k < r; ++k)              // ③ 最后枚举断点
                f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + s[r] - s[l - 1]);

    cout << f[1][n] << "\n";                         // 1..5 合并的最小代价
    return 0;
}
注意

把外层 len 换成“枚举 l,再枚举 r”,仍然是 r − l 递增;写成 l 从小到大、r 从 l 到 n 就用到未算的 f[k+1][r] → WA,而样例常常刚好能过,最难查。

求 min 的 f 必须初始化成大值(0x3f3f3f3f),求 max 初始化成 0 或 −INF。忘初始化 = 白送 WA。

n 到 1000 时 O(n³) = 1.7×10⁸ 且常数不小,先看数据范围,别硬刚。

新函数拆解 · vector 邻接表(树的存法)
vector<int> g[N]; g[u].push_back(v); for (int v : g[u]) { ... }
组成含义 / 格式要求
vector<int> g[N]开 N 个 vector,第 u 个装 u 的所有邻居。读法:一个数组,每个元素是一条不定长表。
g[u].push_back(v)在 u 的表尾追加 v,O(1) 摊还。树有 n−1 条边,无向存两次 → 共 2(n−1) 次追加。
g[u].size()u 的邻居个数。树里就是度数:根的儿子数 = 度数,其它节点 = 度数 − 1。
for (int v : g[u])范围 for,依次取出邻居。等价于 for (int i = 0; i < (int)g[u].size(); ++i) { int v = g[u][i]; }。
有向还是无向有向树(父→子)只 push 一次;无向树要 push 两次:g[u].push_back(v); g[v].push_back(u);
为什么不用邻接矩阵n = 6000 的矩阵是 36 MB,遍历一个点还要扫 n 格;邻接表遍历一次只花 O(度数)。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 6005;
vector<int> g[N];                 // 全局:默认全是空表

int main() {
    // 无向树:1-2, 1-3, 3-4,每条边两个方向都要存
    g[1].push_back(2); g[2].push_back(1);
    g[1].push_back(3); g[3].push_back(1);
    g[3].push_back(4); g[4].push_back(3);

    for (int v : g[1]) cout << v << ' ';    // 2 3
    cout << "\n" << g[1].size() << "\n";    // 2
    return 0;
}
注意

vector<int> g[N] 放全局才默认全空,且不占栈。放函数里是 n 个局部 vector,n 大时容易爆栈。

范围 for 里写 int v 是拷贝;要改表本身得写 int &v。本节都只读。

边数记牢:树的边是 n−1 条,无向存两次,所以每个 vector 的总长度和是 2(n−1)。

新函数拆解 · 树的 DFS(自底向上的顺序)
void dfs(int u, int fa) { for (int v : g[u]) { if (v == fa) continue; // 不走回头路 dfs(v, u); // ① 先把儿子的答案算完 ... // ② 再用 f[v] 更新 f[u] } }
组成含义 / 格式要求
u当前节点。dfs 的每一层只负责一棵子树,不关心兄弟。
fau 的父亲。树存成无向图后,邻居里有一个是“来的路”,必须跳过,否则两点之间来回横跳。
if (v == fa) continue;这一行就是“不走回头路”。删掉它 → 无限递归 → 爆栈 RE,连输出都没有。
dfs(v, u)传的是 u,不是 fa:v 的父亲是 u。写成 dfs(v, fa) 会让 v 继续往上爬。
①②的顺序所有合并儿子的语句必须写在 dfs(v, u) 之后。写在前面,f[v] 还是初始值,答案偏小且不报错。
叶子节点叶子没有任何邻居能通过 if,for 循环直接结束,函数返回——不需要单独写递归边界。
根怎么选无向树任选一个当根(常写 1);有向树(父→子)要先找入度为 0 的点。
深度递归深度 = 树高,最坏是一条链、深度 n。n ≤ 2×10⁴ 时安全,到 10⁵ 要考虑手写栈。
最小可运行示例
示例
#include <bits/stdc++.h>
using namespace std;

const int N = 100005;
vector<int> g[N];
int sz[N], dep[N];

void dfs(int u, int fa) {
    sz[u] = 1;                        // 自己算一个点
    dep[u] = dep[fa] + 1;             // 深度可以顺着参数/父亲递推
    for (int v : g[u]) {
        if (v == fa) continue;        // 父亲方向是来的路
        dfs(v, u);                    // ★ 先递归:等 sz[v] 算完
        sz[u] += sz[v];               // ★ 再合并
    }
}

int main() {
    g[1].push_back(2); g[2].push_back(1);
    g[1].push_back(3); g[3].push_back(1);
    g[3].push_back(4); g[4].push_back(3);
    dep[0] = 0;
    dfs(1, 0);                        // 0 是虚点,编号从 1 起,绝不会撞上
    cout << sz[1] << " " << sz[3] << " " << dep[4] << "\n";   // 4 2 3
}
注意

fa 初值写 0、编号从 1 开始,0 一定不等于任何邻居——无向树靠这一手省掉 vis 数组。

把两个 ★ 交换顺序:sz[u] += sz[v] 时 sz[v] 还是 0,答案直接少一大截。这是树形 DP 第一大坑。

根取哪个不影响 sz[1] 之外的信息量,但答案若是“某个点”,要在 dfs 里顺手更新全局量。

第一道题是本节的模板:一条链上的合并,加上一个环的处理。先把它彻底做透,第二个例题才有参照物。

例题 · 石子合并洛谷 P1880普及/提高-去洛谷做这题 ↗

在圆形操场四周摆了 N 堆石子,现要将石子有次序地合并成一堆。规定每次只能选相邻的两堆合并成新的一堆,并把新堆的石子数记为该次合并的得分。

求将 N 堆合并成 1 堆的最小得分与最大得分。

数据范围:1 ≤ N ≤ 100,0 ≤ aᵢ ≤ 20。

输入格式:第一行 N;第二行 N 个整数,第 i 个表示第 i 堆的石子数。
输出格式:两行,第一行最小得分,第二行最大得分。

输入: 4 4 5 9 4 输出: 43 54
把“合并顺序”换成“最后一步”

顺序不是状态,最后一步才是。不管前面怎么合,整个区间 [l,r] 合并成一堆的最后一步,一定是由两堆并成的:一堆来自 [l,k],一堆来自 [k+1,r]。所以只要知道“这两段各自合成一堆的最小代价”,就能拼出整段的最小代价:

f[l][r] = min(f[l][k] + f[k+1][r]) + sum(l..r),k 取遍 l..r−1。末尾加的区间和是这次合并的得分:新堆的重量就是两段之和。

为什么这一步加法是对的。最后一步的得分固定为 sum(l,r),与切法无关;前面两段内部的合并互不干扰,各自独立取最优不会冲突。这就是最优子结构,也是这道题能用 DP 的唯一理由。

环形怎么办。环上有 n 种“从哪断开”的选择。暴力枚举断点各做一次 DP 是 O(n⁴)。标准做法:把数组复制一份接在后面,得到长度 2n 的链;环上任何一种断法,都对应这条链上一个长度为 n 的连续窗口。于是所有断法的答案就是 f[l][l+n−1](1 ≤ l ≤ n)里的最优值。

规模。状态 2n × 2n = 4×10⁴,转移 n = 100,总共约 2×10⁶ 次加法取最值,一瞬间。最小和最大用同一个转移,只是 min 换成 max,所以两张表一起算,代码只多两行。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

const int INF = 0x3f3f3f3f;
int n, a[205], s[205];              // a 开两倍:破环成链
int f[205][205], g[205][205];      // f 最小得分,g 最大得分

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) { cin >> a[i]; a[i + n] = a[i]; }   // 复制一份接在后面
    for (int i = 1; i <= 2 * n; ++i) s[i] = s[i - 1] + a[i];          // 前缀和:O(1) 取区间和

    memset(f, 0x3f, sizeof f);     // 求最小:初值要足够大
    memset(g, 0, sizeof g);        // 求最大:得分非负,初值 0 就行
    for (int i = 1; i <= 2 * n; ++i) f[i][i] = g[i][i] = 0;   // 单堆不用合并,得分 0

    for (int len = 2; len <= n; ++len)                 // ① 长度从小到大
        for (int l = 1; l + len - 1 <= 2 * n; ++l) {   // ② 左端点
            int r = l + len - 1;
            for (int k = l; k < r; ++k) {              // ③ 断点:左段 [l,k],右段 [k+1,r]
                int add = s[r] - s[l - 1];             // 本次合并的得分 = 两段总重
                f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + add);
                g[l][r] = max(g[l][r], g[l][k] + g[k + 1][r] + add);
            }
        }

    int mn = INF, mx = 0;
    for (int l = 1; l <= n; ++l) {                     // 枚举环的断点 = 长度 n 的每个窗口
        mn = min(mn, f[l][l + n - 1]);
        mx = max(mx, g[l][l + n - 1]);
    }
    cout << mn << "\n" << mx << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
a[i + n] = a[i]破环成链。原环上相邻的两堆,在复制后的链上仍然相邻;而任何一个长度为 n 的窗口都是环的一种“断开方式”。
s[i] = s[i - 1] + a[i]前缀和。区间和 s[r]−s[l−1] 是 O(1),否则在 k 循环里再求和就多一层 O(n),直接 O(n⁴)。
memset(f, 0x3f, sizeof f)0x3f3f3f3f ≈ 1.06×10⁹。每个字节都填 0x3f,int 四字节就得到这个数,是最常见的 INF 写法。
for (int len = 2; len <= n; ++len)长度上限是 n 而不是 2n:只有长度 n 的窗口才对应一个完整的环,更长的区间没有意义。
l + len - 1 <= 2 * n右端点不能越出复制后的数组。真正要用的最长区间是 [l, l+n−1],l ≤ n 时右端点最远到 2n−1,所以开 2n 格刚好够。
int add = s[r] - s[l - 1]最后一个合并动作的得分。它与 k 无关,所以在 k 循环外算一次就够,省一点常数。
f[l][k] + f[k + 1][r] + add两段内部各自的最优代价 + 这一次合并的代价。k 取遍所有切法,min 就是最优断点。
for (int l = 1; l <= n; ++l)环的断点等价于窗口起点。l 从 1 到 n 就把 n 种断法全试了一遍。

第二道题把“区间”换成“子树”。注意这一次没有任何“区间长度”可以枚举,顺序完全由递归调用保证。

例题 · 没有上司的舞会洛谷 P1352普及/提高-去洛谷做这题 ↗

某大学有 n 个职员,编号 1..n,他们的从属关系构成一棵以校长为根的树,父节点是子节点的直接上司。宴会每邀请一个职员都会增加快乐指数 rᵢ,但如果某职员的直接上司来了,这个职员就不肯来。

求邀请哪些职员能使快乐指数之和最大。

数据范围:1 ≤ n ≤ 6×10³,−128 ≤ rᵢ ≤ 127(可以为负)。

输入格式:第一行 n;接下来 n 行每行一个整数 rᵢ;再接下来 n−1 行,每行两个整数 l、k,表示 k 是 l 的直接上司。
输出格式:一行一个整数,最大快乐指数。

输入: 7 1 1 1 1 1 1 1 1 3 2 3 6 4 7 4 4 5 3 5 输出: 5
状态就是“这个人来不来”

约束只发生在直接父子之间。一个职员来不来,只取决于他父亲来不来;爷爷来不来与他无关。所以处理完 u 的子树后,外界只需要知道一件事:u 来了没有。这就是状态的第二维,只有 0 和 1 两个取值。

f[u][0]:u 不来时,u 的子树能贡献的最大快乐值。f[u][1]:u 来时,u 的子树能贡献的最大快乐值。

转移看着父子关系写。u 不来 → 每个儿子 v 来不来都行,取 max(f[v][0], f[v][1]);u 来了 → 儿子一定不能来,只能加 f[v][0]。两句话就是全部转移。

顺序。f[u] 依赖所有 f[v],而 f[v] 依赖 v 的子树——只能用 DFS 后序:先递归每个儿子,回到 u 再累加。这里没有“长度为 1、2、3”可枚举,顺序完全由递归结构给出。

两个边界。叶子没有儿子,求和为空:f[leaf][0] = 0,f[leaf][1] = r[leaf]。根要从入度为 0 的点里找——输入只告诉你“谁是上司”,不会直接告诉你谁是校长。

要注意 r 可以是负数。f[u][1] 直接以 r[u] 起头,不能“负的就不要”,因为选了 u 就是选了 u 这个前提。本题规定关系一定是一棵树,所以不需要判环。

完整可编译代码(C++17)
#include <bits/stdc++.h>
using namespace std;

int n, r[6005];
vector<int> g[6005];        // g[u]:u 的所有直接下属(有向,父 → 子)
int f[6005][2];             // f[u][0] u 不来;f[u][1] u 来
bool hasFa[6005];           // 用来找根:没有上司的人就是根

void dfs(int u) {
    f[u][1] = r[u];         // u 来:先拿到自己的快乐值
    f[u][0] = 0;            // u 不来:自己贡献 0
    for (int v : g[u]) {    // 有向树,边只从父指向子,不会走回头路
        dfs(v);             // ★ 先把儿子的两个值算出来
        f[u][0] += max(f[v][0], f[v][1]);   // u 不来 → 儿子来不来都行
        f[u][1] += f[v][0];                 // u 来了 → 儿子一定不能来
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> r[i];
    for (int i = 1; i < n; ++i) {
        int l, k;                  // k 是 l 的直接上司
        cin >> l >> k;
        g[k].push_back(l);         // 加边 k → l
        hasFa[l] = true;           // l 有上司,不可能是根
    }

    int root = 1;
    for (int i = 1; i <= n; ++i)
        if (!hasFa[i]) { root = i; break; }   // 入度为 0 的点

    dfs(root);
    cout << max(f[root][0], f[root][1]) << "\n";
    return 0;
}
逐行拆解
代码片段含义与理由
void dfs(int u)只有一个参数:本题边是有向的父→子,从 u 出发只会往下走,不需要 fa 判回头。无向树才要 fa。
f[u][1] = r[u]; f[u][0] = 0;初始化写成“自己这一份”。r[u] 可能是负数,照样加进去,不能跳过。
for (int v : g[u])遍历所有直接下属。递归调用写在累加之前,是因为累加要用算好的 f[v]。
f[u][0] += max(f[v][0], f[v][1])u 不来时,儿子 v 没有任何限制,两种都合法,取大的。
f[u][1] += f[v][0]u 来了,v 一定不准来,所以只能加 v 不来的那一份。写成 max 是本题最典型的错。
hasFa[l] = true读边时顺手记下“谁有上司”,省掉一次入度统计。
if (!hasFa[i]) { root = i; break; }唯一没有上司的人就是校长。从别的点开始 dfs 会漏掉根之上的部分 → 答案偏小。
max(f[root][0], f[root][1])根来与不来都是合法方案,取大的。
这两个模板还能套在哪

认区间 DP 的信号:答案能由“最后一次操作把序列切成两半”描述;n 大概在 100~500;中间可以插一个断点 k。石子合并、能量项链(环形,同样复制 2n)、最长回文子序列(f[l][r] 看两端是否相等)、括号匹配类,都是这个骨架。

认树形 DP 的信号:答案在树上,子树之间互相独立,约束只在父子之间。这时一定有第二维表示“父/子选没选”,比如选或不选、来或不来、装多少件。没有这一维,父子约束就没法表达。

环形问题的两套写法:①复制成 2n,答案取窗口;②强行分类讨论“第一堆和最后一堆是否合并”,对区间 DP 也能用,但要加一维状态。前者代码短、后者有时更快,初学一律用前者。

树形 DP 的通用写法:先 dfs 儿子,回来再合并,最后更新全局答案。换题时通常只改三处:第二维的含义、初始化(f[u][…] 的起点)、以及儿子那一步怎么并进父亲。

这一节的 6 个坑,按症状记

区间 DP 写成 for (l) for (r = l; r <= n; ++r) for (k):用到还没算的 f[k+1][r] → WA,样例经常照样过,因为小数据正好被前几轮覆盖。

求最小却没给 f 初始化(全 0)→ 输出 0 或明显偏小的数 → WA。0x3f3f3f3f 是最稳的 INF。

环形题忘了复制成 2n,只在 1..n 上做:只算了一个断点 → WA,n 小的时候甚至可能蒙对。

树 DFS 漏了 if (v == fa) continue;:两个点互相调用 → 无限递归 → RE(爆栈),表现为进程直接退出、无任何输出。

合并儿子的语句写在 dfs(v, u) 之前:f[v] 还是初始值 → WA,答案恒定偏小,且能跑完不崩。

数组只按 n 开:n = 100 却开 f[105][105],复制成 2n 后访问 f[200][…] → 越界 → RE 或随机 WA。习惯按“题目上界 × 2 再加几格”开。

自己动手复现

关掉页面,把 P1880 的完整代码从零敲一遍,交上去确认输出 43 和 54。

改一改①:把三层循环改成 l 从 1 到 2n、r 从 l 到 2n、k 在最里层(也就是不再按长度递增),再跑样例。第一行会输出 1061109567——那正是 INF 本身,说明 f 压根没算出来;程序却不报任何错。这就是“顺序即正确性”。

改一改②:拿 P1352 的代码,在 dfs(u) 开头加一句 printf("进入 %d\n", u),在函数末尾加一句 printf("离开 %d\n", u),观察“离开”总是先于父亲——后序就是这么来的。

改一改③:把 P1352 里 f[u][1] += f[v][0] 改成 += max(f[v][0], f[v][1]),样例输出会从 5 变成 7,多出来的正是不该来的上级。

数学补给 · 区间 DP 的转移次数到底是多少

状态数是 n²,但真跑起来不是 n³ 次,而是少得多。数一数三层循环总共进多少次最内层。

固定长度 len,区间有 n − len + 1 个,每个区间的断点有 len − 1 个。总次数:

T(n) = Σlen=2..n (n − len + 1)(len − 1) = C(n+1, 3) = n(n−1)(n+1)/6

代入验证:n = 3 时 C(4,3) = 4;手算 (2)(1) + (1)(2) = 4 ✓。n = 4 时 C(5,3) = 10;手算 (3)(1)+(2)(2)+(1)(3) = 10 ✓。

nC(n+1,3)1 秒内可行?说明
100166650轻松石子合并的实际量级
3004499500很轻松P1775 弱化版的数据
50020833250可以常数要小,别在 k 循环里做加法以外的操作
1000166666500勉强1.7×10⁸,要压常数
20001.33×10⁹不行必须换四边形不等式或决策单调性优化

所以“区间 DP 能过 n ≤ 500 左右”是个可以直接背的数字。环形题因为数组翻倍、len 只到 n,转移次数约是线性版的两倍,n = 100 时仍是 3×10⁵ 量级。

树形 DP 那边则是另一套账:每个节点进一次 dfs,每次把每条出边看一遍,全部加起来 2(n−1) 次循环体。树的 DP 复杂度永远是 O(n)(或 O(n·状态数)),跟深度无关,跟形状也无关。

举一反三 · 想清楚这些再往下走
  1. 把石子合并的得分规则改成“两堆石子数之差”,区间 DP 还成立吗?“最后一步的代价固定为 sum(l,r)”这个性质还在不在?
  2. 如果每次允许合并相邻的 3 堆,len 的起点、k 的含义、cost 各要怎么改?复杂度变了吗?
  3. 树形 DP 里“父亲来了儿子就不能来”是硬约束。若改成“父亲来了,儿子的快乐值减半”,状态还需要加维度吗?
  4. 求“树上一条路径的最大点权和(点权可负)”能不能复用 f[u][0/1]?第二维的含义要换成什么?
  5. P1880 只要求得分。如果要求输出最优的合并顺序,你要多开一维数组,还是只记断点?空间差多少?
  6. 环形问题的“复制成 2n”和“讨论首尾是否合并”两种写法,分别在什么时候更省时间和空间?
自测:能全部回答再进下一节
挑战题 · 最大子树和
洛谷 P1122普及/提高-去洛谷做这题 ↗

一株花上共连有 n 朵花,n−1 条枝干把它们连成一棵树。每朵花有一个“美丽指数”,可能是负数。

一次“修剪”指删掉一条枝干,于是整株花分成两株,扔掉其中一株。可以进行任意次修剪(也可以不剪),使最后剩下的那一株(也可能只剩一朵花)上所有花的美丽指数之和最大,求这个最大值。

输入格式:第一行一个整数 n(1 ≤ n ≤ 16000);第二行 n 个整数,第 i 个表示第 i 朵花的美丽指数(|aᵢ| ≤ 10⁹);接下来 n−1 行,每行两个整数 a、b,表示 a 与 b 之间有枝干相连。
输出格式:一行一个整数,表示最大美丽指数之和。

数据保证答案的绝对值不超过 2147483647。

引导性提示(先只看这里)

修剪之后剩下的东西一定是树上“连通的一块”,而且它可以看成某棵子树的一部分。

自己定一个根。对每个点问一句:如果剩下的这一块必须包含它,并且只能往它的子树里取,最多能拿多少?

子树里那些“拖后腿”的部分可以整块剪掉——修剪这个操作正好允许你这么做。

想看思路了?点开完整思路

定根。任取 1 号为根,把无向树变成有向的父子关系。

状态。f[u] = 以 u 为根、必须包含 u、且只在 u 的子树内取点的连通块的最大权值和。

转移。f[u] = a[u] + Σv 是 u 的儿子 max(0, f[v])。儿子那棵子树如果整体是负贡献,就连同这条边一起剪掉(这正是一次合法的修剪);是正的才接上来。

答案。剩下的那一块总有一个“最靠上的点” u,它就是那一块在根意义下的最高点,所以它一定被某个 f[u] 算到过。取所有 f[u] 的最大值即可。答案允许只留一朵花,所以初始答案不能是 0 —— 必须从 -∞ 起,否则全是负数时会被 0 顶掉。

复杂度。每个点算一次,每条边过一次 → O(n)。n = 16000 时瞬间跑完。

类型。单点权值到 10⁹,n 到 16000,一块的权值和最大可达 1.6×10¹³,必须用 long long。

还是不会?点开题解代码与拆解
参考代码
#include <bits/stdc++.h>
using namespace std;

const int N = 16005;
int n;
long long w[N], f[N], ans = LLONG_MIN;   // f[u]:必须含 u 的连通块最大权值和
vector<int> g[N];

void dfs(int u, int fa) {
    f[u] = w[u];                         // 自己这一份先算上
    for (int v : g[u]) {
        if (v == fa) continue;           // 父亲方向是来的路,跳过
        dfs(v, u);                       // ★ 先递归:等 f[v] 算完
        if (f[v] > 0) f[u] += f[v];      // ★ 负贡献的子树整块剪掉(就是一次修剪)
    }
    ans = max(ans, f[u]);                // 每个点都可能是那一块的最高点
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> w[i];
    for (int i = 1; i < n; ++i) {
        int a, b;
        cin >> a >> b;
        g[a].push_back(b);
        g[b].push_back(a);               // 无向树,两个方向都要存
    }

    dfs(1, 0);                           // 0 是虚点,编号从 1 起
    cout << ans << "\n";
    return 0;
}
关键片段拆解
片段作用
long long w[N], f[N], ans = LLONG_MIN权值到 10⁹、点数到 16000,一块的和可达 1.6×10¹³,int 装不下。ans 用 −∞ 起头,保证全是负数时也能取到“最大的那个负数”。
vector<int> g[N]1.6 万个 vector,总边数 3.2 万条,内存不到 1 MB。
dfs(int u, int fa)无向树必须有 fa:否则 u→v→u 来回走,直接爆栈。
f[u] = w[u]初始化就是“只留自己这一朵”,这是一切连通块的起点。
if (f[v] > 0) f[u] += f[v]正贡献接上,负贡献剪掉。这一行同时完成了“取或不取这棵子树”的决策,所以不需要再套一层 0/1 背包式的循环。
ans = max(ans, f[u])答案不是 f[1]:只留一朵花、或最高的那朵在中途时,最高点未必是根。
g[a].push_back(b); g[b].push_back(a);边是双向的。只存一次会有很多点根本走不到 → 答案偏小,且能跑完不报错。
这题最容易错的地方
  • f、w、ans 用 int:n = 16000 且权值都是 10⁹ 时和到 1.6×10¹³,溢出后变成负数 → 大测试点全挂(WA)。
  • ans 初始化为 0:所有花都难看(全负)时正确答案是最大的那个负数,0 会把它顶掉 → WA。
  • 漏写 if (v == fa) continue;:无向树会两点之间来回递归 → RE(爆栈)。
  • 无向边只 push 一次:树被当成有向图,一部分子树永远访问不到 → WA,且不崩不报错。
延伸阅读 / 参考