树形DP

树形 DP 即在树上进行的 DP。

常见的两种转移方向:

  • 父节点 \(\rightarrow\) 子节点:如求节点深度,\(dp_u = dp_{fa} + 1\)
  • 子节点 \(\rightarrow\) 父节点:如求子树大小,\(dp_u = 1 + \sum dp_v\)

例题:P2052 [NOI2011] 道路修建

题目的核心是计算一个树中所有边的成本之和,每条边的成本定义为边的长度乘以边两侧的节点数量之差

对于树中的任意一条边,如果将它移除,整个树会分裂成两个不相连的部分。这条边的成本就由它的长度,以及分裂后两个部分的节点数量(国家数量)决定。

首先任意指定一个根节点,比如 1 号节点,然后从这个根节点开始进行 DFS 遍历。对于一条边 \((u,v)\),假设在 DFS 中 \(u\)\(v\) 的父节点,那么,当移除这条边时:一边是\(v\) 为根的子树,另一边是除了 \(v\) 的子树之外的所有节点。设 \(sz_v\) 为以 \(v\) 为根的子树的节点总数,那么,第一部分的节点数就是 \(sz_v\)。整棵树有 \(n\) 个节点,所以第二部分的节点数就是 \(n-sz_v\)。因此,边 \((u,v)\) 的成本就可以计算为 \(len \times |sz_v - (n - sz_v)|\),化简后得到 \(len \times | 2 \times sz_v - n|\)

一个节点的子树大小等于它所有子节点的子树大小之和,再加上 1(节点自身),这需要在访问完所有子节点后(即在“后序”位置)进行计算。

参考代码
#include <cstdio>
#include <vector>
#include <cmath>
using namespace std;
using ll = long long;
// 定义边的结构体,包含目标节点和边的长度
struct Edge {
    int to, len;
};
int n; // 全局变量,存储国家(节点)总数
vector<vector<Edge>> tree; // 邻接表,存储树的结构
vector<int> sz; // sz[i] 存储以节点i为根的子树的大小
vector<ll> cost; // cost[i] 存储以节点i为根的子树中所有道路的总费用
/**
 * @brief 深度优先搜索函数,用于计算子树大小和道路费用
 * @param u 当前访问的节点
 * @param p 当前节点的父节点(防止DFS时走回头路)
 */
void dfs(int u, int p) {
    // 初始化当前子树大小为1(包含节点u自身)
    sz[u] = 1;
    // 初始化当前子树内部的费用为0
    cost[u] = 0;
    // 遍历与节点u相连的所有边
    for (const Edge& edge : tree[u]) {
        int v = edge.to; // 边的另一个端点
        int len = edge.len; // 边的长度
        // 如果v是u的父节点,则跳过,避免死循环
        if (v == p) continue;
        // 递归地对子节点v进行深度优先搜索
        dfs(v, u);
        // 当子节点v的DFS完成后,拥有了sz[v]和cost[v]的信息
        // 1. 更新父节点u的子树大小
        sz[u] += sz[v];
        // 2. 累加来自子树v内部的道路总费用
        cost[u] += cost[v];
        // 3. 累加连接u和v这条道路本身的费用
        //    移除(u,v)边后,一侧是v的子树,节点数为 sz[v]
        //    另一侧是剩下所有节点,数量为 n - sz[v]
        //    费用 = len * |sz[v] - (n - sz[v])| = len * |2*sz[v] - n|
        cost[u] += 1ll * len * abs(2 * sz[v] - n);
    }
}
int main()
{
    scanf("%d", &n);
    // 根据节点数量n,调整邻接表、子树大小数组和费用数组的大小
    tree.resize(n + 1);
    sz.resize(n + 1);
    cost.resize(n + 1);
    // 读取n-1条边的信息,构建树
    for (int i = 1; i < n; i++) {
        int a, b, c;
        scanf("%d%d%d", &a, &b, &c);
        // 由于是双向道路,需要在两个节点的邻接表中都添加这条边
        tree[a].push_back({b, c});
        tree[b].push_back({a, c});
    }
    // 从节点1开始进行DFS,设其父节点为0(一个不存在的节点)
    dfs(1, 0);
    // dfs(1,0)结束后,cost[1]中存储了整棵树的总费用
    printf("%lld\n", cost[1]);
    return 0;
}

例题:P2016 战略游戏

本题是求解树上的最小顶点覆盖,对于一般的图,这是一个 NP-Hard 问题。但对于树这种特殊结构的图,可以使用动态规划高效求解,也就是所谓的树形 DP

首先,由于题目给出的是一棵无根树,为了进行 DP,需要先指定一个根节点(例如,节点 0),从而确定树中各节点的父子关系,选择哪个节点作为根不影响最终答案。

对于树中的任意一个节点 \(u\),在它上面放置士兵有两种选择:或者不放,这两种选择会直接影响其子树的最优决策。因此,可以围绕这个选择来定义 DP 状态。

  • \(f_{u,1}\):表示在以 \(u\) 为根的子树中,为了覆盖所有边,并且在节点 \(u\) 上放置一个士兵的情况下,所需的最小士兵数。
  • \(f_{u,0}\):表示在以 \(u\) 为根的子树中,为了覆盖所有边,并且不在节点 \(u\) 上放置士兵的情况下,所需的最少士兵数。

目标是求出整棵树的最小士兵数,即 \(\min(f_{\text{root}, 0}, f_{\text{root}, 1})\)

通过一次深度优先搜索(DFS),在回溯(后序遍历)的过程中,从叶子节点向上计算每个节点的 DP 值。

