树形DP

树形 DP,即在树上进行的 DP。由于树固有的递归性质,树形 DP 一般都是递归进行的。具体来说,在树形动态规划当中,我们一般先算子树再进行合并,在实现上与树的后序遍历相似,都是先遍历子树,遍历完之后将子树的值传给父亲。简单来说,动态规划的过程大概就是先递归访问所有子树,再在根上合并。

一般树形DP

没有上司的舞会

题目描述

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

分析

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

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

  • $ dp[i,0]= \sum \max ( dp[x,0],dp[x,1] )$(上司不参加舞会时,下属可以参加,也可以不参加)
  • $ dp[i,1]= \sum dp[x,0]+r_i $(上司参加舞会时,下属都不会参加)

我们可以通过 DFS,在处理完子节点后更新当前节点最优解。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e4 + 10;
int n, root, r[maxn];
int dp[maxn][2];
bool f[maxn];
vector<int> g[maxn];

void in() {
    int k, l;
    cin >> n;
    for (int i = 1; i <= n; i++)
        cin >> r[i];
    for (int i = 1; i < n; i++) {
        cin >> l >> k;
        g[k].emplace_back(l);
        f[l] = 1; //不会成为根 
    }
}

int find_root() { //没有父节点的节点即为根 
    for (int i = 1; i <= n; i++) {
        if (!f[i]) {
            return i;
        }
    }
    return -1;
}

void dfs(int x) {
    dp[x][0] = 0;
    dp[x][1] = r[x]; //注意加上当前点的贡献 
    for (int i = 0; i < g[x].size(); i++) { 
        int v = g[x][i];
        dfs(v);
        dp[x][0] += max(dp[v][0], dp[v][1]); //情况1 
        dp[x][1] += dp[v][0];				 //情况2 
    }
}

int main() {
    ios::sync_with_stdio(false);
    in();
    root = find_root();
    dfs(root);
    cout << max(dp[root][0], dp[root][1]) << endl;
    return 0;
}

树上背包问题

顾名思义,是树形DP与背包的结合。

有线电视网

题目描述

某收费有线电视网计划转播一场重要的足球比赛。他们的转播网和用户终端构成一棵树状结构,这棵树的根结点位于足球比赛的现场,树叶为各个用户终端,其他中转站为该树的内部节点。
从转播站到转播站以及从转播站到所有用户终端的信号传输费用都是已知的,一场转播的总费用等于传输信号的费用总和。
现在每个用户都准备了一笔费用想观看这场精彩的足球比赛,有线电视网有权决定给哪些用户提供信号而不给哪些用户提供信号。
写一个程序找出一个方案使得有线电视网在不亏本的情况下使观看转播的用户尽可能多

分析

本题有两个限制,一是不亏本,二是观看转播的用户尽量多。看到这种最值类的问题,又成树状结构,考虑树形dp。定义 \(dp[i][j]\) 为在以 \(i\) 为根的子树的子树上,选取 \(j\) 个用户的最大收益。那么计算答案时只需遍历根节点,找到收益为正的最大人数即可。
一些细节:题目在给花费是以边权的形式给出的,我们只需将边权下沉至对应的子节点即可,$ r[i] $ 存储的是联通该点的净收益。还需要 $f[i] $,用于统计以i为根的子树所能联通的用户数目(叶子数)。
我们可以推出以下状态转移方程(记v为x子节点):

  • $f[x]+=f[v] $ (计算叶子数)、
  • $ dp[x][j]= \max (dp[x][j],dp[x][j-k]+dp[v][k]) 0<i \le f[x] 0 \le j \le f[v] $
  • 当x为叶子节点时,令 $ f[x] = 1 $, $ dp[i][1]=r[x]$ (选取一个用户贡献即为$ r[x] $)
  • 当x为非叶子节点时,令 $ dp[i][0]=r[x] $ (可以确保每次都把当前点的花费计入,结束递归时恢复为0即可)。
代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 3e3 + 10;
int n, m, r[maxn];
int dp[maxn][maxn], f[maxn];
vector<int> child[maxn];

