CSP-S 提高级课程中心 / 算法进阶 / 第40讲
CSP-S 提高级 · 算法进阶 · s5-18

多维动态规划 与 树型动态规划

动态规划不只有「一维一条线」:把状态升到二维,就能比对两个序列;把状态挂到树上,就能让每个子树独立求解、后序合并成全局答案。

2 类状态空间 O(nm) 多维 DP O(n²) 树上背包 3 个经典案例 12 道洛谷原题
本讲 · 第 40/52 讲多维动态规划与树型动态规划
模块 01

情景导航

用「档案校对」和「年会邀请名单」两件校史馆的麻烦事,把多维 DP 与树型 DP 讲成一件很自然的事。

故事校史馆的两件麻烦事

你是校史馆的信息化志愿者,刚接手一个数字化项目,馆长给你派了两件看起来很不一样的活儿。

任务一(档案校对):馆里有两份校友手写的回忆稿,一份是 A 稿,一份是 B 稿。馆长想知道:「把 A 稿改成 B 稿,最少要动几个字?」允许的动作只有三种:插入一个字、删除一个字、替换一个字。A 稿可能有两千字,逐字比对显然不现实。

任务二(年会邀请名单):校友会要在年会上邀请一部分校友。但校友之间存在明显的师承链——老教授带出的学生、学生又带出学生,整条师承关系正好是一棵树。出于礼节,馆长定了条规矩:同一条师承链上「直接相邻」的两个人,不能同时邀请(否则师弟见了师兄会尴尬)。每个人有一个「出席贡献值」,请在规矩之内让总贡献值最大。

任务三(预算分配):如果年会预算有限,只能邀请 恰好 m 位校友,且校友的师承关系要求「要请学生,必须先请他的导师」,那么该请哪 m 位?

三个任务,对应动态规划的两大流派。

• 任务一的两个「稿子」天然构成一张二维表格:行是 A 稿的前 i 个字,列是 B 稿的前 j 个字,格子里的数就是「把前 i 个字变成前 j 个字的最少操作数」——这就是多维动态规划;
• 任务二、任务三的对象是一棵树:每个人的「要不要邀请」以及「请几位」,都只取决于他的子树内部——把状态挂到树上、后序遍历自底向上合并,就是树型动态规划。

多维 DP 解决「两个序列之间」的问题,树型 DP 解决「一棵树上」的问题——形式不同,套路完全一致。

💡 故事背后的算法思想

共同点:两类问题都在做同一件事——把一个大问题拆成若干个「规模更小的同类子问题」,用一张表把子问题的答案存起来,再按既定顺序拼出大问题的答案。

多维 DP 的「表」是二维(甚至三维)数组,任一格子只依赖它左边、上边、左上的格子,所以按行(或按列)从前往后填即可;树型 DP 的「表」是每个点上的若干状态,任一状态只依赖它儿子的状态,所以用后序遍历自底向上算即可。

工程意义:很多同学做 DP 卡住,不是因为「不会写循环」,而是因为「不知道状态该怎么定」。本讲的核心训练目标就是:看到题目能迅速说出「状态是什么、从哪来、边界在哪」这三件事。

①生活类比
✏️

改错别字 = 编辑距离

把一句话改写成另一句话,增、删、改三种动作各有代价。想用最少的动作改完,就得逐字比较、权衡取小。

→ f[i][j] 二维转移
👨‍👩‍👧

分家过日子 = 树型DP

每个小家庭过得好不好,只取决于自家和自家孩子;把孩子的结果汇总上来就是全家的结果。

→ 子树 = 子问题
🎒

公司分预算 = 树上背包

总预算按部门层层下拨,每个部门内部再分。分下去的额度就是「背包容量」。

→ 背包容量在子树间分配
📡

换个基站看信号 = 换根DP

同一张网,站在不同的站点上看到的「最远站点」不一样。先算一个站,再用公式推其他站。

→ 一次 DFS 推全部根
②核心定义

状态(State):用来刻画「一个子问题」的若干变量,通常写成数组下标,例如 f[i][j] 表示「A 的前 i 个字符变成 B 的前 j 个字符」。状态的维数 = 描述一个子问题所需的最少信息量。

转移(Transition):子问题之间的依赖关系,写成递推式。多维 DP 的转移往往来自「左、上、左上」三个方向的取小/取大;树型 DP 的转移来自「儿子的状态求和或取最值」。

边界(Base Case):规模最小、可以直接写出答案的子问题。多维 DP 的边界通常是表格的第一行与第一列;树型 DP 的边界是叶节点。

编辑距离(Levenshtein Distance):把字符串 A 变成字符串 B 所需的最少「插入 / 删除 / 替换」操作次数。

树型动态规划:状态定义在树的节点上,每个节点先递归算出所有儿子的状态,再把儿子的状态合并成自己的状态。因为儿子必须先于父亲算完,所以处理的自然顺序是后序遍历。

树上背包:树型 DP 的一种,状态形如 f[u][j],「在 u 的子树中选 j 个(或花 j 的代价)」时的最优值。合并两个儿子时,需要枚举「分给这个儿子多少额度」,是典型的分组背包结构。

换根 DP(Rerooting):当题目要求「对每个点作为根分别求答案」时,先任取一点为根算一遍,再沿树边用公式把「父亲作为根时的答案」推算出「儿子作为根时的答案」,总复杂度仍为 O(n)。

📌 本讲核心知识地图

板块核心内容对应大纲
多维状态设计行列双下标;转移来自三个方向;边界是第一行/第一列动态规划【7】
编辑距离 / LCS插入=上格+1,删除=左格+1,替换=左上格+代价动态规划【7】
树型 DP 模板后序遍历;f[u][0/1] 两个状态;儿子结果合并到父亲树型动态规划【7】
选与不选f[u][0]=Σmax(f[v][0],f[v][1]);f[u][1]=val[u]+Σf[v][0]树型动态规划【7】
树上背包f[u][j] 合并儿子时枚举分配额度;复杂度 O(n²)树型动态规划【7】
换根 DP先算基准根,再用公式 O(1) 推儿子;常需维护最大/次大树型动态规划【7】
③核心口诀
🔑 多维 DP 诀

状态先想「两个下标」:f[i][j] = A 的前 i 个 与 B 的前 j 个 之间的关系。
转移只认三个方向:上格(删)、左格(插)、左上格(改/匹配);边界就是第一行与第一列。

🔑 树型 DP 诀

后序遍历先算儿子,再把儿子合并进来;每个点的状态就是「选它 / 不选它」或者「在子树里选几个」。
叶节点即边界,根节点即答案。

🔑 树上背包诀

外层倒序枚举容量,内层枚举给儿子分多少;合并两个儿子时必须先复制一份(或严格倒序),否则同一个儿子会被重复选中。
复杂度不是 O(n·V),而是由子树大小之和决定的 O(n²)。

模块 02

知识探究

先学会「怎么给状态加维度」,再学会「怎么把状态挂到树上」——两条主线各有模板与套路。

1️⃣多维状态设计:从一条线到一张表

一维 DP 的困境:背包、LIS、数字三角形这些问题的状态只有一个下标,「处理到哪里了」。可一旦题目里出现了两个对象(两个字符串、两个序列、两个数组),一个下标就说不清了——因为「A 走到第几个」和「B 走到第几个」是两件独立的事。

破局办法:把「A 的位置」和「B 的位置」都作为状态的一部分,于是状态就变成了 f[i][j]——一维不够就加一维,这是 DP 设计里最朴素也最有效的招数。

关键问题:怎么判断该加几维?问自己一句话:「我要描述的这个子问题,最少需要几个数字才能唯一确定?」比如「A 的前 i 个字符变成 B 的前 j 个字符」,两个数字缺一不可,所以是二维。若还要求「用了多少次替换」,那就要三维 f[i][j][k]。

二维 DP 的三个方向

填表时,格子 f[i][j] 只可能来自它的三个「邻居」:
· 上方 f[i-1][j]:A 多走一步 → 对应「删除 A 的那个字符」
· 左方 f[i][j-1]:B 多走一步 → 对应「向 A 插入 B 的那个字符」
· 左上 f[i-1][j-1]:两边同时走一步 → 对应「匹配(免费)或替换(+1)」

填表顺序为什么是「逐行从左到右」

因为 f[i][j] 依赖的三个格子都已经被填过:
· f[i-1][j] 在上一行 → 已填
· f[i][j-1] 在本行左边 → 刚填
· f[i-1][j-1] 在上一行左边 → 已填
所以按 i 从小到大、j 从小到大枚举,天然满足拓扑序,不需要额外排序。

🧩 加维度的三段式问句

拿到题目先别急着写代码,照着这三句问自己:
① 子问题是什么?——「A 的前 i 个变成 B 的前 j 个」;
② 这个子问题的答案由哪些更小的子问题决定?——「(i-1,j)、(i,j-1)、(i-1,j-1)」;
③ 最小的子问题(边界)是什么,答案是多少?——「i=0 或 j=0 时,答案就是另一个的长度」。
三句话答完,代码基本就出来了。

2️⃣编辑距离:多维 DP 的标准范例

问题:给定字符串 A(长度 n)与 B(长度 m),每次可以插入一个字符、删除一个字符、替换一个字符。求把 A 变成 B 的最少操作次数。

状态:f[i][j] = 把 A 的前 i 个字符变成 B 的前 j 个字符所需的最少操作次数。

操作含义转移来源递推式
删除删掉 A 的第 i 个字符上方f[i][j] = f[i-1][j] + 1
插入在 A 中插入 B 的第 j 个字符左方f[i][j] = f[i][j-1] + 1
替换把 A 的第 i 个改成 B 的第 j 个左上f[i][j] = f[i-1][j-1] + 1
匹配两个字符正好相同,什么都不用做左上f[i][j] = f[i-1][j-1]

综合起来就是那个著名的三段式转移:

C++编辑距离的核心转移(两行代码)
if (A[i - 1] == B[j - 1])
    f[i][j] = f[i - 1][j - 1];                    // 匹配,免费
else
    f[i][j] = min({f[i - 1][j], f[i][j - 1], f[i - 1][j - 1]}) + 1;
//         删除(上)        插入(左)          替换(左上)
编辑距离 DP 表填写示意:A = "cat",B = "cut"(行取 A,列取 B)
0123
1012
2112
3221
第 1 行第 1 列:'c' == 'c' → 直接抄左上角 0(匹配,免费)
第 3 行第 3 列:'t' == 't' → 直接抄左上角 1(匹配,免费)
最终答案 f[3][3] = 1(把 a 替换成 u,只需 1 次操作)

边界条件(绝不能漏):f[i][0] = i(把 A 的前 i 个字符全部删掉);f[0][j] = j(向空串里插入 B 的前 j 个字符)。这两行是整张表的「初始地基」,写错了后面全错。

⚠️ 下标陷阱:状态里的 i、j 表示「前几个字符」,是从 1 开始数的;而字符串本身从 0 开始编号。所以判断字符是否相同时必须写 A[i-1] == B[j-1],写成 A[i] 会整体错位一格,是本题最高频的 WA 原因。

3️⃣树型 DP 模板:把子问题挂到子树上

