【动态规划】树形DP

1. 什么是树形 DP?

树形 DP 是在树结构上进行的动态规划。由于树是无环连通图,具有天然的递归结构(子树),非常适合用递归 + 状态转移的方式来求解。

特点:

  • 状态通常定义在节点上,如 dp[u] 表示以节点 u 为根的子树的某种最优值。
  • 转移过程通过遍历子树完成,即先处理所有子节点,再更新父节点。
  • 常用 DFS(深度优先搜索)实现后序遍历。

2. 树的基本性质与遍历方式

树的性质

  • n 个节点,n-1 条边。
  • 任意两点间有且仅有一条路径。
  • 无环、连通。

遍历方式

  • DFS(深度优先搜索):最常用,用于递归处理子树。
  • BFS(广度优先搜索):可用于层次遍历,但 DP 中较少用。

在树形 DP 中,我们通常使用 后序遍历(Post-order DFS):先处理所有子节点,再处理当前节点。


3. 树形 DP 的基本思想

  1. 建树:使用邻接表存储树(注意无向图需避免回溯)。
  2. 选择根节点:通常任选一个节点(如 1 号节点)作为根,将无根树转为有根树。
  3. 状态定义:定义 dp[u][...] 表示以 u 为根的子树在某种状态下的最优解。
  4. 状态转移:从子节点 v 向父节点 u 转移。
  5. 边界条件:叶子节点通常有明确的初始值。
  6. 答案提取:根据问题,答案可能是 dp[root] 或所有节点中的最大值等。

4. 经典例题 1:没有上司的舞会(最大独立集)

题目描述:公司有 n 名员工,形成一棵树(上司-下属关系)。每个员工有一个快乐值。如果某个员工参加舞会,他的直接下属就不能参加。求最大快乐值总和。

本质:求树的最大独立集(即选点,任意两点不相邻)。

状态定义

dp[u][0] 表示 不选 节点 u 时,以 u 为根的子树的最大快乐值。
dp[u][1] 表示 节点 u 时,以 u 为根的子树的最大快乐值。

状态转移

  • dp[u][0] = Σ max(dp[v][0], dp[v][1])u 不选,则子节点 v 可选可不选。
  • dp[u][1] = happy[u] + Σ dp[v][0]u 选,则所有子节点 v 都不能选。

边界条件

叶子节点:dp[leaf][0] = 0, dp[leaf][1] = happy[leaf]

C++ 实现

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

const int MAXN = 6005;

vector<int> tree[MAXN];  // 邻接表
int happy[MAXN];         // 快乐值
int dp[MAXN][2];         // dp[u][0/1]: 不选/选 u 的最大值
bool hasFather[MAXN];    // 标记是否有父节点(用于找根)

void dfs(int u) {
    dp[u][1] = happy[u];  // 选 u
    dp[u][0] = 0;         // 不选 u

    for (int v : tree[u]) {
        dfs(v);  // 先处理子节点

        // 状态转移
        dp[u][0] += max(dp[v][0], dp[v][1]);
        dp[u][1] += dp[v][0];
    }
}

int main() {
    int n;
    cin >> n;

    // 输入快乐值
    for (int i = 1; i <= n; i++) {
        cin >> happy[i];
    }

    // 建树
    for (int i = 1; i < n; i++) {
        int a, b;
        cin >> a >> b;
        tree[b].push_back(a);   // b 是 a 的上司
        hasFather[a] = true;
    }

    // 找根节点(没有父节点的)
    int root = 1;
    while (hasFather[root]) root++;

    // 从根开始 DFS
    dfs(root);

    // 答案是根节点选或不选的最大值
    cout << max(dp[root][0], dp[root][1]) << endl;

    return 0;
}

5. 经典例题 2:树的重心

题目描述:树的重心是指删除该节点后,使得剩下的连通块中最大子树的节点数最小的节点。

本质:最小化最大子树大小。

状态定义

sz[u] 表示以 u 为根的子树的节点总数(包括 u 自身)。
maxPart[u] 表示删除 u 后,最大连通块的大小。

状态转移

  • sz[u] = 1 + Σ sz[v]vu 的子节点)
  • maxPart[u] = max( max(sz[v]), n - sz[u] )
    n - sz[u] 是父方向的连通块)

C++ 实现

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

const int MAXN = 100005;

vector<int> tree[MAXN];
int sz[MAXN];           // 子树大小
int maxPart[MAXN];      // 删除 u 后最大连通块大小
int n, bestNode;
int minMaxPart = 1e9;

void dfs(int u, int parent) {
    sz[u] = 1;
    int maxSubtree = 0;  // u 的最大子树大小

    for (int v : tree[u]) {
        if (v == parent) continue;  // 避免回溯到父节点

        dfs(v, u);
        sz[u] += sz[v];
        maxSubtree = max(maxSubtree, sz[v]);
    }

    // 父方向的连通块大小
    int parentPart = n - sz[u];
    maxPart[u] = max(maxSubtree, parentPart);

    // 更新最优解
    if (maxPart[u] < minMaxPart) {
        minMaxPart = maxPart[u];
        bestNode = u;
    }
}

int main() {
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        tree[u].push_back(v);
        tree[v].push_back(u);
    }

    dfs(1, -1);  // 从节点 1 开始,父节点为 -1

    cout << "树的重心是: " << bestNode << endl;
    cout << "最大连通块大小: " << minMaxPart << endl;

    return 0;
}

