题目1:爬楼梯(LeetCode 70)
问题描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例:
输入: n = 3
输出: 3
解释: 有三种方法可以爬到楼顶
1. 1阶 + 1阶 + 1阶
2. 1阶 + 2阶
3. 2阶 + 1阶
解题思路
核心思想: 动态规划(斐波那契数列)
-
到达第
n阶的方法数 = 到达第n-1阶的方法数 + 到达第n-2阶的方法数 -
因为最后一步要么走1阶,要么走2阶
-
状态转移方程:
dp[n] = dp[n-1] + dp[n-2] -
初始条件:
dp[1] = 1(走1阶),dp[2] = 2(1+1 或 2)
Java代码(带详细注释)
java
/**
* LeetCode 70. 爬楼梯
* 难度:简单
* 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
* 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
*/
public class ClimbingStairs {
/**
* 解法1:动态规划(使用数组)
* 时间复杂度:O(n),空间复杂度:O(n)
*/
public int climbStairs(int n) {
// 边界检查
if (n <= 2) {
return n;
}
// dp[i] 表示到达第 i 阶的方法数
int[] dp = new int[n + 1];
dp[1] = 1; // 1阶:只有1种方法(走1步)
dp[2] = 2; // 2阶:有2种方法(1+1 或 2)
// 从第3阶开始计算
for (int i = 3; i <= n; i++) {
// 到达第i阶的方法 = 到达第i-1阶的方法 + 到达第i-2阶的方法
// 因为最后一步可以是1阶或2阶
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
/**
* 解法2:滚动数组优化(空间优化)
* 时间复杂度:O(n),空间复杂度:O(1)
* 因为 dp[i] 只依赖于 dp[i-1] 和 dp[i-2],所以只需要两个变量
*/
public int climbStairsOptimized(int n) {
if (n <= 2) {
return n;
}
int prev2 = 1; // dp[i-2],初始为dp[1]
int prev1 = 2; // dp[i-1],初始为dp[2]
int current = 0;
for (int i = 3; i <= n; i++) {
current = prev1 + prev2; // dp[i] = dp[i-1] + dp[i-2]
prev2 = prev1; // 滚动:dp[i-2] = dp[i-1]
prev1 = current; // 滚动:dp[i-1] = dp[i]
}
return current;
}
}
流程图(Mermaid)
执行过程图解:
n = 5
初始化:
dp[1] = 1 → 第1阶:1种方法
dp[2] = 2 → 第2阶:2种方法
计算:
i=3: dp[3] = dp[2] + dp[1] = 2 + 1 = 3
方法:111, 12, 21
i=4: dp[4] = dp[3] + dp[2] = 3 + 2 = 5
方法:1111, 112, 121, 211, 22
i=5: dp[5] = dp[4] + dp[3] = 5 + 3 = 8
方法:8种
最终答案: 8 ✓
题目2:杨辉三角(LeetCode 118)
问题描述
给定一个非负整数 numRows,生成杨辉三角的前 numRows 行。
示例:
输入: numRows = 5
输出:
[
[1],
[1,1],
[1,2,1],
[1,3,3,1],
[1,4,6,4,1]
]
解题思路
核心思想: 动态规划(逐行构建)
-
每一行的第一个和最后一个元素都是 1
-
中间元素 = 上一行相邻两个元素之和
-
状态转移:
dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
Java代码(带详细注释)
java
import java.util.ArrayList;
import java.util.List;
/**
* LeetCode 118. 杨辉三角
* 难度:简单
* 给定一个非负整数 numRows,生成杨辉三角的前 numRows 行。
*/
public class PascalsTriangle {
public List<List<Integer>> generate(int numRows) {
// 创建结果列表
List<List<Integer>> result = new ArrayList<>();
// 边界检查
if (numRows <= 0) {
return result;
}
// 逐行构建
for (int i = 0; i < numRows; i++) {
// 创建当前行
List<Integer> row = new ArrayList<>();
// 每一行的第一个元素是 1
row.add(1);
// 计算中间元素(从第2行开始)
if (i > 0) {
// 获取上一行
List<Integer> prevRow = result.get(i - 1);
// 中间元素 = 上一行相邻两个元素之和
for (int j = 1; j < i; j++) {
int sum = prevRow.get(j - 1) + prevRow.get(j);
row.add(sum);
}
}
// 每一行的最后一个元素是 1(第1行只有一个1)
if (i > 0) {
row.add(1);
}
// 将当前行加入结果
result.add(row);
}
return result;
}
}
流程图(Mermaid)
执行过程图解:
numRows = 5
第1行 (i=0):
row = [1]
result = [[1]]
第2行 (i=1):
row = [1]
prevRow = [1]
中间元素: j从1到0,没有中间元素
row.add(1) → row = [1, 1]
result = [[1], [1, 1]]
第3行 (i=2):
row = [1]
prevRow = [1, 1]
中间元素:
j=1: sum = prevRow[0] + prevRow[1] = 1 + 1 = 2
row = [1, 2]
row.add(1) → row = [1, 2, 1]
result = [[1], [1, 1], [1, 2, 1]]
第4行 (i=3):
row = [1]
prevRow = [1, 2, 1]
中间元素:
j=1: sum = prevRow[0] + prevRow[1] = 1 + 2 = 3
j=2: sum = prevRow[1] + prevRow[2] = 2 + 1 = 3
row = [1, 3, 3]
row.add(1) → row = [1, 3, 3, 1]
result = [[1], [1, 1], [1, 2, 1], [1, 3, 3, 1]]
第5行 (i=4):
row = [1]
prevRow = [1, 3, 3, 1]
中间元素:
j=1: sum = 1 + 3 = 4
j=2: sum = 3 + 3 = 6
j=3: sum = 3 + 1 = 4
row = [1, 4, 6, 4]
row.add(1) → row = [1, 4, 6, 4, 1]
result = [[1], [1, 1], [1, 2, 1], [1, 3, 3, 1], [1, 4, 6, 4, 1]]
最终结果: ✓
杨辉三角的数学性质:
1 ← 第0行 (2^0)
1 1 ← 第1行 (2^1)
1 2 1 ← 第2行 (2^2)
1 3 3 1 ← 第3行 (2^3)
1 4 6 4 1 ← 第4行 (2^4)
1. 每行数字之和 = 2^(行号)
2. 每行是 (a+b)^n 的展开系数
3. 每个数 = 组合数 C(n, k)
题目3:打家劫舍(LeetCode 198)
问题描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金。相邻的房屋不能同时偷窃 ,否则会触发报警。给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报的情况下,一夜之内能够偷窃到的最高金额。
示例:
输入: [1, 2, 3, 1]
输出: 4
解释: 偷窃 1号房屋 (金额 = 1) + 3号房屋 (金额 = 3) = 4
不能偷窃 2号 (2) + 3号 (3),因为相邻
解题思路
核心思想: 动态规划
-
状态定义:
dp[i]表示偷窃前i个房屋能获得的最大金额 -
状态转移:对于第
i个房屋,有两种选择:-
不偷 :
dp[i-1](和偷前 i-1 个一样) -
偷 :
dp[i-2] + nums[i-1](偷当前 + 前 i-2 个的最优值)
-
-
状态转移方程:
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
Java代码(带详细注释)
java
/**
* LeetCode 198. 打家劫舍
* 难度:中等
* 你是一个专业的小偷,计划偷窃沿街的房屋。相邻的房屋不能同时偷窃。
* 计算不触动警报的情况下,能够偷窃到的最高金额。
*/
public class HouseRobber {
/**
* 解法1:动态规划(使用数组)
* 时间复杂度:O(n),空间复杂度:O(n)
*/
public int rob(int[] nums) {
// 边界检查
if (nums == null || nums.length == 0) {
return 0;
}
if (nums.length == 1) {
return nums[0];
}
int n = nums.length;
// dp[i] 表示偷窃前 i 个房屋的最大金额
int[] dp = new int[n + 1];
dp[0] = 0; // 没有房屋,金额为0
dp[1] = nums[0]; // 只有1个房屋,只能偷它
// 从第2个房屋开始计算
for (int i = 2; i <= n; i++) {
// 对于第i个房屋(索引i-1):
// 1. 不偷:dp[i-1]
// 2. 偷:dp[i-2] + nums[i-1]
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}
return dp[n];
}
/**
* 解法2:滚动数组优化(空间优化)
* 时间复杂度:O(n),空间复杂度:O(1)
*/
public int robOptimized(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
if (nums.length == 1) {
return nums[0];
}
int prev2 = 0; // dp[i-2],初始为dp[0]
int prev1 = nums[0]; // dp[i-1],初始为dp[1]
int current = 0;
for (int i = 2; i <= nums.length; i++) {
// dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
current = Math.max(prev1, prev2 + nums[i - 1]);
prev2 = prev1;
prev1 = current;
}
return prev1;
}
}
流程图(Mermaid)
执行过程图解:
nums = [1, 2, 3, 1]
初始化:
dp[0] = 0 → 没有房屋
dp[1] = 1 → 只偷第1个房屋
计算:
i=2(偷前2个房屋):
不偷第2个:dp[1] = 1
偷第2个:dp[0] + nums[1] = 0 + 2 = 2
dp[2] = max(1, 2) = 2
决策:偷第2个(2)
i=3(偷前3个房屋):
不偷第3个:dp[2] = 2
偷第3个:dp[1] + nums[2] = 1 + 3 = 4
dp[3] = max(2, 4) = 4
决策:偷第1个+第3个(1+3=4)
i=4(偷前4个房屋):
不偷第4个:dp[3] = 4
偷第4个:dp[2] + nums[3] = 2 + 1 = 3
dp[4] = max(4, 3) = 4
决策:偷第1个+第3个(1+3=4)
最终答案: 4 ✓
决策过程可视化:
房屋: [1, 2, 3, 1]
↓ ↓ ↓ ↓
方案1: 偷 1, 3 → 1 + 3 = 4 ✓
方案2: 偷 2, 4 → 2 + 1 = 3
方案3: 偷 1, 4 → 1 + 1 = 2
方案4: 偷 2 → 2
方案5: 偷 3 → 3
最优: 4 ✓
问题1:为什么是 new int[n + 1],不能是 new int[n]?
答: 因为 dp[i] 表示偷窃前 i 个 房屋的最大金额,i 的范围是 0 到 n。
核心理解:
java
int n = nums.length; // 房屋数量
// dp[i] 表示:偷窃前 i 个房屋(索引 0 到 i-1)的最大金额
int[] dp = new int[n + 1];
// 索引范围:0, 1, 2, ..., n
// ↑ ↑
// 没有房屋 所有房屋
// 如果使用 new int[n]:
int[] dp = new int[n];
// 索引范围:0, 1, 2, ..., n-1
// 问题:dp[n] 表示偷窃所有房屋,但 n 超出了范围!
详细对比:
| 房屋数 | 数组长度 | 可用索引 | 能否表示"所有房屋" |
|---|---|---|---|
n = 3 |
n = 3 |
0, 1, 2 | ❌ 不能(缺少索引3) |
n = 3 |
n+1 = 4 |
0, 1, 2, 3 | ✅ 可以(索引3) |
为什么需要 dp[0] = 0?
java
// dp[0] 表示"没有房屋"的情况
dp[0] = 0; // 没有房屋可偷,金额为0
// dp[1] 表示"只有第1个房屋"的情况
dp[1] = nums[0]; // 只能偷这一个
// 这样定义的好处:
// 当 i=2 时,dp[2] = max(dp[1], dp[0] + nums[1])
// ↑ ↑
// 不偷第2个 偷第2个
如果使用 new int[n],问题出在哪?
java
// 错误示例:使用 new int[n]
int[] dp = new int[n]; // 假设 n=3,索引 0, 1, 2
dp[0] = 0; // 没有房屋?但索引0表示第1个房屋,冲突!
dp[1] = nums[0]; // 索引1表示第2个房屋?混乱!
// 问题:索引含义不清晰
// dp[0] 到底表示"没有房屋"还是"第1个房屋"?
// dp[2] 表示"第3个房屋"还是"前3个房屋"?
// 会导致代码混乱和错误!
问题2:会不会有两个很大的数,但是不能同时选,跳过两个房子就能选了,并且是最优解?
答: 这个问题太棒了!这正好是 DP 设计的核心 ------ DP 自动处理了这种"隔两个房子"的情况!
假设场景:
房屋: [5, 1, 1, 5]
索引: 0 1 2 3
不能偷相邻:
- 选择 5(0) + 5(3) = 10 ✓(隔了两个房屋)
- 选择 5(0) + 1(2) = 6
- 选择 1(1) + 5(3) = 6
- 选择 5(0) + 1(1) = ❌ 相邻
- 选择 1(2) + 5(3) = ❌ 相邻
最优解:10(偷索引0和索引3)
DP 如何自动处理这种情况?
nums = [5, 1, 1, 5]
n = 4
dp[0] = 0
dp[1] = 5 // 偷第1个:5
i=2:
不偷第2个:dp[1] = 5
偷第2个:dp[0] + nums[1] = 0 + 1 = 1
dp[2] = max(5, 1) = 5 // 决策:不偷第2个
i=3:
不偷第3个:dp[2] = 5
偷第3个:dp[1] + nums[2] = 5 + 1 = 6 ← 注意!这里跳到 dp[1]
dp[3] = max(5, 6) = 6 // 决策:偷第1个 + 第3个(5+1=6)
i=4:
不偷第4个:dp[3] = 6
偷第4个:dp[2] + nums[3] = 5 + 5 = 10 ← 关键!跳到 dp[2]
dp[4] = max(6, 10) = 10 // 决策:偷第1个 + 第4个(5+5=10)
最终答案:10 ✓
关键点:dp[i-2] 跳过了两个房屋!
当计算 dp[4] 时:
偷第4个房屋 → dp[2] + nums[3]
↑
前2个房屋(索引0,1)的最优解
dp[2] = 5 意味着:偷索引0(5)的决策
所以:dp[2] + nums[3] = 5 + 5 = 10
= 偷第1个房屋(5) + 偷第4个房屋(5)
= 跳过了第2、3个房屋!✓
图解跳两个房屋:
房屋: [5, 1, 1, 5]
dp[4] 的计算路径:
dp[4] = max(不偷4, 偷4)
= max(dp[3], dp[2] + 5)
↑ ↑
不偷4 偷4时要跳过一个
dp[2] 的计算路径:
dp[2] = max(不偷2, 偷2)
= max(dp[1], dp[0] + 1)
↑ ↑
不偷2 偷2时要跳过一个
dp[1] = 5(偷第1个)
完整决策链:
偷第4个(5) ← dp[2] ← 不偷第2个 ← dp[1] ← 偷第1个(5)
↑
跳过了第2个
但第3个呢?从 dp[2] 到 dp[4] 跳过了第3个!
结果:偷第1个(5) + 偷第4个(5) = 10 ✓
更多例子:
// 场景1:间隔两个房屋
nums = [100, 1, 1, 100, 100]
↑ ↑
最优方案:100 + 100 = 200
dp计算:
dp[0] = 0
dp[1] = 100
dp[2] = max(100, 1) = 100
dp[3] = max(dp[2]=100, dp[1]+1=101) = 101 // 偷0+2
dp[4] = max(dp[3]=101, dp[2]+100=200) = 200 // 偷0+4 ✓
// 场景2:间隔三个房屋
nums = [100, 1, 1, 1, 100]
↑ ↑
最优方案:100 + 100 = 200
dp计算:
dp[0] = 0
dp[1] = 100
dp[2] = 100
dp[3] = 100
dp[4] = max(dp[3]=100, dp[2]+1=101) = 101
dp[5] = max(dp[4]=101, dp[3]+100=200) = 200 // 偷0+4 ✓
总结:DP 如何自动处理"跳过两个房子":
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
↑ ↑
不偷当前 偷当前(自动跳过一个)
当偷当前房屋时,使用 dp[i-2],这意味着:
- 已经考虑了前 i-2 个房屋的最优解
- 这个最优解可能已经跳过了某些房屋
- 也可能包含了许多间隔的房屋
所以,dp 通过状态转移,自动计算了所有可能的跳过方案!
关键理解:
dp[i] 不是一个"选择方案",而是"最优金额"
dp[i] 本身已经包含了:
✅ 跳过第1个房屋
✅ 跳过第2个房屋
✅ 跳过中间某个房屋
✅ 所有可能的跳过组合的最优解
这就是动态规划的威力:将复杂问题分解为子问题,子问题的最优解自动组合成大问题的最优解!
扩展思考:为什么 DP 能处理"任意间隔"?
// 打家劫舍的本质是"不选相邻元素的最大子序列和"
房屋: [a0, a1, a2, a3, a4, a5]
dp[5] 的决策树:
dp[5]
/ \
不偷5 偷5
↓ ↓
dp[4] dp[3] + a4
/ \
不偷4 偷4
↓ ↓
dp[3] dp[2] + a3
/ \ / \
... ... ... ...
这棵树穷举了所有"不相邻"的组合!
虽然我们只计算了 dp[5],但它实际上探索了:
- 偷5 → 不能偷4 → 考虑3
- 偷5 → 不能偷4 → 不偷3 → 考虑2
- 不偷5 → 考虑4 → 偷4 → 不能偷3 → 考虑2
- ... 所有情况!
dp 通过 memoization(记忆化)避免了重复计算,但保证了考虑所有情况!