前置芝士:Here

本文是基于 OI wiki 上的文章加以修改完成,感谢社区的转载支持和其他方面的支持

树形 DP,即在树上进行的 DP。由于树固有的递归性质,树形 DP 一般都是递归进行的。

基础

以下面这道题为例,介绍一下树形 DP 的一般过程。

例题 洛谷 P1352 没有上司的舞会

题目描述

某大学有 $n$ 个职员,编号为 $1 \sim N$。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 $a_i$,但是呢,如果某个职员的上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。所以,请你编程计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。

我们可以定义 \(f(i,0/1)\) 代表以 \(i\) 为根的子树的最优解(第二维的值为 0 代表 \(i\) 不参加舞会的情况,1 代表 \(i\) 参加舞会的情况)。

显然,我们可以推出下面两个状态转移方程(其中下面的 \(x\) 都是 \(i\) 的儿子):

  • \(f(i,0) = \sum\max \{f(x,1),f(x,0)\}\)(上司不参加舞会时,下属可以参加,也可以不参加)
  • \(f(i,1) = \sum{f(x,0)} + a_i\)(上司参加舞会时,下属都不会参加)

我们可以通过 DFS,在返回上一层时更新当前结点的最优解。

代码
const int N = 1e4 + 10;
vector tr[N];
int f[N][2], v[N], Happy[N], n;
void dfs(int u) {
f[u][0] = 0; f[u][1] = Happy[u];
for (auto v : tr[u]) {
dfs(v);
f[u][0] += max(f[v][0], f[v][1]);
f[u][1] += f[v][0];
}
}
int main() {
cin.tie(nullptr)->sync_with_stdio(false);
cin >> n;
for (int i = 1; i <= n; ++i) cin >> Happy[i];
for (int i = 1, x, y; i < n; ++i) {
cin >> x >> y;
v[x] = 1;// x has a father
tr[y].push_back(x);
}
int root;
for (int i = 1; i <= n; ++i)
if (!v[i]) {root = i; break;}
dfs(root);
cout << max(f[root][0], f[root][1]) << "\n";
}

相关练习

树上背包

树上的背包问题,简单来说就是背包问题与树形 DP 的结合。

例题 洛谷 P2014 CTSC1997 选课

题目描述

现在有 $n$ 门课程,第 $i$ 门课程的学分为 $a_i$,每门课程有零门或一门先修课,有先修课的课程需要先学完其先修课,才能学习该课程。
一位学生要学习 $m$ 门课程,求其能获得的最多学分数。


$n,m \le 300$

每门课最多只有一门先修课的特点,与有根树中一个点最多只有一个父亲结点的特点类似。

因此可以想到根据这一性质建树,从而所有课程组成了一个森林的结构。为了方便起见,我们可以新增一门 \(0\) 学分的课程(设这个课程的编号为 \(0\)),作为所有无先修课课程的先修课,这样我们就将森林变成了一棵以 \(0\) 号课程为根的树。

我们设 \(f(u,i,j)\) 表示以 \(u\) 号点为根的子树中,已经遍历了 \(u\) 号点的前 \(i\) 棵子树,选了 \(j\) 门课程的最大学分。

转移的过程结合了树形 DP 和背包 DP 的特点,我们枚举 \(u\) 点的每个子结点 \(v\),同时枚举以 \(v\) 为根的子树选了几门课程,将子树的结果合并到 \(u\) 上。

记点 \(x\)​ 的儿子个数为 \(s_x\)​,以 \(x\)​ 为根的子树大小为 \(siz_x\)​,很容易写出下面的转移方程:

\[f(u,i,j)=\max_{v,k \leq j,k \leq siz_v} f(u,i-1,j-k)+f(v,s_v,k)
\]

注意上面转移方程中的几个限制条件,这些限制条件确保了一些无意义的状态不会被访问到。

\(f\) 的第二维可以很轻松地用滚动数组的方式省略掉,注意这时需要倒序枚举 \(j\) 的值。

我们可以证明,该做法的时间复杂度为 \(O(nm)\)[1]

代码
const int N = 310;
vectore[N];
int f[N][N], s[N], n, m;
void dfs(int x) {
f[x][0] = 0;
for (int v : e[x]) { // 循环子节点(物品)
dfs(v);
for (int t = m; t >= 0; --t) // 倒序循环当前选课总门数(当前背包体积)
for (int j = 0; j <= t; ++j) // 循环更深子树上的选课门数(组内物品)
f[x][t] = max(f[x][t], f[x][t - j] + f[v][j]);
/* 或者
for (int j = t; j >= 0; j--)
if (t + j <= m)
f[x][t+j] = max(f[x][t+j], f[x][t] + f[y][j]);
这两种写法j分别用了正序和倒序循环
是为了正确处理组内体积为0的物品(本题正序倒序都可以AC是因为体积为0的物品价值恰好也为0)
请读者结合0/1背包问题中DP的“阶段”理论思考 */
}
if (x != 0) // x不为0时,选修x本身需要占用1门课,并获得相应学分
for (int t = m; t > 0; t--) f[x][t] = f[x][t - 1] + s[x];
}
int main() {
cin.tie(nullptr)->sync_with_stdio(false);
cin >> n >> m;
for (int i = 1, x; i <= n; ++i) {
cin >> x >> s[i];
e[x].push_back(i);
}
memset(f, 0xcf, sizeof(f)); // -inf
dfs(0);
cout << f[0][m] << "\n";
}