对于当前节点 \(u\) 和它的一个子节点 \(v\)

  1. 计算 \(f_{u,1}\)(在节点 \(u\) 上放士兵)
    • 因为在 \(u\) 上已经放了士兵,所以连接 \(u\)\(v\) 的边 \((u,v)\) 已经被覆盖了。
    • 此时,对于子树 \(v\),只需保证 \(v\) 子树内部的所有边被覆盖即可。可以选择在 \(v\) 上放士兵,也可以选择不放,取决于哪种方式在 \(v\) 子树中用的士兵更少。
    • 因此,应该累加所有子节点 \(v\)\(\min(f_{v,0},f_{v,1})\)
    • 状态转移方程为 \(f_{u,1}=1+\sum\min(f_{v,0},f_{v,1})\)(对于所有 \(v \in \text{children}(u)\)),这里的 1 代表放在节点 \(u\) 上的那个士兵。
  2. 计算 \(f_{u,0}\)(不在节点 \(u\) 上放士兵)
    • 因为在 \(u\) 上没有放士兵,为了覆盖连接 \(u\)\(v\) 的边 \((u,v)\)必须在子节点 \(v\) 上放置一个士兵。
    • 这对 \(u\) 的所有子节点 \(v\) 都成立。
    • 因此,必须累加所有子节点 \(v\)\(f_{v,1}\)
    • 状态转移方程为 \(f_{u,0}=\sum f_{v,1}\)(对于所有 \(v \in \text{children}(u)\))。

对于一个叶子节点 \(u\)

  • \(f_{u,1}=1\)(在 \(u\) 上放一个士兵,没有子树需要考虑)
  • \(f_{u,0}=0\)(在 \(u\) 上不放士兵,没有子树需要考虑)
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 1505;

// 使用邻接表存储树结构
vector<int> tr[N];
// dp[u][0]: 表示在 u 节点不放士兵的情况下,覆盖 u 子树所需的最小士兵数
// dp[u][1]: 表示在 u 节点放士兵的情况下,覆盖 u 子树所需的最小士兵数
int f[N][2];

/**
 * @brief 通过深度优先搜索进行树形DP
 * @param u 当前节点
 * @param from u的父节点,用于防止向上遍历
 */
void dfs(int u, int from) {
    // 初始化状态:
    // 如果 u 是叶子节点,不放士兵代价为0,放士兵代价为1
    f[u][0] = 0; 
    f[u][1] = 1;

    // 遍历 u 的所有邻居(子节点)
    for (int v : tr[u]) {
        if (v == from) continue; // 跳过父节点
        
        // 递归计算子节点 v 的dp值
        dfs(v, u);

        // --- 状态转移 ---
        // 1. 计算 f[u][0] (当前节点 u 不放士兵)
        // 为了覆盖(u,v)这条边,子节点 v 必须放士兵
        f[u][0] += f[v][1];

        // 2. 计算 f[u][1] (当前节点 u 放士兵)
        // (u,v)这条边已经被覆盖,子节点 v 可以放也可以不放,取两者中的较小值
        f[u][1] += min(f[v][0], f[v][1]);
    }
}

int main()
{
    int n; scanf("%d", &n);
    // 读入数据并建树(无向边)
    for (int i = 0; i < n; i++) {
        int id, k; scanf("%d%d", &id, &k);
        for (int j = 0; j < k; j++) {
            int r; scanf("%d", &r);
            // 每条边只出现一次,所以需要双向建边
            tr[id].push_back(r); 
            tr[r].push_back(id);
        }
    }

    // 从节点0开始DFS,设0为根节点。根节点的父节点设为一个不存在的节点(如-1或它自身)
    dfs(0, -1);

    // 最终答案是根节点放士兵与不放士兵两种情况的最小值
    printf("%d\n", min(f[0][0], f[0][1]));

    return 0;
}

例题:P1352 没有上司的舞会

这是一个在树形结构上进行决策以求最优解的问题,具体来说,对于每个职员,只有两种决策:邀请他(来参加)或不邀请他(不来参加),一个职员的决策会受到其上司决策的影响。

这种在树上求解最优化问题的结构,非常适合使用树形动态规划来解决。

问题的本质是求解树的最大权独立集,一个独立集是指图中的一个节点集合,其中任意两个节点都没有边直接相连。在这里,“边”就是直接的上下级关系,约束条件“上司来了,下属就不来”正好就是独立集的定义。需要求的是带有点权的独立集中,权值(快乐指数)之和最大的那个。

为了进行树形 DP,首先需要明确父子关系,题目已经给出了一个以校长为根的树。然后,需要为树上的每个节点定义 DP 状态。

对于每个职员(节点)\(u\),他的决策(来或不来)会影响他整个下属团队(子树)能产生的最大快乐指数。因此,可以定义以下状态:

  • \(f_{u,1}\):表示在以 \(u\) 为根的子树中(包含 \(u\) 和他所有的下属),邀请职员 \(u\) 参加的情况下,能获得的最大快乐指数。
  • \(f_{u,0}\):表示在以 \(u\) 为根的子树中,不邀请职员 \(u\) 参加的情况下,能获得的最大快乐指数。

最终目标是求出整棵树(以校长 \(\text{root}\) 为根)能获得的最大快乐指数,即 \(\max(f_{\text{root},0},f_{\text{root},1})\)

采用深度优先搜索(DFS),在后序遍历的位置(即处理完子节点后),自底向上地计算每个节点的 DP 值。

对于当前节点 \(u\) 和他的一个直接下属 \(v\)

  1. 计算 \(f_{u,1}\)(邀请 \(u\) 参加)
    • 如果邀请了 \(u\),那么首先能获得 \(u\) 本身的快乐指数 \(r_u\)
    • 根据规则,所有 \(u\) 的直接下属 \(v\) 都不能参加宴会。
    • 因此,对于每个下属 \(v\),只能从“不邀请 \(v\)”的方案中获取其子树的最大快乐指数,即 \(f_{v,0}\)
    • 将所有下属的这部分快乐指数累加起来,得到状态转移方程 \(f_{u,1}=r_u+\sum f_{v,0}\)(对于所有 \(v \in \text{children}(u)\))。
  2. 计算 \(f_{u,0}\)(不邀请 \(u\) 参加)
    • 如果不邀请 \(u\),那么 \(u\) 本身不会贡献快乐指数。
    • 此时,对于 \(u\) 的每个直接下属 \(v\),他们可以来,也可以不来,不受任何限制。
    • 为了使总快乐指数最大,应该为每个下属 \(v\) 选择能使其子树快乐指数最大的方案,即 \(\max(f_{v,0},f_{v,1})\)
    • 将所有下属的这部分最大快乐指数累加起来,得到状态转移方程 \(f_{u,0}=\sum\max(f_{v,0},f_{v,1})\)(对于所有 \(v \in \text{children}(u)\))。

