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

前置芝士:Here

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

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

基础

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

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

题目描述

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


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

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

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

我们可以通过 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 门课程的学分为 ai,每门课程有零门或一门先修课,有先修课的课程需要先学完其先修课,才能学习该课程。
一位学生要学习 m 门课程,求其能获得的最多学分数。
n,m300


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

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

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

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

记点 x 的儿子个数为 sx,以 x 为根的子树大小为 sizx,很容易写出下面的转移方程:

f(u,i,j)=maxv,kj,ksizvf(u,i1,jk)+f(v,sv,k)

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

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

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

代码
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 为当前结点的子结点。首先需要用 si 来表示以 i 为根的子树中的结点个数,并且有 su=1+sv。显然需要一次 DFS 来计算所有的 si,这次的 DFS 就是预处理,我们得到了以某个结点为根时其子树中的结点总数。

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

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

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

  • 所有不在 v 的子树上的结点深度都增加了一,那么总深度和就增加了 nsv

根据这两个条件就可以推出状态转移方程 fv=fusv+nsv=fu+n2×sv

于是在第二次 DFS 遍历整棵树并状态转移 fv=fu+n2×sv,那么就能求出以每个结点为根时的深度和了。最后只需要遍历一次所有根结点深度和就可以求出答案。

代码
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 博客 ↩︎

posted @   RioTian  阅读(4250)  评论(0编辑  收藏  举报
编辑推荐:
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
阅读排行:
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· 分享4款.NET开源、免费、实用的商城系统
· 解决跨域问题的这6种方案,真香!
· 一套基于 Material Design 规范实现的 Blazor 和 Razor 通用组件库
· 5. Nginx 负载均衡配置案例(附有详细截图说明++)
历史上的今天:
2020-08-06 算法学习笔记:连通图详解
2020-08-06 连通图算法详解之① :Tarjan 和 Kosaraju 算法
点击右上角即可分享
微信分享提示

📖目录