相关练习

换根 DP

树形 DP 中的换根 DP 问题又被称为二次扫描,通常不会指定根结点,并且根结点的变化会对一些值,例如子结点深度和、点权和等产生影响。

通常需要两次 DFS,第一次 DFS 预处理诸如深度,点权和之类的信息,在第二次 DFS 开始运行换根动态规划。

接下来以一些例题来带大家熟悉这个内容。

例题 [POI2008]STA-Station

题目描述

给定一个 $n$ 个点的树,请求出一个结点,使得以这个结点为根时,所有结点的深度之和最大。

注意题目的样例给的输出是错误,正确的输出是 \(24\)

不妨令 \(u\) 为当前结点,\(v\) 为当前结点的子结点。首先需要用 \(s_i\) 来表示以 \(i\) 为根的子树中的结点个数,并且有 \(s_u=1+\sum s_v\)。显然需要一次 DFS 来计算所有的 \(s_i\),这次的 DFS 就是预处理,我们得到了以某个结点为根时其子树中的结点总数。

考虑状态转移,这里就是体现"换根"的地方了。令 \(f_u\) 为以 \(u\) 为根时,所有结点的深度之和。

\(f_v\leftarrow f_u\) 可以体现换根,即以 \(u\) 为根转移到以 \(v\) 为根。显然在换根的转移过程中,以 \(v\) 为根或以 \(u\) 为根会导致其子树中的结点的深度产生改变。具体表现为:

  • 所有在 \(v\) 的子树上的结点深度都减少了一,那么总深度和就减少了 \(s_v\);

  • 所有不在 \(v\) 的子树上的结点深度都增加了一,那么总深度和就增加了 \(n-s_v\);

根据这两个条件就可以推出状态转移方程 \(f_v = f_u - s_v + n - s_v=f_u + n - 2 \times s_v\)。

于是在第二次 DFS 遍历整棵树并状态转移 \(f_v=f_u + n - 2 \times s_v\),那么就能求出以每个结点为根时的深度和了。最后只需要遍历一次所有根结点深度和就可以求出答案。

代码
using pii = pair;
const int N = 2e5 + 10;
vectore[N];
int f[N], d[N], ff[N];
void dfs(int u, int fa) {
for (auto [v, w] : e[u]) {
if (v == fa) continue;
dfs(v, u);
if (d[v] == 1) f[u] += w;
else f[u] += min(f[v], w);
}
}
void dfs1(int u, int fa) {
ff[u] = f[u];
for (auto [v, w] : e[u]) {
if (v == fa)continue;
if (d[v] == 1) {
f[u] -= w;
f[v] += min(f[u], w);
} else {
f[u] -= min(w, f[v]);
f[v] += min(w, f[u]);
}
dfs1(v, u);
}
}
int main() {
cin.tie(nullptr)->sync_with_stdio(false);
int _; for (cin >> _; _--;) {
int n; cin >> n;
for (int i = 1; i <= n; ++i) {
e[i].clear();
d[i] = f[i] = ff[i] = 0;
}
for (int i = 1, u, v, w; i < n; ++i) {
cin >> u >> v >> w;
e[u].push_back({v, w});
e[v].push_back({u, w});
d[u]++, d[v]++;
}
dfs(1, -1);
dfs1(1, -1);
int ans = 0;
for (int i = 1; i <= n; ++i) ans = max(ans, ff[i]);
cout << ans << "\n";
}
}

相关练习

参考资料与注释


  1. 子树合并背包类型的 dp 的复杂度证明 - LYD729 的 CSDN 博客