6. 经典例题 3:树的直径(DP 解法)

题目描述:树的直径是树中最长路径的长度(边数或权值和)。

DP 方法:对每个节点,记录从该节点向下延伸的最长路径和次长路径。

状态定义

down1[u] 表示从 u 向下延伸的最长路径长度。
down2[u] 表示从 u 向下延伸的次长路径长度。
全局答案 ans = max(ans, down1[u] + down2[u])

C++ 实现(边权为 1)

#include <iostream>
#include <vector>
using namespace std;

const int MAXN = 100005;

vector<int> tree[MAXN];
int down1[MAXN], down2[MAXN];  // 最长和次长向下的路径
int ans = 0;

void dfs(int u, int parent) {
    down1[u] = down2[u] = 0;

    for (int v : tree[u]) {
        if (v == parent) continue;

        dfs(v, u);

        int len = down1[v] + 1;  // 边权为 1

        if (len > down1[u]) {
            down2[u] = down1[u];
            down1[u] = len;
        } else if (len > down2[u]) {
            down2[u] = len;
        }
    }

    ans = max(ans, down1[u] + down2[u]);
}

int main() {
    int n;
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        tree[u].push_back(v);
        tree[v].push_back(u);
    }

    dfs(1, -1);
    cout << "树的直径: " << ans << endl;

    return 0;
}

7. 经典例题 4:树上背包问题

题目描述:有 $ n $ 门课,每门课有学分 credit[i],某些课有先修课(形成一棵树)。最多选 $ m $ 门课,求最大学分。

本质:树形依赖背包问题。

状态定义

dp[u][j]:在以 u 为根的子树中,选 j 门课的最大学分。

转移方式

对每个子节点 v,做一次分组背包:

for (int j = m; j >= 1; j--)  // 倒序枚举容量
    for (int k = 0; k < j; k++)  // 在 v 子树中选 k 门
        dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k])

注意:u 本身也要选,所以至少要留 1 个容量给 u

C++ 实现

#include <iostream>
#include <vector>
using namespace std;

const int MAXN = 305;

vector<int> tree[MAXN];
int credit[MAXN];
int dp[MAXN][MAXN];  // dp[u][j]: u 子树选 j 门课的最大收益
int sz[MAXN];        // 子树大小(优化用)

void dfs(int u) {
    sz[u] = 1;
    dp[u][0] = 0;
    dp[u][1] = credit[u];  // 选自己

    for (int v : tree[u]) {
        dfs(v);

        // 合并子树 v(分组背包)
        for (int j = min(sz[u] + sz[v], 300); j >= 1; j--) {
            for (int k = 0; k <= min(j - 1, sz[v]); k++) {
                if (dp[u][j - k] != -1 && dp[v][k] != -1) {
                    dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
                }
            }
        }
        sz[u] += sz[v];
    }
}

int main() {
    int n, m;
    cin >> n >> m;

    for (int i = 1; i <= n; i++) {
        int fa;
        cin >> fa >> credit[i];
        tree[fa].push_back(i);
    }

    // 初始化 dp 为 -1(不可达)
    memset(dp, -1, sizeof(dp));
    dfs(0);  // 加入虚拟根节点 0

    int ans = 0;
    for (int j = 0; j <= m; j++) {
        ans = max(ans, dp[0][j]);
    }

    cout << ans << endl;
    return 0;
}

8. 树形 DP 的常见优化技巧

  1. 子树大小优化:在树上背包中,枚举容量时只需到 min(m, sz[u])
  2. 长链剖分优化:对特定问题(如 k 级祖先),可将复杂度从 $ O(n^2) $ 优化到 $ O(n) $。
  3. 换根 DP(二次扫描):先以某点为根做一次 DP,再通过 DFS 换根,计算每个点为根的答案。
  4. 记忆化搜索:避免重复计算。
  5. 使用 vector 动态分配:节省空间。

9. 总结与注意事项

树形 DP 的通用步骤:

  1. 建图(邻接表)
  2. 确定根节点
  3. 定义状态(dp[u][...]
  4. 写 DFS 函数,后序遍历
  5. 处理子节点后更新父节点
  6. 避免回溯(传 parent 参数)
  7. 提取答案

常见错误:

  • 忘记标记父节点导致无限递归。
  • 状态定义不清晰。
  • 转移方程写错(尤其是树上背包的倒序枚举)。
  • 忽略边界条件(叶子节点)。

10. 完整 C++ 模板代码汇总

// 通用树形 DP 模板
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;

const int MAXN = 100005;
vector<int> tree[MAXN];
int dp[MAXN][2];  // 根据题目调整

void dfs(int u, int parent) {
    // 初始化当前节点
    dp[u][0] = 0;
    dp[u][1] = 1;  // 举例

    for (int v : tree[u]) {
        if (v == parent) continue;
        dfs(v, u);

        // 状态转移(根据题目填写)
        dp[u][0] += max(dp[v][0], dp[v][1]);
        dp[u][1] += dp[v][0];
    }
}

int main() {
    int n;
    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        tree[u].push_back(v);
        tree[v].push_back(u);
    }

    dfs(1, -1);
    cout << max(dp[1][0], dp[1][1]) << endl;
    return 0;
}

结语:树形 DP 是算法竞赛和面试中的重点内容。掌握其核心思想——递归处理子树 + 状态合并,并熟练运用 DFS 遍历,就能解决大部分问题。多做题、多总结,是掌握树形 DP 的关键。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值