核心观察:树有一个极其宝贵的性质——去掉任意一条边,树就断成两个互不相干的部分。这意味着:一个节点的子树内部怎么决策,完全不影响它子树外面的世界。于是「以 u 为根的子树」天然就是一个独立子问题。

状态定义:把状态写在节点上,例如 f[u][0] 表示「u 不参与时的最优值」,f[u][1] 表示「u 参与时的最优值」。更一般地,f[u][j] 可以表示「u 的子树中选 j 个」。

为什么必须是后序遍历:因为 f[u] 要用到所有 f[v](v 是 u 的儿子)。后序遍历的顺序是「先递归完所有儿子,最后回到父亲」,天然保证「算 u 的时候儿子已经全部算完」。这是树型 DP 与普通 DP「按顺序填表」的对应关系。

1 2 3 4 5 6

后序顺序:4 → 5 → 2 → 6 → 3 → 1

每个节点只在它的所有儿子都算完之后才被计算。图中 2 号节点必须等 4、5 都算完才能算;1 号节点必须等 2、3 都算完才能算。

一句话记法:父亲永远最后算——这就是「后序」二字在树型 DP 里的全部含义。

C++树型 DP 的骨架(记住这个形状,90% 的题目都能套)
void dfs(int u, int fa) {
    // ① 初始化:先把「只考虑 u 自己」的情形写好
    f[u][0] = 0;
    f[u][1] = val[u];

    // ② 递归所有儿子(后序:儿子先算完)
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);

        // ③ 把儿子的答案合并到 u 上(这才是每个题的精华所在)
        f[u][0] += max(f[v][0], f[v][1]);
        f[u][1] += f[v][0];
    }
    // ④ 回到 u 之后,f[u] 已经是「u 的整棵子树」的答案
}

🔑 树型 DP 与普通 DP 的对应关系

状态 → 每个节点上的数组 f[u][...];
转移 → 从儿子合并到父亲的那几行代码;
拓扑序 → 后序遍历(儿子先于父亲);
边界 → 叶节点(没有儿子,合并步骤自然跳过);
答案 → 根节点的状态(或所有节点状态的汇总)。
把这五个要素对上号,树型 DP 就再也不是「玄学」了。

4️⃣选与不选:树型 DP 的第一种形态

典型场景:树上每个点有权值,要求选出一个「没有父子关系的点集」(即树的最大权独立集),使权值和最大。这就是「没有上司的舞会」。

状态设计:每个点只有两种命运——被选或不被选。于是每个点开两个状态:

状态含义转移方程
f[u][0]u 不参加时,u 的子树内的最大权值和f[u][0] = Σv∈son(u) max(f[v][0], f[v][1])
f[u][1]u 参加时,u 的子树内的最大权值和f[u][1] = val[u] + Σv∈son(u) f[v][0]

转移含义解读:
· 如果 u 不来,那对每个儿子 v 都没有约束——v 来或不来都可以,取较优的 max(f[v][0], f[v][1]);
· 如果 u 来,那所有直接下属都不能来,所以每个儿子只能取 f[v][0]。

答案:max(f[root][0], f[root][1])——根节点作为最高领导,来不来都合法,取最优即可。

✅ 正确理解:「不选」是自由的

u 不参加时,儿子 v 参加与否都不违反任何约束,所以要对两种状态取 max。这一步决定了 DP 能取到最优解。

❌ 常见误解:「不选」就等于「儿子必须选」

把 f[u][0] 写成 Σ f[v][1],强制儿子全来。这会让很多更优的方案被排除,答案偏小。

💭 为什么要开「两个状态」而不是一个?

因为「u 参加与否」会影响儿子的选择空间。一个状态无法同时表达这两种不同的约束,所以必须把「u 的状态」也写进状态里——这与多维 DP「信息量不够就加维度」是同一个道理。「加维度」和「给每个点开多状态」本质上是同一件事。

5️⃣树上背包:树型 DP 的第二种形态

典型场景:要从树上选出恰好 m 个点(或总代价不超过 V),且满足「选了儿子就必须选父亲」这类连通性约束,求权值和最大。核心难点是「额度怎么在多个儿子之间分配」。

状态设计:f[u][j] = 在 u 的子树中选 j 个点(且必选 u)时的最大权值和。

合并儿子的过程:假设 u 已经合并了前若干个儿子的信息(记作 f[u][·],当前有效范围是 1..siz[u]),现在要合并儿子 v(子树大小 siz[v]):

C++树上背包的合并核心(最关键的四行)
for (int j = min(cap, siz[u] + siz[v]); j >= 1; j--)   // ① 倒序枚举总容量
    for (int k = 1; k <= min(j - 1, siz[v]); k++)  // ② 分给 v 的额度 k
        f[u][j] = max(f[u][j], f[u][j - k] + f[v][k]);     // ③ 用旧值更新新值
siz[u] += siz[v];                                          // ④ 别忘了更新大小

为什么必须「倒序枚举 j」:这与一维 0/1 背包必须倒序是完全相同的道理。若正序枚举,f[u][j-k] 可能在本次合并中已经被更新过(已经包含了 v 的贡献),于是同一个儿子 v 被重复选入,答案偏大。倒序则保证 f[u][j-k] 一定是「还没考虑 v」的旧值。

为什么内层是 k ≤ j-1:因为 u 自己必须被选,至少要占掉 1 个额度,不能把 j 全部让给 v。

STEP 1递归儿子 2、3
STEP 2初始化 f[1][1] = val[1]
STEP 3合并儿子 2
STEP 4合并儿子 3(同样倒序)
STEP 5输出 f[root][m]

复杂度:看上去是「两层循环 × n 个节点」,很吓人。但实际上,合并 u 与 v 这一对的花费是 siz[u] × siz[v],而「所有点对的 siz 乘积之和」恰好是 O(n²)(本质上是枚举了每一对在不同子树里的点)。所以总复杂度是 O(n²),而不是 O(n·V) 或 O(n³)。这就是树上背包能在 n = 10³ 时跑得飞快的原因。

6️⃣换根 DP:让「每个点都当一次根」

问题来源:有些题目要求「对树上的每一个点,分别求出以它为根时的某个答案」(比如到其他所有点的距离之和、最远点距离等)。朴素做法是对每个点都跑一遍 DFS,总复杂度 O(n²),n = 2×10⁵ 时必超时。

核心思想:答案之间存在递推关系。任取一点(比如 1 号)为根,先算一遍 f[1];然后每次「把根从父亲 u 挪到儿子 v」,只有 u、v 这一对的可达范围发生变化,其余部分完全不动。于是可以用一个 O(1) 的公式算出 f[v]。

C++换根 DP 的通用两步式
// 第一遍 DFS:任取 1 为根,算出「只考虑子树」的答案
void dfs1(int u, int fa) {
    siz[u] = 1;
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs1(v, u);
        siz[u] += siz[v];
        down[u] += down[v] + siz[v];   // 例:子树内的距离之和
    }
}

// 第二遍 DFS:把根的答案「平移」给儿子
void dfs2(int u, int fa) {
    for (int v : g[u]) {
        if (v == fa) continue;
        // 以 v 为根时:v 的子树(含 v) 里有 siz[v] 个点,距离各减 1
        //              其余 n - siz[v] 个点,距离各加 1
        ans[v] = ans[u] + (n - siz[v]) - siz[v];
        dfs2(v, u);
    }
}

公式怎么推出来的:以 u 为根时,v 子树里的 siz[v] 个点到 u 的距离 = 到 v 的距离 + 1;其余 n - siz[v] 个点则恰好反过来(到 u 的距离 = 到 v 的距离 - 1)。所以:
ans[v] = ans[u] − siz[v] + (n − siz[v]) = ans[u] + n − 2·siz[v]。

⚠️ 换根 DP 的两种题型

类型一(可加可减型):答案是「一堆东西的和」,换根时只有一部分点距离变化,直接做加减即可。上面求「距离和」就是这一类。
类型二(需要维护次大型):答案涉及「最远距离 / 最大链长」这类取 max 的量。取 max 不能做减法!所以必须额外记录「最大值来自哪个儿子」以及「次大值」,换根时若最大值正好来自要挪走的那个儿子,就改用次大值。这是换根 DP 最经典的坑。

🔑 换根诀

第一遍自底向上算子树,第二遍自顶向下推全局;
要加要减看「子树内外各有多少点」,要取 max 就必须存「次大值」。

案例 1编辑距离(洛谷 P2758)
入门多维 DP 模板

题目描述

设 A 和 B 是两个字符串。我们要用最少的字符操作次数,将字符串 A 转换为字符串 B。这里所说的字符操作共有三种:
1. 删除一个字符;2. 插入一个字符;3. 将一个字符改为另一个字符。
求将 A 变为 B 的最少操作次数。|A|, |B| ≤ 2000。

输入格式:共两行,第一行为字符串 A,第二行为字符串 B。
输出格式:一行,一个整数,表示最少操作次数。

输入样例:
kitten
sitting
输出样例:3

💡 题目分析

状态:f[i][j] —— 把 A 的前 i 个字符变成 B 的前 j 个字符所需的最少操作次数。

转移:考虑 A 的第 i 个字符(A[i-1])与 B 的第 j 个字符(B[j-1]):
· 若两者相同:不需要任何操作,f[i][j] = f[i-1][j-1];
· 若两者不同:三种操作取最小 —— 删掉 A 的第 i 个(f[i-1][j]+1)、插入 B 的第 j 个(f[i][j-1]+1)、把 A 的第 i 个改成 B 的第 j 个(f[i-1][j-1]+1)。

边界:f[i][0] = i,f[0][j] = j。
答案:f[n][m]。
复杂度:时间 O(nm),空间 O(nm)(可优化到 O(min(n,m)))。

🎬 交互演示:DP 表第 2 行(A 用掉前两个字符 "ca")的填表过程

0
1
1
2

依次为 f[2][0] ~ f[2][3]:边界 2 → 'a' vs 'c' 替换得 1 → 'a' vs 'u' 替换得 1 → 'a' vs 't' 替换得 2

C++P2758 编辑距离 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 2005;
int f[MAXN][MAXN];          // f[i][j]: A 前 i 个 → B 前 j 个 的最少操作数

int main() {
    string A, B;
    cin >> A >> B;
    int n = A.size(), m = B.size();

    // ---- 边界:第一列(全删)与第一行(全插) ----
    for (int i = 0; i <= n; i++) f[i][0] = i;
    for (int j = 0; j <= m; j++) f[0][j] = j;

    // ---- 逐行从左到右填表:保证三个来源都已算过 ----
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (A[i - 1] == B[j - 1])
                f[i][j] = f[i - 1][j - 1];              // 匹配:免费
            else
                f[i][j] = min({f[i - 1][j],          // 删除 A 的第 i 个
                               f[i][j - 1],          // 插入 B 的第 j 个
                               f[i - 1][j - 1]}) + 1;  // 替换
        }
    }
    cout << f[n][m] << "\n";
    return 0;
}
运行结果
输入: kitten
      sitting
输出: 3

说明: kitten → sitten (替换 k→s) → sittin (替换 e→i) → sitting (插入 g)
      共 3 次操作,这是最少的方案

💡 三个操作与三个方向的对应关系(务必记牢)

