区间DP与树形DP

数据结构与算法专题 · 进阶动态规划范式

专题:数据结构与算法系统学习

关键词:数据结构, 算法, 区间DP, 树形DP, 矩阵链乘, 石子合并, 四边形不等式, 最大独立集, 树的重心

一、区间DP概述

区间DP(Interval Dynamic Programming)是动态规划的一个重要分支,其研究对象是区间上的最优决策问题。与线性DP不同,区间DP的状态通常定义为一段连续区间上的最优解,通过将大区间拆分为若干小区间来推导状态转移方程。区间DP的核心思想可以概括为一句口诀:"大区间由小区间合并而来"

区间DP的典型特征是:问题可以转化为对一段序列的某个区间进行操作,且操作的结果仅依赖于该区间内子区间的最优解。例如,合并石子、矩阵链乘、括号匹配等问题,本质上都是在区间上求解最优方案。

区间DP在算法竞赛和面试中出现的频率较高,通常作为中等偏难难度的题目出现。掌握区间DP不仅要求理解其状态定义和转移方程,还需要熟练运用长度遍历区间合并的代码模板。

核心特征:状态 `dp[i][j]` 表示区间 `[i, j]` 上的某种最优值(最大值、最小值、方案数等),转移时枚举分割点 `k`,将 `[i, j]` 拆分为 `[i, k]` 和 `[k+1, j]` 两个子区间。

二、区间DP核心思想

2.1 大区间拆分为小区间

区间DP最本质的思想就是分治。对于一个长度为 n 的区间 `[i, j]`,要计算它的最优解,我们枚举区间内的一个分割点 `k`(`i ≤ k < j`),将原区间切分为左子区间 `[i, k]` 和右子区间 `[k+1, j]`。左右子区间的最优解已经计算完毕(通过更小的区间得到),那么 `[i, j]` 的最优解就是所有可能分割方案中最好的那个。

这一思想要求我们在计算大区间之前,必须先计算出所有更小的区间。这就引出了区间DP最独特的遍历方式——按区间长度从小到大遍历

dp[i][j] = max/min { dp[i][k] + dp[k+1][j] + cost(i, j, k) }    (i ≤ k < j)

2.2 长度遍历(阶段划分)

线性DP通常按照下标顺序(从小到大的 i)进行遍历,但区间DP不能这么做。原因很简单:假设我们按照 i 从小到大、j 从大到小遍历,当计算 dp[i][j] 时需要用到 dp[i][k] 和 dp[k+1][j],其中 dp[k+1][j] 的区间起点 k+1 大于 i,如果 i 从小到大的顺序遍历,dp[k+1][j] 可能尚未计算。

解决这个问题的标准方法是按区间长度 len 从小到大遍历(也称作"阶段"划分)。因为长度为 len 的区间只会依赖长度小于 len 的子区间,只要保证所有长度小于 len 的区间均已计算完毕,那么当前区间的所有子区间就都是已知的。

2.3 区间合并(枚举分割点)

在确定了区间 `[i, j]` 后,第三步是枚举分割点 k。分割点的枚举范围是从 i 到 j-1,对于每个 k,我们计算将左右子区间合并的代价(cost),取所有方案中的最优值。

这里的关键是正确计算合并代价 cost(i, j, k),它因具体问题而异。例如在石子合并问题中,合并代价是区间 [i, j] 内所有石子重量之和;在矩阵链乘问题中,合并代价是左矩阵的行数乘以左右矩阵的共享维度再乘以右矩阵的列数。

三个核心步骤:① 枚举区间长度 len → ② 枚举区间起点 i 确定区间 [i, j] → ③ 枚举分割点 k 进行状态转移。

三、区间DP标准模板

下面给出区间DP最通用的代码模板。无论具体问题如何变化,先枚举长度、再枚举起点、最后枚举分割点的三层循环结构是固定不变的。