void in() {
    int k, a, c;
    cin >> n >> m;
    for (int i = 1; i <= n - m; ++i) {
        cin >> k;
        for (int j = 1; j <= k; ++j) {
            cin >> a >> c;
            r[a] -= c;//边权下沉
            child[i].emplace_back(a);
        }
    }
    for (int i = n - m + 1; i <= n; ++i) {
        cin >> c;
        r[i] += c;
    }
}

void dfs(int x) {
    memset(dp[x], -0x3f, sizeof dp[x]);
    if (x > n - m) {
        f[x] = 1;
        dp[x][1] = r[x];
    } else {
        dp[x][0] = r[x];
    }
    for (int i = 0; i < child[x].size(); ++i) {
        int v = child[x][i];
        dfs(v);
        f[x] += f[v];
        for (int j = f[x]; j >= 1; --j) {
            for (int k = j; k >= 1; --k) {
                dp[x][j] = max(dp[x][j], dp[x][j - k] + dp[v][k]);
            }
        }
    }
    dp[x][0] = 0;
}

int getans() {
    for (int i = n; i >= 1; --i) { //注意这里应该倒序遍历,避免样例中个数少,贡献负的情况。 
        if (dp[1][i] >= 0)
            return i;
    }
    return 0;
}

int main() {
    ios::sync_with_stdio(false);
    in();
    dfs(1);
    cout << getans();
    return 0;
}

换根DP

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

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

[POI 2008] STA-Station

题目描述

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

一个结点的深度之定义为该节点到根的简单路径上边的数量。

分析

对于任意的根节点 $ r $ ,我们可以通过一次遍历求出所有结点的深度之和,复杂度$ O(n) $,如果以每一个节点为根各进行一次DP,复杂度为 \(O(n^2)\)不太优秀,此时需要一个更优秀的方法。假设我们已知以 $ u $ 为根节点的深度之和 $ dp[u] $、子树大小 $ f[u] $,以及一个和 $ u $ 有边相连的节点 $ v $。通过 $ dp[u] $求 $ dp[v] $可以理解为换根。具体实现如下。
当我们把根从 $ u $ 换至 $ v $:

  • 以$ u $为根的子树上每一个点的深度都会增加1,那么对于答案的贡献就是加上 $ f[u] $
  • 而以$ v $为根的子树上每一个点的深度都会减少1,那么对于答案的贡献就是减去 $ f[v] $
  • 又因为 $ f[u]= n-f[v] $,所有得到转移方程 $ dp[v]=dp[u]+n-2*f[v] $

a68b324a8fbaca2c56d80d7a37b34f6560c58d0076ca4059eea539c68120368f.png
571d612de58bd01e9007264c8726326044aaeb5ca255df08588bf1398d4af5fa.png

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e6 + 10;

int n, dep[maxn], ansi = 1;
long long dp[maxn], f[maxn]; // 分别表示以i为根时的节点深度和,子树大小 
vector<int> child[maxn];

void add(int a, int b) {
    child[a].push_back(b); 
}

void in() {
    cin >> n;
    int x, y;
    for (int i = 2; i <= n; i++) {
        cin >> x >> y;
        add(x, y);
        add(y, x);
    }
}

void dfs1(int x, int fa) { // 预处理以1为根的节点深度和 
    dp[x] = dep[x] = dep[fa] + 1;
    f[x] = 1;
    for (int i = 0; i < child[x].size(); i++) {
        int v = child[x][i];
        if (v == fa) continue;
        dfs1(v, x);
        f[x] += f[v];
        dp[x] += dp[v];
    }
}

void dfs2(int x, int fa) { // 换根 
    if (fa) dp[x] = dp[fa] + n - 2 * f[x];
    if (dp[ansi] < dp[x]) ansi = x;
    for (int i = 0; i < child[x].size(); i++) {
        int v = child[x][i];
        if (v == fa) continue;
        dfs2(v, x);
    }
}

int main() {
    ios::sync_with_stdio(false);
    in();
    dfs1(1, 0);
    dfs2(1, 0);
    cout << ansi << endl; 
    return 0;
}

题单

posted @ 2025-02-08 14:26  wxw_zl  阅读(33)  评论(0)    收藏  举报