很多同学记不住「删除对应上格还是左格」,其实只要想清楚一件事:
「删除 A 的第 i 个字符」意味着 A 往前推进、B 不动,所以要从「A 少一个字符」的状态 f[i-1][j] 出发 —— 那是上一行同一列(上格)。
「插入 B 的第 j 个字符」意味着 B 往前推进、A 不动,所以从 f[i][j-1] 出发 —— 那是本行左边一列(左格)。
「替换」意味着两边同时推进,所以从 f[i-1][j-1] 出发 —— 左上格。
理解「谁推进、谁不动」,就再也不会记混方向了。

案例 2没有上司的舞会(洛谷 P1352)
经典树型 DP · 选与不选

题目描述

某大学有 n 个职员,编号 1~n,他们的关系就像一棵以校长为根的树。每个职员有一个快乐指数 r[i]。现在要举办一场舞会,如果某个职员的上司来了,那么这个职员就无论如何也不肯来。请计算邀请哪些职员可以使快乐指数总和最大,并求出最大的快乐指数。

输入格式:第一行 n;接下来 n 行,第 i 行是 r[i];接下来 n-1 行,每行两个整数 l, k,表示 k 是 l 的直接上司。
输出格式:一行,一个整数,表示最大的快乐指数。n ≤ 6000,-128 ≤ r[i] ≤ 127。

输入样例:
7
1 1 1 1 1 1 1(占 7 行)
1 3
2 3
6 4
7 4
4 5
3 5

输出样例:5

💡 题目分析

这是「树的最大权独立集」:在树上选一些点,要求选出的点中不存在父子关系。

状态:f[u][0] 表示「u 不参加时,u 的子树能得到的最大快乐值」;f[u][1] 表示「u 参加时」的最大值。

转移:f[u][0] = Σ max(f[v][0], f[v][1]);f[u][1] = r[u] + Σ f[v][0]。

建树细节:输入给的是「儿子 l,父亲 k」,所以 g[k].push_back(l)。同时统计每个点的入度,入度为 0 的点就是根(校长)。这一步非常重要——不找出根就没法开始 DFS。

答案:max(f[root][0], f[root][1])。

🎬 交互演示:后序遍历依次处理每个节点(样例中根为 5 号)
1从根 5 号进入,先把 f[5][0]=0、f[5][1]=r[5]=1 初始化好,然后递归第一个儿子 4 号。
2进入 4 号,初始化 f[4][0]=0、f[4][1]=1,递归儿子 6 号。6 号是叶子,直接算完:f[6][0]=0, f[6][1]=1。
3回到 4 号合并 6 号:f[4][0] += max(0,1) = 1;f[4][1] += f[6][0] = 0,所以 f[4][1] = 1。
44 号继续递归第二个儿子 7 号。7 号是叶子:f[7][0]=0, f[7][1]=1。合并后 f[4][0] = 1 + 1 = 2,f[4][1] = 1 + 0 = 1。
54 号处理完毕:f[4][0]=2, f[4][1]=1。(含义:4 号不来时,可从 6、7 中全请,得 2;4 号来的话 6、7 都不能来,只得 1。)
6回到 5 号合并 4 号:f[5][0] += max(2,1) = 2;f[5][1] += f[4][0] = 2,所以 f[5][1] = 1 + 2 = 3。接着递归第二个儿子 3 号。
73 号的两个儿子 1、2 都是叶子,各贡献 1,于是 f[3][0]=2,f[3][1]=1+0=1。回到 5 号合并:f[5][0] = 2 + 2 = 4;f[5][1] = 3 + 2 = 5。
8最终答案 max(f[5][0], f[5][1]) = max(4, 5) = 5。最优方案是请 5 号 + 他的四个孙子 1、2、6、7,共 5 人。
提示:注意每个节点都是「先初始化自己 → 再逐个合并儿子」,这就是「后序遍历 + 合并」的标准节奏。
C++P1352 没有上司的舞会 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 6005;
int n, r[MAXN], f[MAXN][2];   // f[u][0]: u 不来; f[u][1]: u 来
int indeg[MAXN];                 // 入度,用来找根
vector<int> g[MAXN];

void dfs(int u) {
    f[u][0] = 0;                  // 初始化:只考虑 u 自己
    f[u][1] = r[u];

    for (int v : g[u]) {           // 递归所有直接下属
        dfs(v);
        f[u][0] += max(f[v][0], f[v][1]);  // u 不来:v 随意
        f[u][1] += f[v][0];               // u 来:v 必须不来
    }
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> r[i];
    for (int i = 1; i < n; i++) {
        int l, k; cin >> l >> k;      // k 是 l 的上司
        g[k].push_back(l);
        indeg[l]++;
    }
    int root = 1;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) { root = i; break; }  // 入度为 0 即根

    dfs(root);
    cout << max(f[root][0], f[root][1]) << "\n";
    return 0;
}
运行结果
输入: 7
      1
      1
      1
      1
      1
      1
      1
      1 3
      2 3
      6 4
      7 4
      4 5
      3 5
输出: 5

说明: 树形结构为 5 → {4, 3}; 3 → {1, 2}; 4 → {6, 7}
      最优方案:请 5、1、2、6、7 共 5 人,快乐指数总和为 5

⚠️ 为什么本题必须找根?

因为 f[root] 才代表「整棵树」的答案。如果随便从一个点开始 DFS,就只会算出这个点的子树,答案会偏小。
找根的方法有两种:① 统计入度,入度为 0 的点就是根(推荐,O(n) 且不会出错);② 用并查集把所有点合并,最后看哪个点是它自己所在集合的根。
更进一步:如果题目没有保证「一定存在一个根」,那就需要建一个虚根把所有入度为 0 的点接起来,再对虚根做 DFS。

案例 3选课(洛谷 P2014 / CTSC1997)
进阶树型 DP · 树上背包

题目描述

在大学里每个学生都要修一定的学分才能毕业。有些课程有先修课:要修课程 i,必须先修完它的先修课 ki(ki = 0 表示没有先修课)。每门课有学分 si。学生最多可选 m 门课,求能获得的最大学分。

输入格式:第一行两个整数 n, m;接下来 n 行,第 i 行两个整数 ki, si。
输出格式:一行一个整数,表示最大学分。n ≤ 300,m ≤ n。

输入样例:
7 4
2 2
0 1
0 4
2 1
7 1
7 6
2 2
输出样例:13

💡 题目分析

建树:把「先修课」看作父亲,则所有课程构成一片森林(可能有多棵树)。为了统一处理,建一个 0 号虚根,把所有 k_i = 0 的课程接在它下面,森林就变成了一棵以 0 为根的树。这是森林问题转树问题的通用手段。

状态:f[u][j] = 在 u 的子树中选 j 门课,且必须包含 u 时的最大学分。注意「必选 u」这个限定——因为想修 u 的子孙,就必须先修 u。

转移:合并儿子 v 时,枚举「给 v 的子树分 j′ 个名额」:f[u][j] = max(f[u][j], f[u][j-k] + f[v][k])。

答案:f[0][m+1]——虚根 0 自己占掉 1 个名额(它没有学分,代价为 0),剩下 m 个名额才是真正要修的课。

复杂度:O(n²)。看似是「两层循环 × n 个节点」,但因为合并 u 与 v 的花费是 siz[u]×siz[v],所有点对的乘积之和为 O(n²)。

C++P2014 选课 —— 完整代码(注意三处边界!)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 305;
int n, m, s[MAXN], siz[MAXN];
int f[MAXN][MAXN];        // f[u][j]: u 子树中选 j 门(含 u)的最大学分
vector<int> g[MAXN];

void dfs(int u) {
    siz[u] = 1;
    f[u][1] = s[u];                     // 必须选 u 自己(虚根 s[0]=0)

    for (int v : g[u]) {
        dfs(v);
        // ① j 倒序:保证 f[u][j-k] 是「还没考虑 v」的旧值
        for (int j = min(m + 1, siz[u] + siz[v]); j >= 1; j--) {
            // ② k 的上下界:至少给 u 留 1 个位置(k <= j-1)
            //            且不能超过 u 已有的容量(k >= j - siz[u])
            for (int k = max(1, j - siz[u]); k <= min(j - 1, siz[v]); k++)
                f[u][j] = max(f[u][j], f[u][j - k] + f[v][k]);
        }
        siz[u] += siz[v];                 // ③ 合并完别忘了更新子树大小
    }
}

int main() {
    cin >> n >> m;
    for (int i = 1; i <= n; i++) {
        int k; cin >> k >> s[i];
        g[k].push_back(i);                // 0 号是虚根,把森林连成一棵树
    }
    dfs(0);
    cout << f[0][m + 1] << "\n";      // 虚根占 1 个名额
    return 0;
}
运行结果
输入: 7 4
      2 2
      0 1
      0 4
      2 1
      7 1
      7 6
      2 2
输出: 13

说明: 建树后:0 → {2, 3}; 2 → {1, 4, 7}; 7 → {5, 6}
      0 号不占学分,实际选 4 门课
      最优方案:选 2(1分) + 7(2分) + 6(6分) + 3(4分) = 13 分
      验证:修 7 前必须先修 2,方案合法;3 是另一棵独立的树,互不影响

⚠️ 树上背包的三个「必须」

① 必须倒序枚举 j。与一维 0/1 背包同理,正序会让 f[u][j-k] 已经把 v 的贡献算进去,导致同一棵子树被重复选取。
② 必须给 u 留至少 1 个名额(k ≤ j-1)。因为状态定义里写了「必选 u」;如果 k = j,意味着 u 一个名额都没有,与定义矛盾。
③ 必须限制 k 的下界(k ≥ j - siz[u])。否则 f[u][j-k] 会读到「超出 u 当前子树大小」的非法值(初始的 0),造出虚假答案。这是树上背包最容易出的错,写代码时务必带上这个下界。

模块 03

实践应用

完整题目 + 样例 + 多角度分析 + 代码 + 运行结果。

题1多维 DP + 空间优化

最长公共子序列(LCS)

给定两个字符串 A、B(长度均不超过 5000),求它们的最长公共子序列的长度。
输入格式:两行,分别为字符串 A 与 B。
输出格式:一行一个整数。
数据范围:|A|, |B| ≤ 5000。内存限制 16 MB。

输入样例:abcbdab
bdcaba
输出样例:4

💡 题目分析

状态:f[i][j] = A 的前 i 个字符与 B 的前 j 个字符的最长公共子序列长度。

转移:· 若 A[i-1] == B[j-1],这个字符可以「配对」,f[i][j] = f[i-1][j-1] + 1;
· 否则至少有一个字符用不上,取「丢掉 A 的第 i 个」与「丢掉 B 的第 j 个」中的较优者:f[i][j] = max(f[i-1][j], f[i][j-1])。

边界:f[0][j] = f[i][0] = 0(与空串的 LCS 长度为 0)。
答案:f[n][m]。

方法一:直接开二维数组 内存超限
n = m = 5000 时,int f[5005][5005] 需要 5005² × 4 B ≈ 100 MB,远超 16 MB 的限制。

方法二:滚动数组 空间 O(m) 推荐
观察转移式:f[i][j] 只依赖本行左边与上一行。所以只需要保存两行:当前行与上一行,用 i & 1 做下标即可,内存降到约 40 KB。凡是一维 DP 只依赖「前一行」,就能用滚动数组。

