【Hot100】10.动态规划
这一类的核心思想是:将复杂问题分解为重叠子问题,通过记录子问题的解(状态)来避免重复计算。
- 解题四步曲:
- 定义状态 (
dp数组):dp[i]代表什么含义?(例如:爬到第i阶的方法数、前i个元素的最大和)。 - 状态转移方程:如何从已知状态推导出未知状态?(例如:
dp[i] = dp[i-1] + dp[i-2])。 - 初始化 (Base Case):最简单的情况是什么?(例如:
dp[0]=1,dp[1]=1)。 - 遍历顺序:从小到大?还是其他顺序?确保计算
dp[i]时,所需的前置状态已计算完毕。
- 定义状态 (
- 优化技巧:
- 空间压缩:如果
dp[i]只依赖前几个状态,可以用变量代替数组(如爬楼梯)。 - 多状态 DP:当单一状态不足以描述情境时(如股票问题),需要定义多个状态数组(持有/不持有/冷冻期)。
- 空间压缩:如果
📝 本类题型总结
| 题目 | 核心技巧 | 关键差异点 (vs 爬楼梯) |
|---|---|---|
| 70. 爬楼梯 | 线性递推 | 基准:dp[i] = dp[i-1] + dp[i-2]。 |
| 198. 打家劫舍 | 间隔选择 | dp[i] = max(dp[i-1], dp[i-2] + nums[i])。选或不选。 |
| 121. 买卖股票 | 极值记录 | 记录 minPrice,profit = price - min。非典型递推。 |
| 152. 乘积最大 | 双状态维护 | 需同时维护 max 和 min,防止负数翻转。 |
| 300. LIS | 双重循环 | dp[i] 依赖所有 j<i 且 nums[j]<nums[i]。$O(N^2)$。 |
| 322. 零钱兑换 | 完全背包 | dp[i] = min(dp[i], dp[i-coin] + 1)。求最小值。 |
| 118. 杨辉三角 | 二维递推 | dp[i][j] = dp[i-1][j-1] + dp[i-1][j]。 |
🏆 母题:LeetCode 70. 爬楼梯 (Climbing Stairs)
题目内容:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
核心逻辑:
- 状态定义:
dp[i]表示爬到第i阶的方法数。 - 转移方程:要到第
i阶,只能从i-1爬 1 步,或从i-2爬 2 步。即dp[i] = dp[i-1] + dp[i-2]。 - 空间优化:只需要前两个状态,用
a和b滚动更新即可。
class Solution {
public int climbStairs(int n) {
if (n <= 2) return n;
// ⚠️ [空间优化] 用两个变量代替 dp 数组
int a = 1; // 代表 dp[i-2]
int b = 2; // 代表 dp[i-1]
for (int i = 3; i <= n; i++) {
// ⚠️ [核心逻辑] 状态转移:当前 = 前一步 + 前两步
int temp = a + b;
a = b; // 更新 dp[i-2] 为原来的 dp[i-1]
b = temp; // 更新 dp[i-1] 为当前的 dp[i]
}
return b;
}
}
🔄 变体 1:LeetCode 198. 打家劫舍 (House Robber)
题目内容:你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,但相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
相比母题的变化:
- 状态定义:
dp[i]表示偷窃到第i个房屋(不一定偷第i个)时的最大金额。 - 转移方程:
- 偷第
i个:则不能偷i-1,金额为dp[i-2] + nums[i]。 - 不偷第
i个:金额为dp[i-1]。 - 取最大值:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])。
- 偷第
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0) return 0;
if (nums.length == 1) return nums[0];
int a = nums[0]; // dp[i-2]
int b = Math.max(nums[0], nums[1]); // dp[i-1]
for (int i = 2; i < nums.length; i++) {
// ⚠️ [变化点] 转移方程:max(不偷当前, 偷当前+前前个)
int temp = Math.max(b, a + nums[i]);
a = b;
b = temp;
}
return b;
}
}
🔄 变体 2:LeetCode 121. 买卖股票的最佳时机 (Best Time to Buy and Sell Stock)
题目内容:给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
相比母题的变化:
- 思维转换:这不是典型的
dp[i]依赖dp[i-1]的递推,而是贪心 + 状态记录。 - 核心逻辑:
- 维护一个
minPrice:记录历史最低价格。 - 每天计算:
profit = price - minPrice。 - 更新
maxProfit。 - 本质上是 DP 的状态压缩:
dp[i](第 i 天卖出的最大利润) =prices[i] - min(prices[0...i-1])。
- 维护一个
class Solution {
public int maxProfit(int[] prices) {
if (prices == null || prices.length == 0) return 0;
int minPrice = prices[0];
int maxProfit = 0;
for (int i = 1; i < prices.length; i++) {
// ⚠️ [变化点 1] 更新历史最低价
if (prices[i] < minPrice) {
minPrice = prices[i];
}
// ⚠️ [变化点 2] 计算当前卖出的利润,并更新最大利润
else if (prices[i] - minPrice > maxProfit) {
maxProfit = prices[i] - minPrice;
}
}
return maxProfit;
}
}
🔄 变体 3:LeetCode 152. 乘积最大子数组 (Maximum Product Subarray)
题目内容:给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
相比母题的变化:
- 难点:负数的存在。负数乘以负数会变成正数(最大)。
- 双状态 DP:
maxDP[i]:以i结尾的最大乘积。minDP[i]:以i结尾的最小乘积(可能是很大的负数,等待下一个负数翻身)。
- 转移方程:
maxDP[i] = max(nums[i], maxDP[i-1]*nums[i], minDP[i-1]*nums[i])minDP[i] = min(nums[i], maxDP[i-1]*nums[i], minDP[i-1]*nums[i])
class Solution {
public int maxProduct(int[] nums) {
int maxSoFar = nums[0];
int currentMax = nums[0];
int currentMin = nums[0];
for (int i = 1; i < nums.length; i++) {
int tempMax = currentMax;
int tempMin = currentMin;
// ⚠️ [变化点] 同时更新最大值和最小值
// 当前最大值可能是:当前数本身、前最大*当前、前最小*当前(负负得正)
currentMax = Math.max(nums[i], Math.max(tempMax * nums[i], tempMin * nums[i]));
currentMin = Math.min(nums[i], Math.min(tempMax * nums[i], tempMin * nums[i]));
maxSoFar = Math.max(maxSoFar, currentMax);
}
return maxSoFar;
}
}
🔄 变体 4:LeetCode 300. 最长递增子序列 (Longest Increasing Subsequence)
题目内容:给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
相比母题的变化:
- 定义变化:
dp[i]表示以nums[i]结尾的最长递增子序列长度。 - 转移方程:需要遍历
j < i的所有情况。- 如果
nums[j] < nums[i],则dp[i] = max(dp[i], dp[j] + 1)。
- 如果
- 复杂度:$O(N^2)$。(注:最优解可用二分查找优化到 $O(N \log N)$,但 DP 思路更基础)。
class Solution {
public int lengthOfLIS(int[] nums) {
if (nums.length == 0) return 0;
int[] dp = new int[nums.length];
// ⚠️ [初始化] 每个位置至少长度为 1 (自己)
java.util.Arrays.fill(dp, 1);
int maxLen = 1;
for (int i = 1; i < nums.length; i++) {
// ⚠️ [变化点] 内层循环检查所有前面的元素
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
}
🔄 变体 5:LeetCode 322. 零钱兑换 (Coin Change)
题目内容:给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1。
相比母题的变化:
- 模型:完全背包问题(硬币可以无限使用)。
- 状态定义:
dp[i]表示凑成金额i所需的最少硬币数。 - 初始化:
dp[0] = 0,其他初始化为amount + 1(代表无穷大)。 - 转移方程:
dp[i] = min(dp[i], dp[i - coin] + 1)。
import java.util.Arrays;
class Solution {
public int coinChange(int[] coins, int amount) {
// ⚠️ [初始化] 最大值设为 amount + 1,因为最多也就 amount 个 1 元硬币
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i - coin >= 0) {
// ⚠️ [变化点] 状态转移:取最小值
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
}
🔄 变体 6:LeetCode 118. 杨辉三角 (Pascal's Triangle)
题目内容:给定一个非负整数 numRows,生成「杨辉三角」的前 numRows 行。
相比母题的变化:
- 二维 DP:
dp[i][j]表示第i行第j列的值。 - 转移方程:每个数等于它上方两数之和。
dp[i][j] = dp[i-1][j-1] + dp[i-1][j]。 - 边界处理:每行的第一个和最后一个元素都是 1。
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<List<Integer>> generate(int numRows) {
List<List<Integer>> triangle = new ArrayList<>();
for (int i = 0; i < numRows; i++) {
List<Integer> row = new ArrayList<>();
// ⚠️ [变化点] 初始化当前行,先填满 1
for (int j = 0; j <= i; j++) {
row.add(1);
}
// ⚠️ [变化点] 更新中间的值 (除了首尾)
for (int j = 1; j < i; j++) {
// 状态转移:上一行的左上方 + 上一行的正上方
int val = triangle.get(i - 1).get(j - 1) + triangle.get(i - 1).get(j);
row.set(j, val);
}
triangle.add(row);
}
return triangle;
}
}

浙公网安备 33010602011771号