// 区间DP标准模板 // 状态: dp[i][j] 表示区间 [i, j] 上的最优解 // 初始化: 长度为1的区间 for (int i = 0; i < n; i++) { dp[i][i] = 0; // 根据具体问题初始化 } // 阶段: 枚举区间长度 len for (int len = 2; len <= n; len++) { // 状态: 枚举区间起点 i for (int i = 0; i + len - 1 < n; i++) { int j = i + len - 1; // 区间终点 dp[i][j] = INF; // 初始化为较大值(求最小值时) // 决策: 枚举分割点 k for (int k = i; k < j; k++) { // 合并左右子区间的代价 int cost = mergeCost(i, k, j); dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost); } } } // 答案: dp[0][n-1] 为整个序列的最优解

模板的要点分析:

在 Python 中同样可以方便地实现区间DP,下面给出对应的 Python 版本:

# Python 区间DP模板 n = len(arr) dp = [[0] * n for _ in range(n)] # 初始化长度为1的区间 for i in range(n): dp[i][i] = 0 # 按区间长度从小到大遍历 for length in range(2, n + 1): # 区间长度 for i in range(0, n - length + 1): # 区间起点 j = i + length - 1 # 区间终点 dp[i][j] = float('inf') # 枚举分割点 for k in range(i, j): cost = merge_cost(i, k, j) dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost) print(dp[0][n-1]) # 整个区间的最优解

提示:在某些问题中,分割点 k 的枚举范围可以进行优化(如四边形不等式优化),k 不一定需要从 i 到 j-1 全部枚举。但对于初学者,建议先从完整枚举开始理解。

四、区间DP经典问题

4.1 矩阵链乘(Matrix Chain Multiplication)

矩阵链乘是区间DP最经典的入门问题。给定 n 个矩阵 A₁, A₂, ..., Aₙ,其中 Aᵢ 的维度为 pᵢ₋₁ × pᵢ,求完全括号化方案使得标量乘法次数最少。

问题分析:矩阵乘法满足结合律,即 (A×B)×C = A×(B×C),但不同的结合顺序导致计算量差异巨大。例如:A(10×100)、B(100×5)、C(5×50),(A×B)×C 需要 10×100×5 + 10×5×50 = 7500 次乘法,而 A×(B×C) 需要 100×5×50 + 10×100×50 = 75000 次乘法,相差 10 倍。

状态定义:dp[i][j] 表示从第 i 个矩阵乘到第 j 个矩阵的最小乘法次数(第 i 个矩阵的维度为 p[i-1] × p[i])。

转移方程:

dp[i][j] = min { dp[i][k] + dp[k+1][j] + p[i-1] × p[k] × p[j] }    (i ≤ k < j)

# 矩阵链乘 - Python实现 def matrix_chain_order(p): """p: 矩阵维度数组, p[i-1] x p[i] 是第i个矩阵的维度""" n = len(p) - 1 # 矩阵个数 dp = [[0] * (n + 1) for _ in range(n + 1)] # length: 区间长度(矩阵个数) for length in range(2, n + 1): for i in range(1, n - length + 2): j = i + length - 1 dp[i][j] = float('inf') for k in range(i, j): cost = dp[i][k] + dp[k+1][j] + p[i-1] * p[k] * p[j] if cost < dp[i][j]: dp[i][j] = cost return dp[1][n] # 示例: 4个矩阵, 维度 30×35, 35×15, 15×5, 5×10 p = [30, 35, 15, 5, 10] print(matrix_chain_order(p)) # 输出: 11875

4.2 石子合并(Stone Merge)

石子合并问题是区间DP的另一个经典原型。有 N 堆石子排成一排,每堆石子有一定数量。每次只能合并相邻的两堆石子,合并代价为两堆石子数量之和。经过 N-1 次合并后变为一堆,求最小总代价。

状态定义:dp[i][j] 表示合并区间 [i, j] 内所有石子的最小代价。

转移方程:

dp[i][j] = min { dp[i][k] + dp[k+1][j] + sum(i, j) }    (i ≤ k < j)

