当前位置: 首页 > news >正文

动态规划-学习笔记

这是一份动态规划(Dynamic Programming, DP)完整学习笔记。笔记将从一星难度(入门)到五星难度(进阶),循序渐进,涵盖核心思想、经典模型和解题方法论。
本来打算今天更新背包问题的题解的,但事情比较多,就拿一篇笔记来更新吧+_+


动态规划核心思想 (Pre-Star)

在开始之前,我们必须理解动态规划的本质。它不是一种特定的算法,而是一种解决问题的思想

  1. 什么是动态规划?

动态规划是一种将复杂问题分解成更小的、可重复的子问题,并通过存储子问题的解来避免重复计算,从而找到原问题最优解的方法。

2. DP 的两个核心特征:

  • 最优子结构 (Optimal Substructure): 原问题的最优解可以由其子问题的最优解构成。这意味着我们可以通过组合子问题的最优解,来得到原问题的最优解。

  • 重叠子问题 (Overlapping Subproblems): 在问题的求解过程中,许多子问题会被多次重复计算。DP 的高效正体现在它只需计算一次,然后将结果存起来供后续使用。

3. 解题的两种主要实现方式:

  • 自顶向下 (Top-Down) 与记忆化搜索: 从原问题出发,通过递归函数求解。如果遇到已经计算过的子问题,直接返回存储的结果,否则进行计算并存入备忘录。

  • 自底向上 (Bottom-Up) 与递推: 从最小的子问题开始,迭代地计算并填充 DP 表,直到计算出原问题的解。这是更常见、通常也更高效的实现方式。

  1. 动态规划解题五步法 (非常重要!)

几乎所有的 DP 问题都可以套用以下五个步骤来思考:

  1. 定义 dp 数组的含义: 这是最关键的一步。明确 dp[i]dp[i][j] 代表什么,比如 dp[i] 是前 i 个元素能获得的最大价值。

  2. 找出递推关系式(状态转移方程): 这是 DP 的核心。思考 dp[i] 是如何由之前的状态(如 dp[i-1], dp[i-2] 等)推导出来的。

  3. 初始化 dp 数组: 根据递推公式和 dp 数组的定义,设置好初始值(base case),比如 dp[0] 的值。

  4. 确定遍历顺序: 思考是应该从前向后遍历,还是从后向前,或者二维数组的内外层循环顺序。这取决于状态转移方程中 dp[i] 依赖于哪些历史状态。

  5. 根据 dp 数组推导出最终解: 最终答案可能直接是 dp 数组的某个值,也可能是整个数组的最大值。


★☆☆☆☆ (一星): 线性 DP 入门

这个阶段主要是理解 DP 的基本思想,通常处理一维数组或序列问题。

经典问题1:斐波那契数列 / 爬楼梯
  • 问题描述 (爬楼梯): 你在爬一个 n 阶的楼梯。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶?

  • 五步法分析:

    1. dp 定义: dp[i] 表示爬到第 i 阶楼梯的不同方法数。

    2. 递推公式: 要到达第 i 阶,可以从第 i-1 阶爬 1 步上来,也可以从第 i-2 阶爬 2 步上来。所以 dp[i] = dp[i-1] + dp[i-2]

    3. 初始化: dp[1] = 1 (爬到第1阶只有1种方法),dp[2] = 2 (可以 1+1 或 2)。

    4. 遍历顺序:i = 3n,从前向后遍历。

    5. 最终解: 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)
  • 问题描述: 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,但相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

  • 五步法分析:

    1. dp 定义: dp[i] 表示偷窃前 i 间房屋所能获得的最大金额。

    2. 递推公式: 对于第 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])

    3. 初始化: dp[0] = 0 (没有房子),dp[1] = nums[0] (只有一间房,必偷)。

    4. 遍历顺序:i = 2n,从前向后。

    5. 最终解: 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 网格的左上角,它每次只能向右或者向下移动一步。机器人试图达到网格的右下角。问总共有多少条不同的路径?

  • 五步法分析:

    1. dp 定义: dp[i][j] 表示从左上角到达坐标 (i, j) 的路径数。

    2. 递推公式: 要到达 (i, j),只能从 (i-1, j) (上面) 或 (i, j-1) (左面) 过来。所以 dp[i][j] = dp[i-1][j] + dp[i][j-1]

    3. 初始化: 第一行和第一列的所有格子都只有一条路径到达,所以 dp[i][0] = 1dp[0][j] = 1

    4. 遍历顺序: 从上到下,从左到右。

    5. 最终解: 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]。求解将哪些物品装入背包,可使这些物品的重量总和不超过背包容量,且价值总和最大。

  • 五步法分析 (二维数组版本):

    1. dp 定义: dp[i][j] 表示从前 i 件物品中任意选择,放入容量为 j 的背包中,所能获得的最大价值。

    2. 递推公式: 对于第 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])

    3. 初始化: dp[0][j] = 0 (不选任何物品,价值为0)。dp[i][0] = 0 (背包容量为0,价值为0)。

    4. 遍历顺序: 外层循环物品 i 从 1到 N,内层循环背包容量 j 从 1 到 V。

    5. 最终解: dp[N][V]

  • 代码示例 (C++,空间优化后的一维数组版本):

    一维数组优化是背包问题的精髓。

    1. dp 定义: dp[j] 表示容量为 j 的背包所能获得的最大价值。

    2. 递推公式: dp[j] = max(dp[j], dp[j - weight[i]] + value[i])

    3. 初始化: dp 数组全部初始化为 0。

    4. 遍历顺序: 外层循环物品 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) 解法):

    1. dp 定义: dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。

    2. 递推公式: 对于 nums[i],我们需要向前遍历所有 j < i。如果 nums[i] > nums[j],说明 nums[i] 可以接在以 nums[j] 结尾的递增子序列后面,形成一个更长的序列。所以 dp[i] = max(dp[i], dp[j] + 1)

    3. 初始化: dp 数组所有元素都初始化为 1,因为每个元素自身都构成一个长度为 1 的递增子序列。

    4. 遍历顺序: 外层循环 i 从 0 到 n-1,内层循环 j 从 0 到 i-1

    5. 最终解: 最终答案是整个 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 次为例。

  • 五步法分析:

    1. dp 定义: 需要三个维度来定义状态。dp[i][k][s] 表示第 i 天,最多允许 k 次交易,当前持股状态为 s (0:不持股, 1:持股) 时所拥有的最大利润。

    2. 递推公式:

      • 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)

    3. 初始化: dp[-1][k][0] = 0, dp[-1][k][1] = -infinity (还没开始不可能持股)。

    4. 遍历顺序: 天数 i 从 0 到 n-1,交易次数 k 从 1 到 K

    5. 最终解: dp[n-1][K][0] (最后一天不持股的利润一定是最大的)。

  • 这个通用框架可以解决几乎所有的股票问题,只需要根据题意微调状态转移方程即可。