对于叶子节点 \(u\)(没有下属的职员),\(f_{u,1}=r_u\)\(f_{u,0}=0\)

另外,题目输入的是 \(l,k\)\(k\)\(l\) 的上司),根节点是唯一一个没有上司的职员。可以用一个布尔数组记录每个职员是否作为“下属”出现过,那个唯一没出现过的就是根。

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 6e3 + 5;

int r[N];     // r[i]: 职员i的快乐指数
// f[u][0]: 以 u 为根的子树,且 u 不参加舞会时,能获得的最大快乐指数
// f[u][1]: 以 u 为根的子树,且 u 参加舞会时,能获得的最大快乐指数
int f[N][2];  
bool h[N];    // h[i] 用于标记职员i是否为别人的下属,用于寻找根节点
vector<int> tr[N]; // 邻接表,存储从上司到下属的关系

/**
 * @brief 通过深度优先搜索进行树形DP
 * @param u 当前节点(职员)
 */
void dfs(int u) {
    // 初始化状态/边界条件:
    // 如果 u 不参加,则其子树的初始快乐值为0
    f[u][0] = 0;
    // 如果 u 参加,则至少获得 u 自身的快乐值 r[u]
    f[u][1] = r[u];

    // 遍历 u 的所有直接下属 v
    for (int v : tr[u]) {
        // 递归计算下属 v 的dp值
        dfs(v);

        // --- 状态转移 ---
        // 1. 更新 f[u][0] (当前职员 u 不参加)
        // 此时,下属 v 可以参加也可以不参加,取两者中的较大值以使快乐指数最大化
        f[u][0] += max(f[v][0], f[v][1]);

        // 2. 更新 f[u][1] (当前职员 u 参加)
        // 此时,下属 v 必定不能参加
        f[u][1] += f[v][0];
    }
}

int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &r[i]);
    }

    // 读入关系并建树
    for (int i = 1; i < n; i++) {
        int l, k; scanf("%d%d", &l, &k); // k是l的上司
        tr[k].push_back(l); // 从上司k到下属l连边
        h[l] = true;        // 标记l有上司,所以l不是根节点
    }

    // 寻找根节点
    // 根节点是唯一一个没有上司的职员,即其 h 标记仍为 false
    int root = 0;
    for (int i = 1; i <= n; i++) {
        if (!h[i]) {
            root = i; 
            break;
        }
    }

    // 从根节点开始进行树形DP
    dfs(root);

    // 最终答案是根节点参加与不参加两种情况的最大值
    printf("%d\n", max(f[root][0], f[root][1]));

    return 0;
}

习题:P2899 [USACO08JAN] Cell Phone Network G

解题思路

注意本题和 P2016 战略游戏 的区别,战略游戏是选择一些点从而覆盖所有的边,本题是选择一些点从而覆盖所有的点。

在战略游戏中,一条边可能会被两端的点覆盖到,因此对于每个点对应的子树需要设计两个状态(选/不选)。类似地,在本题中,我们可以要分三种状态:

  • \(dp_{u,0}\) 表示 \(u\) 被自己覆盖的情况下对应子树的最少信号塔数量
  • \(dp_{u,1}\) 表示 \(u\) 被子节点覆盖的情况下对应子树的最少信号塔数量
  • \(dp_{u,2}\) 表示 \(u\) 被父节点覆盖的情况下对应子树的最少信号塔数量

则有状态转移:

  • \(dp_{u,0} = \sum \limits_{v \in son_u} \min \{dp_{v,0},dp_{v,1},dp_{v,2}\}\),因为 \(u\) 处自己放置了信号塔,因此子节点处放或不放都可以
  • \(dp_{u,1} = dp_{v',0} + \sum \limits_{v \in son_u \land v \ne v'} \min \{ dp_{v,0},dp_{v,1} \}\),此时至少要有一个子节点放置信号塔,其他可放可不放,因此 \(v'\) 应该是所有子节点 \(v\)\(dp_{v,0} - \min \{ dp_{v,0}, dp_{v,1} \}\) 最小的那个子节点;注意若 \(u\) 没有子树即 \(u\) 为叶子节点,此时 \(dp_{u,1}=1\)
  • \(dp_{u,2} = \sum \limits_{v \in son_u} \min \{ dp_{v,0}, dp_{v,1} \}\),因为本节点处不放,靠父节点放置来覆盖,所以子节点中除了状态 \(2\) 以外都可以
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 10005;
vector<int> tree[N];
int dp[N][3];
// dp[u][0]:u处放置
// dp[u][1]:u处依赖子节点放置
// dp[u][2]:u处依赖父节点放置
void dfs(int u, int fa) {
    dp[u][0] = 1;
    int best = -1;
    for (int v : tree[u]) {
        if (v == fa) continue;
        dfs(v, u);
        dp[u][0] += min(min(dp[v][0], dp[v][1]), dp[v][2]);
        dp[u][2] += min(dp[v][0], dp[v][1]);
        dp[u][1] += min(dp[v][0], dp[v][1]);
        // 寻找必须要放置的那个子节点
        int cur_diff = dp[v][0] - min(dp[v][0], dp[v][1]);
        int best_diff = dp[best][0] - min(dp[best][0], dp[best][1]);
        if (best == -1 || cur_diff < best_diff)
            best = v;
    }
    if (best != -1) {
        // 至少要在一个子节点处放置
        dp[u][1] += dp[best][0] - min(dp[best][0], dp[best][1]);
    } else {
        dp[u][1] = 1; // 没有子树,必须放置
    }
}
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i < n; i++) {
        int a, b; scanf("%d%d", &a, &b);
        tree[a].push_back(b);
        tree[b].push_back(a);
    }
    dfs(1, 0);
    printf("%d\n", min(dp[1][0], dp[1][1]));
    return 0;
}