C++LCS —— 滚动数组版(空间从 100MB 降到 40KB)
#include <bits/stdc++.h>
using namespace std;

const int MAXM = 5005;
int f[2][MAXM];              // 只保留两行:f[0][·] 与 f[1][·]

int main() {
    string A, B;
    cin >> A >> B;
    int n = A.size(), m = B.size();

    for (int i = 1; i <= n; i++) {
        int cur = i & 1, pre = (i - 1) & 1;
        f[cur][0] = 0;                  // 每行开头都要重置边界
        for (int j = 1; j <= m; j++) {
            if (A[i - 1] == B[j - 1])
                f[cur][j] = f[pre][j - 1] + 1;   // 配对成功
            else
                f[cur][j] = max(f[pre][j], f[cur][j - 1]);// 丢 A 或丢 B
        }
    }
    cout << f[n & 1][m] << "\n";
    return 0;
}
运行结果
输入: abcbdab
      bdcaba
输出: 4

说明: 一个最长公共子序列是 "bcba"(也可是 "bdab"、"bcab"),长度为 4

⚠️ 滚动数组最容易错的两个地方

① 忘了重置 f[cur][0] = 0。因为 cur 这一行两轮之前被用过,第 0 列残留着旧值。不重置会让「与空串的 LCS」变成非 0,答案整体偏大。
② 用 i % 2 而不是 i & 1?两者等价,但 i & 1 更快。真正要小心的是:必须用 (i-1) & 1 取上一行,写成 (i+1) & 1 在某些实现里会出错(虽然数学上等价,但可读性差、容易改错)。
最后提醒:滚动数组会丢失历史信息。如果题目要求「输出具体方案」而不只是长度,就不能用滚动数组,必须保留完整的二维表(或用别的技巧记录决策路径)。

题2树型 DP · 覆盖型

树的最小顶点覆盖

给定一棵 n 个节点的树(n ≤ 10⁵),请选出最少的节点,使得树中每一条边都至少有一个端点被选中。输出最少节点数。

输入格式:第一行 n;接下来 n-1 行,每行两个整数 u, v 表示一条边。
输出格式:一行一个整数。
输入样例:5
1 2
2 3
2 4
4 5

输出样例:2

💡 题目分析

状态:dp[u][0] = u 不选 时,使 u 的子树内所有边都被覆盖所需的最少节点数;dp[u][1] = u 选 时的最少节点数。

转移(关键在 dp[u][0]):
· u 选:对每个儿子 v,边 (u,v) 已经被 u 覆盖了,v 选不选都行 → dp[u][1] = 1 + Σ min(dp[v][0], dp[v][1]);
· u 不选:边 (u,v) 就必须由 v 来覆盖,所以每个儿子 v 都必须选 → dp[u][0] = Σ dp[v][1]。

答案:min(dp[root][0], dp[root][1])。

与「没有上司的舞会」的对比 对照记忆 提高组高频

维度最大权独立集(舞会)最小顶点覆盖(本题)
约束选的不能有父子关系每条边至少一端被选
dp[u][0] 含义u 不来,儿子随意u 不选,儿子必须全选
dp[u][0] 转移Σ max(dp[v][0], dp[v][1])Σ dp[v][1]
dp[u][1] 转移val[u] + Σ dp[v][0]1 + Σ min(dp[v][0], dp[v][1])
答案max(dp[root][0], dp[root][1])min(dp[root][0], dp[root][1])

惊人的对偶关系:两者状态方程几乎「语法同构」,只是 max ↔ min、儿子取「最优」↔「全部」 互换了位置。更神奇的是,在二分图(树是特殊的二分图)上,最大独立集 + 最小顶点覆盖 = 顶点总数(Kőnig 定理的推论)。所以如果题目给了点权,用「总权值 − 最大独立集」就能得到「最小顶点覆盖」,两题其实是一体两面。

C++树的最小顶点覆盖 —— 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int n, dp[MAXN][2];
vector<int> g[MAXN];

void dfs(int u, int fa) {
    dp[u][0] = 0;                       // u 不选:一个都不用
    dp[u][1] = 1;                       // u 选:先把自己算上

    for (int v : g[u]) {
        if (v == fa) continue;
        dfs(v, u);
        dp[u][0] += dp[v][1];                            // u 不选 → 儿子必须全选
        dp[u][1] += min(dp[v][0], dp[v][1]);             // u 选 → 儿子随意取优
    }
}

int main() {
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v; cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);              // 无向边,双向加
    }
    dfs(1, 0);
    cout << min(dp[1][0], dp[1][1]) << "\n";
    return 0;
}
运行结果
输入: 5
      1 2
      2 3
      2 4
      4 5
输出: 2

说明: 树形为 1-2, 2-3, 2-4, 4-5
      选 {2, 4}:边(1,2)由2覆盖、(2,3)由2覆盖、
                  (2,4)由2或4覆盖、(4,5)由4覆盖,共 2 个点
      下界证明:边(1,2)与边(4,5)没有公共端点,必须各选一个,故最少为 2

💭 为什么 dp[u][0] 必须「儿子全选」,不能像舞会那样取 max?

因为两个问题的约束性质不同:
· 舞会是「禁止型」约束——只禁止「父子同时选」,所以 u 不来时,儿子来不来都不违规,可以自由取舍(取 max)。
· 顶点覆盖是「要求型」约束——要求「每条边至少一端被选」,所以 u 不选时,边 (u,v) 唯一能指望的就是 v,儿子必须全部承担。
这套分析方法可以推广到所有树型 DP:先问「u 的这个状态,对儿子施加了什么约束」,再据此决定是取 max/min 还是无条件求和。约束决定转移,而不是反过来。

题3换根 DP · 需维护次大值

树上每个点的偏心距

给定一棵 n 个节点的树(n ≤ 2×10⁵,边权均为 1)。对每个点 u,定义它的偏心距为「u 到其他所有点中最远的那个点的距离」。请输出所有点的偏心距。

输入格式:第一行 n;接下来 n-1 行,每行两个整数 u, v。
输出格式:一行 n 个整数,第 i 个表示点 i 的偏心距。
输入样例:5
1 2
2 3
3 4
4 5

输出样例:4 3 2 3 4

💡 题目分析

朴素做法:对每个点各跑一次 BFS/DFS 求最远距离 O(n²) 超时
更快的观察:从任意点 u 出发,最远的点一定在直径端点上(本课程第 38 讲讲过的性质)。所以只要求出直径两端 A、B,答案就是 max(dist(u,A), dist(u,B))。但「求出两个端点后对每个点求距离」仍需三次 BFS/DFS,虽然可以 O(n),却只在「边权非负且只求偏心距」时管用——换根 DP 是更通用的框架。

换根 DP 做法 O(n) 通用

状态拆解:u 的最远点只有两种可能——在 u 的子树内,或者在 u 的子树外。于是拆成两个量:
· down[u]:从 u 出发、只向子树内走的最远距离(第一遍 DFS,自底向上);
· up[u]:从 u 出发、向子树外走的最远距离(第二遍 DFS,自顶向下)。
答案:ecc[u] = max(down[u], up[u])。

难点:如何推出 up[v]?对 u 的儿子 v:从 v 往「子树外」走,第一步必然是走到 u(距离 1),然后有两种选择:
① 继续从 u 往上走,最远距离是 up[u];
② 从 u 走进「u 的其他子树」,最远距离是「u 的不经过 v 的最长向下链」。
所以 up[v] = max(up[u], bestNotThroughV(u)) + 1。

关键陷阱:「不经过 v 的最长向下链」不能做减法(取 max 不可逆)!必须预先记录 u 的最大值 best1、次大值 best2,以及最大值来自哪个儿子 id1。
若 id1 == v,说明最长链正好在 v 这一支,必须改用次大值 best2;否则直接用 best1。

C++换根 DP 求偏心距 —— 完整代码(核心是 max/次大/来源三件套)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int n, down[MAXN], up[MAXN];
int best1[MAXN], best2[MAXN], id1[MAXN];  // 最长 / 次长 / 最长来自谁
vector<int> g[MAXN];

// ---------- 第一遍:自底向上求 down ----------
void dfs1(int u, int fa) {
    best1[u] = best2[u] = 0;   // 边权非负,初值 0 安全
    id1[u] = 0;
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs1(v, u);
        int len = down[v] + 1;
        if (len > best1[u]) {          // 刷新最大值,旧的最大值降为次大
            best2[u] = best1[u]; best1[u] = len; id1[u] = v;
        } else if (len > best2[u]) {   // 只刷新次大值
            best2[u] = len;
        }
    }
    down[u] = best1[u];
}

// ---------- 第二遍:自顶向下求 up ----------
void dfs2(int u, int fa) {
    for (int v : g[u]) {
        if (v == fa) continue;
        // 若最长向下链恰好经过 v,则不能再用它,改用次大值
        int fromU = (id1[u] == v) ? best2[u] : best1[u];
        up[v] = max(up[u], fromU) + 1;
        dfs2(v, u);
    }
}

int main() {
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v; cin >> u >> v;
        g[u].push_back(v); g[v].push_back(u);
    }
    up[1] = 0;                      // 根没有「上方」,初始为 0
    dfs1(1, 0);
    dfs2(1, 0);
    for (int i = 1; i <= n; i++)
        cout << max(down[i], up[i]) << " \n"[i == n];
    return 0;
}
运行结果
输入: 5
      1 2
      2 3
      3 4
      4 5
输出: 4 3 2 3 4

说明: 这是一条 5 个点的链 1-2-3-4-5
      点1 最远到点5,距离 4;点2 最远到点5,距离 3
      点3 最远到点1 或点5,距离 2
      点4 最远到点1,距离 3;点5 最远到点1,距离 4
      链上的偏心距呈「山形」,中点最小 —— 这也正是链的「直径中点」

💭 如果边权可以为负,上面代码哪里会出错?

答案是初值。best1[u] = best2[u] = 0 会带来两个问题:
① 若所有边权都是负数,真正的「最长链」是负数,但 0 更大,于是 best1 被错误地保留为 0,等于假装「原地不动」比走任何边都好;
② 只有 1 个儿子时 best2 停留在 0,而它本应代表「不存在」的 -∞,于是 up[v] 会算出一个不存在的路径长度。
修法:把初值改为 best1[u] = best2[u] = -INF,并在 down[u] = best1[u] 处写 down[u] = max(0, best1[u])(允许「原地不动」这个合法选择)。凡是要「取 max 的换根 DP」,初值都必须用 -∞ 而不是 0。

模块 04

总结提升

①核心要点与万能模板
知识板块必须记住的核心
多维状态信息量不够就加维度;两个对象 → 两个下标 f[i][j]
二维填表顺序逐行从左到右;保证「上、左、左上」都已算过
编辑距离匹配 → 抄左上;否则 min(上, 左, 左上) + 1
LCS相等 → 左上 + 1;否则 max(上, 左)
边界二维:第一行与第一列;树上:叶节点
树型 DP 顺序后序遍历:儿子全部算完才算父亲
选与不选f[u][0]=Σmax(f[v][0],f[v][1]);f[u][1]=val[u]+Σf[v][0]
最小顶点覆盖dp[u][0]=Σdp[v][1];dp[u][1]=1+Σmin(dp[v][0],dp[v][1])
树上背包j 倒序;k ≤ j−1(给 u 留位置);k ≥ j−siz[u](防非法值)
树上背包复杂度合并 u、v 花费 siz[u]×siz[v],总和 O(n²)
换根 DP第一遍自底向上、第二遍自顶向下;公式 ans[v]=ans[u]+Δ
换根取 maxmax 不可做减法 → 必须额外维护次大值与最大值来源
C++万能模板:树型 DP 通用骨架(选与不选 / 覆盖 / 背包 三种形态一次看全)
int n, siz[MAXN], f[MAXN][MAXV], g[MAXN][2];
vector<int> g2[MAXN];

