欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。

目录
引言
- ✅ 回溯 (Backtracking): 一种“发散”的、通过“选择-递归-撤销”来探索所有解空间的暴力美学。我们用它解决了排列和组合问题。
- ✅ 分治 (Divide and Conquer): 一种“收敛”的、通过“分而治之、合而为一”来高效解决问题的思想。我们用它掌握了面试中的常青树——二分查找。
接下来,让我们一起了解动态规划。
动态规划(Dynamic Programming,DP)
DP 的本质就是 “空间换时间” 和 “记忆化 (Memoization)” 。它专门用来解决那些包含了大量重叠子问题 (Overlapping Subproblems) 的难题。
口诀:记录过去,预见未来。
重叠子问题,一般都需要递归,DP与递归关系紧密。
LeetCode
70. 爬楼梯
简单、经典的动态规划提。
在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶?
示例: n = 3
输出: 3
解释: 有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2 阶 + 1 阶
【朴素的递归思考】
-
想爬到第 n 阶,我只有两种可能:
- 从第 n-1 阶爬 1 步上来。
- 从第 n-2 阶爬 2 步上来。
-
所以,爬到第 n 阶的方法数 f(n),就等于爬到第 n-1 阶的方法数 f(n-1) 加上爬到第 n-2 阶的方法数 f(n-2)。
-
即 f(n) = f(n-1) + f(n-2)。这不就是斐波那契数列吗!
这是,重叠子问题出现了,如果我们用纯递归来算 f(5):
- f(5) = f(4) + f(3)
- f(4) = f(3) + f(2)
- f(3) = f(2) + f(1)
显然,为了算 f(5),f(3) 被计算了两次,f(2) 被计算了三次… 当 n 很大时,这种重复计算是灾难性的。
动态规划采用“记录重复子问题”的方式解决问题,也就是拿小本本(数组或者Hash)表记录:
- 先算 f(1) = 1,记下来。
- 再算 f(2) = 2,记下来。
- 算 f(3) 时,不用再递归了,直接查小本本:f(3) = f(2) + f(1) = 2 + 1 = 3。记下来。
- 算 f(4) 时,查本本:f(4) = f(3) + f(2) = 3 + 2 = 5。记下来。
这就是动态规划。通过一个 dp 数组,自底向上地解决问题,每一步都利用了之前已经计算好的结果。
DP-五步法
几乎所有的基础 DP 问题,都可以通过下面这五个固定的步骤来求解。只要严格按照这个框架来思考,再难的 DP 问题也能被分解成一个个可以解决的小块。
以“70. 爬楼梯”为例,来走一遍这五步:
第一步:定义 dp 数组的含义
- 这是最最最重要的一步!必须清晰地定义
dp[i]代表什么。 - 对于本题: 我们可以定义
dp[i]为:爬到第 i 级台阶,总共有dp[i]种不同的方法。
第二步:找出递推公式 (状态转移方程)
- 思考
dp[i]是如何由之前的 dp 状态推导出来的。 - 对于本题: 根据我们之前的分析,要想到达第 i 级台阶,只能从第 i-1 级或第 i-2 级上来。所以,到达第 i 级的方法数,就是这两种方法的总和。
- 公式:
dp[i] = dp[i-1] + dp[i-2]
第三步:初始化 dp 数组
- 根据递推公式,我们需要为 dp 数组提供最初的“启动值”,也就是基础情况 (Base Case)。
- 对于本题:
*dp[1] = 1(爬到第1级台阶,只有一种方法:1步)
*dp[2] = 2(爬到第2级台阶,有两种方法:1+1 或 2)- 注意:有时候为了公式整齐,我们会设置
dp[0],这取决于你的定义。在这里,从1和2开始更直观。
- 注意:有时候为了公式整齐,我们会设置
第四步:确定遍历顺序
- 思考 for 循环应该从前往后,还是从后往前。
- 对于本题: 我们的递推公式
dp[i] = dp[i-1] + dp[i-2]明确告诉我们,要计算dp[i],必须先知道dp[i-1]和dp[i-2]的值。所以,遍历顺序必须是从前向后。
第五步:根据 dp 数组得出最终答案
- 题目要求的是什么?对应到 dp 数组的哪个值?
- 对于本题: 题目要求爬到第 n 阶的方法数,根据我们的定义,这正好就是
dp[n]。
代码如下:
class Solution {
public int climbStairs(int n) {
// 处理边界情况
if (n <= 2) {
return n;
}
// 1. 定义 dp 数组。大小设为 n+1 是为了让下标 i 和台阶数 i 对应起来
// dp[i] 的含义是:爬到第 i 级台阶的方法数
int[] dp = new int[n + 1];
// 3. 初始化 dp 数组的 base case
dp[1] = 1;
dp[2] = 2;
// 4. 确定遍历顺序:从前向后
// 从 3 开始,因为 1 和 2 已经初始化了
for (int i = 3; i <= n; i++) {
// 2. 运用递推公式
dp[i] = dp[i - 1] + dp[i - 2];
}
// 5. 返回最终答案
return dp[n];
}
}
我们清晰地完成了70题,但是这样的代码还有优化的空间,就是要计算 dp[i],我们实际上只需要知道它前面两个状态 (dp[i-1] 和 dp[i-2]) 的值。至于 dp[i-3], dp[i-4] … 这些更早的状态,我们根本用不上了。
我们没有必要记录用不上的数据,因此,我们可以只用两个变量来滚动地记录最新的两个状态,从而将空间复杂度从 O(n) 优化到 O(1)。这个技巧被称为 “滚动数组” 或 “状态压缩”。
最终解如下:
class Solution {
public int climbStairs(int n) {
if (n <= 2) {
return n;
}
// pre1 代表 dp[i-2] 的值,初始化为 f(1)
int pre1 = 1;
// pre2 代表 dp[i-1] 的值,初始化为 f(2)
int pre2 = 2;
// 我们已经有了 f(1) 和 f(2) 的值,
// 所以我们从 i=3 开始,一直计算到 n
for (int i = 3; i <= n; i++) {
// 1. 计算当前 dp[i] 的值
int current = pre1 + pre2;
// 2. 更新两个指针,为下一次循环做准备
// 原来的 pre2 (dp[i-1]) 变成了下一次循环的 pre1 (dp[i-2])
pre1 = pre2;
// 刚算出来的 current (dp[i]) 变成了下一次循环的 pre2 (dp[i-1])
pre2 = current;
}
// 循环结束后,pre2 里存储的就是 dp[n] 的值
return pre2;
}
}
其他练习题:
53. 最大子数组和
322. 零钱兑换
2072

被折叠的 条评论
为什么被折叠?