例题:P5658 [CSP-S2019] 括号树

分析:本题 \(n\) 小的数据点保证为链,直接枚举 \(i\),代表从根节点到 \(i\) 号节点。枚举 \([1,i]\) 中的子区间左右端点 \([l,r]\),判断该子串是否符合括号匹配。

参考代码
#include <cstdio>
typedef long long LL;
const int N = 500005;
char s[N];
int f[N];
bool check(int l, int r) {
    int left = 0;
    for (int i = l; i <= r; i++) {
        if (s[i] == '(') left++;
        else if (left == 0) return false;
        else left--;
    }
    return left == 0;
}
int main()
{
    int n; scanf("%d%s", &n, s + 1);
    for (int i = 2; i <= n; i++) scanf("%d", &f[i]);
    LL ans = 0;
    for (int i = 2; i <= n; i++) {
        // 1~i
        LL cnt = 0;
        for (int l = 1; l <= i; l++) {
            for (int r = l; r <= i; r++) {
                // [l, r]
                if (check(l, r)) {
                    cnt++;
                }
            }
        }
        ans ^= cnt * i;
    }
    printf("%lld\n", ans);
    return 0;
}

实际得分 \(20\) 分。

考虑数据为链但 \(n \le 5 \times 10^5\) 的问题做法,此时可以看作是一个线性序列上的问题。

考虑用 \(dp_i\) 表示以 \(s_i\) 结尾的合法括号串,如果 \(s_i\) 是左括号,显然 \(dp_i = 0\);而如果 \(s_i\) 是右括号,这时实际上需要找到与该右括号对应匹配的左括号(这个问题可以借助一个栈来实现),则该左括号到当前的右括号构成了一个合法括号串,而实际上如果这个左括号的前一位是一个合法括号串的结尾,那么之前的合法括号串拼上刚匹配的这个括号串也是一个合法括号串,因此这时 \(dp_i = dp_{pre-1} + 1\),这里的 \(pre\) 代表当前右括号匹配的左括号的位置。

题目要求计算的是合法括号子串的数量,因此只需计算 \(dp\) 结果的前缀和即为前 \(i\) 个字符形成的字符串中合法括号子串的数量。

参考代码
#include <cstdio>
#include <stack>
using namespace std;
typedef long long LL;
const int N = 500005;
char s[N];
int f[N];
LL dp[N]; // 以s[i]结尾的括号子串数量
LL sum[N]; // 1~i中的括号子串数量,即dp的前缀和
int main()
{
    int n; scanf("%d%s", &n, s + 1);
    for (int i = 2; i <= n; i++) scanf("%d", &f[i]);
    stack<int> stk; // 记录左括号的位置
    LL ans = 0;
    for (int i = 1; i <= n; i++) {
        if (s[i] == '(') {
            stk.push(i);
        } else if (!stk.empty()) {
            int pre = stk.top();
            stk.pop();
            dp[i] = dp[pre - 1] + 1;
        }
        sum[i] = sum[i - 1] + dp[i];
        ans ^= sum[i] * i;
    }
    printf("%lld\n", ans);
    return 0;
}

实际得分 \(55\) 分。

把处理链的思路转化到任意树上。

其中 \(dp\)\(sum\) 的计算方式可以类推过来,只不过链上通过“减一”表达上一个位置的方式对应到树上要变成“父节点”。因此原来的计算式子需要调整一下:

  • \(dp_i = dp_{pre-1} + 1 \rightarrow dp_i = dp_{f_{pre}} + 1\)
  • \(sum_i = sum_{i-1} + dp_i \rightarrow sum_i = sum_{f_i} + dp_i\)

除此以外,还需要解决树上的括号栈的递归与回溯问题。发生回溯后,栈里的信息可能会和当前状态不匹配。比如某个节点(左括号)有多棵子树,进入其中一棵子树之后,该子树中的右括号匹配掉了这个左括号(出栈),而接下来再进入下一棵子树时这个左括号依然需要在栈中。

因此回溯时,我们要执行当时递归时相反的操作。比如,当前节点是右括号,如果此时栈不为空,栈会弹出一个元素以匹配当前右括号。我们可以记录这个信息,在最后回溯前把它重新压入栈中,保持状态的一致性。

参考代码
#include <cstdio>
#include <vector>
#include <stack>
using namespace std;
typedef long long LL;
const int N = 500005;
char s[N];
vector<int> tree[N];
stack<int> stk;
int f[N];
LL dp[N], sum[N];
void dfs(int cur, int fa) {
    int tmp = 0;
    if (s[cur] == '(') {
        stk.push(cur); tmp = -1;
        dp[cur] = 0;
    } else if (stk.empty()) {
        dp[cur] = 0;
    } else {
        tmp = stk.top(); stk.pop(); 
        dp[cur] = dp[f[tmp]] + 1; 
    }
    sum[cur] = sum[fa] + dp[cur];
    for (int to : tree[cur]) dfs(to, cur);
    if (tmp == -1) stk.pop();
    else if (tmp > 0) stk.push(tmp);
}
int main()
{
    int n;
    scanf("%d%s", &n, s + 1);
    for (int i = 2; i <= n; i++) {
        scanf("%d", &f[i]);
        tree[f[i]].push_back(i);
    }
    dfs(1, 0);
    LL ans = 0;
    for (int i = 1; i <= n; i++) ans ^= (sum[i] * i);
    printf("%lld\n", ans);
    return 0;
}

例题:P4084 [USACO17DEC] Barn Painting G