其中 sum(i, j) 是区间 [i, j] 内所有石子重量之和,可以通过前缀和 O(1) 计算。

# 石子合并 - Python实现 def stone_merge(stones): n = len(stones) # 前缀和,用于快速计算区间和 prefix = [0] * (n + 1) for i in range(1, n + 1): prefix[i] = prefix[i-1] + stones[i-1] def range_sum(i, j): return prefix[j+1] - prefix[i] dp = [[0] * n for _ in range(n)] # 按区间长度遍历 for length in range(2, n + 1): for i in range(0, n - length + 1): j = i + length - 1 dp[i][j] = float('inf') for k in range(i, j): cost = dp[i][k] + dp[k+1][j] + range_sum(i, j) if cost < dp[i][j]: dp[i][j] = cost return dp[0][n-1] # 示例: 4堆石子, 数量 [4, 1, 1, 4] stones = [4, 1, 1, 4] print(stone_merge(stones)) # 输出: 18 # 合并过程: (4+1)=5(代价5), (1+4)=5(代价5), (5+5)=10(代价10), 总代价=5+5+10=20 # 最优方案: (1+1)=2(代价2), (4+2)=6(代价6), (6+4)=10(代价10), 总代价=2+6+10=18

4.3 括号匹配(Bracket Matching)

给定一个只包含 '('、')'、'['、']'、'{'、'}' 的字符串,求最长合法括号子序列的长度(注意是子序列而非子串,即可以不连续)。

状态定义:dp[i][j] 表示子串 s[i..j] 中最长合法括号子序列的长度。

转移方程:

# 括号匹配 - Python实现 def longest_valid_bracket(s): n = len(s) dp = [[0] * n for _ in range(n)] # 判断是否匹配 def match(a, b): return (a == '(' and b == ')') or \ (a == '[' and b == ']') or \ (a == '{' and b == '}') for length in range(2, n + 1): for i in range(0, n - length + 1): j = i + length - 1 if match(s[i], s[j]): inner = dp[i+1][j-1] if i+1 <= j-1 else 0 dp[i][j] = inner + 2 for k in range(i, j): dp[i][j] = max(dp[i][j], dp[i][k] + dp[k+1][j]) return dp[0][n-1] # 示例 s = "([{}])()" print(longest_valid_bracket(s)) # 输出: 8 (全部匹配) s2 = "([)]" print(longest_valid_bracket(s2)) # 输出: 2 (最长合法子序列为 "()" 或 "[]")

4.4 回文子串分割(Palindrome Partitioning II)

给定一个字符串 s,将 s 分割成若干子串,使得每个子串都是回文串。求最少分割次数。

问题分析:这是区间DP与预处理结合的经典问题。如果直接做区间DP,时间复杂度为 O(n³),可能超时。常见的优化方式是先用 O(n²) 预处理出 isPal[i][j] 表示子串 s[i..j] 是否为回文串,再做区间DP。

# 回文子串分割 - Python实现 def min_cut(s): n = len(s) if n == 0: return 0 # 预处理: is_pal[i][j] 表示 s[i..j] 是否为回文串 is_pal = [[False] * n for _ in range(n)] for i in range(n): is_pal[i][i] = True for i in range(n - 1): is_pal[i][i+1] = (s[i] == s[i+1]) for length in range(3, n + 1): for i in range(0, n - length + 1): j = i + length - 1 is_pal[i][j] = (s[i] == s[j] and is_pal[i+1][j-1]) # 区间DP: dp[i] 表示前缀 s[0..i] 的最少分割次数 dp = [float('inf')] * n for i in range(n): if is_pal[0][i]: dp[i] = 0 else: for j in range(i): if is_pal[j+1][i]: dp[i] = min(dp[i], dp[j] + 1) return dp[n-1] # 示例 s = "aab" print(min_cut(s)) # 输出: 1 -> "aa" + "b" s2 = "abccba" print(min_cut(s2)) # 输出: 0 -> 本身就是回文串

五、四边形不等式优化

