深入学习C++:什么是树状DP
一. 基础知识
树状DP的定义:
树形 DP 是动态规划在树结构上的应用,其核心思想是利用树的递归特性,通过子树的状态推导父节点的状态,最终求解整个树的最优解。由于树是一种无环的连通图,且具有明确的层次关系(父节点与子节点),天然适合用递归或递推的方式处理,因此树形 DP 在树相关的优化问题中被广泛使用。
树形 DP 的核心特点
1. 依赖树的递归结构:树的每个节点可以看作其子树的根,子树的解是父节点解的基础,因此通常采用自底向上(后序遍历)或自顶向下(前序遍历)的方式计算。
2. 状态定义与子树强相关:状态设计需体现 “当前节点 + 子树状态” 的关联,例如 “以节点u
为根的子树中,满足某条件的最优值”。
3. 无后效性:树的层次结构确保子树的状态不会影响非子树节点的状态,符合动态规划 “无后效性” 的要求。
树形 DP 的基本步骤
1. 选择根节点:树是无向图,通常任选一个节点作为根(如节点 1),将树转化为 “有向树”(父节点指向子节点),简化递归逻辑。
2. 定义状态:根据问题需求,设计状态dp[u][...]
,其中u
表示当前节点,方括号内的参数表示与u
相关的附加状态(如 “选 / 不选u
”“子树是否满足某条件” 等)。
3. 推导状态转移方程:基于子节点的状态,计算父节点的状态。例如,若u
的子节点为v1, v2, ..., vk
,则dp[u]
的取值依赖于dp[v1], dp[v2], ..., dp[vk]
的组合。
4. 递归计算:通过后序遍历(先处理子树,再处理父节点)或前序遍历(先处理父节点,再传递状态到子树),完成所有节点的状态计算。
5. 求解最终答案:根据根节点的状态(或结合其他节点的状态)得到整个树的最优解。
树状 DP 的典型应用场景与案例
树形 DP 的应用场景多为 “树的优化问题”,以下是几个经典案例:
案例 1:树上最大独立集
问题:在一棵树上选择若干节点,使得任意两个选中节点不相邻,求选中节点的最大数量(独立集:无相邻节点的子集)。
-
状态定义:
dp[u][0]
:以u
为根的子树中,不选u
时的最大独立集大小。dp[u][1]
:以u
为根的子树中,选u
时的最大独立集大小。
-
状态转移:
- 若选
u
,则其所有子节点不能选(否则相邻),因此:
dp[u][1] = 1 + sum(dp[v][0] for v in u的子节点)
- 若不选
u
,则其每个子节点可选或不选(取最大值),因此:
dp[u][0] = sum(max(dp[v][0], dp[v][1]) for v in u的子节点)
- 若选
-
最终答案:
max(dp[root][0], dp[root][1])
(root
为任选的根节点)。
案例 2:树的直径
问题:树中任意两节点的最长路径(直径)长度(边数或节点数)。
-
状态定义:
dp[u]
:以u
为根的子树中,从u
出发到子树中某节点的最长路径长度。
-
状态转移:
对节点u
的所有子节点v
,计算dp[v] + 1
(+1
表示u
到v
的边),取其中的前两大值a
和b
,则经过u
的最长路径为a + b
。遍历所有节点,最大的a + b
即为树的直径。 -
计算方式:通过后序遍历计算
dp[u]
,同时记录全局最大的a + b
。
案例 3:树上最小支配集
问题:选择最少的节点,使得树中所有节点要么被选中,要么与选中节点相邻(支配集)。
-
状态定义:
dp[u][0]
:u
被选中时,子树的最小支配集大小。dp[u][1]
:u
未被选中,但被其子节点支配(至少一个子节点选中)时,子树的最小支配集大小。dp[u][2]
:u
未被选中,但被其父节点支配(所有子节点均未选中)时,子树的最小支配集大小。
-
状态转移:
dp[u][0]
:u
选中后,子节点可自由选择(选 / 不选 / 被支配),取最小值之和加 1:
dp[u][0] = 1 + sum(min(dp[v][0], dp[v][1], dp[v][2]) for v in 子节点)
dp[u][1]
:u
未被选中,但至少一个子节点选中。需保证存在子节点v
满足dp[v][0]
,其余子节点取min(dp[v][0], dp[v][1])
:
dp[u][1] = sum(min(dp[v][0], dp[v][1])) + (若所有子节点都未选,则需补选一个子节点,取最小的dp[v][0] - min(dp[v][0], dp[v][1]))
dp[u][2]
:u
未被选中,依赖父节点支配,因此子节点必须被自身或其子女支配(即子节点不能依赖u
):
dp[u][2] = sum(min(dp[v][0], dp[v][1]) for v in 子节点)
-
最终答案:
min(dp[root][0], dp[root][1])
(根节点无父节点,因此dp[root][2]
无效)。
二. 树形 DP 的关键技巧
根的选择:树是无向的,通常任选一个节点(如1
号节点)作为根,通过 DFS 将树转化为 “有向父子树”,简化递归。
状态设计:状态需精准反映 “节点与子树的关系”,例如 “选 / 不选”“是否被支配” 等,避免遗漏关键信息。
遍历顺序:多数问题采用后序遍历(先处理子树,再合并结果到父节点),少数问题(如传递父节点状态到子树)需前序遍历。
空间优化:树的节点数为n
时,状态数组通常为O(n)
或O(n×k)
(k
为状态维度),时间复杂度多为O(n)
(每个节点处理一次)。
三. 示例
问题描述:
在一棵二叉树上,每个节点代表一户人家,节点的值表示该户的财物数量。要求不能抢劫相邻的两户人家(即父节点和子节点不能同时被抢劫),求能抢劫到的最大财物总和。
树形 DP 设计:
1. 状态定义
对每个节点 u
,定义两种状态:
dp[u][0]
:不抢劫节点u
时,以u
为根的子树能获得的最大财物。dp[u][1]
:抢劫节点u
时,以u
为根的子树能获得的最大财物。
2. 状态转移方程
-
若抢劫节点
u
(dp[u][1]
):
则其左、右子节点 不能被抢劫,因此:
dp[u][1] = u.val + dp[left][0] + dp[right][0]
-
若不抢劫节点
u
(dp[u][0]
):
则其左、右子节点 可抢或不抢(取最大值),因此:
dp[u][0] = max(dp[left][0], dp[left][1]) + max(dp[right][0], dp[right][1])
3. 递归计算
采用 后序遍历 方式:
- 先递归计算左、右子树的状态。
- 再根据子树状态计算当前节点的状态。
- 最终答案为根节点两种状态的最大值:
max(dp[root][0], dp[root][1])
。
代码如下:
#include <iostream>
#include <algorithm>
using namespace std;
// 二叉树节点结构
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
// 递归计算每个节点的两种状态
pair<int, int> dfs(TreeNode* node) {
if (node == nullptr) {
return {0, 0}; // 空节点:不抢和抢的收益都是0
}
// 递归计算左子树
auto [left_not_rob, left_rob] = dfs(node->left);
// 递归计算右子树
auto [right_not_rob, right_rob] = dfs(node->right);
// 计算当前节点的两种状态
int not_rob = max(left_not_rob, left_rob) + max(right_not_rob, right_rob);
int rob = node->val + left_not_rob + right_not_rob;
return {not_rob, rob};
}
int rob(TreeNode* root) {
auto [not_rob_root, rob_root] = dfs(root);
return max(not_rob_root, rob_root);
}
// 测试示例
int main() {
TreeNode* root = new TreeNode(3);
root->left = new TreeNode(2);
root->right = new TreeNode(3);
root->left->right = new TreeNode(3);
root->right->right = new TreeNode(1);
cout << "最大抢劫金额:" << rob(root) << endl; // 输出 7(抢劫3 + 3 + 1 或 2 + 3)
return 0;
}
复杂度分析:
- 时间复杂度:
O(n)
,其中n
是树的节点数。每个节点仅被访问一次。 - 空间复杂度:
O(h)
,h
是树的高度。递归栈的深度取决于树的高度(平衡树为log n
,最坏情况为n
)。