分析:状态设计比较直接,设 \(dp_{u,c}\) 表示以 \(u\) 为根节点的子树,节点 \(u\) 的颜色为 \(c\) 的方案数,即对于所有初始状态,\(dp_{u,1} = dp_{u,2} = dp_{u,3} = 1\),如果某个节点被上了指定的颜色,那么该节点的状态中另外两种上色状态方案数为 \(0\)

对于每个节点,由于不能与子节点颜色相同,则有:

  • \(dp_{u,1} = \prod \limits_{v \in son_u} (dp_{v,2} + dp_{v,3})\)
  • \(dp_{u,2} = \prod \limits_{v \in son_u} (dp_{v,1} + dp_{v,3})\)
  • \(dp_{u,3} = \prod \limits_{v \in son_u} (dp_{v,1} + dp_{v,2})\)
参考代码
#include <cstdio>
#include <vector>
using namespace std;
const int N = 100005;
const int MOD = 1000000007;
vector<int> tree[N];
int c[N], dp[N][4];
void dfs(int u, int fa) {
    int ans1 = 1, ans2 = 1, ans3 = 1;
    for (int v : tree[u]) {
        if (v == fa) continue;
        dfs(v, u);
        ans1 = 1ll * (dp[v][2] + dp[v][3]) % MOD * ans1 % MOD;
        ans2 = 1ll * (dp[v][1] + dp[v][3]) % MOD * ans2 % MOD;
        ans3 = 1ll * (dp[v][1] + dp[v][2]) % MOD * ans3 % MOD;
    }
    if (c[u] == 0 || c[u] == 1) dp[u][1] = ans1;
    if (c[u] == 0 || c[u] == 2) dp[u][2] = ans2;
    if (c[u] == 0 || c[u] == 3) dp[u][3] = ans3;
}
int main()
{
    int n, k;
    scanf("%d%d", &n, &k);
    for (int i = 1; i < n; i++) {
        int x, y;
        scanf("%d%d", &x, &y);
        tree[x].push_back(y); tree[y].push_back(x);
    }
    while (k--) {
        int b;
        scanf("%d", &b); scanf("%d", &c[b]);
    }
    dfs(1, 0);
    printf("%d\n", ((dp[1][1] + dp[1][2]) % MOD + dp[1][3]) % MOD);
    return 0;
}

例题:P7073 [CSP-J2020] 表达式

分析:对于初始情况,可以通过后缀表达式与栈建立二叉树,通过树上 DP 进行结果计算。

对于 \(30\%\) 的数据,可以修改后重新进行 DP。

参考代码
#include <cstdio>
#include <stack>
using std::stack;
const int N = 1000005;
const int OFFSET = 100000;
char s[10];
int dp[N], lc[N], rc[N], t[N];
void dfs(int u) {
    if (lc[u]) dfs(lc[u]);
    if (rc[u]) dfs(rc[u]);
    if (t[u] == 1) dp[u] = !dp[lc[u]];
    else if (t[u] == 2) dp[u] = dp[lc[u]] & dp[rc[u]];
    else if (t[u] == 3) dp[u] = dp[lc[u]] | dp[rc[u]];
}
int main()
{
    int n, len = OFFSET;
    stack<int> stk;
    while (true) {
        scanf("%s", s);
        if (s[0] >= '0' && s[0] <= '9') {
            sscanf(s, "%d", &n);
            break;
        }
        if (s[0] == 'x') {
            int xid; sscanf(s + 1, "%d", &xid);
            stk.push(xid);
        } else if (s[0] == '!') {
            t[++len] = 1; 
            lc[len] = stk.top(); 
            stk.pop(); 
            stk.push(len);
        } else {
            t[++len] = (s[0] == '&' ? 2 : 3);
            rc[len] = stk.top(); stk.pop(); 
            lc[len] = stk.top(); stk.pop(); 
            stk.push(len);
        }  
    }
    for (int i = 1; i <= n; i++) scanf("%d", &dp[i]);
    int q; scanf("%d", &q);
    for (int i = 1; i <= q; i++) {
        int idx; scanf("%d", &idx);
        dp[idx] = !dp[idx];
        dfs(len);
        printf("%d\n", dp[len]);
        dp[idx] = !dp[idx];
    }
    return 0;
}

而要想通过所有的数据点,必须一次知道每个变量改变后的算式结果,或者说改变这一项会不会改变算式结果,可以从根节点开始讨论,弄清楚当这一棵子树的值改变时计算结果是否会改变,只递归进入会对算式结果产生改变的子树,到达叶节点时对相应项进行标记。

对于操作符为 ! 的情况,一定进入它的子节点。

对于操作符为 & 的情况,如果两个子节点的计算结果均为 \(1\),则不管哪棵子树的计算结果发生变化都会对当前节点产生影响,则两边都要递归下去,如果一个值为 \(1\),另一个为 \(0\),则进入值为 \(0\) 的子树,如果两个值都为 \(0\),则不用继续递归。

对于操作符为 | 的情况,如果两个子节点的计算结果均为 \(0\),则不管哪棵子树的计算结果发生变化都会对当前节点产生影响,则两边都要递归下去,如果一个值为 \(1\),另一个为 \(0\),则进入值为 \(1\) 的子树,如果两个值都为 \(1\),则不用继续递归。

对于每次询问,如果该项无标记,则答案为一开始的计算结果,否则为原始计算结果取反。