// ============ 形态一:选与不选(最大权独立集) ============
void dp1(int u, int fa) {
    g[u][0] = 0; g[u][1] = val[u];
    for (int v : g2[u]) {
        if (v == fa) continue;
        dp1(v, u);
        g[u][0] += max(g[v][0], g[v][1]);   // 禁止型约束:儿子自由
        g[u][1] += g[v][0];
    }
}

// ============ 形态二:覆盖型(最小顶点覆盖) ============
void dp2(int u, int fa) {
    g[u][0] = 0; g[u][1] = 1;
    for (int v : g2[u]) {
        if (v == fa) continue;
        dp2(v, u);
        g[u][0] += g[v][1];                     // 要求型约束:儿子必须扛
        g[u][1] += min(g[v][0], g[v][1]);
    }
}

// ============ 形态三:树上背包 ============
void dp3(int u, int fa, int cap) {
    siz[u] = 1; f[u][1] = val[u];          // 必选 u
    for (int v : g2[u]) {
        if (v == fa) continue;
        dp3(v, u, cap);
        for (int j = min(cap, siz[u] + siz[v]); j >= 1; j--)   // 倒序!
            for (int k = max(1, j - siz[u]); k <= min(j - 1, siz[v]); k++)
                f[u][j] = max(f[u][j], f[u][j - k] + f[v][k]);
        siz[u] += siz[v];
    }
}

// ============ 形态四:换根(两遍 DFS) ============
void reroot1(int u, int fa) {  /* 自底向上:算 down[u] / best1 / best2 / id1 */ }
void reroot2(int u, int fa) {  /* 自顶向下:up[v] = max(up[u], 不过 v 的最长) + 1 */ }
🔑 一句话总纲

多维 DP 靠「加维度 + 三个方向的取优」,树型 DP 靠「后序遍历 + 合并儿子」;背包记得倒序、换根记得存次大、覆盖记得儿子全扛。

②拓展案例
拓展1三种写法对比

编辑距离能不能写成递归?三种写法(记忆化搜索 / 递推 / 滚动数组)各有什么取舍?

查看参考答案

写法一:记忆化搜索(自顶向下) 最贴近定义

int f[MAXN][MAXN];
int solve(int i, int j) {
    if (i == 0) return j;              // 边界:A 空了,只能插
    if (j == 0) return i;              // 边界:B 空了,只能删
    if (f[i][j] != -1) return f[i][j];  // 已算过,直接用
    int res;
    if (A[i - 1] == B[j - 1])
        res = solve(i - 1, j - 1);
    else
        res = 1 + min({solve(i - 1, j), solve(i, j - 1), solve(i - 1, j - 1)});
    return f[i][j] = res;             // 记录下来再返回
}

优点:几乎是把「状态定义」直接翻译成代码,不需要思考枚举顺序——因为递归天然会先算子问题。当转移关系复杂、拓扑序难以人工确定时,记忆化搜索能救命。

写法二:递推(自底向上) 常数最小
用两层循环按 i、j 递增填表。优点是没有函数调用开销、常数小,且便于滚地数组优化。缺点是需要自己想清楚枚举顺序。

写法三:滚动数组 空间最优
只保留两行,空间从 O(nm) 降到 O(m)。但它丢掉了完整的历史信息,无法回溯出具体方案。

写法时间空间能输出方案吗适合场景
记忆化搜索O(nm)(常数大)O(nm)✅ 可以转移复杂、拓扑序难定
递推O(nm)(常数小)O(nm)✅ 可以常规写法、准备优化空间
滚动数组O(nm)O(m)❌ 不行只求最优值、内存吃紧

输出方案的做法(回溯源路径):从 (n, m) 出发倒着走,每一步判断这个格子是从哪个邻居转移来的,把对应操作记录下来,最后反转输出。关键在于:既然要回溯,就必须保留完整的二维表——这是滚动数组的代价。

启发:「记忆化搜索 → 递推 → 滚动数组」是 DP 的标准三步进化路线。上手时先用记忆化搜索写对,再改成递推提速,最后按内存限制决定是否滚动。不要一上来就追求最优写法,先保证正确。

拓展2复杂度分析

树上背包的两层循环看起来嵌套很深,为什么总复杂度是 O(n²) 而不是 O(n³)?

查看参考答案

先写出「朴素估计」为什么吓人:n 个节点,每个节点处枚举 j 到 min(cap, siz[u]+siz[v])(最多 n),内层再枚举 k(最多 n),单点看似 O(n²),总计 O(n³)。但这个估计是松的。

关键:紧的界是「按点对计数」。考虑「合并 u 与儿子 v」这一步,它的实际循环次数是 O(siz[u] × siz[v])(外层 j 与内层 k 的乘积规模正比于两边子树大小的乘积)。

再对全树求和:把 siz[u] × siz[v] 展开来看,它恰好等于「以 u 为 LCA 的点对数量」(一个点在 u 的已合并部分、另一个在 v 的子树里)。而树上任意一对点 (x, y) 有且仅有一个 LCA,所以:

Σ(u,v) 为父子对 siz[u]·siz[v] = 树上的无序点对数 = O(n²)

所以总复杂度是 O(n²)——这是一个极紧的界,实测常数还很小。当 n = 300 时约 9 万次运算;n = 2000 时约 400 万次,仍然很快。

实用剪枝技巧(把常数再压小):
① 外层 j 上界写 min(cap, siz[u] + siz[v]),不要傻写 cap;
② 内层 k 的下界写 max(1, j - siz[u]),避免访问到「超出 u 已有容量」的非法状态;
③ 循环顺序很重要:把「子树小的先合并」或「后合并大的」,实测常数更优;
④ 如果题目不需要严格的「必须选 u」,可以考虑把 f 的维度压一维,做类似滚动数组的优化。

启发:「看起来是三次方,实际是平方」这类结论在算法竞赛里反复出现(比如点分治的 O(n log²n) 证明、斜率优化的均摊分析)。看到嵌套循环,不要只看循环层数,要看每层的实际取值范围。

拓展3建模迁移

有一个「买主件才能买附件」的购物问题:主件最多带 2 个附件,预算 V。这和树上背包是什么关系?

查看参考答案

问题描述(洛谷 P1064 金明的预算方案):有 n 件物品,每件有价格与重要度。物品分为主件与附件,要买附件必须先买它的主件,且每件主件最多有 2 个附件。预算为 V,求「价格×重要度」的最大总和。

观察一:这是「依赖背包」。物品之间的依赖关系构成一个森林:主件是父亲,附件是儿子(每个父亲最多 2 个儿子)。依赖背包在结构上就是树上背包。

观察二:由于每个主件最多 2 个附件,可以「枚举组合」来简化。对每个主件 u,共有 4 种购买方式:
① 什么都不买;② 只买 u;③ 买 u + 附件1;④ 买 u + 附件2;⑤ 买 u + 两个附件。
(注意 ③④⑤ 共 3 种带附件的组合,加上 ② 共 4 种「买 u」的方式)

于是每个主件被压缩成一个「物品组」,组内互斥(只能选一种买法),就变成了一道标准的分组背包:

// 把每个主件 u 预处理成 4 个「候选方案」
// 方案 k:花费 cost[k],价值 val[k]
int cnt = 0;
cost[++cnt] = p[u];          val[cnt] = p[u] * w[u];              // 只买主件
if (有附件1)
    cost[++cnt] = p[u]+p[a1]; val[cnt] = p[u]*w[u]+p[a1]*w[a1];
if (有附件2)
    cost[++cnt] = p[u]+p[a2]; val[cnt] = p[u]*w[u]+p[a2]*w[a2];
if (两个附件都有)
    cost[++cnt] = p[u]+p[a1]+p[a2]; val[cnt] = /* 三者之和 */;

// 然后做分组背包:每组只能选一个
for (每个主件 u)
    for (int j = V; j >= 0; j--)               // 倒序!
        for (int k = 1; k <= cnt; k++)
            if (j >= cost[k])
                f[j] = max(f[j], f[j - cost[k]] + val[k]);

两种做法的对比:

做法复杂度适用条件
转成分组背包(枚举组合)O(n·V)附件数很少(本题 ≤ 2)
直接做树上背包O(n·V²) 或 O(n²)附件数不限,通用

结论:当「依赖树」的形状特殊(每个父亲儿子数极少)时,把「子树」枚举成若干「组合」,就能把树上背包降级为分组背包,复杂度从 O(n·V²) 降到 O(n·V)。这是非常实用的建模技巧:看到「附件 ≤ k」,先想想能不能枚举组合。

反过来:如果题目改成「每个主件可以有任意多个附件」(比如树形依赖的任意形状),就必须老老实实写树上背包,O(n·V) 的简化将不再成立。

③思考与启发

💭 思考1:DP 强调的「无后效性」,在树型 DP 里是如何被保证的?

「无后效性」的意思是:一旦某个状态确定了,它后面的决策就与「怎么到达这个状态」无关。树型 DP 里这一点为什么天然成立?

查看参考答案

答案藏在树的结构里。树是无环的:从一个点出发,只有「往子树里走」和「往回走」两个方向,绝不可能绕一圈回到自己。所以「u 的子树」与「u 子树外的世界」之间,仅仅通过一条边 (u, fa[u]) 相连。

这意味着:子树内部的任何决策,都不可能影响子树外部(因为除了那条边,没有任何其他通路)。于是「以 u 为根的子树」就真的是一个自包含的、可以独立求解的子问题。这就是无后效性。

对比一下有环的图:如果有环,那么「进入某个子区域」后还能绕回来,该区域的答案就会依赖外部——此时后效性出现,简单的树型 DP 立刻失效,必须换成「最短路 / 拓扑排序 / 缩点 / 基环树特判」等处理方式。

启发:「树型 DP 能用」的本质前提是树的「割边」性质——删除任意边就断开。这个性质在竞赛中反复被使用(重心分治、点分治、树链剖分、虚树……),值得牢牢记在心里。凡是能保证「局部与全局隔离」的结构,都可以做分治式的 DP。

💭 思考2:多维 DP 的空间能不能一直降到一维?滚动数组的适用条件是什么?

编辑距离用了两行滚动,0/1 背包把二维压成一维。为什么有的能压到极限(一维),有的只能压到两行?

查看参考答案

判据是:转移时需要的「历史信息」是「上一行整行」还是「上一行的一个前缀」。

0/1 背包能压成一维,因为它的转移是 f[j] = max(f[j], f[j-w] + v):只需要「容量比 j 小的那些位置」,而这些位置在倒序枚举时还没被本轮更新过——它们保留的正好是「上一轮的旧值」。所以一维数组 + 倒序 = 天然的滚动。