经典问题2:区间 DP (Interval DP)
  • 问题描述 (戳气球):n 个气球,编号为 0 到 n-1,每个气球上都标有一个数字,这些数字存在数组 nums 中。现在要求你戳破所有的气球。每当你戳破一个气球 i 时,你将会获得 nums[left] * nums[i] * nums[right] 个硬币。这里的 leftright 代表和 i 相邻的两个气球的序号。戳破了气球 i 后,leftright 就变成了相邻的气球。求所能获得硬币的最大数量。

  • 分析: 这个问题如果正向思考(先戳哪个),会发现后续状态难以确定。反向思考:最后戳哪个气球?

    1. dp 定义: dp[i][j] 表示戳破区间 (i, j) (开区间) 内所有气球能获得的最大硬币数。

    2. 递推公式: 假设在区间 (i, j) 中,我们最后戳破的气球是 k (i < k < j)。那么此时 ij 就是 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]) for k in (i, j)

    3. 初始化: dp 数组初始化为 0。为了方便处理边界,可以在 nums 数组两边各加一个 1。

    4. 遍历顺序: 区间 DP 的遍历顺序很特别。外层循环是区间的长度 len (从 3 到 n),内层循环是区间的起始点 i。这样保证在计算大区间 dp[i][j] 时,所有它依赖的小区间都已经计算完毕。

    5. 最终解: dp[0][n-1] (n是新数组的长度)。


★★★★★ (五星): 状态压缩与树形/图 DP

这是 DP 的最高境界,通常与位运算、图论、树等结构结合,解决 NP-Hard 问题在小规模数据下的最优解。

经典问题1:状态压缩 DP (Bitmask DP)
  • 适用场景: 当问题中某个维度的状态数量不多 (通常 Nle20),且每个状态可以用二进制位来表示(是/否,用过/没用过)时。

  • 问题描述 (旅行商问题 TSP): 给定 n 个城市和一个距离矩阵,求从一个城市出发,访问所有其他城市一次后回到出发点的最短路径。

  • 分析:

    1. dp 定义: dp[mask][i] 表示访问过的城市集合为 mask (一个整数,其二进制表示中第 j 位为 1 代表城市 j 已访问),当前停留在城市 i 时的最短路径长度。

    2. 递推公式: 要从状态 dp[mask][i] 转移,我们可以枚举下一个要去的城市 j。前提是 j 不在 mask 集合中。

      • dp[mask | (1 << j)][j] = min(dp[mask | (1 << j)][j], dp[mask][i] + dist[i][j])
    3. 初始化: dp[1 << start_node][start_node] = 0

    4. 遍历顺序: 外层循环 mask 从 1 到 (1 << n) - 1,中层循环当前城市 i,内层循环下一个城市 j

    5. 最终解: min(dp[(1 << n) - 1][i] + dist[i][start_node]) for all i