参考代码
#include <cstdio>
#include <stack>
using std::stack;
const int N = 1000005;
const int OFFSET = 100000;
char s[10];
int dp[N], lc[N], rc[N], t[N], flag[N];
void dfs(int u) {
    if (lc[u]) dfs(lc[u]);
    if (rc[u]) dfs(rc[u]);
    if (t[u] == 1) dp[u] = !dp[lc[u]];
    else if (t[u] == 2) dp[u] = dp[lc[u]] & dp[rc[u]];
    else if (t[u] == 3) dp[u] = dp[lc[u]] | dp[rc[u]];
}
void calc(int u) {
    if (t[u] == 0) {
        flag[u] = 1; return;
    }
    if (t[u] == 1) {
        calc(lc[u]);
    } else {
        int l = dp[lc[u]], r = dp[rc[u]];
        if (t[u] == 2) {
            if (l == 1 && r == 1) {
                calc(lc[u]); calc(rc[u]);  
            } else if (l == 1 && r == 0) {
                calc(rc[u]);
            } else if (l == 0 && r == 1) {
                calc(lc[u]);
            }
        } else {
            if (l == 0 && r == 0) {
                calc(lc[u]); calc(rc[u]);
            } else if (l == 1 && r == 0) {
                calc(lc[u]);
            } else if (l == 0 && r == 1) {
                calc(rc[u]);
            }
        }
    }
}
int main()
{
    int n, len = OFFSET;
    stack<int> stk;
    while (true) {
        scanf("%s", s);
        if (s[0] >= '0' && s[0] <= '9') {
            sscanf(s, "%d", &n);
            break;
        }
        if (s[0] == 'x') {
            int xid; sscanf(s + 1, "%d", &xid);
            stk.push(xid);
        } else if (s[0] == '!') {
            t[++len] = 1; 
            lc[len] = stk.top(); 
            stk.pop(); 
            stk.push(len);
        } else {
            t[++len] = (s[0] == '&' ? 2 : 3);
            rc[len] = stk.top(); stk.pop(); 
            lc[len] = stk.top(); stk.pop(); 
            stk.push(len);
        }  
    }
    for (int i = 1; i <= n; i++) scanf("%d", &dp[i]);
    dfs(len);
    calc(len);
    int q; scanf("%d", &q);
    for (int i = 1; i <= q; i++) {
        int idx; scanf("%d", &idx);
        printf("%d\n", dp[len] ^ flag[idx]);
    }
    return 0;
}

习题:CF1010D Mars rover

解题思路

类似于 P7073 [CSP-J2020] 表达式,多了一种 XOR 运算。而 XOR 运算时,任何一个输入变,输出必变,所以类似于 NOT,一定要进入它的两个子节点。

参考代码
#include <cstdio>
#include <vector>
using namespace std;
const int MAXN = 1000005;

char s[10];
vector<int> tree[MAXN]; // 邻接表存树
// v[i]: 节点i的类型。>=0: IN节点,值为v[i]; -1:NOT; -2:AND; -3:OR; -4:XOR
int v[MAXN];
int dp[MAXN];   // dp[i]: 节点i在初始状态下的输出值
int flag[MAXN]; // flag[i]=1: 表示节点i值的改变能够影响到根节点的最终输出

/**
 * @brief 第一遍DFS (后序遍历): 计算电路中每个节点在初始输入下的值
 * @param cur 当前节点
 */
void calc(int cur) {
    // 递归处理子节点
    for (int nxt : tree[cur]) calc(nxt);

    if (v[cur] >= 0) { // 如果是输入节点
        dp[cur] = v[cur];
    } else { // 如果是逻辑门
        if (v[cur] == -1) { // NOT
            dp[cur] = 1 - dp[tree[cur][0]];
        } else {
            int l = dp[tree[cur][0]], r = dp[tree[cur][1]];
            if (v[cur] == -2) dp[cur] = l & r;   // AND
            else if (v[cur] == -3) dp[cur] = l | r; // OR
            else dp[cur] = l ^ r;                  // XOR
        }
    }
}

/**
 * @brief 第二遍DFS (前序遍历): 判断每个节点的变化是否能影响最终结果
 * @param cur 当前节点
 */
void dfs(int cur) {
    if (v[cur] < 0) { // 如果是逻辑门
        if (v[cur] == -1) { // NOT: 变化总能传播
            dfs(tree[cur][0]);
        } else if (v[cur] == -4) { // XOR: 变化总能传播
            dfs(tree[cur][0]);
            dfs(tree[cur][1]);
        } else if (v[cur] == -2) { // AND
            int l = dp[tree[cur][0]], r = dp[tree[cur][1]];
            // 只有当另一个输入为1时,当前输入的变化才能传播
            if (r == 1) dfs(tree[cur][0]);
            if (l == 1) dfs(tree[cur][1]);
        } else if (v[cur] == -3) { // OR
            int l = dp[tree[cur][0]], r = dp[tree[cur][1]];
            // 只有当另一个输入为0时,当前输入的变化才能传播
            if (r == 0) dfs(tree[cur][0]);
            if (l == 0) dfs(tree[cur][1]);
        }
    } else { // 如果是输入节点
        // 如果DFS能到达一个输入节点,说明它的变化能影响最终结果
        flag[cur] = 1;
    }
}

int main()
{
    int n, x, y;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%s", s);
        if (s[0] == 'I') {
            scanf("%d", &x);
            v[i] = x;
        } else if (s[0] == 'N') {
            scanf("%d", &x);
            v[i] = -1; tree[i].push_back(x);
        } else {
            scanf("%d%d", &x, &y);
            v[i] = s[0] == 'A' ? -2 : (s[0] == 'O' ? -3 : -4);
            tree[i].push_back(x); tree[i].push_back(y);
        }
    }

    // 第一遍DFS,计算初始值
    calc(1);
    
    // 标记根节点的变化能影响自身
    flag[1] = 1;
    // 第二遍DFS,从根节点开始,判断影响的传播路径
    dfs(1);

    // 输出结果
    for (int i = 1; i <= n; i++) {
        if (v[i] >= 0) { // 只对输入节点输出
            // 初始输出 dp[1], 如果flag[i]为1,说明翻转后结果会变,异或1;否则不变,异或0
            printf("%d", dp[1] ^ flag[i]);
        }
    }
    printf("\n");
    return 0;
}

习题:ABC259F Select Edges

解题思路

这是一个在树上进行带约束的决策以求最优解的问题,是树形动态规划的一个典型应用。