对于某些满足特定性质的区间DP问题,可以将枚举分割点 k 的时间复杂度从 O(n) 降低到 O(1)(均摊),从而将总复杂度从 O(n³) 降低到 O(n²)。这个优化被称为四边形不等式优化(Quadrilateral Inequality Optimization),也叫 Knuth 优化或决策单调性优化。

5.1 适用条件

四边形不等式优化适用于满足以下两个性质的区间DP问题:

典型的满足四边形不等式的问题包括:石子合并、最优二叉搜索树等。

5.2 优化后的模板

利用决策单调性,我们在枚举分割点时,不需要从 i 枚举到 j-1,只需从 opt[i][j-1] 枚举到 opt[i+1][j]。

# 四边形不等式优化后的区间DP模板 # 适用于满足决策单调性的问题(如石子合并) n = len(arr) dp = [[0] * n for _ in range(n)] opt = [[0] * n for _ in range(n)] # 记录最优分割点 # 初始化 for i in range(n): dp[i][i] = 0 opt[i][i] = i # 长度为1的区间,分割点为自己 # 按区间长度遍历 for length in range(2, n + 1): for i in range(0, n - length + 1): j = i + length - 1 dp[i][j] = float('inf') # 利用决策单调性缩小枚举范围 for k in range(opt[i][j-1], opt[i+1][j] + 1): if k < j: # 确保分割点有效 cost = dp[i][k] + dp[k+1][j] + range_sum(i, j) if cost < dp[i][j]: dp[i][j] = cost opt[i][j] = k print(dp[0][n-1])

时间复杂度对比:O(n³) → O(n²)。对于 n=1000 的数据规模,优化前需要 10 亿次操作(无法接受),优化后只需要 100 万次操作(轻松运行)。

5.3 石子合并的完整优化实现

# 四边形不等式优化 - 石子合并完整实现 def stone_merge_optimized(stones): n = len(stones) prefix = [0] * (n + 1) for i in range(1, n + 1): prefix[i] = prefix[i-1] + stones[i-1] def range_sum(i, j): return prefix[j+1] - prefix[i] dp = [[0] * n for _ in range(n)] opt = [[0] * n for _ in range(n)] for i in range(n): dp[i][i] = 0 opt[i][i] = i for length in range(2, n + 1): for i in range(0, n - length + 1): j = i + length - 1 dp[i][j] = float('inf') # 决策单调性: k 的范围被缩小 left = opt[i][j-1] right = opt[i+1][j] for k in range(left, right + 1): if k < j: cost = dp[i][k] + dp[k+1][j] + range_sum(i, j) if cost < dp[i][j]: dp[i][j] = cost opt[i][j] = k return dp[0][n-1] # 大数据量测试 import random, time stones = [random.randint(1, 100) for _ in range(500)] start = time.time() result = stone_merge_optimized(stones) elapsed = time.time() - start print(f"结果: {result}, 耗时: {elapsed:.3f}秒") # O(n²) 优化后,500个元素只需约0.1秒

注意:四边形不等式优化并不是通用的,它要求代价函数满足四边形不等式和单调性。如果不确定是否满足,可以通过打表验证 opt[i][j] 是否满足 opt[i][j-1] ≤ opt[i][j] ≤ opt[i+1][j] 这一性质。

六、树形DP概述

树形DP(Tree Dynamic Programming)是将动态规划思想应用到树形结构上的算法范式。树是一种天然适合递归的数据结构,树的子树结构为DP提供了天然的"子问题"划分:每个节点的最优解可以由其子节点的最优解推导而来。

树形DP的核心是后序遍历(Post-order Traversal)或者说自底向上的DP。在遍历过程中,先递归处理子节点,收集子节点的状态信息,然后根据子节点的状态计算当前节点的状态。这种模式与树的递归定义完美契合。

核心思想:先处理子树(子问题),再合并子树的解得到当前节点的解。即"后序DP"。

树形DP通常可以分为两类:

在实际代码中,DFS + 记忆化是最常用的方式,因为它天然契合树的递归结构。

