数据结构与算法
算法动态规划学习路线
动态规划专题导读
很多同学一看到"动态规划"四个字就头皮发麻,其实 DP 没有想象中那么玄学。它本质上就是一种用空间换时间的递推思想:把大问题拆成小问题,把小问题的答案存起来,避免重复计算。这篇导读会帮你建立动规的整体认知,后续文章再逐个模块击破。
一、什么时候该用 DP?
不是所有题目都要上 DP,遇到下面三个信号,基本可以确定往动规方向想:
- 求最值:问"最多/最少/最长/最大"能是多少,例如最少硬币数、最长子序列。
- 求方案数:问"有多少种方法/多少条路径",例如爬楼梯的不同走法。
- 后面状态依赖前面状态:当前决策会受之前选择的影响,且过程中有重叠子问题(同一个小问题会被反复计算)。
反过来的信号也要记住:如果题目只要求输出具体方案本身(比如把每条路径都列出来),DP 存的只是数量,通常得老老实实回溯;如果每一步局部最优能直接推出全局最优且无需反悔,那是贪心的地盘。
再提两个术语:
- 最优子结构:大问题的最优解可以由小问题的最优解推导出来。这是 DP 能工作的前提。
- 重叠子问题:递归展开后同一个子问题出现多次。这是 DP 比暴力递归快的原因。
二、解题五部曲
做动规题最忌讳上来就写代码。建议固定走五步,每一步都问自己一句话:
| 步骤 | 要回答的问题 | 以 70. 爬楼梯为例 |
|---|---|---|
| 1. 状态定义 | dp[i] 的下标和值分别代表什么? |
dp[i]:爬到第 i 阶的方法数 |
| 2. 递推公式 | dp[i] 从哪些更小的状态推来? |
第 i 阶只能从 i-1 跨 1 步或 i-2 跨 2 步上来:dp[i] = dp[i-1] + dp[i-2] |
| 3. 初始化 | 哪些状态推不出来,必须直接给值? | dp[0] = 1,dp[1] = 1(递推的起点) |
| 4. 遍历顺序 | 保证算 dp[i] 时依赖项已就绪 |
从小到大:for i in 2..n |
| 5. 打印调试 | 答案不对时,把 dp 数组打出来对照 | 逐项检查 dp 是从哪一项开始偏离预期 |
五部曲中最容易被轻视的是第 3 步和第 4 步。初始化错一位、遍历方向反了,代码看起来"完全正确"却输出诡异结果——这时候第 5 步打印 dp 数组是最快的排错手段,比盯着代码干瞪眼高效得多。
用 Java 把爬楼梯写完整:
class Solution {
public int climbStairs(int n) {
if (n <= 2) return n;
// 1. dp[i] 表示爬到第 i 阶的方法数
int[] dp = new int[n + 1];
// 3. 初始化递推起点
dp[1] = 1;
dp[2] = 2;
// 4. 从小到大,保证 dp[i-1]、dp[i-2] 已算好
for (int i = 3; i <= n; i++) {
// 2. 递推公式
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}三、DP、记忆化递归、贪心怎么选?
这三种思想经常被混为一谈,用一张表说清它们的分工:
| 维度 | 记忆化递归(自顶向下) | 动态规划(自底向上) | 贪心 |
|---|---|---|---|
| 思路 | 大问题拆小问题,递归 + 缓存 | 从最小子问题递推到大问题 | 每步取局部最优 |
| 实现 | 递归 + memo 数组 | 迭代 + dp 数组 | 通常排序或扫描 |
| 空间 | 递归栈 + 缓存 | 只需 dp 表(可滚动压缩) | 通常 O(1) |
| 风险 | 栈溢出、重复状态难察觉 | 遍历顺序易写错 | 局部最优不保证全局最优 |
| 适用 | 状态转移是树形/图状、状态稀疏 | 状态密集、依赖关系规整 | 能证明贪心策略成立 |
三者可以互相转化:把记忆化递归的递归栈展开就是 DP;如果贪心策略恰好能被证明正确(如跳跃游戏),它比 DP 更快。拿不准时优先写 DP,它是三者中最通用、最不容易出反例的。
四、本专题地图
接下来几篇会按下面的顺序推进,每个模块的侧重点不同:
- 背包问题:01 背包与完全背包的遍历顺序之辨(倒序 vs 正序),以及"组合数 vs 排列数"的双层循环嵌套顺序——背包是半个动规的地基。
- 打家劫舍与股票:状态机 DP 的两个招牌。打家劫舍从线性走到环形再到树形;股票系列把"持有/不持有"两状态扩展出交易次数、冷冻期等维度。
- 子序列与编辑距离:单串(LIS)、双串(LCS)、编辑距离的二维 dp 表画法,以及区间 DP 入门(回文子串)。
五、LeetCode 题单总表
按模块分块列出,建议做完一块再进下一块:
入门热身(体会五部曲)
| 题号 | 题名 | 一句话考点 |
|---|---|---|
| 509 | 斐波那契数 | 最原始的递推,五部曲走一遍流程 |
| 70 | 爬楼梯 | 递推公式就是变形斐波那契 |
| 746 | 使用最小花费爬楼梯 | 从"方案数"过渡到"求最值" |
| 62 | 不同路径 | 二维 dp 的初始化与遍历 |
| 63 | 不同路径 II | 二维 dp 遇到障碍物如何跳过 |
| 343 | 整数拆分 | 递推要在多个拆分方案中取最大 |
| 96 | 不同的二叉搜索树 | 枚举左右子树规模的乘积求和 |
背包问题
| 题号 | 题名 | 一句话考点 |
|---|---|---|
| 416 | 分割等和子集 | 01 背包的"能否恰好装满"判定 |
| 1049 | 最后一块石头的重量 II | 把对半分的思路转成 01 背包 |
| 494 | 目标和 | 加减号转化为子集和的计数 |
| 474 | 一和零 | 二维费用的 01 背包 |
| 518 | 零钱兑换 II | 完全背包求组合数,外物内背 |
| 377 | 组合总和 IV | 完全背包求排列数,外背内物 |
| 322 | 零钱兑换 | 完全背包求最少硬币数 |
| 279 | 完全平方数 | 物品是平方数的完全背包 |
| 139 | 单词拆分 | 完全背包思路判断可达性 |
打家劫舍与股票
| 题号 | 题名 | 一句话考点 |
|---|---|---|
| 198 | 打家劫舍 | 相邻不能偷的线性状态机 |
| 213 | 打家劫舍 II | 环形拆成两条链取最大 |
| 337 | 打家劫舍 III | 树形 DP,偷/不偷两个状态后序遍历 |
| 121 | 买卖股票的最佳时机 | 只能交易一次的最简状态机 |
| 122 | 买卖股票的最佳时机 II | 可多次交易的两状态递推 |
| 123 | 买卖股票的最佳时机 III | 限定 2 次交易的状态维度扩展 |
| 188 | 买卖股票的最佳时机 IV | 把 2 次推广到 k 次交易 |
| 309 | 最佳买卖股票时机含冷冻期 | 卖出后多一个冷冻状态 |
| 714 | 买卖股票的最佳时机含手续费 | 交易时扣手续费的变形 |
子序列与编辑距离
| 题号 | 题名 | 一句话考点 |
|---|---|---|
| 300 | 最长递增子序列 | O(n²) DP 与贪心+二分双解法 |
| 674 | 最长连续递增序列 | 连续版只需和前一个比 |
| 718 | 最长重复子数组 | 双串 DP 求公共连续子数组 |
| 1143 | 最长公共子序列 | 双串 DP 的经典模板 |
| 1035 | 不相交的线 | 题面换皮,本质就是 LCS |
| 53 | 最大子序和 | 一维 dp + 负数直接重开的贪心味 |
| 392 | 判断子序列 | LCS 思想的简化版 |
| 115 | 不同的子序列 | 删串计数,二维 DP 难题 |
| 583 | 两个字符串的删除操作 | 编辑距离的简化版 |
| 72 | 编辑距离 | 增删改三操作的状态转移 |
| 647 | 回文子串 | 区间 DP 统计回文个数 |
| 516 | 最长回文子序列 | 区间 DP 求最长回文 |
六、学习建议
- 每题先自己写五部曲的前两步(状态定义 + 递推公式),卡住了再看题解,看完必须合上题解重写一遍。
- 背包部分务必亲手画一维滚动数组的更新表格,倒序遍历不亲手推一遍永远记不牢。
- 股票系列建议把 121 到 309 的状态转移表放在一张纸上对比,状态机的"加维度"套路一眼就通了。
- 二维 DP(LCS、编辑距离、回文)要练到能徒手画出 dp 表并填出前几行。
下一篇我们从背包问题开始,聊聊为什么容量必须倒序遍历这个经典细节。