为了在树上进行 DP,首先需要确定一个根节点(例如,节点 1),从而建立父子关系。对于树中的一个节点 \(u\),它的决策会受到其父节点和子节点的影响。关键在于连接 \(u\) 和其父节点 \(\text{fa}\) 的边 \((u,\text{fa})\) 是否被选择,因为这个选择会影响到节点 \(u\) 的选择边数限制。

因此,可以定义如下状态:

  • \(f_{u,0}\):表示在以 \(u\) 为根的子树中,不选择\((u,\text{fa})\) 的情况下,子树内能得到的最大边权和。
  • \(f_{u,1}\):表示在以 \(u\) 为根的子树中,选择\((u,\text{fa})\) 的情况下,子树内能得到的最大边权和。

最终答案是 \(f_{\text{root},0}\),因为根节点没有父节点,相当于“不选择连接父节点的边”。

通过一次深度优先搜索(DFS),在后序遍历的位置(即处理完子节点后),自底向上地计算每个节点的 DP 值。

对于当前节点 \(u\) 和它的所有子节点 \(v_1,v_2,\dots,v_k\)

首先,假设 \(u\) 与其所有子节点 \(v_i\) 的边都不选择。在这种情况下,对于每个子树 \(v_i\),能获得的最大权值和是 \(f_{v_i,0}\)(因为边 \((v_i,u)\) 没有被选择)。所以,可以先将所有子树的这部分贡献累加起来作为基础值。

现在,考虑“选择”连接 \(u\) 和其子节点 \(v_i\) 的边(设其权值为 \(w_i\))能带来多少额外收益

  • 如果选择边 \((u,v_i)\),会获得权值 \(w_i\)
  • 同时,对于子树 \(v_i\),必须切换到“连接父节点的边被选择”的状态,即 \(f_{v_i,1}\)
  • 之前的基础贡献是 \(f_{v_i,0}\)
  • 因此,选择边 \((u,v_i)\) 带来的净收益是 \((f_{v_i,1}+w_i)-f_{v_i,0}\)

现在,对于节点 \(u\),有一系列连接其子节点的边可以选,每条边都有一个对应的额外收益。由于 \(u\) 有选择边数限制,不能无限制地选择。为了使总权值和最大,应该贪心地选择那些额外收益值最大的边,直到达到选择边数限制。

计算 \(f_{u,0}\):此时边 \((u,\text{fa})\) 未被选择,所以 \(u\) 最多可以选择 \(d_u\) 条连接子节点的边,将基础收益加上排序后前 \(d_u\) 个最大的正额外收益值。

计算 \(f_{u,1}\):此时边 \((u,\text{fa})\) 已被选择,占用了 \(u\) 的一个选择边数名额,所以 \(u\) 最多只能选择 \(d_u-1\) 条连接子节点的边,将基础收益加上排序后前 \(d_u-1\) 个最大的正额外收益值。

时间复杂度为 \(O(N \log N)\)

参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
using ll = long long;
using pi = pair<int, int>;
const int N = 300005;

int d[N]; // d[i]: 节点i的度数限制
// dp[u][0]: 以u为根的子树中, 不选(u, parent)边时, 能获得的最大权值和
// dp[u][1]: 以u为根的子树中, 选择(u, parent)边时, 能获得的最大权值和
ll dp[N][2];
vector<pi> tr[N]; // 邻接表存树, {v, w} 表示邻居v和边权w

/**
 * @brief 通过深度优先搜索进行树形DP
 * @param u 当前节点
 * @param fa u的父节点
 */
void dfs(int u, int fa) {
    vector<ll> tmp; // 存储选择连接子节点的边所能带来的正收益

    // 遍历u的所有邻居v
    for (pi e : tr[u]) {
        int v = e.first, w = e.second;
        if (v == fa) continue; // 跳过父节点

        // 递归计算子节点v的dp值
        dfs(v, u);

        // --- 状态转移:基础贡献 ---
        // 无论如何,都可以不选择(u,v)这条边,这样就能获得dp[v][0]的收益
        // 先把这部分基础收益累加上
        dp[u][0] += dp[v][0];
        dp[u][1] += dp[v][0];

        // --- 计算选择边(u,v)的额外收益 ---
        // 只有当子节点v的度数限制允许时(d[v]>0),才有可能选择边(u,v)
        if (d[v] > 0) {
            // 如果选择(u,v),总收益是 w + dp[v][1],而基础收益是 dp[v][0]
            // 所以额外收益(profit)就是 (w + dp[v][1] - dp[v][0])
            ll profit = (ll)dp[v][1] + w - dp[v][0];
            if (profit > 0) { // 只考虑有正收益的边
                tmp.push_back(profit);
            }
        }
    }

    // --- 贪心选择收益最大的边 ---
    // 将所有正的额外收益从大到小排序
    sort(tmp.begin(), tmp.end(), [](ll x, ll y) {
        return x > y;
    });

    // 根据选择数量限制,将最大的若干个profit累加到dp值中
    int use = 0; // 已选的连接子节点的边数
    for (ll x : tmp) {
        // 对于 dp[u][0], u的限制是 d[u]
        if (use < d[u]) {
            dp[u][0] += x;
        }
        // 对于 dp[u][1], 因为(u,fa)边已被选,u的限制是 d[u]-1
        if (use < d[u] - 1) {
            dp[u][1] += x;
        }
        use++;
    }
}

int main()
{
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &d[i]);
    }
    for (int i = 1; i < n; i++) {
        int u, v, w;
        scanf("%d%d%d", &u, &v, &w);
        tr[u].push_back({v, w});
        tr[v].push_back({u, w});
    }

    // 从节点1开始DFS,设1为根节点
    dfs(1, 0);

    // 最终答案是dp[1][0],因为根节点1没有父节点,相当于不选父边的情况
    printf("%lld\n", dp[1][0]);

    return 0;
}

习题:P3574 [POI2014] FAR-FarmCraft

解题思路

\(dp_u\) 表示假如在 \(0\) 时刻到达 \(u\),它的子树被安装好的时间。

