C++动态规划入门指南——助力CSP竞赛夺冠(加强版)
文章目录
- C++动态规划入门指南——助力CSP竞赛夺冠
C++动态规划入门指南——助力CSP竞赛夺冠
各位准备参加CSP(中国计算机学会软件能力认证)的同学,大家好!今天我们要探讨的是算法领域非常重要且实用的一项技术——动态规划(Dynamic Programming, DP)。无论你是刚接触编程不久的新手,还是已经有一定基础想进一步提升自己的选手,掌握动态规划都将对你的比赛成绩产生巨大的影响。下面让我们一起走进这个充满魅力的话题吧!
🌟 一、什么是动态规划?
简单来说,动态规划是一种通过分解问题、存储中间结果来避免重复计算,从而提高效率的算法设计方法。它适用于那些具有以下特点的问题:
- 重叠子问题:原问题可以分解为多个相似的子问题,这些子问题会被多次求解。
- 最优子结构:问题的最优解可以从其子问题的最优解中构建出来。
- 无后效性:当前状态一旦确定,后续决策不受之前决策的影响。
举个经典的例子——斐波那契数列。如果我们用普通的递归方式计算第 n n n项,会有很多重复计算;而使用动态规划,我们只需要线性的时间复杂度就能得到结果。
🔧二、动态规划的基本步骤
1️⃣ 定义状态
我们需要明确如何表示一个问题的状态。通常用一个数组或表格来保存每个状态的值。例如,在爬楼梯问题中,dp[i]表示到达第 i i i阶楼梯的方法数。
📝 2️⃣ 找出状态转移方程
这是动态规划的核心部分,即找出当前状态与之前状态之间的关系。比如在爬楼梯问题中,由于每次只能走一步或两步,所以有:dp[i] = dp[i-1] + dp[i-2]。
🎯 3️⃣ 初始化边界条件
根据具体问题设定初始值。例如,对于斐波那契数列,我们知道F(0)=0, F(1)=1;而在爬楼梯问题里,我们可以设dp[0]=1(起点有一种方式),dp[1]=1。
⚙️ 4️⃣ 计算并获取最终结果
按照状态转移方程逐步填充我们的DP表,直到得到所需的答案。很多时候,最终答案会出现在最后一个元素或者某个特定位置上。
📚三、常见题型分类及示例解析
动态规划作为一种核心算法思想,根据其应用场景和数据特征可分为多个分支。以下是主要的分支分类及典型场景说明:
1. 线性动态规划(Linear DP)
📌 什么是线性动态规划?
线性DP是最基础且常见的一类动态规划问题,其核心特征是状态转移仅依赖于前驱的一个或少数几个状态。这类问题通常具有以下特点:
✅ 单维度状态序列:用一维数组 dp[] 存储中间结果;
➡️ 单向递推关系:当前状态由之前的状态直接推导而来(如 dp[i] = f(dp[i−1], ...));
📊 无复杂结构依赖:不涉及区间划分、树形分支或环形闭合等复杂情况。
🔍 典型应用场景
以下是线性DP常出现的几类经典问题:
| 类型 | 示例题目 | 关键性质 |
|---|---|---|
| 基础递推 | 斐波那契数列 | f(n) = f(n−1) + f(n−2) |
| 路径计数 | 爬楼梯、不同路径 | 每一步的选择影响后续累计值 |
| 最优化求解 | 最大子数组和、最小花费 | 维护局部最优解逐步扩展至全局 |
| 决策型问题 | 买卖股票的最佳时机 | 结合历史信息做出即时判断 |
🚀 通用解题框架
解决线性DP问题的标准化流程如下:
- 定义状态 → 设
dp[i]表示处理到第i个元素时的某种属性(如方案数、最大值等); - 推导方程 → 根据题意写出
dp[i]与之前状态的关系式; - 初始化边界 → 确保初始条件正确(如
dp[0]或dp[1]); - 迭代计算 → 按顺序填充整个DP表;
- 返回结果 → 最终答案通常是最后一个状态或其中的极值。
💡 例题精讲 & C++代码实现
例1: 爬楼梯
题目描述:假设你正在爬楼梯,每次可以迈1个台阶或2个台阶。问到达楼顶共有多少种不同的方法?
输入:n = 3 → 输出:5 (1+1+1 / 1+2 / 2+1)
思路分析:要到达第i阶,只能从i−1走一步上来,或者从i−2走两步上来。因此总方案数为这两部分之和。
时间复杂度: O ( n ) O(n) O(n),空间复杂度可优化至 O ( 1 ) O(1) O(1)。
int climbStairs(int n) {
if (n <= 1) return n; // 边界条件:0或1级只有一种方式
vector<int> dp(n + 1);
dp[0] = 1; // 起点算作一种虚拟步骤
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]; // 状态转移方程
}
return dp[n];
}
空间优化版:
int climbStairs_optimized(int n) {
int a = 1, b = 1, c;
for (int i = 2; i <= n; i++) {
c = a + b; // 相当于dp[i] = dp[i-1]+dp[i-2]
a = b; // 更新前两个值
b = c;
}
return b;
}
例2: 最大连续子数组和(Kadane算法)
题目描述:给定一个整数数组
nums,找出其中连续子数组的最大和。
输入示例:[−2,1,−3,4,−1,2,1,−5,4] → 输出:6(对应子数组 [4,−1,2,1])
核心思想:维护当前子数组的和curSum,若加上新元素后比自身更小,则重新开始计算子数组。
技巧点拨:不需要完整保存所有历史状态,只需记录当前的最优值即可!
int maxSubArray(vector<int>& nums) {
int maxSum = nums[0], curSum = nums[0];
for (int i = 1; i < nums.size(); i++) {
curSum = max(nums[i], curSum + nums[i]); // 决定是否延续之前的子数组
maxSum = max(maxSum, curSum); // 更新全局最大值
}
return maxSum;
}
例3: 买卖股票的最佳时机 I
题目描述:给定每日股价列表
prices,选择某一天买入并在未来的某天卖出,求最大利润。
输入示例:[7,1,5,3,6,4] → 输出:5(低价1买入,高价6卖出)
策略解析:遍历过程中始终记录遇到的最低价格minPrice,并用当前价格减去它尝试更新最大利润。
注意陷阱:必须先买后卖,不能颠倒顺序!
int maxProfit(vector<int>& prices) {
int minPrice = INT_MAX, maxProfit = 0;
for (int price : prices) {
minPrice = min(minPrice, price); // 更新历史最低价
maxProfit = max(maxProfit, price - minPrice); // 计算潜在收益
}
return maxProfit;
}
⚙️ 性能对比与选型建议
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 标准DP数组法 | 逻辑清晰易调试 | 占用 O ( n ) O(n) O(n)额外空间 | 需要完整记录中间过程时 |
| 滚动变量法 | 空间复杂度降为 O ( 1 ) O(1) O(1) | 代码稍复杂 | 仅需前后有限个状态时 |
| 贪心替代方案 | 极致的时间效率 | 仅适用于特定模型 | Kadane算法这类特殊情形 |
🧠 学习路线图
掌握线性DP后,你可以继续挑战这些进阶主题:
🔹 二维拓展 → 尝试将其思想应用到矩阵中的路径问题;
🔹 多状态合并 → 同时跟踪多个指标(如最长递增子序列的长度+末尾值);
🔹 变形变体 → 包括限制条件下的排列组合问题(如含障碍物的网格路径)。
通过系统练习线性DP题目,你将建立起对动态规划本质的深刻理解,为攻克更复杂的区间DP、树形DP等打下坚实基础!
2. 区间动态规划(Interval DP)
📌 什么是区间动态规划?
区间动态规划是一种专门处理区间型问题的动态规划范式,其核心特征是:
✅ 状态定义为区间:dp[i][j] 表示区间 [i, j] 内的最优解;
🔗 依赖子区间结果:大区间的解由若干个子区间的解组合而成;
🔄 分治思想:通过枚举区间内的分割点 k,将区间 [i, j] 划分为 [i, k] 和 [k+1, j],再合并子问题的解。
🔍 典型应用场景
区间DP适用于以下类型的问题:
| 类型 | 示例题目 | 关键操作 |
|---|---|---|
| 序列比对 | 最长公共子序列 (LCS) | 比较两个序列的区间匹配 |
| 矩阵运算优化 | 矩阵链乘法 | 选择括号化方式使乘法次数最少 |
| 区间数据统计 | 最大子段和、石子合并 | 合并相邻区间的代价最小化 |
| 生物信息学 | RNA二级结构预测 | 碱基配对形成的环状结构分析 |
🚀 通用解题框架
解决区间DP问题的标准化流程如下:
- 定义状态 →
dp[i][j]表示区间[i, j]的最优解; - 推导方程 → 根据题意写出
dp[i][j]与子区间dp[i][k],dp[k+1][j]的关系式; - 初始化边界 → 单个元素的区间(即
i == j)需单独初始化; - 填表顺序 → 按区间长度从小到大计算(短区间→长区间);
- 返回结果 → 最终答案通常是整个区间
dp[0][n-1]或其中的极值。
💡 例题精讲 & C++代码实现
例1: 最长公共子序列 (LCS)
题目描述:给定两个字符串
text1和text2,返回它们的最长公共子序列的长度。
输入示例:text1 = "abcde",text2 = "ace"→ 输出:3(“ace”)
思路分析:若text1[i] == text2[j],则 LCS 长度加1;否则取左侧或上方的最大值。
状态转移方程:
- 如果
s1[i] == s2[j]→dp[i][j] = dp[i-1][j-1] + 1- 否则 →
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
int longestCommonSubsequence(string text1, string text2) {
int m = text1.size(), n = text2.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1[i - 1] == text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
例2: 矩阵链乘法(Minimum Scalar Multiplications)
题目描述:给定一系列矩阵的维度数组
p[](如p = [10, 20, 30]表示三个矩阵 A(10×20), B(20×30)),求矩阵相乘所需的最小标量乘法次数。
输入示例:p = [30, 35, 15, 5, 10, 20]→ 输出:2675
思路分析:枚举最后一个乘号的位置k,将矩阵链分为左右两部分,递归计算两部分的代价加上合并代价。
状态转移方程:dp[i][j] = min(dp[i][k] + dp[k][j] + p[i-1]*p[k]*p[j]),其中i ≤ k < j
int matrixChainMultiplication(vector<int>& p) {
int n = p.size() - 1; // 矩阵数量
vector<vector<int>> dp(n + 1, vector<int>(n + 1, INT_MAX));
// 初始化单个矩阵的代价为0
for (int i = 1; i <= n; i++) {
dp[i][i] = 0;
}
// 按区间长度从小到大计算
for (int len = 2; len <= n; len++) {
for (int i = 1; i <= n - len + 1; i++) {
int j = i + len - 1;
for (int k = i; k < j; k++) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + p[i - 1] * p[k] * p[j]);
}
}
}
return dp[1][n];
}
例3: 石子合并(最低成本合并相邻堆)
题目描述:有一排石子堆,每堆有若干石子。每次合并相邻的两堆,代价为两堆石子总数。求将所有石子合并成一堆的最小总代价。
输入示例:stones = [4, 5, 9]→ 输出:43
思路分析:枚举最后一次合并的位置k,将区间[i, j]分为[i, k]和[k+1, j],总代价为左半部分代价 + 右半部分代价 + 当前合并代价。
状态转移方程:dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum(stones[i..j])),其中i ≤ k < j
int mergeStones(vector<int>& stones) {
int n = stones.size();
vector<vector<int>> dp(n, vector<int>(n, 0));
vector<int> prefixSum(n + 1, 0);
for (int i = 0; i < n; i++) {
prefixSum[i + 1] = prefixSum[i] + stones[i];
}
// 按区间长度从小到大计算
for (int len = 2; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
dp[i][j] = INT_MAX;
for (int k = i; k < j; k++) {
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + prefixSum[j + 1] - prefixSum[i]);
}
}
}
return dp[0][n - 1];
}
⚙️ 性能对比与选型建议
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 标准区间DP | O ( n 3 ) O(n^3) O(n3) | O ( n 2 ) O(n^2) O(n2) | 小规模数据( n ≤ 100 n \leq 100 n≤100) |
| 四边形不等式优化 | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | 满足单调性的特殊情况 |
| 记忆化搜索 | O ( n 2 ) O(n^2) O(n2) | O ( n 2 ) O(n^2) O(n2) | 递归深度可控时 |
🧠 学习路线图
掌握区间DP后,你可以继续挑战这些进阶主题:
🔹 三维区间DP → 扩展到立体空间中的区域划分;
🔹 环形区间DP → 处理首尾相连的循环结构;
🔹 概率型区间DP → 结合随机事件的概率计算。
通过系统练习区间DP题目,你将熟练掌握如何处理复杂的区间划分问题,并为后续学习树形DP、状压DP等高级技巧奠定坚实基础!
3. 背包问题系列
📌 背包问题概述
背包问题是一类经典的组合优化问题,核心在于在有限资源(如背包容量)下选择物品以最大化收益。根据物品的选择限制可分为三大类:
| 类型 | 物品数量限制 | 典型场景 |
|---|---|---|
| 0/1背包 | 每件物品最多选 1 1 1次 | 装包不带重复物品 |
| 完全背包 | 物品无限供应 | 硬币凑整、资源分配 |
| 多重背包 | 每件物品有上限 | 限量促销商品选购 |
🔍 通用建模要素
无论何种类型,均需明确以下参数:
weight[]: 各物品的重量/体积value[]: 各物品的价值capacity: 背包总容量n: 物品总数
目标是找到一个子集,使得总重量不超过容量且总价值最大。
一、0/1 背包问题
🎯 问题特征
每个物品要么完整放入( 1 1 1次),要么不放( 0 0 0次),不可拆分。
💡 动态规划思路
- 状态定义:
dp[i][j]表示前i个物品在容量j下的最大价值。 - 状态转移方程:
- 若不选第
i个物品:dp[i][j] = dp[i-1][j] - 若选第
i个物品(需满足j >= weight[i]):dp[i][j] = dp[i-1][j - weight[i]] + value[i] - 最终取两者最大值:
dp[i][j] = max(...)
- 若不选第
- 初始化:
dp[0][j] = 0(无物品时价值为 0 0 0),dp[i][0] = 0(容量为 0 0 0时无法装入)。 - 空间优化:使用一维数组
dp[j],逆序遍历防止重复计算。
🧠 C++ 代码实现
#include <iostream>
#include <vector>
using namespace std;
int knapsack01(int capacity, vector<int>& weights, vector<int>& values) {
int n = weights.size();
vector<int> dp(capacity + 1, 0); // 一维DP数组
for (int i = 0; i < n; i++) { // 遍历每个物品
for (int j = capacity; j >= weights[i]; j--) { // 逆序遍历容量
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
int main() {
vector<int> weights = {2, 3, 4};
vector<int> values = {5, 8, 9};
int capacity = 5;
cout << "Max value (0/1): " << knapsack01(capacity, weights, values) << endl; // 输出 13
return 0;
}
二、完全背包问题
🎯 问题特征
每个物品可无限次选取(如硬币面额固定,可随意使用)。
💡 动态规划思路
与0/1背包的唯一区别是内层循环改为正序遍历,允许同一物品被多次选中。
🧠 C++ 代码实现
int unboundedKnapsack(int capacity, vector<int>& weights, vector<int>& values) {
int n = weights.size();
vector<int> dp(capacity + 1, 0);
for (int i = 0; i < n; i++) { // 遍历每个物品
for (int j = weights[i]; j <= capacity; j++) { // 正序遍历容量
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
int main() {
vector<int> weights = {2, 3};
vector<int> values = {5, 8};
int capacity = 7;
cout << "Max value (Unbounded): " << unboundedKnapsack(capacity, weights, values) << endl; // 输出 21
return 0;
}
三、多重背包问题
🎯 问题特征
每个物品最多选 count[i] 次。
💡 动态规划思路
- 二进制拆分优化:将每个物品拆分为若干个“新物品”,每个新物品的数量为
1, 2, 4, ..., remaining,使其能组合出所有可能的数量。- 例如,某物品最多选 5 5 5次 → 拆分为 1 + 2 + 2 1+2+2 1+2+2(共 3 3 3个新物品)。
- 转化为0/1背包问题求解。
🧠 C++ 代码实现
void splitItems(int count, int weight, int value, vector<pair<int, int>>& newItems) {
for (int k = 1; k <= count; k *= 2) {
newItems.emplace_back(k * weight, k * value);
count -= k;
}
if (count > 0) {
newItems.emplace_back(count * weight, count * value);
}
}
int multipleKnapsack(int capacity, vector<int>& weights, vector<int>& values, vector<int>& counts) {
vector<pair<int, int>> newItems;
int n = weights.size();
for (int i = 0; i < n; i++) {
splitItems(counts[i], weights[i], values[i], newItems);
}
vector<int> dp(capacity + 1, 0);
for (auto& item : newItems) {
int w = item.first, v = item.second;
for (int j = capacity; j >= w; j--) {
dp[j] = max(dp[j], dp[j - w] + v);
}
}
return dp[capacity];
}
int main() {
vector<int> weights = {2, 3};
vector<int> values = {5, 8};
vector<int> counts = {3, 2}; // 第一个物品最多选3次,第二个最多选2次
int capacity = 7;
cout << "Max value (Multiple): " << multipleKnapsack(capacity, weights, values, counts) << endl; // 输出 21
return 0;
}
⚙️ 三类背包问题对比
| 特性 | 0/1背包 | 完全背包 | 多重背包 |
|---|---|---|---|
| 物品数量限制 | 每件最多 1 1 1次 | 无限次 | 每件最多指定次数 |
| 内层循环方向 | 逆序(防重复) | 正序(允许重复) | 逆序(拆分后视为0/1) |
| 时间复杂度 | O ( N × C ) O(N\times C) O(N×C) | O ( N × C ) O(N\times C) O(N×C) | O ( N × log 2 C ) O(N \times\log_2 C) O(N×log2C)(拆分后) |
| 典型场景 | 装箱问题 | 硬币凑整 | 限量抢购 |
通过系统掌握背包问题的动态规划解法,你将具备解决大多数组合优化问题的能力!
4. 树形动态规划(Tree DP)
📌 什么是树形动态规划?
树形动态规划是一种专门针对树结构数据设计的动态规划范式,其核心特征是:
✅ 递归分解子树:将问题拆解为子树子问题,通过子节点的信息推导父节点的状态;
🔄 后序遍历思想:先处理所有子节点,再处理当前节点(类似后序遍历);
🌳 无环依赖:树结构的天然层级关系避免了循环依赖,适合递推求解。
🔍 典型应用场景
树形DP广泛应用于以下场景:
| 类型 | 示例问题 | 关键操作 |
|---|---|---|
| 路径统计 | 树的直径、最长路径和 | 子节点路径延伸至父节点 |
| 节点属性统计 | 最大独立集、最小顶点覆盖 | 子节点状态影响父节点的选择 |
| 资源分配 | 收集金币、苹果采摘 | 子节点贡献累加至父节点 |
| 博弈论问题 | 猫鼠游戏、石子游戏变种 | 双方交替决策下的最优策略 |
🚀 通用解题框架
解决树形DP问题的标准化流程如下:
- 定义状态 →
dp[u]表示以节点u为根的子树中的最优解; - 递归处理子节点 → 对每个子节点
v递归调用 DFS,获取子树的解; - 状态转移方程 → 根据子节点的解
dp[v]更新当前节点u的状态; - 合并子节点信息 → 若需跨子节点组合(如最大路径),需枚举所有可能的组合方式;
- 返回结果 → 最终答案可能是某个节点的
dp值,或全局维护的极值。
💡 例题精讲 & C++代码实现
例1: 二叉树中的最大路径和
题目描述:给定一棵二叉树,找到一条路径使得路径上的节点值之和最大。路径可以从任意节点开始和结束,但必须沿父子关系连续。
输入示例:根节点值为 [-10,9,20,null,null,15,7] → 输出:42(路径 15→20→7)
思路分析:每个节点的最大路径和可能出现在以下三种情况之一:
- 左子树的最大路径 + 当前节点 + 右子树的最大路径(形成贯穿节点的路径);
- 仅包含当前节点和左子树的最大路径;
- 仅包含当前节点和右子树的最大路径。
关键点:使用后序遍历计算每个节点的贡献值,并用全局变量记录最大路径和。
#include <iostream>
#include <algorithm>
using namespace std;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
class Solution {
private:
int maxSum; // 全局最大路径和
// 返回以当前节点为起点的最大单侧路径和
int dfs(TreeNode* node) {
if (!node) return 0;
// 递归计算左右子树的最大单侧路径和(负数则舍弃)
int leftMax = max(0, dfs(node->left));
int rightMax = max(0, dfs(node->right));
// 更新全局最大路径和(可能经过当前节点)
maxSum = max(maxSum, leftMax + rightMax + node->val);
// 返回当前节点为起点的最大单侧路径和(只能选一边)
return max(leftMax, rightMax) + node->val;
}
public:
int maxPathSum(TreeNode* root) {
maxSum = INT_MIN;
dfs(root);
return maxSum;
}
};
int main() {
// 构建示例树:-10 -> 9 (left), 20 (right) -> 15 (left), 7 (right)
TreeNode* root = new TreeNode(-10);
root->left = new TreeNode(9);
root->right = new TreeNode(20);
root->right->left = new TreeNode(15);
root->right->right = new TreeNode(7);
Solution sol;
cout << "Max Path Sum: " << sol.maxPathSum(root) << endl; // 输出 42
return 0;
}
例2: 求树的直径
题目描述:树的直径是指树中任意两点间的最长路径长度。
输入示例:同上例 → 输出:4(路径 9→-10→20→7)
思路分析:树的直径一定经过某个节点,且该节点的最长路径由两个子树的最深路径组成。
关键点:在DFS过程中记录每个节点的最大深度,并更新直径。
class Solution {
private:
int diameter; // 全局直径
// 返回以当前节点为根的子树的最大深度
int dfs(TreeNode* node) {
if (!node) return 0;
int leftDepth = dfs(node->left);
int rightDepth = dfs(node->right);
// 更新直径(当前节点连接左右子树的最深路径)
diameter = max(diameter, leftDepth + rightDepth);
// 返回当前节点的最大深度(用于父节点计算)
return 1 + max(leftDepth, rightDepth);
}
public:
int diameterOfBinaryTree(TreeNode* root) {
diameter = 0;
dfs(root);
return diameter;
}
};
int main() {
// 复用上一示例树
Solution sol;
cout << "Tree Diameter: " << sol.diameterOfBinaryTree(root) << endl; // 输出 4
return 0;
}
例3: 收集树上所有苹果的最小时间
题目描述:给定一棵树,某些节点上有苹果。从根节点出发,每条边耗时2单位时间,求收集所有苹果并返回根节点的最小时间。
输入示例:树结构为 0-1-2(苹果在2),0-3(苹果在3)→ 输出:8(路径 0→1→2→1→0→3→0)
思路分析:只有当子树中有苹果时,才需要遍历该子树。总时间为所有有效边的两倍(往返)。
关键点:自底向上判断子树是否需要访问(即是否含有苹果)。
class Solution {
private:
int totalTime; // 总时间
// 返回子树中是否有苹果
bool hasApple(TreeNode* node) {
if (!node) return false;
bool leftHas = hasApple(node->left);
bool rightHas = hasApple(node->right);
// 如果子树有苹果,则需要遍历当前边(耗时2*2=4)
if (leftHas || rightHas || node->val == 1) {
totalTime += 2; // 往返时间
return true;
}
return false;
}
public:
int minTime(TreeNode* root) {
totalTime = 0;
hasApple(root);
return totalTime;
}
};
int main() {
// 构建示例树:0(无苹果) -> 1(无) -> 2(有苹果), 0->3(有苹果)
TreeNode* root = new TreeNode(0);
root->left = new TreeNode(1);
root->right = new TreeNode(3);
root->left->right = new TreeNode(2);
root->right->val = 1; // 标记苹果
root->left->right->val = 1; // 标记苹果
Solution sol;
cout << "Min Time: " << sol.minTime(root) << endl; // 输出 8
return 0;
}
⚙️ 性能对比与选型建议
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 标准树形DP | O ( N ) O(N) O(N) | O ( H ) O(H) O(H)(递归栈) | 普通树结构( H H H为树高) |
| 记忆化搜索 | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | 复杂状态转移的场景 |
| 迭代法(BFS/DFS) | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | 非递归实现需求 |
🧠 学习路线图
掌握树形DP后,你可以继续挑战这些进阶主题:
🔹 多叉树扩展 → 处理非二叉树结构(如 N N N叉树);
🔹 带权树问题 → 边权影响路径计算;
🔹 限制条件下的树形DP → 如节点容量限制、时间窗口约束等。
通过系统练习树形DP题目,你将熟练掌握如何处理复杂的树结构问题,并为后续学习图论DP、状压DP等高级技巧奠定坚实基础!
5. 状态压缩动态规划(Bitmask DP / 状压DP)
🎯 什么是状态压缩动态规划?
状态压缩动态规划是一种通过低维数据结构(如整数、长整型)编码高维状态的优化技术,其核心在于:
✅ 紧凑状态表示: 用二进制位、哈希值等方式压缩状态,显著降低空间复杂度;
🌟 高效状态转移: 借助位运算(如 &, |, ^, <<, >>)快速操作和枚举子集;
⚙️ 应对指数级状态爆炸: 适用于状态维度较高但实际有效状态较少的场景(如 N ≤ 20 N\leq20 N≤20)。
🔍 典型应用场景
| 类型 | 示例问题 | 关键操作 |
|---|---|---|
| 集合枚举 | 旅行商问题(TSP)、子集异或最大值 | 位掩码枚举所有子集 |
| 排列组合 | 全排列生成、错位排列计数 | 康托展开+剪枝 |
| 路径搜索 | 迷宫最短路径、棋盘覆盖 | 状态+坐标联合编码 |
| 博弈论 | Nim游戏、威佐夫博弈 | SG函数+记忆化搜索 |
| 资源分配 | 背包问题变种、任务调度 | 容量限制下的状态叠加 |
🚀 通用解题框架
-
状态编码 → 将复杂状态映射为整型变量(如
mask表示已访问节点集合); -
状态转移 → 通过位运算分解/合并子状态(如
mask ^ (1 << i)去掉第i位); -
边界条件 → 初始状态一般为空集(
mask = 0)或全集(mask = (1 << n) - 1); -
递推顺序 → 根据依赖关系选择正向/逆向迭代,或递归+记忆化;
-
结果提取 → 最终答案可能是单个状态的值,或全局极值。
💡 例题精讲 & C++代码实现
例1: 旅行商问题(TSP)
题目描述:给定 n 个城市间的距离矩阵,求访问所有城市恰好一次并返回起点的最短路径。
输入示例:4个城市,距离矩阵对称 → 输出:最短环路长度。
思路分析:用dp[mask][u]表示已访问城市集合为mask,当前位于城市u的最短路径。通过状态转移逐步扩展访问的城市。
#include <iostream>
#include <vector>
#include <cstring>
#include <climits>
using namespace std;
const int MAXN = 20;
int dist[MAXN][MAXN]; // 城市间距离矩阵
int dp[1 << MAXN][MAXN]; // dp[mask][u]: 访问过mask中的城市,最后停在u的最短路径
int tsp(int mask, int u, int n) {
if (mask == (1 << n) - 1) return dist[u][0]; // 返回起点
if (dp[mask][u] != -1) return dp[mask][u]; // 已计算过
int res = INT_MAX;
for (int v = 0; v < n; ++v) {
if (!(mask & (1 << v))) { // v未被访问过
res = min(res, tsp(mask | (1 << v), v, n) + dist[u][v]);
}
}
return dp[mask][u] = res;
}
int main() {
int n;
cin >> n;
for (int i = 0; i < n; ++i)
for (int j = 0; j < n; ++j)
cin >> dist[i][j];
memset(dp, -1, sizeof(dp));
cout << tsp(1 << 0, 0, n) << endl; // 从城市0出发
return 0;
}
例2: 最大独立集(MIS)
题目描述:给定一个无向图,选出最多的顶点使得它们两两不相邻。
输入示例:图结构为三角形 → 输出:1(任选一顶点)。
思路分析:用dp[mask]表示在子集mask中的最大独立集大小。通过逐位尝试加入新顶点更新状态。
#include <bits/stdc++.h>
using namespace std;
vector<int> graph[20]; // 邻接表存图
int dp[1 << 20]; // dp[mask]: 该子集的最大独立集大小
int main() {
int n;
cin >> n;
for (int i = 0; i < n; ++i) {
int u, v;
while (cin >> u >> v, u != -1 || v != -1) {
graph[u].push_back(v);
graph[v].push_back(u);
}
}
dp[0] = 0;
for (int mask = 1; mask < (1 << n); ++mask) {
dp[mask] = dp[mask ^ (mask & -mask)]; // 继承前驱状态
for (int i = 0; i < n; ++i) {
if (mask & (1 << i)) { // i在mask中
bool valid = true;
for (int j : graph[i]) {
if (mask & (1 << j)) { // j也在mask中,冲突
valid = false;
break;
}
}
if (valid) {
dp[mask] = max(dp[mask], dp[mask ^ (1 << i)] + 1);
}
}
}
}
cout << dp[(1 << n) - 1] << endl;
return 0;
}
例3: 硬币游戏(Coin Game)
题目描述:两人轮流从一排硬币两端取硬币,求先手能获得的最大总金额。
输入示例:硬币序列 [3,9,1,2] → 输出:12(先手取3,后手取9,先手再取2)。
思路分析:用dp[l][r]表示区间 [l, r] 内先手的最大收益。通过选择左/右端点转移状态。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int dfs(vector<int>& coins, int l, int r, vector<vector<int>>& memo) {
if (l > r) return 0;
if (memo[l][r] != -1) return memo[l][r];
// 选择左端,对手面对[l+1, r]
int left = coins[l] + (coins[l+1] + dfs(coins, l+2, r, memo) <= dfs(coins, l+1, r-1, memo) ?
dfs(coins, l+1, r-1, memo) : coins[l+1] + dfs(coins, l+2, r, memo));
// 选择右端,对手面对[l, r-1]
int right = coins[r] + (coins[l] + dfs(coins, l+1, r-1, memo) <= dfs(coins, l, r-2, memo) ?
dfs(coins, l, r-2, memo) : coins[l] + dfs(coins, l+1, r-1, memo));
return memo[l][r] = max(left, right);
}
int main() {
vector<int> coins = {3,9,1,2};
int n = coins.size();
vector<vector<int>> memo(n, vector<int>(n, -1));
cout << dfs(coins, 0, n-1, memo) << endl; // 输出 12
return 0;
}
⚙ 性能对比与选型建议
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 标准状压DP | O ( N × 2 N ) O(N\times2^N) O(N×2N) | O ( 2 N ) O(2^N) O(2N) | 小规模精确解( N ≤ 20 N\leq20 N≤20) |
| 启发式剪枝 | O ( N ! ) O(N!) O(N!) | O ( N ) O(N) O(N) | 大规模近似解(如A*算法) |
| 迭代+滚动数组 | O ( N × 2 N ) O(N\times2^N) O(N×2N) | O ( 2 N ) O(2^N) O(2N) | 空间敏感型问题 |
| Meet-in-Middle | O ( N × 2 N 2 ) O(N\times2^{N\over2}) O(N×22N) | O ( 2 N 2 ) O(2^{N\over2}) O(22N) | 对称性较强的问题 |
🧠 学习路线图
掌握状压DP后,可进阶挑战以下方向:
🔹 高维状态压缩 → 二维/三维网格问题的降维技巧;
🔹 双状态协同 → 同时维护两个互补状态(如剩余物品数+已选物品数);
🔹 混合进制编码 → 非二进制状态表示(如三进制处理颜色属性);
🔹 实时约束处理 → 动态调整状态合法性(如背包容量限制)。
通过系统训练状压DP,你将具备解决复杂组合优化问题的能力,并为学习图论DP、随机化算法奠定坚实基础!
6. 环形动态规划(Circular DP)
🔄 什么是环形动态规划?
环形动态规划是一种专门处理首尾相连的环状结构的动态规划范式,其核心挑战在于:
🔗 循环依赖性:环的起点与终点相邻,导致直接套用线性DP会产生逻辑矛盾;
🔍 破环技巧:通常通过拆环成链(固定起点/终点)或分情况讨论消除循环依赖;
💡 多状态设计:需额外维护状态信息以区分环的起始位置或边界条件。
📌 典型应用场景
| 类型 | 示例问题 | 关键操作 |
|---|---|---|
| 区间约束 | 环形房屋抢劫(House Robber II) | 分两种情况:抢第一间/不抢最后一间的子问题 |
| 路径规划 | 环形数组的最大子数组和 | 分两种情况:最大和跨越末尾与开头 |
| 资源分配 | 环形流水线调度优化 | 任务分配需考虑前后衔接代价 |
| 博弈论 | 多人圆桌游戏策略 | 玩家决策受左右邻居影响 |
🛠️ 通用解题框架
-
识别环形特征 → 判断问题是否涉及首尾相连的约束(如数组首尾相邻、矩阵首行末列相连);
-
拆环为链 → 通过固定起点或分情况讨论将环转化为线性结构:
- 方法1:假设不选第一个元素,则问题退化为线性DP(适用于禁止相邻的场景);
- 方法2:分别计算两种互斥的情况(如抢第一间房时不能抢最后一间),取最大值;
-
状态转移方程 → 根据拆解后的线性结构设计递推公式;
-
合并结果 → 综合所有拆分情况的结果得到最终答案。
🏆 例题精讲 & C++代码实现
例1: 环形房屋抢劫
题目描述:沿圆形排列的房屋存放着不同金额的钱,小偷不能同时抢劫相邻的房屋,求能抢到的最大金额。
输入示例:[2,3,2]→ 输出:3(抢劫第2间)。
思路分析:由于房屋是环形排列,若抢劫第一间,则不能抢劫最后一间;若不抢劫第一间,则可自由抢劫其余房屋。将问题拆分为两个线性DP问题,取最大值。
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
int rob(vector<int>& nums) {
if (nums.empty()) return 0;
if (nums.size() == 1) return nums[0];
// 情况1:不抢第一间,问题转化为线性DP [1, n-1]
int res1 = linearRob(nums, 1, nums.size() - 1);
// 情况2:不抢最后一间,问题转化为线性DP [0, n-2]
int res2 = linearRob(nums, 0, nums.size() - 2);
return max(res1, res2);
}
private:
// 标准线性房屋抢劫问题的DP解法
int linearRob(vector<int>& nums, int start, int end) {
if (start > end) return 0;
int prevNo = 0, prevYes = nums[start];
for (int i = start + 1; i <= end; ++i) {
int currYes = prevNo + nums[i];
int currNo = max(prevYes, prevNo);
prevNo = currNo;
prevYes = currYes;
}
return max(prevYes, prevNo);
}
};
int main() {
Solution sol;
vector<int> nums = {2, 3, 2};
cout << "Max Money: " << sol.rob(nums) << endl; // 输出 3
return 0;
}
例2: 环形数组的最大子数组和
题目描述:给定一个环形整数数组,找出连续子数组的最大和(子数组至少包含一个元素)。
输入示例:[5, -3, 5]→ 输出:10(子数组 [5,5])。
思路分析:最大子数组可能有两种情况:① 常规线性数组的最大子数组;② 跨越数组末尾和开头的子数组(总和减去最小子数组和)。
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
int maxSubarraySumCircular(vector<int>& nums) {
int total = 0;
int maxSum = nums[0], minSum = nums[0];
int curMax = 0, curMin = 0;
for (int num : nums) {
total += num;
curMax = max(num, curMax + num);
maxSum = max(maxSum, curMax);
curMin = min(num, curMin + num);
minSum = min(minSum, curMin);
}
// 如果所有数均为负数,返回最大的单个元素
if (maxSum < 0) return maxSum;
return max(maxSum, total - minSum);
}
};
int main() {
Solution sol;
vector<int> nums = {5, -3, 5};
cout << "Max Subarray Sum: " << sol.maxSubarraySumCircular(nums) << endl; // 输出 10
return 0;
}
⚙ 性能对比与选型建议
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 拆环为链 | O ( N ) O(N) O(N) | O ( 1 ) O(1) O(1) | 简单环形约束(如单层环) |
| 双指针+前缀和 | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | 环形数组统计类问题 |
| 二维DP建模 | O ( N 2 ) O(N^2) O(N2) | O ( N 2 ) O(N^2) O(N2) | 复杂环形路径规划 |
| 单调队列优化 | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | 滑动窗口类环形问题 |
🧠 学习路线图
掌握环形DP后,可进阶探索以下方向:
🌀 多层嵌套环 → 处理二维网格中的环形路径(如螺旋矩阵);
🌀 动态拓扑排序 → 结合环检测算法解决依赖关系中的环;
🌀 图论扩展 → 将环形结构抽象为图中的环路,应用最短路径算法;
🌀 模运算优化 → 利用取模特性减少状态存储空间。
通过系统练习环形DP题目,你将熟练掌握如何处理首尾相连的复杂结构,并为学习图论DP、网络流算法奠定坚实基础!
7. 计数类动态规划(Counting DP)
🔢 什么是计数类动态规划?
计数类动态规划是一种以统计方案总数为核心的动态规划范式,其核心特点在于:
💰 目标为数值增长而非最优值:关注满足条件的方案数量,而非最大/最小收益;
➕ 状态转移以累加为主:通过将子问题的解相加得到父问题的解;
📦 组合数学思想融合:常需结合排列组合原理设计状态转移方程。
📌 典型应用场景
| 类型 | 示例问题 | 关键操作 |
|---|---|---|
| 路径计数 | 网格路径数、爬楼梯变种 | 每一步的选择数累加至下一步 |
| 组合生成 | 硬币兑换组合数、IP地址分配 | 不同面额/段的组合方式统计 |
| 序列构造 | 括号生成、回文子序列数 | 合法性剪枝+状态转移 |
| 划分问题 | 整数拆分、集合分割 | 避免重复计数的策略设计 |
🛠 通用解题框架
- 定义状态 →
dp[i][j]表示处理到第i步、处于状态j时的方案数; - 状态转移 → 根据当前选择的不同可能性,将子问题的方案数累加到当前状态;
- 初始化 → 初始状态通常对应空集或起始点的方案数为 1 1 1;
- 去重处理 → 若存在重复计数风险(如交换顺序视为相同),需调整转移逻辑;
- 结果提取 → 最终答案一般为所有可能状态的方案数之和。
🏆 例题精讲 & C++代码实现
例1: 爬楼梯(不同走法总数)
题目描述:每次可以爬1阶或2阶楼梯,求爬到n阶的总方案数。
输入示例:n=3 → 输出:5([1+1+1, 1+2, 2+1, 2+1, 1+2])。
思路分析:到达第i阶的方案数等于到达第i-1阶和i-2阶的方案数之和(因为最后一步可以是1阶或2阶)。
#include <iostream>
using namespace std;
int climbStairs(int n) {
if (n <= 1) return 1;
int prev1 = 1; // dp[i-1]
int prev2 = 1; // dp[i-2]
for (int i = 2; i <= n; ++i) {
int current = prev1 + prev2; // 当前方案数 = 前两步方案数之和
prev2 = prev1;
prev1 = current;
}
return prev1;
}
int main() {
int n = 3;
cout << "Total Ways: " << climbStairs(n) << endl; // 输出 5
return 0;
}
例2: 硬币兑换 II(组合数)
题目描述:给定不同面额的硬币和一个总金额,求凑成总金额的组合数(每种硬币无限次使用)。
输入示例:coins=[1,2,5], amount=5 → 输出:4([1×5, 1×3+2×1, 1×1+2×2, 5×1])。
思路分析:用dp[i]表示凑成金额i的组合数,遍历每个硬币面额,更新大于等于该面额的所有金额的组合数。
#include <vector>
#include <algorithm>
using namespace std;
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1, 0);
dp[0] = 1; // 金额为0时只有一种方案(不选任何硬币)
for (int coin : coins) {
for (int i = coin; i <= amount; ++i) {
dp[i] += dp[i - coin]; // 累加使用当前硬币的方案数
}
}
return dp[amount];
}
};
int main() {
Solution sol;
vector<int> coins = {1, 2, 5};
int amount = 5;
cout << "Combinations: " << sol.change(amount, coins) << endl; // 输出 4
return 0;
}
例3: 不同二叉搜索树的数量
题目描述:给定n个节点,求能生成的不同二叉搜索树的数量。
输入示例:n=3 → 输出:5。
思路分析:利用卡塔兰数性质,G(n) = sum(G(i)*G(n-i-1)),其中i为根节点的值。
#include <vector>
using namespace std;
int numTrees(int n) {
vector<int> dp(n + 1, 0);
dp[0] = 1; // 空树算一种
for (int i = 1; i <= n; ++i) {
for (int j = 0; j < i; ++j) {
dp[i] += dp[j] * dp[i - j - 1]; // 左子树j个节点,右子树i-j-1个节点
}
}
return dp[n];
}
int main() {
int n = 3;
cout << "Unique BST Count: " << numTrees(n) << endl; // 输出 5
return 0;
}
⚖ 性能对比与选型建议
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 标准一维DP | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | 简单线性递推(如爬楼梯) |
| 二维表DP | O ( N 2 ) O(N^2) O(N2) | O ( N 2 ) O(N^2) O(N2) | 双变量约束(如BST计数) |
| 记忆化搜索 | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | 复杂递归结构(如括号生成) |
| 数学公式优化 | O ( 1 ) O(1) O(1) | O ( 1 ) O(1) O(1) | 已知通项公式(如卡塔兰数) |
📚 学习路线图
掌握计数类DP后,可进阶挑战以下方向:
🔍 多重约束计数 → 添加额外限制条件(如货币限额、节点权值);
🔍 容斥原理结合 → 排除非法方案(如互斥事件);
🔍 生成函数应用 → 通过多项式乘法建模复杂组合问题;
🔍 模运算防溢出 → 大数计数时取模防止整数溢出。
通过系统练习计数类DP题目,你将熟练掌握组合数学与动态规划的结合技巧,并为学习概率DP、数位DP等高级主题奠定坚实基础!
8. 博弈型动态规划(Game Theory DP)
⚔ 什么是博弈型动态规划?
博弈型动态规划是一种用于模拟双方轮流决策且均采取最优策略的动态规划范式,其核心在于:
🎯 对抗性决策:当前玩家的选择直接影响对手的策略空间;
💡 逆向思维:从终局倒推每一步的胜负状态(而非正向累加);
🔄 状态对称性:需同时考虑“当前玩家能否必胜”和“对手是否必败”的双重视角。
♟ 典型应用场景
| 类型 | 示例问题 | 关键操作 |
|---|---|---|
| 取石子游戏 | Coins in a Line | 每次取 1 2 1\over2 21个石子,最后取者胜 |
| 数组端点选择 | Predict the Winner | 从数组两端取数,总分高者胜 |
| 尼姆博弈 | Nim Game | 异或运算决定胜负状态 |
| 卡片游戏 | Card Game Solver | 轮流抽牌比大小 |
| 资源争夺 | Stone Game | 分配有限资源的策略博弈 |
🛠️ 通用解题框架
- 定义状态 →
dp[i]表示当前玩家面对局面i时能否必胜(布尔值); - 状态转移 → 若存在一种选择使得对手处于必败态,则当前玩家必胜;否则必败;
- 边界条件 → 当无合法移动时(如石子耗尽),当前玩家输;
- 递归/迭代实现 → 可采用记忆化搜索或自底向上的动态规划;
- 结果判定 → 根据初始状态
dp[initial]判断先手胜负。
🏆 例题精讲 & C++代码实现
例1: Coins in a Line
题目描述:Alice和Bob轮流从一排N个石子的最右端取1个或2个石子,取最后一个石子的人获胜。判断先手Alice是否必胜。
输入示例:N=5 → 输出:true(Alice第一次取2个石子,后续可控制胜利)。
思路分析:
- 设
f[i]表示面对i个石子时先手是否必胜。- 转移方程:
f[i] = !f[i-1] || !f[i-2](只要存在一种选择使对手必败,则当前先手必胜)。- 边界条件:
f[0]=false,f[1]=true,f[2]=true。
bool firstWillWin(int n) {
if (n == 0) return false;
if (n <= 2) return true;
bool f[n + 1];
f[0] = false;
f[1] = f[2] = true;
for (int i = 3; i <= n; ++i) {
f[i] = !f[i - 1] || !f[i - 2];
}
return f[n];
}
优化空间复杂度至 O ( 1 ) O(1) O(1):
bool firstWillWin(int n) {
if (n == 0) return false;
if (n <= 2) return true;
bool a = true, b = true; // f[i-2], f[i-1]
for (int i = 3; i <= n; ++i) {
bool c = !a || !b; // f[i]
a = b;
b = c;
}
return b;
}
例2: Predict the Winner
题目描述:给定数组
nums,玩家1和玩家2轮流从数组两端取数,最终得分高者胜。判断玩家1是否能赢(平局算玩家1赢)。
输入示例:nums=[1,5,2]→ 输出:false(玩家2得分更高)。
思路分析:
- 定义
dp[i][j]表示当前玩家在区间[i, j]内能获得的最大净胜分(自身得分减去对手得分)。- 转移方程:
dp[i][j] = max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])。- 最终判断
dp[0][n-1] >= 0。
bool PredictTheWinner(vector<int>& nums) {
int n = nums.size();
vector<vector<int>> dp(n, vector<int>(n, 0));
for (int i = n - 1; i >= 0; --i) {
for (int j = i; j < n; ++j) {
if (i == j) {
dp[i][j] = nums[i];
} else {
dp[i][j] = max(nums[i] - dp[i + 1][j], nums[j] - dp[i][j - 1]);
}
}
}
return dp[0][n - 1] >= 0;
}
例3: Can Win Basketball Game(变种)
题目描述:两人轮流拿1~3个石头,拿最后一个石头的人赢。给定石头数量n,判断先手是否能赢。
输入示例:n=4 → 输出:true(先手取1个,剩余3个,无论对方怎么拿,先手都可拿最后一个)。
思路分析:
- 若
n % 4 != 0,先手可通过策略性取石子使对手始终面临4的倍数的局面,最终必胜;否则必败。
bool canWinBasketball(int n) {
return n % 4 != 0;
}
⚖️ 性能对比与选型建议
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 标准一维DP | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | 简单线性博弈(如取石子) |
| 二维区间DP | O ( N 2 ) O(N^2) O(N2) | O ( N 2 ) O(N^2) O(N2) | 数组两端选择类问题 |
| 数学规律优化 | O ( 1 ) O(1) O(1) | O ( 1 ) O(1) O(1) | 周期性明显的博弈(如模 4 4 4) |
| 记忆化搜索 | O ( N ) O(N) O(N) | O ( N ) O(N) O(N) | 复杂递归结构(如树形博弈) |
📚 学习路线图
掌握博弈型DP后,可进阶挑战以下方向:
🧠 多玩家博弈 → 扩展至三人及以上玩家的纳什均衡策略;
🧠 不完全信息博弈 → 结合概率模型处理不确定性;
🧠 博弈树剪枝 → 通过Alpha-Beta剪枝优化极大极小值搜索;
🧠 强化学习结合 → 将DP视为价值函数更新的基础框架。
通过系统练习博弈型DP题目,你将熟练掌握对抗性决策的核心逻辑,并为学习博弈论、强化学习等高级主题奠定坚实基础!
总结对比表
| 分支 | 典型状态定义 | 核心难点 | 代表问题 |
|---|---|---|---|
| 线性DP | dp[i] |
简单递推关系 | 斐波那契、爬楼梯 |
| 区间DP | dp[i][j] |
区间分割与合并逻辑 | LCS、矩阵链乘法 |
| 背包问题 | dp[i][w] |
物品选择与容量限制 | 0/1背包、完全背包 |
| 树形DP | 节点状态+子节点反馈 | 树结构的递归处理 | 打家劫舍III |
| 状压DP | 二进制掩码 | 状态压缩与枚举 | TSP、集合覆盖 |
| 环形DP | 线性化后的双情况处理 | 首尾衔接的特殊逻辑 | 环形房屋抢劫 |
| 计数类DP | dp[i]=方案数 |
避免重复计数 | 不同路径、整数拆分 |
| 博弈型DP | dp[i]=当前玩家能否获胜 |
对抗策略的逆向推导 | 石子游戏、尼姆博弈 |
掌握这些分支的典型模式和解题模板,能帮助你在CSP竞赛中快速定位问题类型并设计高效的动态规划解法。
💡四、实战技巧分享
✔️记忆化搜索 vs 迭代法
初学者可以先尝试自顶向下的记忆化搜索(递归+缓存),这种方式更直观易于理解;熟练后转向自底向上的迭代法,效率更高且节省栈空间。
⚖️优化空间复杂度
观察状态转移的特点,有时可以将二维数组压缩成一维数组。例如在0/1背包问题中,因为每次更新只依赖于上一行的数据,所以我们可以使用滚动数组技巧将空间复杂度从 O ( N × W ) O(N\times W) O(N×W)降到 O ( W ) O(W) O(W)。
🌈五、为什么重要?
动态规划不仅是解决许多复杂问题的有效工具,更是锻炼你逻辑思维能力和编程技巧的好方法。在CSP竞赛中,动态规划相关的题目频繁出现,掌握了这项技能,你就能在比赛中更加游刃有余,取得更好的成绩!
希望这篇博客能帮助大家更好地理解和掌握C++中的动态规划。祝各位在即将到来的CSP比赛中取得优异成绩!加油!💪🎉
更多推荐
所有评论(0)