七、树形DP核心思想与模板

7.1 树的递归结构

一棵树的任意一个节点连同它的所有后代构成一棵子树。这个递归性质意味着:如果我们能解决子树的问题,就能通过合并子树的解来解决整棵树的问题。这是树形DP可行的理论基础。

具体来说,对于树上的节点 u,其 DP 状态通常定义为"以 u 为根的子树上的某个最优值"。转移时则需要枚举 u 的子节点 v,将 v 子树的解与 u 的决策结合。

7.2 后序遍历与子树合并

树形DP的DFS模板通常如下:

# 树形DP通用DFS模板 def dfs(u, parent): """u: 当前节点, parent: 父节点(防止往回走)""" # 1. 初始化当前节点的状态 dp[u] = initial_value(u) # 2. 遍历所有子节点 for v in adjacency[u]: if v == parent: continue # 递归处理子节点 dfs(v, u) # 3. 合并子节点的状态到当前节点 dp[u] = combine(dp[u], dp[v]) # 4. 返回当前节点的状态 return # 从根节点开始(通常指定0或1为根) dfs(root, -1) # 答案通常在 dp[root] 或所有 dp 值的汇总中

这个模板看似简单,但合并逻辑 combine() 是树形DP的灵魂所在,不同问题的合并方式千差万别。

7.3 树形DP的常见形式

根据合并方式的不同,树形DP有以下几种常见形式:

八、树形DP经典问题

8.1 树的最大独立集(Maximum Independent Set on Tree)

给定一棵树,从中选出尽可能多的节点,使得任意两个被选中的节点都不相邻(即没有边直接相连)。这就是树上的最大独立集问题,也是树形DP最经典的入门题。

状态定义:

转移方程:

最终答案:max(dp[root][0], dp[root][1])

# 树的最大独立集 - Python实现 def max_independent_set(n, edges): """ n: 节点数 (0-indexed) edges: 边列表 [(u1,v1), (u2,v2), ...] """ from collections import defaultdict adj = defaultdict(list) for u, v in edges: adj[u].append(v) adj[v].append(u) # dp[u][0]: 不选u, dp[u][1]: 选u dp = [[0, 0] for _ in range(n)] visited = [False] * n def dfs(u): visited[u] = True dp[u][1] = 1 # 选u, 初始值为1 dp[u][0] = 0 # 不选u, 初始值为0 for v in adj[u]: if not visited[v]: dfs(v) # 选u时, 子节点不能选 dp[u][1] += dp[v][0] # 不选u时, 子节点可选可不选 dp[u][0] += max(dp[v][0], dp[v][1]) dfs(0) return max(dp[0][0], dp[0][1]) # 示例: 一条链 0-1-2-3 n = 4 edges = [(0, 1), (1, 2), (2, 3)] print(max_independent_set(n, edges)) # 输出: 2 (选节点0和2, 或1和3)

8.2 树的重心(Centroid of a Tree)

树的重心是指树中的一个节点,删除该节点后得到的若干子树中,最大的子树的大小最小。这个节点被称为树的重心。树的重心在树的分解(点分治)中有重要应用。

算法思路:通过树形DP计算每个节点的子树大小,然后计算删除每个节点后最大连通块的大小。对于节点 u,删除 u 后会产生若干连通块:每个子节点 v 所在的子树(大小为 sz[v]),以及"上方"的部分(大小为 n - sz[u])。这些连通块大小的最大值即为删除 u 后的代价,取代价最小的节点即为重心。