编辑距离不能压成一维,因为它的转移要用到 f[i-1][j-1](上一行的左后方)。在一维数组里,这个位置在向右推进时已经被本轮覆盖了,直接读会读到新值。
变通方案:额外用一个变量 prev 保存「上一格被覆盖之前的旧值」。这样确实能把编辑距离压到 O(m),但代码可读性会明显下降。

// 编辑距离的一维极限写法(仅供理解,考场慎用)
int f[MAXM];
for (int j = 0; j <= m; j++) f[j] = j;        // 第 0 行
for (int i = 1; i <= n; i++) {
    int diag = f[0];                           // 保存 f[i-1][0]
    f[0] = i;                                    // f[i][0]
    for (int j = 1; j <= m; j++) {
        int old = f[j];                         // 先存下 f[i-1][j]
        if (A[i-1] == B[j-1]) f[j] = diag;
        else f[j] = 1 + min({f[j], f[j-1], diag});
        diag = old;                              // 成为下一格的「左上」
    }
}

实用建议:考场上优先写「两行滚动」——它既省内存又好懂、不易错。只有当内存限制真的卡死(如本题的 16 MB 配 n=m=5000)时才考虑一维极限写法,而且一定要先写对二维版本对拍验证。

启发:空间优化不是「越省越好」,而是「在满足内存限制的前提下,选最容易写对的那个」。「两行滚动 + 清空首列」几乎能应付所有情况,是性价比最高的方案。

💭 思考3:如果题目给的不是树,而是一张有环的图,树型 DP 还能用吗?

「没有上司的舞会」在树上能 O(n) 解决。如果把关系改成「一张任意图」(相邻的点不能同时选),问题会变成什么样?

查看参考答案

在一般图上,这个问题是「最大权独立集」,它是一个 NP-hard 问题——不存在多项式时间的精确算法(除非 P = NP)。从树到图,难度是一次「量级级」的跳跃。

为什么树上能做到 O(n)?因为树上「父子分层」天然给出了一个拓扑序:每个点的状态只依赖它的儿子,不会互相牵制。图上有环时,环上的点互相依赖,无法排出这样一个单向的顺序。

常见的「退一步」处理办法:
① 树形图(每个点入度 ≤ 1 / 有向无环):可以按拓扑序 DP;
② 基环树(恰有一个环):把环上的一条边「断掉」变成树,做两次树型 DP(强制该边的一端选 / 不选),再取较优值。这就是「基环树 DP」的标准套路;
③ 一般稀疏图:只能上近似算法、状压(点数很小,下一讲的内容)或转为 2-SAT / 网络流(特定权值形式下可行);
④ 二分图:最大独立集 = 总点数 − 最小顶点覆盖 = 总点数 − 最大匹配(Kőnig 定理),可用匈牙利算法 / 网络流 O(n√m) 求解。树是二分图,这正是树上能快速求的原因之一。

启发:算法竞赛里一个重要的思维习惯是——先判断问题属于哪一类结构,再决定用哪套工具。
「是不是树?」→ 是,就用树型 DP;
「是不是二分图?」→ 是,就考虑匹配 / 网络流;
「点数是不是 ≤ 20?」→ 是,就上状压 DP;
「什么结构都没有?」→ 认清这是 NP-hard,去看数据范围里有没有别的特殊性质。
「认清问题的结构」往往比「背算法」更重要。

模块 05

学习检测

五个最容易翻车的地方,考前务必扫一眼。

易错1

编辑距离:下标错位与边界遗漏

❌ 错误
if (A[i] == B[j]) —— 状态里的 i、j 是「前几个字符」,从 1 开始;字符串却是从 0 编号。
或者只写了 for (i) f[i][0] = i; 就忘了 for (j) f[0][j] = j;。
✅ 正确
if (A[i-1] == B[j-1]),两个下标都要减 1。
边界两行都要写:f[i][0]=i 与 f[0][j]=j。
口诀:状态从 1 数、字符串从 0 数,判断时两个都减 1!
易错2

树上背包:忘了倒序枚举容量 j

❌ 错误
for (int j = 1; j <= cap; j++) 正序枚举。这样 f[u][j-k] 可能已经在「本轮合并 v」时被更新过,等于同一个儿子的内容被算了两次,答案偏大。
✅ 正确
for (int j = min(cap, siz[u]+siz[v]); j >= 1; j--) 倒序,保证 f[u][j-k] 取到的是「还没考虑 v」的旧值。
与一维 0/1 背包必须倒序是完全同一个道理。
口诀:背包一律倒着走,正着走就要重复选!
易错3

树上背包:k 的下界没限制,读到非法 0

❌ 错误
只写 for (int k = 1; k <= min(j-1, siz[v]); k++)。
当 j - k > siz[u] 时,f[u][j-k] 是一个从未被赋值的格子(值为 0),会造出「凭空多出的分数」,答案偏大且难以察觉。
✅ 正确
加上下界:
for (int k = max(1, j - siz[u]); k <= min(j - 1, siz[v]); k++)
保证 j - k ≤ siz[u],即 f[u][j-k] 一定是合法状态。
口诀:k 的上界看儿子、下界看父亲,两头都要卡死!
易错4

换根 DP:对「最大值」做减法

❌ 错误
想求「u 的不经过儿子 v 的最长向下链」,写成 down[u] - 1 或类似的减法。
因为 down[u] 是取 max 得到的,max 不可逆:最长链根本不在 v 这一支时,减完仍然是个错数。
✅ 正确
预先在第一次 DFS 里记录最大值 best1、次大值 best2、最大值来自哪个儿子 id1;
然后 int fromU = (id1[u] == v) ? best2[u] : best1[u];
若最长链恰好经过 v,就改用次大值;否则直接用最大值。
口诀:换根要取 max,必须存「次大」和「来源」!
易错5

森林问题没建虚根,或随手从 1 号开始 DFS

❌ 错误
「没有上司的舞会」里直接 dfs(1)。如果 1 号不是根,就只算出了 1 号的子树,其他部分完全没被统计,答案偏小。
「选课」里直接把 k=0 的课当根分别 DFS,多个根的答案无法合并。
✅ 正确
有唯一根时:统计入度,入度为 0 的点就是根(O(n),不会错)。
多棵树(森林)时:建一个虚根 0,把所有根的答案接上去统一处理;答案在虚根处读取(注意虚根本身占用的名额/代价)。
口诀:先找根、再 DFS;森林就加虚根!
📋多维 DP 与树型 DP 速查表
任务状态 / 转移复杂度
编辑距离相等时 f[i][j]=f[i-1][j-1];否则 min(上,左,左上)+1O(nm)
最长公共子序列相等时 f[i-1][j-1]+1;否则 max(上,左)O(nm)
最长上升子序列f[i] = max(f[j]) + 1 (a[j]<a[i]);可优化到 O(n log n)O(n²)/O(n log n)
最大权独立集f[u][0]=Σmax(f[v][0],f[v][1]);f[u][1]=val[u]+Σf[v][0]O(n)
最小顶点覆盖dp[u][0]=Σdp[v][1];dp[u][1]=1+Σmin(dp[v][0],dp[v][1])O(n)
最小支配集dp[u][0/1/2] 三态(自选 / 儿子覆盖 / 父亲覆盖)O(n)
树上背包f[u][j],合并时 j 倒序、k∈[max(1,j-siz[u]), min(j-1,siz[v])]O(n²)
换根(可加可减)ans[v] = ans[u] + (n-siz[v]) - siz[v]O(n)
换根(取 max)维护 best1 / best2 / id1,up[v]=max(up[u], 不过v的最长)+1O(n)
二维滚动数组f[i&1][j],每行开头重置 f[cur][0]空间 O(m)
📌笔记卡
📖 必记概念
  • 多维状态:信息量不够就加维度
  • 三个方向:上(删)、左(插)、左上(改/匹配)
  • 树型 DP:子树 = 独立子问题
  • 后序遍历 = 儿子先于父亲的拓扑序
  • 换根 DP:两遍 DFS,先向下再向上
🧠 必背模板
  • f[i][j]=min({f[i-1][j],f[i][j-1],f[i-1][j-1]})+1
  • f[u][0]+=max(f[v][0],f[v][1])
  • dp[u][0]+=dp[v][1](覆盖型)
  • for(j=cap;j>=1;j--) for(k=max(1,j-siz[u]);k<=min(j-1,siz[v]);k++)
  • int fromU=(id1[u]==v)?best2[u]:best1[u];
⚠️ 考场提醒
  • 字符串下标 = 状态下标 − 1
  • 二维 DP 边界两行都不能漏
  • 树上背包一律倒序枚举容量
  • 森林问题先建虚根
  • 换根取 max 必须存次大值
  • n ≤ 2×10⁵ 的深链要防爆栈
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1手算 DP 表

求把 "book" 变成 "back" 的最少操作次数(允许插入、删除、替换)。请完整画出 5×5 的 DP 表。

查看参考答案

逐行填表:

∅back
∅01234
b10123
o21123
o32223
k43332

答案:f[4][4] = 2,即把两个 o 分别替换成 a 和 c。

三处关键格子:
· f[1][1] = 0:'b' == 'b',直接抄左上角 f[0][0] = 0,匹配是免费的;
· f[3][3] = 2:'o' vs 'c' 不同,取 min(f[2][3]=2, f[3][2]=2, f[2][2]=1) + 1 = 2;
· f[4][4] = 2:'k' == 'k',直接抄左上角 f[3][3] = 2。

易错:① 边界两行写成「全部为 0」;② 判断时用 A[i] 而非 A[i-1],整张表错位;③ 忘记「匹配免费」这一支,导致所有匹配都被当成替换 +1,答案偏大。

A2LCS 手算

求 A = "ABCBDAB" 与 B = "BDCABA" 的最长公共子序列长度,并给出一个最长公共子序列。

查看参考答案

答案:长度为 4,例如 "BCBA"(也可以是 "BDAB"、"BCAB")。

验证 "BCBA":在 A = ABCBDAB 中依次取第 2、4、6、7 位得 B、B、A、B —— 等一下,这个取法得到的是 "BBAB",不是 "BCBA"。

重新构造:要在 A 中按顺序找到 B、C、B、A。A = A(1) B(2) C(3) B(4) D(5) A(6) B(7) → 取 2、3、4、6 位得 "BCBA" ✅;
在 B = B(1) D(2) C(3) A(4) B(5) A(6) 中,取 1、3、5、6 位得 "BCBA" ✅。两边都能按顺序取出,长度为 4,合法。

为什么不能更长:A 与 B 中字符 A 各出现 2 次、B 各出现 2 次、C 各 1 次、D 各 1 次。若长度 ≥ 5,则需要从 {A×2, B×2, C, D} 中选出 5 个并按序配对。但因为 D 在 A 中位于第 5 位(很靠后),在 B 中位于第 2 位(很靠前),两边都难以与其他字符按序匹配到 5 个长度。用 DP 表严格计算可得 f[7][6] = 4,这是精确的最大值。

易错:「公共子序列」不要求连续!很多同学误以为是「公共子串」(要求连续),那是另一个问题(用 f[i][j] = f[i-1][j-1]+1 当且仅当相等,否则清零,答案取全局最大值)。看清题目问的是 subsequence(可不连续)还是 substring(必须连续)。