【算法学习笔记】动态规划与数据结构的结合,在树上做DP的更多相关文章

  1. C / C++算法学习笔记(8)-SHELL排序

    原始地址:C / C++算法学习笔记(8)-SHELL排序 基本思想 先取一个小于n的整数d1作为第一个增量(gap),把文件的全部记录分成d1个组.所有距离为dl的倍数的记录放在同一个组中.先在各组 ...

  2. Manacher算法学习笔记 | LeetCode#5

    Manacher算法学习笔记 DECLARATION 引用来源:https://www.cnblogs.com/grandyang/p/4475985.html CONTENT 用途:寻找一个字符串的 ...

  3. Johnson算法学习笔记

    \(Johnson\)算法学习笔记. 在最短路的学习中,我们曾学习了三种最短路的算法,\(Bellman-Ford\)算法及其队列优化\(SPFA\)算法,\(Dijkstra\)算法.这些算法可以快 ...

  4. 某科学的PID算法学习笔记

    最近,在某社团的要求下,自学了PID算法.学完后,深切地感受到PID算法之强大.PID算法应用广泛,比如加热器.平衡车.无人机等等,是自动控制理论中比较容易理解但十分重要的算法. 下面是博主学习过程中 ...

  5. Johnson 全源最短路径算法学习笔记

    Johnson 全源最短路径算法学习笔记 如果你希望得到带互动的极简文字体验,请点这里 我们来学习johnson Johnson 算法是一种在边加权有向图中找到所有顶点对之间最短路径的方法.它允许一些 ...

  6. Redis学习笔记一:数据结构与对象

    1. String(SDS) Redis使用自定义的一种字符串结构SDS来作为字符串的表示. 127.0.0.1:6379> set name liushijie OK 在如上操作中,name( ...

  7. Java学习笔记——浅谈数据结构与Java集合框架(第一篇、List)

    横看成岭侧成峰,远近高低各不同.不识庐山真面目,只缘身在此山中. --苏轼 这一块儿学的是云里雾里,咱们先从简单的入手.逐渐的拨开迷雾见太阳.本次先做List集合的三个实现类的学习笔记 List特点: ...

  8. 算法学习笔记——sort 和 qsort 提供的快速排序

    这里存放的是笔者在学习算法和数据结构时相关的学习笔记,记录了笔者通过网络和书籍资料中学习到的知识点和技巧,在供自己学习和反思的同时为有需要的人提供一定的思路和帮助. 从排序开始 基本的排序算法包括冒泡 ...

  9. 从零开始系列-R语言基础学习笔记之二 数据结构(二)

    在上一篇中我们一起学习了R语言的数据结构第一部分:向量.数组和矩阵,这次我们开始学习R语言的数据结构第二部分:数据框.因子和列表. 一.数据框 类似于二维数组,但不同的列可以有不同的数据类型(每一列内 ...

  10. R语言实现关联规则与推荐算法(学习笔记)

    R语言实现关联规则 笔者前言:以前在网上遇到很多很好的关联规则的案例,最近看到一个更好的,于是便学习一下,写个学习笔记. 1 1 0 0 2 1 1 0 0 3 1 1 0 1 4 0 0 0 0 5 ...

随机推荐

  1. 本地SQL Server怎么连接服务器上的数据库

    在阿里云的安全组规则中的入方向加上端口1433就好了,首先得要有这个,其他的都是后话

  2. R语言--图形基本使用1

    1 使用图形 1.1 交互式绘图 使用的是内置数据集:mtcars 画出散点图:plot(mtcars$mpg,mtcars$wt) 给图形加标题:title("车辆耗油与重量之间的关系&q ...

  3. 4.13、nfs挂载优化及优缺点

    1.硬盘:sas/ssd磁盘,买多块,硬件raid5/raid0,网卡吞吐量要大,至少千兆(多网卡bond0) 2.nfs客户端挂载说明: 文件系统有自己的权限,挂载是建立在文件系统之上的,然后更改挂 ...

  4. Vulkan移植GPUImage的安卓Demo展示

    演示Android apk下载 需要Android 8以上. 先看效果图,大约一百多种滤镜,有超过一半的滤镜有参数设置,其参数调整界面使用反射自动生成与绑定. 如下每种选择一些进行展示. 视觉效果 图 ...

  5. Python中的json学习

    p.p1 { margin: 0; font: 14px ".PingFang SC"; color: rgba(53, 53, 53, 1) } p.p2 { margin: 0 ...

  6. mysql binlog恢复数据实战

    在前面,我们了解了mysql binlog日志的作用以及使用方法:  http://www.php20.cn/article/237 在后面讲到了,可以通过binlog进行恢复数据,那么,具体步骤是怎 ...

  7. base64文件解码

    $str = str_replace(' ', '+', $str); //替换空字符串为+$str = str_replace('\n', '',$str); //置空换行符$str = str_r ...

  8. HCNA Routing&Switching之路由基础

    在开始聊路由之前,我们首先要明白在网络通讯里,什么是路由?什么是路由表.路由器以及网关的相关术语:路由简单讲就是指网络数据包从源头到目标的路径,主要用来为不同网络间通讯提供数据包转发依据:路由表就是多 ...

  9. 『动善时』JMeter基础 — 55、使用非GUI模式运行JMeter(命令行模式)

    目录 1.JMeter的非GUI模式说明 2.为什么使用非GUI模式运行JMeter 3.使用非GUI模式运行JMeter (1)非GUI模式运行JMeter步骤 (2)其它参数说明 4.CLI模式运 ...

  10. 深度解析 Lucene 轻量级全文索引实现原理

    一.Lucene简介 1.1 Lucene是什么? Lucene是Apache基金会jakarta项目组的一个子项目: Lucene是一个开放源码的全文检索引擎工具包,提供了完整的查询引擎和索引引擎, ...