经典问题2:树形 DP (Tree DP)
  • 适用场景: 在树形结构上求解最优问题。

  • 问题描述 (打家劫舍 III): 房屋排列成二叉树结构。如果偷窃了某个节点,就不能偷窃其直接相连的子节点。求最大偷窃金额。

  • 分析:

    1. dp 定义: 对于每个节点 u,我们需要知道两个状态:偷它和不偷它能得到的最大收益。所以定义一个 pair 或大小为 2 的数组 dp[u]dp[u][0] 表示不偷 u 时,在以 u 为根的子树中能获得的最大收益。dp[u][1] 表示偷 u 时…

    2. 递推公式 (通过后序遍历/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]

    3. 初始化: 递归到叶子节点时,dp[leaf][0] = 0, dp[leaf][1] = leaf->val

    4. 遍历顺序: 使用深度优先搜索(DFS),在回溯时(即后序遍历)进行状态转移。

    5. 最终解: 对于根节点 root,答案是 max(dp[root][0], dp[root][1])


总结与练习建议

  • Practice is Everything: 动态规划是“做”出来的,不是“看”出来的。大量的练习是掌握它的唯一途径。

  • 从模仿开始: 刚开始可以对着题解的思路,自己尝试复现五步法,然后独立写出代码。

  • 画图,画表: 尤其是对于二维 DP,手动填充 DP 表是理解状态转移过程的绝佳方法。

  • 推荐练习平台: LeetCode、AtCoder、Codeforces。LeetCode 上的 DP 题目有很好的分类和难度梯度,非常适合系统性学习。

希望这份笔记能为你打开动态规划的大门!祝你学习愉快!


文章转载自:

http://GbrM0tqv.cxtbh.cn
http://XJXlloz6.cxtbh.cn
http://FfDuO7E6.cxtbh.cn
http://FZD9OfEj.cxtbh.cn
http://Ak5JgH6g.cxtbh.cn
http://fIw4KYGp.cxtbh.cn
http://wZX8Te8f.cxtbh.cn
http://WUg6OMdy.cxtbh.cn
http://GWl4e2Fc.cxtbh.cn
http://3uDDsDvD.cxtbh.cn
http://QrGtExcd.cxtbh.cn
http://gNwX7WOC.cxtbh.cn
http://XG0ZueFG.cxtbh.cn
http://TxYIlV53.cxtbh.cn
http://6SvGfRuB.cxtbh.cn
http://r4Kxv9gB.cxtbh.cn
http://qssV1uxB.cxtbh.cn
http://pJzoxGT7.cxtbh.cn
http://Ivr11DH7.cxtbh.cn
http://mCAMt2fk.cxtbh.cn
http://rbxe0dCl.cxtbh.cn
http://8R5SEdyQ.cxtbh.cn
http://cxrhxRxJ.cxtbh.cn
http://wZJhTY2O.cxtbh.cn
http://trLhNbWq.cxtbh.cn
http://08UF1lhE.cxtbh.cn
http://QIImqsQU.cxtbh.cn
http://jz3ClCKI.cxtbh.cn
http://mOZLyUsI.cxtbh.cn
http://hOrNJGcO.cxtbh.cn
http://www.dtcms.com/a/373427.html

相关文章:

  • Java分布式锁详解
  • Docker学习笔记(四):网络管理与容器操作
  • 基于MATLAB的FIR和IIR低通带通滤波器实现
  • SpringMVC 程序开发
  • 深入理解 Linux hostname 命令:从日常操作到运维实战
  • SN码追溯技术全景解析:AI时代的数字身份革命
  • AI 小白入门:探索模型上下文协议(MCP)及其前端应用
  • 代码随想录70期day5
  • Vue3源码reactivity响应式篇之reactive响应式对象的track与trigger
  • GitHub高星标项目:基于大数据的心理健康分析系统Hadoop+Spark完整实现
  • Google Guice @Inject、@Inject、@Singleton等注解的用法
  • 【MATLAB组合导航代码,平面】CKF(容积卡尔曼滤波)作为融合方法,状态量8维,观测量4维,包含二维平面上的严格的INS推导。附完整代码
  • Go Style 代码风格规范
  • Java 16 中引入的 record的基本用法
  • uni-app iOS 性能监控全流程 多工具协作的实战优化指南
  • shell 中 expect 详解
  • 告别低效:构建健壮R爬虫的工程思维
  • Ubuntu中显示英伟达显卡的工具软件或者指令
  • 银行卡号识别案例
  • 【golang学习笔记 gin 】1.2 redis 的使用
  • AI提示词(Prompt)基础核心知识点
  • VTK开发笔记(五):示例Cone2,熟悉观察者模式,在Qt窗口中详解复现对应的Demo
  • Excel 表格 - Excel 减少干扰、专注于内容的查看方式
  • Vue3 + Ant Design Vue 全局配置中文指南
  • CSS in JS 的演进:Styled Components, Emotion 等的深度对比与技术选型指引
  • 哈士奇vs网易高级数仓:数据仓库的灵魂是模型、数据质量还是计算速度?| 易错题
  • Windows 命令行:cd 命令2,切换到多级子目录
  • C++ 8
  • GD32入门到实战45--LVGL开发(Code::Blocks)之创建控件
  • 算法题(202):乌龟棋