A3树型DP手算

树:1-2、1-3、3-4。每个点的权值为 r[1]=3, r[2]=5, r[3]=4, r[4]=6。求最大权独立集(不能同时选相邻的两点)。

查看参考答案

答案:11,方案是选 {2, 4}(5 + 6 = 11)。

枚举验证所有极大独立集:
· {2, 4}:2 与 4 不相邻(路径为 2-1-3-4)→ 权值和 5 + 6 = 11 ✅
· {1, 4}:1 与 4 不相邻 → 3 + 6 = 9
· {2, 3}:2 与 3 不相邻 → 5 + 4 = 9
· {3}:4;{2}:5;{4}:6;{1}:3
最大值确实是 11。

用树型 DP 算(以 1 为根):
叶节点 2:f[2][0]=0, f[2][1]=5
叶节点 4:f[4][0]=0, f[4][1]=6
节点 3(儿子是 4):f[3][0] = max(0,6) = 6;f[3][1] = 4 + f[4][0] = 4
节点 1(儿子是 2、3):
  f[1][0] = max(f[2][0],f[2][1]) + max(f[3][0],f[3][1]) = max(0,5) + max(6,4) = 5 + 6 = 11
  f[1][1] = 3 + f[2][0] + f[3][0] = 3 + 0 + 6 = 9
答案 = max(11, 9) = 11 ✅ 与枚举一致。

易错:① 混淆 f[u][0] 的含义,误以为「u 不选则儿子必须选」——那是顶点覆盖的规则,本题是禁止型约束,儿子可以自由取舍;② 建树时把无向边只加了一遍,导致某个方向走不通;③ 忘了找根(本题 1 号恰好是根,但换一组数据就未必)。

A4树上背包手算

树:1 号点连着 2、3、4 号点。权值 val[1]=2, val[2]=5, val[3]=3, val[4]=4。要求选恰好 2 个点且必须包含 1 号(即选出的点形成包含根的连通块),求最大权值和。

查看参考答案

状态:f[u][j] = 在 u 的子树中选 j 个点且必含 u 的最大权值和。

初始化与合并:
f[1][1] = val[1] = 2(先只考虑 1 自己)
合并儿子 2(子树大小 1,f[2][1] = 5):
  j = min(2, 1+1) = 2 起倒序:
    j=2:k 从 max(1, 2-1)=1 到 min(1,1)=1 → f[1][2] = max(0, f[1][1] + f[2][1]) = 2 + 5 = 7
    j=1:k 从 1 到 0,空循环 → f[1][1] 不变 = 2
合并儿子 3(f[3][1] = 3):
  j=2:k=1 → f[1][2] = max(7, f[1][1] + 3) = max(7, 5) = 7(不变)
合并儿子 4(f[4][1] = 4):
  j=2:k=1 → f[1][2] = max(7, f[1][1] + 4) = max(7, 6) = 7(不变)

答案:7,方案是选 {1, 2},权值和 2 + 5 = 7。

核心体会:合并每个儿子时,只能「从 u 已有的份额里分一部分给这个儿子」。因为儿子之间彼此没有依赖,所以可以逐个合并;又因为同一棵子树不能被重复选取,所以必须倒序枚举 j。这两点就是树上背包的全部精髓。

易错:① 正序枚举 j(导致儿子 2 被重复选中,可能算出 2+5+5=12 这种非法答案);② k 的下界没写 max(1, j-siz[u]),读到未赋值的 0,可能在本题中不显形,但在更大数据上必然出错。

🔵 B组 · 提高(4题)

B1多维 DP + 空间优化

数字三角形:从顶部走到底部,每次只能走向下一行的相邻两个位置之一,求路径上数字之和的最大值。

查看参考答案

输入样例:5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5  输出:30

思路一:自顶向下 O(n²)
f[i][j] = 走到第 i 行第 j 列时的最大和:f[i][j] = a[i][j] + max(f[i-1][j-1], f[i-1][j])。
需要小心处理边界(第 1 列没有左上角、最后一个位置没有正上方)。

思路二:自底向上 更推荐
从倒数第二行开始往上推:f[i][j] = a[i][j] + max(f[i+1][j], f[i+1][j+1])。
优点:完全不需要处理边界——每个位置都有两个完整的来源,最后答案就是 f[1][1]。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
int n, a[MAXN][MAXN], f[MAXN][MAXN];

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= i; j++) cin >> a[i][j];

    for (int j = 1; j <= n; j++) f[n][j] = a[n][j];   // 边界:最后一行
    for (int i = n - 1; i >= 1; i--)          // 自底向上
        for (int j = 1; j <= i; j++)
            f[i][j] = a[i][j] + max(f[i+1][j], f[i+1][j+1]);
    cout << f[1][1] << "\n";
    return 0;
}

空间优化:观察转移只用到了「下一行」,所以可以把 f 压成一维:f[j] = a[i][j] + max(f[j], f[j+1]),在 i 从 n-1 递减的循环里原地更新。注意:这次不需要倒序!因为 f[j] 用到的 f[j] 与 f[j+1] 都是「下一行」的值,而我们刚好是从 i+1 行更新到 i 行,只要j 从小到大遍历就不会覆盖尚未使用的值(f[j+1] 还是旧值)。

运行结果:上面样例输出 30(路径 7→3→8→7→5,和为 30)。

易错:① 一维优化时枚举方向搞反(本题是 j 正序,与背包倒序相反,要理解「为什么」而不是死记);② 自顶向下写法忘记处理第 1 列的边界;③ 数组开太小(n ≤ 1000 时用 [1005][1005])。

B2树型DP · 多状态合并

用树型 DP 求树的直径(而不是两次 BFS/DFS),并说明两者各自的适用范围。

查看参考答案

状态:d1[u] = 从 u 出发、向子树内走的最长链长度;d2[u] = 次长链长度(来自不同的儿子)。

合并思路:对每个儿子 v,候选链长为 d1[v] + w(u,v),把它与 d1[u]、d2[u] 比较并维护前两名。每处理完一个儿子,就用 d1[u] + d2[u] 更新全局答案——它表示「一条从某个子树上来、经过 u、再下到另一个子树去」的路径,这正是直径的候选。

int d1[MAXN], d2[MAXN], diam = 0;

void dfs(int u, int fa) {
    d1[u] = d2[u] = 0;
    for (auto& e : g[u]) {
        if (e.to == fa) continue;
        dfs(e.to, u);
        int len = d1[e.to] + e.w;           // 经过这条边能延伸的最长链
        if (len > d1[u]) { d2[u] = d1[u]; d1[u] = len; }
        else if (len > d2[u]) d2[u] = len;
    }
    diam = max(diam, d1[u] + d2[u]);        // 经过 u 的最长路径
}

两种方法的适用范围:

方法复杂度要求能否顺便求其他信息
两次 BFS/DFSO(n)边权必须非负能给出直径端点
树形 DPO(n)无限制(含负权)能顺便求重心、最长链等

为什么两次搜索要求「边权非负」?它的正确性依赖一个引理:「从任意点出发的最远点一定是直径端点」。这个引理在存在负权边时会失效——因为「绕远路」反而让路径更短,最远点可能落在别处。树形 DP 不依赖这个引理,所以对负权也成立。

易错:① 忘记「两个最长链必须来自不同儿子」这一条(其实代码用 d1/d2 的更新顺序天然保证了,但若写成「取最大的两个儿子的 d1 之和」而没排除同一个儿子就会出错);② 只在 DFS 结束后用 d1[root]+d2[root] 更新答案——必须每个节点都更新,因为直径不一定经过根。

B3三状态树型DP

树的最小支配集:选最少的点,使得每个点「要么被选中,要么与某个被选中的点相邻」。请设计 dp 状态。

查看参考答案

为什么两个状态不够?因为「u 不选」时,u 可能由父亲覆盖,也可能由某个儿子覆盖——这两种情况的后续影响完全不同,所以必须拆开。这又是一次「信息量不够就加维度」。

状态含义要求
dp[u][0]u 被自己覆盖(u 被选中)儿子只需「被覆盖」即可,三种状态都可
dp[u][1]u 不被自己选,但被某个儿子覆盖至少一个儿子必须选
dp[u][2]u 不被自己选,靠父亲覆盖(u 的所有儿子都必须已被覆盖)儿子不能取 dp[v][2]

转移:
· dp[u][0] = 1 + Σ min(dp[v][0], dp[v][1], dp[v][2])(u 被选,儿子随便怎样都已被覆盖);
· dp[u][2] = Σ min(dp[v][0], dp[v][1])(u 靠父亲,儿子不能指望 u 来覆盖,所以不能取 dp[v][2]);
· dp[u][1] = Σ min(dp[v][0], dp[v][1]),但必须保证至少有一个儿子取了 dp[v][0]。做法是:先按 min(dp[v][0], dp[v][1]) 求和;若过程中没有任何一个儿子选择 dp[v][0],就把「换成 dp[v][0] 代价最小」的那个儿子改过来(加上 dp[v][0] − dp[v][1] 的最小差值)。

答案:min(dp[root][0], dp[root][1])——根没有父亲,所以不能取 dp[root][2]。

易错:① dp[u][1] 忘记「强制至少一个儿子选」,答案偏小;② dp[u][2] 里错误地允许了 dp[v][2],导致「父子互相指望对方」的死循环;③ 根节点取了 dp[root][2]。

统一记忆:树型 DP 的三种典型形态——选与不选(2 状态,禁止型约束)、最小顶点覆盖(2 状态,要求型约束)、最小支配集(3 状态,覆盖来源不同)。它们的骨架完全一致,区别只在「u 的状态给儿子施加了什么约束」。

B4换根 DP 模板

洛谷 P1364 医院设置:树上有 n 个点,每个点有权值 w[i](人口),边权为 1。求「到所有点的距离×人口的加权和」最小的点,并输出这个最小值。

查看参考答案

第一步(自底向上):求出 siz[u](子树人口和)与 down[u](子树内所有点到 u 的加权距离和)。
siz[u] = w[u] + Σ siz[v];down[u] = Σ (down[v] + siz[v])。

第二步(自顶向下):设 ans[u] 为「以 u 为医院时的总加权距离」。已知 ans[u],如何求 ans[v](v 是 u 的儿子)?
把医院从 u 挪到 v:
· v 子树内的 siz[v] 个人,每人路程近 1,减少 siz[v];
· 其余 total − siz[v] 个人(total 为总人口),每人路程远 1,增加 total − siz[v]。
所以 ans[v] = ans[u] + total − 2·siz[v]。

long long siz[MAXN], down[MAXN], ans[MAXN], total;

void dfs1(int u, int fa) {
    siz[u] = w[u];
    for (int v : g[u]) {
        if (v == fa) continue;
        dfs1(v, u);
        siz[u] += siz[v];
        down[u] += down[v] + siz[v];       // 子树内每人多走一条边
    }
}

void dfs2(int u, int fa) {
    for (int v : g[u]) {
        if (v == fa) continue;
        ans[v] = ans[u] + total - 2 * siz[v];
        dfs2(v, u);
    }
}

// 调用:dfs1(1,0); ans[1] = down[1]; dfs2(1,0);
// 答案:min over i of ans[i]