# 树的重心 - Python实现 def find_centroid(n, edges): from collections import defaultdict adj = defaultdict(list) for u, v in edges: adj[u].append(v) adj[v].append(u) sz = [0] * n # 子树大小 max_part = [0] * n # 删除节点后最大连通块大小 visited = [False] * n def dfs(u): visited[u] = True sz[u] = 1 max_part[u] = 0 for v in adj[u]: if not visited[v]: dfs(v) sz[u] += sz[v] # 子树的连通块大小 max_part[u] = max(max_part[u], sz[v]) # "上方"部分的大小 max_part[u] = max(max_part[u], n - sz[u]) dfs(0) # 找到 max_part 最小的节点 centroid = min(range(n), key=lambda x: max_part[x]) return centroid, max_part[centroid] # 示例: 星形图, 0为中心 n = 6 edges = [(0, 1), (0, 2), (0, 3), (0, 4), (0, 5)] centroid, cost = find_centroid(n, edges) print(f"重心: {centroid}, 最大子树大小: {cost}") # 输出: 重心: 0, 最大子树大小: 5 # 示例: 链 0-1-2-3-4 n = 5 edges2 = [(0, 1), (1, 2), (2, 3), (3, 4)] centroid2, cost2 = find_centroid(n, edges2) print(f"重心: {centroid2}, 最大子树大小: {cost2}") # 输出: 重心: 2, 最大子树大小: 2

重要性质:一棵树有一个或两个重心。如果有两个重心,它们一定相邻。重心还有一个重要性质:树中所有节点到重心的距离之和最小。

8.3 树上最长路径(树的直径 / Tree Diameter)

树的直径是树中最长路径的长度(边数或权重之和)。树上最长路径可以通过两次 DFS/BFS 求解,也可以用树形DP求解。树形DP的方法可以同时处理带权树和负权边。

状态定义:dp1[u] 表示从 u 到其子树中某个节点的最长距离,dp2[u] 表示次长距离。

转移:对于子节点 v,dist = dp1[v] + weight(u, v),用 dist 更新 dp1[u] 和 dp2[u]。

答案:max(dp1[u] + dp2[u]),即所有节点作为路径最高点时,最长距离+次长距离的最大值。

# 树的直径 - 树形DP实现(支持带权边) def tree_diameter(n, edges_with_weight): """ n: 节点数 edges_with_weight: [(u, v, w), ...] 带权边 返回: (直径长度, 直径的两个端点) """ from collections import defaultdict adj = defaultdict(list) for u, v, w in edges_with_weight: adj[u].append((v, w)) adj[v].append((u, w)) dp1 = [0] * n # 最长距离 dp2 = [0] * n # 次长距离 visited = [False] * n diameter = 0 def dfs(u): nonlocal diameter visited[u] = True for v, w in adj[u]: if not visited[v]: dfs(v) dist = dp1[v] + w # 更新最长和次长 if dist > dp1[u]: dp2[u] = dp1[u] dp1[u] = dist elif dist > dp2[u]: dp2[u] = dist # 以u为路径最高点时的直径 diameter = max(diameter, dp1[u] + dp2[u]) dfs(0) return diameter # 示例: 带权树 n = 5 edges = [(0, 1, 2), (1, 2, 3), (1, 3, 1), (3, 4, 4)] print(tree_diameter(n, edges)) # 输出: 10 (路径0-1-3-4: 2+1+4=7, 或2-1-3-4: 3+1+4=8...) # 实际最长路径: 2-1-3-4: 3+1+4=8

8.4 树的中心(Center of a Tree)

树的中心是树中到最远节点的距离最小的节点。与重心不同,中心关注的是"到最远节点的距离",最小化这个距离。树的中心可以有一个或两个(相邻)。

算法思路:需要计算每个节点到其最远节点的距离,然后取最小值。这需要两次DFS:

# 树的中心 - Python实现(两次DFS/换根DP) def tree_center(n, edges_with_weight): from collections import defaultdict adj = defaultdict(list) for u, v, w in edges_with_weight: adj[u].append((v, w)) adj[v].append((u, w)) # down1[u]: 到子树中最远节点的距离 # down2[u]: 到子树中次远节点的距离 # child[u]: down1对应的子节点 down1 = [0] * n down2 = [0] * n child = [-1] * n up = [0] * n # 经过父节点能到达的最远距离 visited = [False] * n # 第一次DFS: 自底向上计算down def dfs1(u): visited[u] = True for v, w in adj[u]: if not visited[v]: dfs1(v) dist = down1[v] + w if dist > down1[u]: down2[u] = down1[u] down1[u] = dist child[u] = v elif dist > down2[u]: down2[u] = dist # 第二次DFS: 自顶向下计算up(换根) def dfs2(u): visited[u] = True for v, w in adj[u]: if not visited[v]: # v经过父节点u的最远距离 if child[u] == v: # v是down1对应的子节点, 用down2 up[v] = max(up[u], down2[u]) + w else: # 否则用down1 up[v] = max(up[u], down1[u]) + w dfs2(v) dfs1(0) visited = [False] * n dfs2(0) # 每个节点的最远距离 max_dist = [max(down1[i], up[i]) for i in range(n)] center_val = min(max_dist) centers = [i for i in range(n) if max_dist[i] == center_val] return centers, center_val # 示例 n = 5 edges = [(0, 1, 2), (1, 2, 3), (1, 3, 1), (3, 4, 4)] centers, val = tree_center(n, edges) print(f"中心节点: {centers}, 最小最远距离: {val}")

8.5 树形背包(Tree Knapsack / 有依赖的背包问题)

树形背包是树形DP中最灵活也最复杂的一类问题,典型代表是"有依赖的背包问题"(如金明的预算方案、选课问题)。每个节点有体积和价值,选择父节点后才能选择子节点,求在总体积限制下的最大价值。

状态定义:dp[u][j] 表示在以 u 为根的子树中,选择总体积不超过 j 的物品(且 u 必须选)的最大价值。

转移思路:将每个子节点 v 看作一个分组,每组有若干种选择(选 0 体积的、选 1 体积的、...、选 m 体积的),做分组背包合并。

# 树形背包 - Python实现(选课问题) # 每门课有学分和先修课要求,选择不超过 m 门课,求最大总学分 def course_selection(n, m, credits, prerequisites): """ n: 课程数 m: 可选课程数 credits: 学分列表 [c0, c1, ..., cn-1] prerequisites: 先修课列表 [-1, 0, -1, ...], -1表示无先修 """ from collections import defaultdict # 建树(虚拟根节点 -1 作为所有无先修课程的父节点) children = defaultdict(list) for i in range(n): if prerequisites[i] == -1: children[-1].append(i) else: children[prerequisites[i]].append(i) # dp[u][j]: 以u为根的子树选j门课的最大学分 dp = {} def dfs(u): # 实际选修的节点用正数索引 if u == -1: # 虚拟根节点,没有学分 child_dp = [0] * (m + 1) for v in children[u]: v_dp = dfs(v) # 分组背包合并 new_dp = [0] * (m + 1) for j in range(m + 1): for k in range(j + 1): new_dp[j] = max(new_dp[j], child_dp[j - k] + v_dp[k]) child_dp = new_dp return child_dp # 真实节点:必须选u才能选子节点 total = [0] + [-float('inf')] * m total[1] = credits[u] # 只选u cur = [0] * (m + 1) for v in children[u]: v_dp = dfs(v) # 合并每个子节点 new_cur = cur[:] for j in range(m + 1): for k in range(m - j + 1): if cur[j] != -float('inf') and v_dp[k] != -float('inf'): new_cur[j + k] = max(new_cur[j + k], cur[j] + v_dp[k]) cur = new_cur # 与total合并: total选j门, cur选k门, 总选j+k门 result = [0] * (m + 1) for j in range(m + 1): for k in range(m - j + 1): if total[j] != -float('inf') and cur[k] != -float('inf'): result[j + k] = max(result[j + k], total[j] + cur[k]) return result dp_result = dfs(-1) return max(dp_result[:m+1]) # 示例 n, m = 4, 3 credits = [2, 3, 4, 5] prerequisites = [-1, 0, 0, 1] # 课程0(2学分, 无先修), 课程1(3学分, 先修0), 课程2(4学分, 先修0), 课程3(5学分, 先修1) result = course_selection(n, m, credits, prerequisites) print(f"选{m}门课的最大学分: {result}") # 选择: 课程0(2)+课程1(3)+课程3(5)=10