发现对下面子树的遍历顺序会影响最终结果,考虑这个顺序,假设针对 \(u\) 的某两棵子树 \(v_1\)\(v_2\)

  • 假设先走 \(v_1\) 再走 \(v_2\),则此时可能的完成时间是 \(\max (1 + dp_{v_1}, 2 \times sz_{v_1} + 1 + dp_{v_2})\),前者表示 \(v_1\) 那棵子树完成时间更晚,后者表示 \(v_2\) 那棵子树完成时间更晚,此时要先走完 \(v_1\) 子树再走到 \(v_2\) 才能加 \(dp_{v_2}\)
  • 假设先走 \(v_2\) 再走 \(v_1\),则此时可能的完成时间是 \(\max (1 + dp_{v_2}, 2 \times sz_{v_2} + 1 + dp_{v_1})\)

显然我们希望 \(v_1\) 子树和 \(v_2\) 子树形成更好的遍历顺序,考虑按这上面的式子对子树排序。

注意,用上面的式子比大小对子节点排序需要证明 \(\max (1 + dp_{v_1}, 2 \times sz_{v_1} + 1 + dp_{v_2}) < \max (1 + dp_{v_2}, 2 \times sz_{v_2} + 1 + dp_{v_1})\) 这个式子具有传递性。这是可以证明的:假设小于号前面的式子取到第一项,此时这个式子必然满足,因为小于号后面式子的第二项必然比它大,传递性显然成立;假如小于号前面的式子取到第二项,此时相当于需要 \(2 \times sz_{v_1} + dp_{v_2} < 2 \times sz_{v_2} + dp_{v_1}\),这个式子经过移项可以使得小于号左边只和 \(v_1\) 有关,右边只和 \(v_2\) 有关,因此传递性得证。

所以我们可以按这种方式对子树排序,按照子树的遍历依次更新 \(dp_u\),这里的转移式是 \(2 \times sum + 1 + dp_v\),其中 \(sum\) 代表在 \(v\) 这棵子树之前的子树大小总和。

注意最后答案是 \(dp_1\)\(2 \times (n-1) + c_1\) 的较大值,因为题目要求走一圈后回到点 \(1\) 才能开始给 \(1\) 装软件。

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::sort;
using std::max;
const int N = 5e5 + 5;
vector<int> tree[N];
int c[N], sz[N], n, dp[N];
void dfs(int u, int fa) {
    dp[u] = c[u]; sz[u] = 1;
    for (int v : tree[u]) {
        if (v == fa) continue;
        dfs(v, u);
        sz[u] += sz[v];
    }
    sort(tree[u].begin(), tree[u].end(), [](int i, int j) {
        int i_before_j = max(1 + dp[i], 2 * sz[i] + 1 + dp[j]);
        int j_before_i = max(1 + dp[j], 2 * sz[j] + 1 + dp[i]);
        return i_before_j < j_before_i;
    });
    int sum = 0;
    for (int v : tree[u]) {
        if (v == fa) continue;
        dp[u] = max(dp[u], 2 * sum + 1 + dp[v]);
        sum += sz[v];
    }
}
int main()
{
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &c[i]);
    }
    for (int i = 1; i < n; i++) {
        int a, b; scanf("%d%d", &a, &b);
        tree[a].push_back(b); tree[b].push_back(a);
    }
    dfs(1, 0);
    // 1号点要等回来才能装,所以要考虑2*(n-1)+c[1]
    printf("%d\n", max(dp[1], 2 * (n - 1) + c[1])); 
    return 0;
}

例题:P3576 [POI2014] MRO-Ant colony

分析:从叶子节点往上推到食蚁兽所在的边不好做,但是食蚁兽在那条边上捕食的一定是正好 \(k\) 只蚂蚁。

所以可以从食蚁兽所在的边开始推,把这条边的两端点连上一个虚点 \(0\) 作为根,容易发现如果最后正好有 \(k\) 只蚂蚁爬到根,那么在每一条边上可行的蚂蚁数量都是连续区间。

不妨设 \(l_u\) 表示到达 \(u\) 这个点,沿着 \(u\) 往父节点爬时该边可行蚂蚁数量的最小值(闭区间),\(r_u\) 表示最大值(开区间),则有 \(l_u = l_{fa} \times (deg_{fa} - 1)\)\(r_u = r_{fa} \times (deg_{fa} - 1)\),初始值 \(l_0 = k\)\(r_0 = k + 1\)

最后对于每个叶节点,看有多少蚁群满足数量在 \([l_u, r_u)\) 之间,这个二分查找即可,统计数量后乘 \(k\) 即为答案。

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::sort;
using std::lower_bound;
using ll = long long;
const int N = 1000005;
int m[N], n, g, k;
ll l[N], r[N], ans;
vector<int> tree[N];
void dfs(int u, int fa) {
    int deg = tree[u].size();
    if (deg == 1) {
        ans += lower_bound(m + 1, m + g + 1, r[u]) - lower_bound(m + 1, m + g + 1, l[u]);
        return;
    }
    for (int v : tree[u]) {
        if (v == fa) continue;
        l[v] = l[u] * (deg - 1); r[v] = r[u] * (deg - 1);
        dfs(v, u);
    }
}
int main()
{
    scanf("%d%d%d", &n, &g, &k);
    for (int i = 1; i <= g; i++) scanf("%d", &m[i]);
    sort(m + 1, m + g + 1);
    int a, b; scanf("%d%d", &a, &b);
    tree[0].push_back(a); tree[0].push_back(b);
    tree[a].push_back(0); tree[b].push_back(0);
    for (int i = 2; i < n; i++) {
        int a, b; scanf("%d%d", &a, &b);
        tree[a].push_back(b); tree[b].push_back(a);
    }
    l[0] = k; r[0] = k + 1;
    dfs(0, 0);
    printf("%lld\n", ans * k);
    return 0;
}
posted @ 2024-05-21 19:16  RonChen  阅读(220)  评论(0)    收藏  举报