为什么「可加可减」在这里成立?因为答案是一个求和式(所有点贡献相加),换根时只改变每条路径的方向,是纯粹的加减关系,不需要取 max。这正是换根 DP 最简单的一类。

易错:① 忘记 ans[1] = down[1] 这一句初始化(根的总距离就是它作为「第一个根」时的答案);② 用 int 存答案(n=10⁵ 时答案可达 10¹⁰ 量级);③ 把「加权人口」和「点数」搞混,公式里的 siz 必须是人口和而不是节点数。

🔴 C组 · 挑战(4题)

C1DP 方案还原

在编辑距离问题中,除了求出最少操作次数,还要输出具体的操作序列。请给出做法。

查看参考答案

前提:必须保留完整的二维表。滚动数组省掉了历史信息,无法回溯,因此这个任务不能用滚动数组。

核心思路:从 (n, m) 倒着往回走,每一步判断「f[i][j] 是从哪个邻居转移来的」,把对应的操作记下来,最后反转输出。

int i = n, j = m;
vector<string> ops;
while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && A[i-1] == B[j-1]
        && f[i][j] == f[i-1][j-1]) {
        ops.push_back("保留 A[" + to_string(i) + "] = '" + A[i-1] + "'");
        i--; j--;
    }
    else if (i > 0 && j > 0 && f[i][j] == f[i-1][j-1] + 1) {
        ops.push_back("替换 A[" + to_string(i) + "] '" + A[i-1] + "' → '" + B[j-1] + "'");
        i--; j--;
    }
    else if (i > 0 && f[i][j] == f[i-1][j] + 1) {
        ops.push_back("删除 A[" + to_string(i) + "] = '" + A[i-1] + "'");
        i--;
    }
    else {
        ops.push_back("在 A 中插入 B[" + to_string(j) + "] = '" + B[j-1] + "'");
        j--;
    }
}
reverse(ops.begin(), ops.end());
for (auto& s : ops) cout << s << "\n";

三个注意点:
① 判断顺序有讲究。必须先判「匹配」(因为匹配是免费的,会得到数值相同但操作更优的路径),再判替换、删除、插入。若把替换放在最前面,可能把一次免费匹配误报成替换。
② 循环条件是 i > 0 || j > 0,不是 &&。因为可能 i 已经到 0 而 j 还没走完(剩下全是插入操作)。
③ 最后要 reverse,因为我们是从终点往起点走的,记录顺序是反的。

更大的一类问题:「输出方案 / 输出字典序最小方案」在竞赛中非常常见。把操作与 DP 格子对应起来,回溯时优先选择「字典序更小」的那一支(即调整判断顺序),就能得到字典序最小的最优方案。关键技巧是:把 if 的判断顺序当作「优先级」,靠调整顺序来控制字典序。

C2树上背包实战

洛谷 P2015 二叉苹果树:一棵 n 个节点的二叉树,边上有苹果数。必须保留从根出发的恰好 m 条边,问最多能保留多少苹果。

查看参考答案

状态转换:「保留 m 条边」等价于「保留 m+1 个点(含根)」。所以把「边权」挂在儿子节点上,就变成了标准的树上背包:f[u][j] = 在 u 的子树中保留 j 条边(连接 j+1 个点)的最大苹果数。

关键:谁承担边权。让 val[v] 表示「u 到 v 这条边的苹果数」。于是「选中 v」就意味着「选中了 (u,v) 这条边」,边权自然被计入。

int n, m, siz[MAXN], f[MAXN][MAXN], val[MAXN];
// f[u][j]: u 的子树中保留 j 条边(含 u 共 j+1 个点)的最大苹果数

void dfs(int u, int fa) {
    siz[u] = 1;
    for (auto& e : g[u]) {
        if (e.to == fa) continue;
        val[e.to] = e.w;                  // 边权挂到儿子身上
        dfs(e.to, u);
        for (int j = min(m, siz[u] + siz[e.to] - 1); j >= 1; j--) {
            for (int k = max(1, j - (siz[u] - 1)); k <= min(j, siz[e.to] - 1); k++) {
                f[u][j] = max(f[u][j], f[u][j - k] + f[e.to][k] + val[e.to]);
            }
        }
        siz[u] += siz[e.to];
    }
}
// 答案:f[1][m]

与 P2014 选课的差别:
· P2014 用虚根把森林连成树,答案是 f[0][m+1];
· P2015 本身是一棵以 1 为根的树,答案是 f[1][m](不需要虚根);
· P2015 的「份额」是边数,所以 siz 起的作用是 siz−1(可分配的边数);
· P2015 的边权在选中儿子时被计入,所以转移里多了 +val[e.to]。

易错:① 份额用「边数」还是「点数」搞混(本讲的主线是点数,P2015 要转成边数);② 忘记在转移里加上边权 val[e.to];③ k 的上下界没有同步换算成「边数」口径。

C3换根 DP 进阶

求「以树上每一个点为根时的最大权独立集」。n ≤ 10⁵,点权可正可负。

查看参考答案

难点在哪:普通换根 DP 的「可加可减」在这里失效了,因为最大独立集的转移里有 max 操作,而 max 不可撤销。

核心技巧:前缀 / 后缀(或叫「前后缀拼合」)。
对于一个点 u,它有若干儿子 v₁, v₂, …, v_k。当我们要「换根到 v₁」时,需要求出「u 在去掉 v₁ 之后的解」——也就是把 u 的其余儿子 v₂…v_k 以及 u 的「上方部分」全部合并起来的结果。

做法:
① 第一次 DFS(自底向上):求出每个点作为子树根的两个状态 g[u][0], g[u][1](标准树型 DP)。
② 第二次 DFS(自顶向下):对 u 计算出「u 的上方部分」贡献的两个值 up[u][0], up[u][1](含义:在 u 的子树之外,包含 u 的父亲那棵「大子树」的贡献)。
③ 现在 u 的所有儿子贡献 + 上方贡献一共 k+1 项,每项形如 (x0, x1)(不选/选的贡献)。把它们按顺序排好,预处理前缀合并结果与后缀合并结果。
④ 当要算出「以 v_i 为根时 u 的贡献」时,就把 前缀 i−1 的合并结果 与 后缀 i+1 的合并结果 再合并一次,就得到了「去掉 v_i 之后 u 的完整贡献」。
⑤ 再把这个结果当作 v_i 的「上方贡献」,继续向下推。
整个过程的复杂度是 O(n)(每个点的前缀 / 后缀各 O(度),总 O(n))。

合并算子是什么:把「u 的若干邻点分支」看作若干独立部分,合并两个部分 A 与 B 时:
· new[0] = max(A[0], A[1]) + max(B[0], B[1])(u 不选,两边各自自由);
· new[1] = A[1] + B[1](u 选了,两边都必须处于「不含 u」的那一支)。
注意这里的 A、B 描述的只是「u 的邻点分支各自的贡献」,不包含 u 自身的决策;u 自身在最后一步处理:f[u][0] = new[0],f[u][1] = val[u] + new[1]。这样切分之后,「去掉某个分支」才真正等价于「把前缀与后缀再合并一次」,前缀 / 后缀技巧才能成立。

易错与提醒:① 这个技巧叫作「前后缀合并」或「可撤销 DP」,是换根 DP 进阶的核心工具,凡是要换根的「取 max / 取 min」型 DP 都可以用;② 空间上不需要对每个点都存完整的前后缀,可以边扫描边算——但代码复杂度会上升,初学者建议老老实实每点建前后缀数组(空间 O(Σ度) = O(n));③ 若点权可能为负,注意 f[u][1] = val[u] + new[1] 与 f[u][0] = new[0] 的比较里要允许「不选任何点」的情形(若题目要求必须选至少一个点则另作处理)。

C4树上背包 + 贡献法

洛谷 P3177 [HAOI2015] 树上染色:给一棵 n 个点的树(边有长度),选择 k 个点染成黑色,其余 n−k 个点为白色。定义收益为「所有黑点两两之间距离之和 + 所有白点两两之间距离之和」。求收益最大值。

查看参考答案

关键转化:不要按「点对」算,要按「边」算。对每条边 e,设它把树分成「下方子树」与「上方其余部分」。若下方子树中有 x 个黑点、siz − x 个白点(siz 为下方点数),那么这条边被贡献的次数是:

cnt(e) = x·(k − x) + (siz − x)·((n − k) − (siz − x))

解读:第一项是「黑点跨过这条边的点对数」,第二项是「白点跨过这条边的点对数」。每条边独立贡献,总收益 = Σ 边长 × cnt(e)。这就是经典的「贡献法」——把「点对之间」的求和,转成「每条边被多少点对跨越」的求和。

于是状态设计变得非常自然:f[u][j] = 在 u 的子树中,选了 j 个黑点时,子树内部所有边的总贡献。

转移:合并儿子 v 时,枚举「v 的子树里放 j′ 个黑点」:
f[u][j] = max(f[u][j], f[u][j-j'] + f[v][j'] + w(u,v) × cnt)
其中 cnt = j'·(k − j') + (siz[v] − j')·((n − k) − (siz[v] − j'))。

复杂度:树上背包的 O(n²)。n ≤ 2000 时可行。

为什么这题「难」:难在「怎么把收益写成边上的可加量」,而不在 DP 本身。一旦想通了「每条边被跨越的次数 = 黑跨黑 + 白跨白」,剩下的就是一套标准的树上背包。

易错:① 按点对算(O(n²) 个点对,无法做 DP);② cnt 公式算错——注意「上方部分」的黑点数是 k − j'、白点数是 (n−k) − (siz[v] − j');③ 忘记乘边权 w(u,v);④ f 数组初值用 0 但转移中存在「非法状态」时会被误用,需要严格限制 k 的取值范围。

方法论总结:「贡献法 + 树上背包」是提高组到省选的黄金组合。凡是题目要求「所有点对之间的某某量之和」,第一反应应该是:能不能把点对的贡献拆到每条边(或每个点)上?如果能,问题就会被大幅简化。

🏆 洛谷原题精选(12道)

以下 12 道题覆盖多维 DP、树型 DP、换根 DP 与树上背包四条主线,按难度从普及-到省选-排列。

1
P1216
[IOI1994] 数字三角形普及-
2
P1048
[NOIP2005] 采药(0/1 背包)普及-
3
P2758
编辑距离普及+
4
P1439
【模板】最长公共子序列(转 LIS)普及+
5
P1091
[NOIP1999] 合唱队形普及+
6
P1352
没有上司的舞会普及+
7
P1122
最大子树和(树型DP入门)普及+
8
P1364
医院设置(换根DP入门)普及+
9
P3478
[POI2008] STA-Station(换根模板)提高
10
P2015
二叉苹果树(树上背包)提高
11
P2014
[CTSC1997] 选课(树上背包)提高
12
P3177
[HAOI2015] 树上染色(贡献法+背包)提高+
刷题建议:第 1~5 题是多维 DP 的基本功,要求「看到题能在 3 分钟内说出状态与转移」;第 6~9 题是树型 DP 与换根 DP 的四大模板(独立集、子树和、距离和、换根统一公式),必须能默写;第 10~12 题是树上背包,重点是「j 倒序 + k 的双边界」与「贡献法建模」,建议先自己完整推一遍再对答案,切忌直接抄模板。