树形背包的时间复杂度:看似是 O(n × m²) 的(每个节点枚举体积和枚举子节点),但实际上通过适当实现(只枚举到子节点的实际大小),可以做到 O(n × m) 的均摊复杂度。在竞赛中,树形背包通常可以接受 n ≤ 2000, m ≤ 1000 的数据规模。

九、区间DP与树形DP对比总结

对比维度 区间DP 树形DP
数据结构 一维/二维序列(线性结构) 树形结构(递归结构)
状态定义 dp[i][j] 表示区间 [i, j] 上的最优值 dp[u] 或以 u 为根的子树上的最优值
遍历顺序 按区间长度从小到大(长度遍历) 后序遍历(先子后父)
子问题划分 枚举分割点 k,将区间一分为二 递归处理子节点,合并子树结果
时间复杂度 通常 O(n³),可优化到 O(n²) 通常 O(n) 或 O(n × m)
优化技巧 四边形不等式优化 换根DP、树上差分、单调队列
经典问题 矩阵链乘、石子合并、括号匹配 最大独立集、树的重心、树形背包
代码模式 三层循环(长度 + 起点 + 分割点) DFS递归(先递归子节点,再合并)

十、核心要点总结

区间DP要点:

  • 状态 dp[i][j] 必须按区间长度从小到大计算,这是区间DP的根本保障。
  • 三层循环的模板需要熟练掌握:外层长度 → 中层起点 → 内层分割点。
  • 前缀和是计算区间合并代价(如石子重量和)的常用技巧。
  • 四边形不等式优化可以将 O(n³) 降为 O(n²),但需验证代价函数是否满足凸性。
  • 回文子串分割问题的常见优化是先 O(n²) 预处理回文信息再做 DP。

树形DP要点:

  • 树形DP的本质是"后序DP"——先递归处理子节点,再合并子节点结果到当前节点。
  • DFS + 父节点参数(防止往回走)是最基本的树形DP代码模式。
  • 对于需要"上方"信息的题目,通常需要两次 DFS:自底向上 + 自顶向下(换根DP)。
  • 树形背包是较为复杂的树形DP,每个子节点相当于一个分组,做分组背包合并。
  • 树的直径、重心、中心等概念有密切关系但不要混淆:直径是最长路径长度,重心是最均衡的拆分点,中心是到最远节点距离最小的节点。

"动态规划的本质是状态设计和转移,而区间DP和树形DP则是在特定数据结构上应用这一思想。掌握了区间DP的遍历顺序和树形DP的后序合并,就掌握了这两个范式的钥匙。"

十一、练习与进阶

区间DP练习推荐

  1. LeetCode 312 - 戳气球(Burst Balloons):区间DP的变体,分割点定义巧妙。
  2. LeetCode 546 - 移除盒子(Remove Boxes):三维区间DP,难度较高。
  3. LeetCode 1000 - 合并石头的最低成本(Minimum Cost to Merge Stones):石子合并的 K 堆版本。
  4. LeetCode 730 - 统计不同回文子序列(Count Different Palindromic Subsequences):区间DP + 去重。

树形DP练习推荐

  1. LeetCode 337 - 打家劫舍 III(House Robber III):树的最大独立集应用。
  2. LeetCode 834 - 树中距离之和(Sum of Distances in Tree):典型的换根DP。
  3. LeetCode 968 - 监控二叉树(Binary Tree Cameras):树的最小点覆盖变体。
  4. LeetCode 1617 - 统计子树中城市之间最大距离(Count Subtrees With Max Distance Between Cities):树形DP + 直径计算。

进阶方向:区间DP的平行四边形优化证明、区间DP的环状处理(将数组复制一倍处理环形石子合并)、树形DP的树上斜率优化(CHT on Tree)、树形DP与树链剖分结合、动态DP(用矩阵乘法维护DP转移,支持树上修改查询)。