动态规划-学习笔记
这是一份动态规划(Dynamic Programming, DP)完整学习笔记。笔记将从一星难度(入门)到五星难度(进阶),循序渐进,涵盖核心思想、经典模型和解题方法论。
本来打算今天更新背包问题的题解的,但事情比较多,就拿一篇笔记来更新吧+_+
动态规划核心思想 (Pre-Star)
在开始之前,我们必须理解动态规划的本质。它不是一种特定的算法,而是一种解决问题的思想。
- 什么是动态规划?
动态规划是一种将复杂问题分解成更小的、可重复的子问题,并通过存储子问题的解来避免重复计算,从而找到原问题最优解的方法。
2. DP 的两个核心特征:
-
最优子结构 (Optimal Substructure): 原问题的最优解可以由其子问题的最优解构成。这意味着我们可以通过组合子问题的最优解,来得到原问题的最优解。
-
重叠子问题 (Overlapping Subproblems): 在问题的求解过程中,许多子问题会被多次重复计算。DP 的高效正体现在它只需计算一次,然后将结果存起来供后续使用。
3. 解题的两种主要实现方式:
-
自顶向下 (Top-Down) 与记忆化搜索: 从原问题出发,通过递归函数求解。如果遇到已经计算过的子问题,直接返回存储的结果,否则进行计算并存入备忘录。
-
自底向上 (Bottom-Up) 与递推: 从最小的子问题开始,迭代地计算并填充 DP 表,直到计算出原问题的解。这是更常见、通常也更高效的实现方式。
- 动态规划解题五步法 (非常重要!)
几乎所有的 DP 问题都可以套用以下五个步骤来思考:
-
定义
dp
数组的含义: 这是最关键的一步。明确dp[i]
或dp[i][j]
代表什么,比如dp[i]
是前i
个元素能获得的最大价值。 -
找出递推关系式(状态转移方程): 这是 DP 的核心。思考
dp[i]
是如何由之前的状态(如dp[i-1]
,dp[i-2]
等)推导出来的。 -
初始化
dp
数组: 根据递推公式和dp
数组的定义,设置好初始值(base case),比如dp[0]
的值。 -
确定遍历顺序: 思考是应该从前向后遍历,还是从后向前,或者二维数组的内外层循环顺序。这取决于状态转移方程中
dp[i]
依赖于哪些历史状态。 -
根据
dp
数组推导出最终解: 最终答案可能直接是dp
数组的某个值,也可能是整个数组的最大值。
★☆☆☆☆ (一星): 线性 DP 入门
这个阶段主要是理解 DP 的基本思想,通常处理一维数组或序列问题。
经典问题1:斐波那契数列 / 爬楼梯
-
问题描述 (爬楼梯): 你在爬一个
n
阶的楼梯。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶? -
五步法分析:
-
dp
定义:dp[i]
表示爬到第i
阶楼梯的不同方法数。 -
递推公式: 要到达第
i
阶,可以从第i-1
阶爬 1 步上来,也可以从第i-2
阶爬 2 步上来。所以dp[i] = dp[i-1] + dp[i-2]
。 -
初始化:
dp[1] = 1
(爬到第1阶只有1种方法),dp[2] = 2
(可以 1+1 或 2)。 -
遍历顺序: 从
i = 3
到n
,从前向后遍历。 -
最终解:
dp[n]
。
-
-
代码示例 (C++):
int climbStairs(int n) {if (n <= 2) return n;std::vector<int> dp(n + 1);dp[1] = 1;dp[2] = 2;for (int i = 3; i <= n; ++i) {dp[i] = dp[i - 1] + dp[i - 2];}return dp[n];}
经典问题2:打家劫舍 (House Robber I)
-
问题描述: 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,但相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
-
五步法分析:
-
dp
定义:dp[i]
表示偷窃前i
间房屋所能获得的最大金额。 -
递推公式: 对于第
i
间房,你有两个选择:-
不偷第
i
间: 最大金额等于偷前i-1
间的最大金额,即dp[i-1]
。 -
偷第
i
间: 那么第i-1
间就不能偷,最大金额等于偷前i-2
间的最大金额加上第i
间的金额,即dp[i-2] + nums[i-1]
(注意nums
索引)。 -
取两者最大值:
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
。
-
-
初始化:
dp[0] = 0
(没有房子),dp[1] = nums[0]
(只有一间房,必偷)。 -
遍历顺序: 从
i = 2
到n
,从前向后。 -
最终解:
dp[n]
。
-
-
代码示例 (C++):
int rob(std::vector<int>& nums) {if (nums.empty()) return 0;int n = nums.size();if (n == 1) return nums[0];std::vector<int> dp(n + 1);dp[0] = 0;dp[1] = nums[0];for (int i = 2; i <= n; ++i) {dp[i] = std::max(dp[i - 1], dp[i - 2] + nums[i - 1]);}return dp[n];}
★★☆☆☆ (二星): 二维 DP 与经典模型
这个阶段开始引入二维 dp
数组,用于解决需要两个变量来定义状态的问题。背包问题是这个阶段的重中之重。
经典问题1:不同路径 (Unique Paths)
-
问题描述: 一个机器人在一个
m x n
网格的左上角,它每次只能向右或者向下移动一步。机器人试图达到网格的右下角。问总共有多少条不同的路径? -
五步法分析:
-
dp
定义:dp[i][j]
表示从左上角到达坐标(i, j)
的路径数。 -
递推公式: 要到达
(i, j)
,只能从(i-1, j)
(上面) 或(i, j-1)
(左面) 过来。所以dp[i][j] = dp[i-1][j] + dp[i][j-1]
。 -
初始化: 第一行和第一列的所有格子都只有一条路径到达,所以
dp[i][0] = 1
,dp[0][j] = 1
。 -
遍历顺序: 从上到下,从左到右。
-
最终解:
dp[m-1][n-1]
。
-
-
代码示例 (C++):
int uniquePaths(int m, int n) {std::vector<std::vector<int>> dp(m, std::vector<int>(n, 0));for (int i = 0; i < m; ++i) dp[i][0] = 1;for (int j = 0; j < n; ++j) dp[0][j] = 1;for (int i = 1; i < m; ++i) {for (int j = 1; j < n; ++j) {dp[i][j] = dp[i - 1][j] + dp[i][j - 1];}}return dp[m - 1][n - 1];}
经典问题2:0-1 背包问题
-
问题描述: 有
N
件物品和一个容量为V
的背包。第i
件物品的重量是weight[i]
,价值是value[i]
。求解将哪些物品装入背包,可使这些物品的重量总和不超过背包容量,且价值总和最大。 -
五步法分析 (二维数组版本):
-
dp
定义:dp[i][j]
表示从前i
件物品中任意选择,放入容量为j
的背包中,所能获得的最大价值。 -
递推公式: 对于第
i
件物品,有两个选择:-
不放入背包: 最大价值等于只考虑前
i-1
件物品放入容量j
的背包,即dp[i-1][j]
。 -
放入背包: (前提是
j >= weight[i]
),价值等于只考虑前i-1
件物品放入容量为j - weight[i]
的背包的最大价值,再加上第i
件物品的价值,即dp[i-1][j - weight[i]] + value[i]
。 -
取两者最大值:
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i])
。
-
-
初始化:
dp[0][j] = 0
(不选任何物品,价值为0)。dp[i][0] = 0
(背包容量为0,价值为0)。 -
遍历顺序: 外层循环物品
i
从 1到 N,内层循环背包容量j
从 1 到 V。 -
最终解:
dp[N][V]
。
-
-
代码示例 (C++,空间优化后的一维数组版本):
一维数组优化是背包问题的精髓。
-
dp
定义:dp[j]
表示容量为j
的背包所能获得的最大价值。 -
递推公式:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
。 -
初始化:
dp
数组全部初始化为 0。 -
遍历顺序: 外层循环物品
i
,内层循环容量j
必须从后向前遍历! 这是为了保证计算dp[j]
时,所依赖的dp[j - weight[i]]
还是上一轮i-1
的状态。
-
void test_01_knapsack(int W, int N, const std::vector<int>& weights, const std::vector<int>& values) {std::vector<int> dp(W + 1, 0);for (int i = 0; i < N; ++i) { // 遍历物品for (int j = W; j >= weights[i]; --j) { // 遍历背包容量 (倒序)dp[j] = std::max(dp[j], dp[j - weights[i]] + values[i]);}}std::cout << "Max value is: " << dp[W] << std::endl;}
★★★☆☆ (三星): 序列 DP 与背包变种
这个阶段处理更复杂的序列问题和背包问题的变体,需要更深入的思考状态定义和转移。
经典问题1:最长递增子序列 (Longest Increasing Subsequence)
-
问题描述: 给定一个整数序列,找到其中最长递增子序列的长度。子序列不要求连续。
-
五步法分析 (O(N2) 解法):
-
dp
定义:dp[i]
表示以nums[i]
结尾的最长递增子序列的长度。 -
递推公式: 对于
nums[i]
,我们需要向前遍历所有j < i
。如果nums[i] > nums[j]
,说明nums[i]
可以接在以nums[j]
结尾的递增子序列后面,形成一个更长的序列。所以dp[i] = max(dp[i], dp[j] + 1)
。 -
初始化:
dp
数组所有元素都初始化为 1,因为每个元素自身都构成一个长度为 1 的递增子序列。 -
遍历顺序: 外层循环
i
从 0 到n-1
,内层循环j
从 0 到i-1
。 -
最终解: 最终答案是整个
dp
数组中的最大值,而不是dp[n-1]
。
-
-
代码示例 (C++):
int lengthOfLIS(std::vector<int>& nums) {if (nums.empty()) return 0;int n = nums.size();std::vector<int> dp(n, 1);int max_len = 1;for (int i = 1; i < n; ++i) {for (int j = 0; j < i; ++j) {if (nums[i] > nums[j]) {dp[i] = std::max(dp[i], dp[j] + 1);}}max_len = std::max(max_len, dp[i]);}return max_len;}
_注:此问题有更优的 O(NlogN) 解法,使用贪心+二分查找,属于进阶内容。_
经典问题2:完全背包 (Unbounded Knapsack)
-
问题描述: 与 0-1 背包类似,但每种物品可以无限次地放入背包。
-
分析: 与 0-1 背包唯一的区别在于,当你考虑第
i
件物品时,你还可以继续考虑它。这体现在一维dp
数组的遍历顺序上。 -
遍历顺序变化: 内层循环容量
j
从前向后遍历。- 为什么? 在计算
dp[j]
时,我们需要用到dp[j - weight[i]]
的值。在正序遍历中,dp[j - weight[i]]
已经被本轮(第i
件物品)更新过,这等价于第i
件物品已经被放入过一次,可以再次放入。
- 为什么? 在计算
-
代码示例 (C++):
void test_unbounded_knapsack(int W, int N, const std::vector<int>& weights, const std::vector<int>& values) {std::vector<int> dp(W + 1, 0);for (int i = 0; i < N; ++i) { // 遍历物品for (int j = weights[i]; j <= W; ++j) { // 遍历背包容量 (正序)dp[j] = std::max(dp[j], dp[j - weights[i]] + values[i]);}}std::cout << "Max value is: " << dp[W] << std::endl;}
★★★★☆ (四星): 复杂状态与区间 DP
这个阶段的问题状态定义更加复杂,可能包含多个维度,或者需要在一个区间上进行 DP。
经典问题1:股票买卖系列 (Best Time to Buy and Sell Stock)
-
问题描述: 这是一系列问题,限制了交易次数、引入了冷却期或手续费。我们以最多交易 K 次为例。
-
五步法分析:
-
dp
定义: 需要三个维度来定义状态。dp[i][k][s]
表示第i
天,最多允许k
次交易,当前持股状态为s
(0:不持股, 1:持股) 时所拥有的最大利润。 -
递推公式:
-
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
(今天不持股 = 昨天就不持股,或昨天持股今天卖出)
-
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
(今天持股 = 昨天就持股,或昨天不持股今天买入。注意买入消耗一次交易,所以是 k-1)
-
-
初始化:
dp[-1][k][0] = 0
,dp[-1][k][1] = -infinity
(还没开始不可能持股)。 -
遍历顺序: 天数
i
从 0 到n-1
,交易次数k
从 1 到K
。 -
最终解:
dp[n-1][K][0]
(最后一天不持股的利润一定是最大的)。
-
-
这个通用框架可以解决几乎所有的股票问题,只需要根据题意微调状态转移方程即可。
经典问题2:区间 DP (Interval DP)
-
问题描述 (戳气球): 有
n
个气球,编号为 0 到n-1
,每个气球上都标有一个数字,这些数字存在数组nums
中。现在要求你戳破所有的气球。每当你戳破一个气球i
时,你将会获得nums[left] * nums[i] * nums[right]
个硬币。这里的left
和right
代表和i
相邻的两个气球的序号。戳破了气球i
后,left
和right
就变成了相邻的气球。求所能获得硬币的最大数量。 -
分析: 这个问题如果正向思考(先戳哪个),会发现后续状态难以确定。反向思考:最后戳哪个气球?
-
dp
定义:dp[i][j]
表示戳破区间(i, j)
(开区间) 内所有气球能获得的最大硬币数。 -
递推公式: 假设在区间
(i, j)
中,我们最后戳破的气球是k
(i < k < j
)。那么此时i
和j
就是k
相邻的气球。戳破k
的收益是nums[i] * nums[k] * nums[j]
。而在这之前,区间(i, k)
和(k, j)
的气球已经被戳破了,这两部分是独立的。所以dp[i][j] = max(dp[i][k] + dp[k][j] + nums[i] * nums[k] * nums[j])
fork
in(i, j)
。 -
初始化:
dp
数组初始化为 0。为了方便处理边界,可以在nums
数组两边各加一个 1。 -
遍历顺序: 区间 DP 的遍历顺序很特别。外层循环是区间的长度
len
(从 3 到 n),内层循环是区间的起始点i
。这样保证在计算大区间dp[i][j]
时,所有它依赖的小区间都已经计算完毕。 -
最终解:
dp[0][n-1]
(n是新数组的长度)。
-
★★★★★ (五星): 状态压缩与树形/图 DP
这是 DP 的最高境界,通常与位运算、图论、树等结构结合,解决 NP-Hard 问题在小规模数据下的最优解。
经典问题1:状态压缩 DP (Bitmask DP)
-
适用场景: 当问题中某个维度的状态数量不多 (通常 Nle20),且每个状态可以用二进制位来表示(是/否,用过/没用过)时。
-
问题描述 (旅行商问题 TSP): 给定
n
个城市和一个距离矩阵,求从一个城市出发,访问所有其他城市一次后回到出发点的最短路径。 -
分析:
-
dp
定义:dp[mask][i]
表示访问过的城市集合为mask
(一个整数,其二进制表示中第j
位为 1 代表城市j
已访问),当前停留在城市i
时的最短路径长度。 -
递推公式: 要从状态
dp[mask][i]
转移,我们可以枚举下一个要去的城市j
。前提是j
不在mask
集合中。dp[mask | (1 << j)][j] = min(dp[mask | (1 << j)][j], dp[mask][i] + dist[i][j])
-
初始化:
dp[1 << start_node][start_node] = 0
。 -
遍历顺序: 外层循环
mask
从 1 到(1 << n) - 1
,中层循环当前城市i
,内层循环下一个城市j
。 -
最终解:
min(dp[(1 << n) - 1][i] + dist[i][start_node])
for alli
。
-
经典问题2:树形 DP (Tree DP)
-
适用场景: 在树形结构上求解最优问题。
-
问题描述 (打家劫舍 III): 房屋排列成二叉树结构。如果偷窃了某个节点,就不能偷窃其直接相连的子节点。求最大偷窃金额。
-
分析:
-
dp
定义: 对于每个节点u
,我们需要知道两个状态:偷它和不偷它能得到的最大收益。所以定义一个pair
或大小为 2 的数组dp[u]
。dp[u][0]
表示不偷u
时,在以u
为根的子树中能获得的最大收益。dp[u][1]
表示偷u
时… -
递推公式 (通过后序遍历/DFS实现):
-
不偷 u (dp[u][0]): 那么它的左孩子 l 和右孩子 r 都可以偷或不偷,取两者最大值即可。
dp[u][0] = max(dp[l][0], dp[l][1]) + max(dp[r][0], dp[r][1])
-
偷 u (dp[u][1]): 那么它的左右孩子都不能偷。
dp[u][1] = u->val + dp[l][0] + dp[r][0]
-
-
初始化: 递归到叶子节点时,
dp[leaf][0] = 0
,dp[leaf][1] = leaf->val
。 -
遍历顺序: 使用深度优先搜索(DFS),在回溯时(即后序遍历)进行状态转移。
-
最终解: 对于根节点
root
,答案是max(dp[root][0], dp[root][1])
。
-
总结与练习建议
-
Practice is Everything: 动态规划是“做”出来的,不是“看”出来的。大量的练习是掌握它的唯一途径。
-
从模仿开始: 刚开始可以对着题解的思路,自己尝试复现五步法,然后独立写出代码。
-
画图,画表: 尤其是对于二维 DP,手动填充 DP 表是理解状态转移过程的绝佳方法。
-
推荐练习平台: LeetCode、AtCoder、Codeforces。LeetCode 上的 DP 题目有很好的分类和难度梯度,非常适合系统性学习。
希望这份笔记能为你打开动态规划的大门!祝你学习愉快!