LeetCode Hot 100 题解 · 动态规划篇
- 本专题收录 LeetCode Hot 100 中所有 动态规划 相关题目。
- 每道题提供最直接、最容易理解的解题思路,包含详细注释的代码实现,方便笔试和面试复习。
📑 目录
| 题号 | 题目 | 难度 | 类型 | 状态定义 | 核心思路 |
|---|---|---|---|---|---|
| 70 | 70.爬楼梯 | 🟢简单 | 线性递推(斐波那契) | dp[i] = 到达第 i 阶的方案数 |
到第 i 阶只能从第 i-1 或 i-2 阶上来,所以 dp[i] = dp[i-1] + dp[i-2] 。三种写法:非递归 dp 数组、记忆化搜索(递归)、空间压缩滚动变量 O(1)。 |
| 118 | 118.杨辉三角 | 🟢简单 | 二维递推 | row[i] = 当前行第 i 个元素 |
每行首尾是 1,中间每个数等于上一行左右两个数之和:row[i] = pre[i-1] + pre[i],逐行生成。两种等价写法:首尾单独 add(1) / 循环内特判 `j==0 |
| 198 | 198.打家劫舍 | 🟠中等 | 选/不选决策 | 思路一 dp[i] = 偷前 i 家 的最大金额;思路二 dp[i] = 必偷第 i 家的最大金额 |
思路一:偷或不偷取大者,dp[i] = max(dp[i-1], dp[i-2]+nums[i]) (另有滚动变量 O(1) 写法);思路二:必偷 i 时只需 i-1 不偷、前面随意,dp[i] = max(dp[0..i-2]) + nums[i],维护前缀最大值。 |
| 279 | 279.完全平方数 | 🟠中等 | 完全背包(min 件数) | dp[i] = 凑成 i 的最少平方数个数 |
枚举最后用的那个平方数 j*j,剩下的 i-j*j 的答案加 1 取最小:dp[i] = min(dp[i-j*j]) + 1 。两种写法:一维 dp(正序遍历)、记忆化搜索(递归)。 |
| 322 | 322.零钱兑换 | 🟠中等 | 完全背包(min 件数) | dp[i] = 凑成金额 i 的最少硬币数 |
和 279.完全平方数 同一个模板:枚举最后选的那枚 coin,dp[i] = min(dp[i-coin]) + 1 。三种写法:一维 dp、二维 dp(状态来源更显式)、记忆化搜索(递归)。 |
| 139 | 139.单词拆分 | 🟠中等 | 区间拆分(bool) | dp[i] = s[0..i) 能否被拆分成字典单词 |
枚举分割点 j,s[0..j) 能拆且 s[j..i) 在字典里则可拆:dp[i] = dp[j] && dict.contains(s[j..i))。 |
| 300 | 300.最长递增子序列 | 🟠中等 | 子序列型 | dp[i] = 以 nums[i] 结尾的 LIS 长度 |
思路一 O(n²) DP:往前找比它小的 j,dp[i] = dp[j] + 1 取最大,答案取所有 dp[i] 最大值;思路二 O(n log n):贪心 + 二分,维护"长度为 k+1 的子序列最小末尾"数组 tail。 |
| 152 | 152.乘积最大子数组 | 🟠中等 | 以 i 结尾型(双状态) | dpmax[i] / dpmin[i] = 以 nums[i] 结尾的最大/最小乘积 |
乘积有"负负得正"特性,一个负数可能把最小值翻成最大值,所以同时维护当前最大和最小 :dpmax[i] = max(nums[i], dpmax[i-1]*nums[i], dpmin[i-1]*nums[i]),滚动变量即 O(1) 空间。 |
| 416 | 416.分割等和子集 | 🟠中等 | 01 背包(能否装满) | dp[j] = 能否从数组中选出若干数凑出和 j |
等价于能否凑成 sum/2:**`dp[j] = dp[j] |
| 32 | 32.最长有效括号 | 🔴困难 | 以 i 结尾型(本专题最难) | dp[i] = 以 s[i] 结尾的最长有效括号长度 |
思路一 DP:分 ...()(直接配对,dp[i] = dp[i-2] + 2)和 ...))(跳过前面已匹配的段 再看更前面的字符)两种情况递推;思路二栈:栈底始终存"最后一个没被匹配的 ) 的位置"作分界点,i - 栈顶 即当前有效长度。 |
背包问题模板(本专题通用骨架)
为什么先讲这个
本专题里有好几道题本质就是背包问题换皮,先把模板理清,后面具体题目就是往模板里套参数:
| 题目 | 背包类型 | "物品"是什么 | "容量"是什么 | 求什么 |
|---|---|---|---|---|
| 279.完全平方数 | 完全背包 | 平方数 1,4,9,16... |
目标数 n |
最少选几个(min 件数) |
| 322.零钱兑换 | 完全背包 | 硬币面额 | 金额 amount |
最少选几枚(min 件数) |
| 416.分割等和子集 | 01 背包 | 数组元素 | sum/2 |
能否正好装满(bool) |
三道题正好覆盖了两种背包 × 两种求值目标,是背包变体的三个经典代表。
背包问题是什么
有一批物品,每个物品有重量 w[i] 和价值 v[i],背包容量 W。在不超过容量的前提下,怎么选物品使总价值最大?核心决策就一个:每个物品"选"还是"不选"。按物品能否重复选,分成两类:
- 01 背包:每个物品只有一个,选了就不能再选。
- 完全背包:每种物品无限个,选了还能再选同一个。
01 背包模板
每个物品只有一个,选了就没了。
递归版本(记忆化搜索,自顶向下,最贴近状态定义):
java
// dp[i][j] = 前 i 个物品、容量 j 时的最大价值
int[][] dp;
int dfs(int i, int j, int[] w, int[] v) {
if (i < 0) return 0; // 一个物品都没了,价值为 0(递归边界)
if (dp[i][j] != -1) return dp[i][j];
// 决策一:不选第 i 个物品,容量不变
int res = dfs(i - 1, j, w, v);
// 决策二:选第 i 个物品,容量减 w[i],价值加 v[i]
// 注意选了之后是 i-1(这个物品不能再选了,这就是"01"的含义)
if (j >= w[i])
res = Math.max(res, dfs(i - 1, j - w[i], w, v) + v[i]);
return dp[i][j] = res;
}
非递归版本(一维滚动数组,自底向上,面试默认写法):
java
int[] dp = new int[W + 1]; // dp[j] = 容量 j 时的最大价值
for (int i = 0; i < n; i++) { // 枚举每个物品
for (int j = W; j >= w[i]; j--) { // 【关键】倒序遍历!
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
为什么倒序?算
dp[j]时要用到dp[j - w[i]],它必须是"上一轮(第i-1个物品)的值"。如果正序,dp[j-w[i]]已经被这一轮更新过,相当于同一个物品选了两次------那就变成完全背包了。倒序保证每个物品只用一次。
总结模型:当发现题目是给定一个目标值和一个数组,问选取数组中的数满足目标值之后的一些最值或者是否存在解的相关问题可以考虑01背包模型。具体方法是:
- 数组中的值是我们可以选择的东西的重量
w[i],还有一个价值数组v[i],目标值是重量W。 - 开辟
dp数组大小为 = new int选择东西数组的长度W+1并初始化,如果最后求最小你就初始化最大,如果最后求最大,你就初始化最小,一个底线:初始化必须是非法值,不能污染我们的搜索过程;【我们最后选用一维,就是 = new intW+1】 - 枚举可以选择的东西列表
dp[i][j] = 做选择(dp[i-1][j],dp[i-1][j-w[i]]+val[i]);
而化简到一维,就是:dp[i] = 做选择(dp[i],dp[i-w[i]]+value[i]),但是要考虑顺序,要保证遍历到dp[i]时dp[i-w[i]]是上一层的,所以我们需要从大到小遍历,于是模板就出来了:
java
// 最大值01背包的一个模板:
int[] dp = new int[W + 1]; // dp[j] = 容量 j 时的最大价值
for (int i = 0; i < n; i++) { // 枚举每个物品
for (int j = W; j >= w[i]; j--) { // 【关键】倒序遍历!
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
完全背包模板
每种物品无限个,选了还能再选同一个。
递归版本:
java
int [][]dp;
int dfs(int i, int j, int[] w, int[] v) {
if (i < 0) return 0;
if (dp[i][j] != -1) return dp[i][j];
// 决策一:不选第 i 个物品
int res = dfs(i - 1, j, w, v);
// 决策二:选第 i 个物品,容量减 w[i],价值加 v[i]
// 注意选了之后是 i(不是 i-1!因为还能再选同一个,这就是"完全"的含义)
if (j >= w[i])
res = Math.max(res, dfs(i, j - w[i], w, v) + v[i]);
return dp[i][j] = res;
}
非递归版本:
java
int[] dp = new int[W + 1];
for (int i = 0; i < n; i++) {
for (int j = w[i]; j <= W; j++) { // 【关键】正序遍历!
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
为什么正序?完全背包允许同一物品选多次,算
dp[j]时dp[j - w[i]]应该是"这一轮的值"(可能已经选过当前物品),正序正好满足。01 和完全的唯一区别就是内层循环方向:倒序 vs 正序。
总结模型:当发现题目是给定一个目标值和一个数组,问选取数组中的数满足目标值之后的一些最值或者是否存在解的相关问题,若物品可以重复选择,可以考虑完全背包模型。具体方法是:
- 数组中的值是我们可以选择的东西的重量
w[i],还有一个价值数组v[i],目标值是重量W。 - 开辟
dp数组大小为 = new int选择东西数组的长度W+1并初始化,如果最后求最小你就初始化最大,如果最后求最大,你就初始化最小,一个底线:初始化必须是非法值,不能污染我们的搜索过程;【我们最后选用一维,就是 = new intW+1】 - 枚举可以选择的东西列表
dp[i][j] = 做选择(dp[i-1][j],dp[i][j-w[i]]+val[i]);
而化简到一维,就是:dp[i] = 做选择(dp[i],dp[i-w[i]]+value[i]),但是要考虑顺序,要保证遍历到dp[i]时dp[i-w[i]]是本层的,所以我们需要从小到大遍历,于是模板就出来了:
java
// 最大值完全背包的一个模板:
int[] dp = new int[W + 1]; // dp[j] = 容量 j 时的最大价值
for (int i = 0; i < n; i++) { // 枚举每个物品
for (int j = w[i]; j <= W; ++j) { // 【关键】正序遍历!
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
求值目标变体
上面模板都是求"最大价值",但本专题的题不全是求 max。变体只在初始化 和转移符号 上做调整,骨架完全一样:
😐
| 求什么 | 初始化 | 转移方程 | 对应题目 |
|---|---|---|---|
| 最大价值 | dp[0]=0,其余 0 |
dp[j] = max(dp[j], dp[j-w[i]]+v[i]) |
经典背包 |
| 最少件数 | dp[0]=0,其余 MAX |
dp[j] = min(dp[j], dp[j-w[i]]+1) |
322.零钱兑换、279.完全平方数 |
| 能否装满 | dp[0]=true,其余 false |
`dp[j] = dp[j] |
记住一条:求
min初始化为MAX、求max初始化为0、求bool初始化为false,而dp[0]永远是"零状态的合法值"(0或true)。
70.爬楼梯
题目链接 :70.爬楼梯
题目描述 :假设你正在爬楼梯,每次可以爬 1 或 2 个台阶,问爬到第 n 阶有多少种不同的方法。
这题是比较思路明确的斐波那契类的问题,其实在学习组合数学的时候也应该遇到这类题【比较明确的转移关系】。
思路一:动态规划(到第 i 阶的方法 = 从 i-1 上来 + 从 i-2 上来)
核心思想:
- 到第
i阶只能从第i-1阶跨一步上来,或者从第i-2阶跨两步上来,没有别的方式。 - 所以
dp[i] = dp[i-1] + dp[i-2],本质就是斐波那契数列。边界dp[0]=1(站在地面算一种)、dp[1]=1。
时间复杂度 : O ( n ) O(n) O(n),其中 n 为台阶数。
空间复杂度 : O ( n ) O(n) O(n)
非递归写法:
java
class Solution {
int dp[]; // dp[i] -> 到达第i阶的方案数
// 或者只从从i=2开始也可以,凑dp[0]=1即可
public int climbStairs(int n) {
dp = new int[n + 1];
if (n <= 2) {
return n;
}
dp[1] = 1; // 0->1
dp[2] = 2; // 0->1->2 || 0->2
for (int i = 3; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
// private int dfs(int i) {
// if (i <= 2) {
// return i;
// }
// if (dp[i] != 0) {
// return dp[i];
// }
// return dp[i] = dfs(i - 1) + dfs(i - 2);
// }
}
递归写法:
java
class Solution {
int dp[]; // 记忆数组
public int climbStairs(int n) {
dp = new int[n + 1];
return dfs(n);
}
private int dfs(int i) {
if (i <= 2) {
return i;
}
// 已有记忆则直接返回
if (dp[i] != 0) {
return dp[i];
}
return dp[i] = dfs(i - 1) + dfs(i - 2);
}
}
空间压缩
时间复杂度 : O ( n ) O(n) O(n),其中 n 为台阶数。
空间复杂度 : O ( 1 ) O(1) O(1)(滚动数组优化,只需前两个值)。
java
class Solution {
// int dp[];
public int climbStairs(int n) {
// dp = new int[n + 1];
if (n <= 2) {
return n;
}
int p = 1;
int q = 2;
for (int i = 3; i <= n; ++i) {
// dp[i] = dp[i - 1] + dp[i - 2];
int tmp = p + q;
p = q;
q = tmp;
}
// return dp[n];
return q;
}
// private int dfs(int i) {
// if (i <= 2) {
// return i;
// }
// if (dp[i] != 0) {
// return dp[i];
// }
// return dp[i] = dfs(i - 1) + dfs(i - 2);
// }
}
118.杨辉三角
题目链接 :118.杨辉三角
题目描述 :给定一个非负整数 numRows,生成杨辉三角的前 numRows 行。杨辉三角中每个数是它左上方和右上方两个数的和。
思路一:动态规划(逐行生成,中间元素 = 上一行相邻两数之和)
核心思想:
- 每行首尾都是
1,中间的每个数等于上一行左右两个数之和:row[i] = pre[i-1] + pre[i]。 - 第
i行有i个元素(从1开始计数,若从0开始计数,则第i行有i+1个元素),逐行往下生成即可。
时间复杂度 : O ( n 2 ) O(n^2) O(n2),其中 n 为行数,生成的元素总数为 n ( n + 1 ) / 2 n(n+1)/2 n(n+1)/2。
空间复杂度 : O ( 1 ) O(1) O(1)(返回数组不计入额外空间)。
java
class Solution {
public List<List<Integer>> generate(int numRows) {
List<List<Integer>> ans = new ArrayList<>(numRows);
for (int i = 0; i < numRows; ++i) {
List<Integer> curRow = new ArrayList(i + 1); // 第i层有 i+1个元素
curRow.add(1);
for (int j = 1; j < i; ++j) {
curRow.add(ans.get(i - 1).get(j - 1) + ans.get(i - 1).get(j));
}
if (i >= 1)
curRow.add(1);
ans.add(curRow);
}
return ans;
}
}
也可以在循环内特判 j == 0 || j == i 直接 add(1),把首尾和中间元素统一在一个循环里处理,逻辑等价。
或者可以在循环开头时特判是否为开头或结尾,若是直接add(1)即可。
java
class Solution {
public List<List<Integer>> generate(int numRows) {
List<List<Integer>> ans = new ArrayList<>();
for (int i = 0; i < numRows; i++) {
List<Integer> row = new ArrayList<>();
for (int j = 0; j <= i; j++) {
if (j == 0 || j == i) {
row.add(1); // 每行首尾固定为 1
} else {
// 中间元素 = 上一行左右两个相邻元素之和
row.add(ans.get(i - 1).get(j - 1) + ans.get(i - 1).get(j));
}
}
ans.add(row);
}
return ans;
}
}
198.打家劫舍
题目链接 :198.打家劫舍
题目描述 :沿街有一排房屋,每间房里有 nums[i] 的现金。相邻两间房不能同时偷(否则触发报警),问最多能偷到多少钱。
思路一:动态规划(偷或不偷,相邻约束下取大者)动态规划 dpi表示偷前i家最多可以获得的钱
核心思想:
- 到第
i家时只有两种选择:偷这家,那第i-1家不能偷,金额是dp[i-2] + nums[i];不偷这家,金额就是dp[i-1]。 dp[i] = max(dp[i-1], dp[i-2] + nums[i]),取两种选择的最大值。
时间复杂度 : O ( n ) O(n) O(n),其中 n 为房屋数量。
空间复杂度 : O ( n ) O(n) O(n)(dp数组)。
java
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0)
return 0;
if (nums.length == 1)
return nums[0];
int[] dp = new int[nums.length + 1]; // 前index间房子的最大stole的数量
dp[0] = 0;
dp[1] = nums[0];
for (int i = 2; i <= nums.length; ++i)
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
return dp[nums.length];
}
}
时间复杂度 : O ( n ) O(n) O(n),其中 n 为房屋数量。
空间复杂度 : O ( 1 ) O(1) O(1)(滚动数组优化)。
java
class Solution {
public int rob(int[] nums) {
if (nums == null || nums.length == 0)
return 0;
if (nums.length == 1)
return nums[0];
int pre = 0;
int cur = nums[0];
for (int i = 2; i <= nums.length; ++i) {
// dp[i] = Math.max(dp[i-1], dp[i-2]+nums[i-1]);
int tmp = Math.max(cur, pre + nums[i - 1]);
pre = cur;
cur = tmp;
}
return cur;
}
}
思路二:换状态定义(必偷第 i 家,维护前缀最大值)dpi表示必然偷第i家最多可以获得的钱
核心思想:dpi表示必然偷第i家时最多可以获得的钱,则转移方程需要变化。
- 最终结果 = Math.max(dpi),其实更直接来讲,一定是
dp[n-1]或dp[n-2]中的最大值。 - 必然可以想到的是
dp[i]只能由dp[i-2]转移而来,即dp[i] = dp[i-2] + nums[i],但并非如此:
看例子:
text
[2,1,1,2]
如果按照如上思路就是:dp[]: [2,1,3,3],max(3,3) = 3,但是正确答案应该是偷第一个和最后一个 2+2 = 4,
错误原因在于:可以交错,不是说dp[i]必须由dp[i-2]转移而来,也可以由dp[i-3]不偷[i-1]转移而来。
所以:dp[i]唯一的限制其实在于[i-1]处不能偷,而前面的我们管不着,所以应该是dp[i] = Math.max(dp[0],......,dp[i-2])+nums[i],于是我们维护Math.max(dp[0],......,dp[i-2])即可!
时间复杂度 : O ( n ) O(n) O(n),其中 n 为房屋数量。
空间复杂度 : O ( n ) O(n) O(n)(dp数组)。
java
class Solution {
public int rob(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
if (n == 1) return nums[0];
int[] dp = new int[n]; // dp[i] = 必须偷 i 的最大金额
dp[0] = nums[0];
dp[1] = nums[1];
int prevMax = dp[0]; // 到 i-2 为止的最大值
for (int i = 2; i < n; i++) {
dp[i] = prevMax + nums[i];
// 更新 prevMax:下一次 i+1 的 i-1 就是现在的 i-1
prevMax = Math.max(prevMax, dp[i-1]);
}
// 答案一定是最后两个的最大值
return Math.max(dp[n-1], dp[n-2]);
}
}
279.完全平方数
题目链接 :279.完全平方数
题目描述 :给你一个正整数 n,找到若干个完全平方数(如 1, 4, 9, 16, ...)使它们的和等于 n,要求平方数的个数最少,返回这个最少个数。
思路一:动态规划(完全背包,dpi = min(dpi,dpi-j\*j) + 1)
核心思想:
- 套模型:目标值为
n,可以选取的物品为0~ n \sqrt{n} n 的整数,要求结果是所用平方数的最少数量,同一个数可以选多次【完全背包】 - 要凑成
i的最少平方数个数,可以枚举最后用的那个平方数j*j(j*j <= i),剩下的i - j*j的答案加1就是候选。 dp[i] = min(dp[i],dp[i - j*j]) + 1,对所有合法的j取最小。边界dp[0] = 0(凑成 0 需要 0 个数)。
时间复杂度 : O ( n n ) O(n \sqrt{n}) O(nn ),其中 n 为目标数,内层枚举平方数最多 n \sqrt{n} n 个。
空间复杂度 : O ( n ) O(n) O(n)。
java
class Solution {
public int numSquares(int n) {
int dp[] = new int[n + 1];
int INF = n; // 最差不过 n个1
Arrays.fill(dp, INF);
dp[0] = 0; // 0 , 代特值
for (int i = 1; i * i <= n; ++i) { // 枚举每个物品
int num = i * i;
// 完全背包:正序遍历
for (int j = num; j <= n; ++j) {
dp[j] = Math.min(dp[j], dp[j - num] + 1);
}
}
return dp[n];
}
}
递归版本:
java
class Solution {
private static final int[][] memo = new int[101][10001];
static {
for (int[] row : memo) {
Arrays.fill(row, -1); // -1 表示没有计算过
}
}
private static int dfs(int i, int j) {
if (i == 0) {
return j == 0 ? 0 : Integer.MAX_VALUE;
}
if (memo[i][j] != -1) { // 之前计算过
return memo[i][j];
}
if (j < i * i) {
return memo[i][j] = dfs(i - 1, j); // 只能不选
}
return memo[i][j] = Math.min(dfs(i - 1, j), dfs(i, j - i * i) + 1); // 不选 vs 选
}
public int numSquares(int n) {
return dfs((int) Math.sqrt(n), n);
}
}
322.零钱兑换
题目链接 :322.零钱兑换
题目描述 :给你一个整数数组 coins 表示不同面额的硬币,以及一个金额 amount,计算凑成这个金额所需的最少硬币数。如果无法凑出,返回 -1。每种硬币数量不限。
思路一:动态规划(完全背包,dpi = min(dpi,dpi-coin) + 1)
核心思想:
- 套模型:目标值为
amount,可以选取的物品为coins,要求结果是凑成这个金额所需的最少硬币数,同一种金额可以选多次【完全背包】 - 和 279.完全平方数 完全同一个模板:要凑成金额
i,枚举最后选的那枚硬币coin,剩下的i - coin的答案加1就是候选。 dp[i] = min(dp[i], dp[i - coin]) + 1,对所有coin <= i取最小。边界dp[0] = 0;凑不出来的位置保持MAX_VALUE,最后判断一下。
时间复杂度 : O ( n × m ) O(n \times m) O(n×m),其中 n 为金额,m 为硬币种类数。
空间复杂度 : O ( n ) O(n) O(n)。
java
class Solution {
// 一维dp
public int coinChange(int[] coins, int amount) {
int n = coins.length;
// 无穷大取值:amount+1(最多需要amount枚1元硬币,+1确保超过最大可能值)
int INF = amount + 1;
int[] dp = new int[amount + 1];
Arrays.fill(dp, INF);
dp[0] = 0; // 金额0需0枚硬币
for (int coin : coins) { // 枚举物品
// 完全背包:正序遍历
for (int j = coin; j <= amount; ++j) {
dp[j] = Math.min(dp[j], dp[j - coin] + 1);
}
}
// 若仍为INF,说明无法凑成,返回-1
return dp[amount] == INF ? -1 : dp[amount];
}
}
一维看不清状态来源的话,把「前 i 个硬币、金额 j」显式摆成二维------转移里 dpij-num 的 i 不减一,就是"还能重复选"的直接体现:
java
class Solution {
// 二维dp
public int coinChange(int[] coins, int amount) {
int n = coins.length;
// 无穷大取值:amount+1(最多需要amount枚1元硬币,+1确保超过最大可能值)
int INF = amount + 1;
// dp[i][j] 考虑前i个物体,构成和为j的最少硬币数
int[][] dp = new int[n+1][amount+1];
// 初始化:前0个硬币的状态
for (int j =1; j<= amount; j++) {
dp[0][j] = INF; // 无硬币时,仅j=0可达,其余均不可达
}
dp[0][0] = 0; // 金额0需0枚硬币
for (int i=1;i<= n; i++) {
int num = coins[i-1];
for (int j = 0;j<= amount; j++) {
// 不选第i个硬币,继承前i-1个硬币的状态
dp[i][j] = dp[i-1][j];
// 选第i个硬币(可重复选,故用dp[i][j-num])
if (j >= num && dp[i][j-num] != INF) {
dp[i][j] = Math.min(dp[i][j], dp[i][j-num] + 1);
}
}
}
// 若仍为INF,说明无法凑成,返回-1
return dp[n][amount] == INF ? -1 : dp[n][amount];
}
}
递归版本(记忆化搜索):
java
class Solution {
public int coinChange(int[] coins, int amount) {
int n = coins.length;
int[][] memo = new int[n][amount + 1];
for (int[] row : memo) {
Arrays.fill(row, -1); // -1 表示没有计算过
}
int ans = dfs(n - 1, amount, coins, memo);
return ans < Integer.MAX_VALUE / 2 ? ans : -1;
}
private int dfs(int i, int c, int[] coins, int[][] memo) {
if (i < 0) {
return c == 0 ? 0 : Integer.MAX_VALUE / 2; // 除 2 防止下面 + 1 溢出
}
if (memo[i][c] != -1) { // 之前计算过
return memo[i][c];
}
if (c < coins[i]) { // 只能不选
return memo[i][c] = dfs(i - 1, c, coins, memo);
}
// 不选 vs 继续选
return memo[i][c] = Math.min(dfs(i - 1, c, coins, memo), dfs(i, c - coins[i], coins, memo) + 1);
}
}
139.单词拆分
题目链接 :139.单词拆分
题目描述 :给你一个字符串 s 和一个字典 wordDict(字符串列表),判断 s 能否被拆分成一个或多个字典中出现的单词。字典中的单词可以重复使用。
思路一:动态规划(dpi = dpj && s[j...i) 在字典中)
核心思想:
dp[i]表示s[0..i)能否被拆分。枚举分割点j,如果s[0..j)能拆分(dp[j]为真)且s[j..i)在字典里,那s[0..i)就能拆分。dp[i] = dp[j] && wordDict.contains(s[j..i)),只要存在一个合法的j就为真。边界dp[0] = true(空串默认可拆分)。
时间复杂度 : O ( n 2 ) O(n^2) O(n2),其中 n 为字符串长度,枚举所有 (j, i) 对,子串判断用哈希集合 O ( 1 ) O(1) O(1)。
空间复杂度 : O ( n ) O(n) O(n)。
java
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> dict = new HashSet<>(wordDict); // 哈希集合 O(1) 查询
int n = s.length();
boolean[] dp = new boolean[n + 1];
dp[0] = true; // 空串默认可拆分
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) { // 枚举分割点 j,s[j..i) 为最后一个词
if (dp[j] && dict.contains(s.substring(j, i))) {
dp[i] = true;
break; // 已找到一个合法拆分,不用再找 j
}
}
}
return dp[n];
}
}
300.最长递增子序列
题目链接 :300.最长递增子序列
题目描述 :给你一个整数数组 nums,找到其中最长严格递增子序列的长度。子序列不要求连续,但要保持元素相对顺序。
思路一:动态规划 O(n²)(dpi = max(dpj) + 1,j < i 且 numsj < numsi)
核心思想:
dp[i]表示以nums[i]结尾的最长递增子序列长度。往前找一个比nums[i]小的nums[j],接到它后面,长度就是dp[j] + 1。dp[i] = max(dp[j]) + 1,对所有j < i且nums[j] < nums[i]取最大。最后答案是所有dp[i]的最大值,因为子序列可以以任意位置结尾。
时间复杂度 : O ( n 2 ) O(n^2) O(n2),其中 n 为数组长度。
空间复杂度 : O ( n ) O(n) O(n)。
java
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // 每个元素自身就是一个长度为 1 的子序列
int ans = 1;
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1); // nums[i] 接到 dp[j] 后面
}
}
ans = Math.max(ans, dp[i]); // 子序列可以以任意位置结尾
}
return ans;
}
}
思路二:贪心 + 二分 O(n log n)(维护单调递增数组,二分找插入位置)
核心思想:
- 想让子序列尽量长,那每一步应该让末尾尽可能小------同样长度下,末尾越小,后面越容易接更大的数。
- 维护一个数组
tail,tail[k]表示长度为k+1的递增子序列的最小末尾。对每个nums[i],在tail中二分找第一个>= nums[i]的位置并替换;如果nums[i]比所有都大,就追加。最终tail的长度就是答案 (但tail本身不一定是真实的子序列)。
时间复杂度 : O ( n log n ) O(n \log n) O(nlogn),其中 n 为数组长度,每个元素做一次二分。
空间复杂度 : O ( n ) O(n) O(n)。
java
class Solution {
public int lengthOfLIS(int[] nums) {
int[] tail = new int[nums.length]; // tail[k] = 长度为 k+1 的子序列的最小末尾
int len = 0; // tail 当前有效长度
for (int num : nums) {
// 二分找第一个 >= num 的位置(左闭右开)
int l = 0, r = len;
while (l < r) {
int mid = l + (r - l) / 2;
if (tail[mid] < num) l = mid + 1;
else r = mid;
}
tail[l] = num; // 替换或追加
if (l == len) len++; // num 比所有都大,子序列长度 +1
}
return len;
}
}
152.乘积最大子数组
题目链接 :152.乘积最大子数组
题目描述 :给你一个整数数组 nums,找到一个非空子数组(连续),使该子数组中各元素的乘积最大,返回这个最大乘积。数组中包含负数。
思路一:动态规划(同时维护当前最大和最小,负数会把最小翻成最大)
核心思想:
- 乘积和求和不同:遇到负数时,最大的乘上负数反而变成最小的,最小的乘上负数反而变成最大的(负负得正)。所以不能只维护最大值,要同时维护最大和最小。
dpmax[i]表示以nums[i]结尾的最大乘积,dpmin[i]表示最小乘积。转移时三个候选取 max/min:nums[i]自己(重新开始)、dpmax[i-1] * nums[i]、dpmin[i-1] * nums[i]。dpmax[i] = max(nums[i], dpmax[i-1]*nums[i], dpmin[i-1]*nums[i]),dpmin[i]同理取 min。用滚动变量省掉数组。
时间复杂度 : O ( n ) O(n) O(n),其中 n 为数组长度。
空间复杂度 : O ( 1 ) O(1) O(1)。
java
class Solution {
public int maxProduct(int[] nums) {
int ans = nums[0];
int dpmax = nums[0], dpmin = nums[0]; // 以当前元素结尾的最大/最小乘积
for (int i = 1; i < nums.length; i++) {
// 负数会让最大变最小、最小变最大,所以三个候选取 max/min
int mx = dpmax * nums[i], mn = dpmin * nums[i];
dpmax = Math.max(nums[i], Math.max(mx, mn)); // 最大乘积
dpmin = Math.min(nums[i], Math.min(mx, mn)); // 最小乘积(留着以后遇到负数翻盘)
ans = Math.max(ans, dpmax);
}
return ans;
}
}
416.分割等和子集
题目链接 :416.分割等和子集
题目描述 :给你一个只包含正整数的非空数组 nums,判断是否可以将其分割成两个子集,使两个子集的元素和相等。
思路一:动态规划 / 01 背包(能否选出若干数凑成 sum/2)
核心思想:
- 两个子集和相等,等价于能否从数组中选出若干数,使它们的和等于总和
sum / 2(如果sum是奇数直接不可能)。 - 这就是标准 01 背包 :
dp[j]表示能否凑出和j,对每个数nums[i],倒序遍历j,dp[j] = dp[j] || dp[j - nums[i]]。 - 倒序遍历是为了保证每个数只用一次(01 背包的特性),正序就变成完全背包了。
时间复杂度 : O ( n × t a r g e t ) O(n \times target) O(n×target),其中 n 为数组长度,target = sum/2。
空间复杂度 : O ( t a r g e t ) O(target) O(target)(一维滚动)。
java
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int num : nums) sum += num;
if (sum % 2 != 0) return false; // 奇数不可能平分
int target = sum / 2;
boolean[] dp = new boolean[target + 1];
dp[0] = true; // 凑出 0 永远可行
for (int num : nums) {
for (int j = target; j >= num; j--) { // 倒序,保证每个数只用一次
dp[j] = dp[j] || dp[j - num]; // 选 num 或不选
}
}
return dp[target];
}
}
32.最长有效括号
题目链接 :32.最长有效括号
题目描述 :给你一个只包含 ( 和 ) 的字符串 s,找出最长的有效(正确配对、连续)括号子串的长度。
思路一:动态规划(dpi = 以 si 结尾的最长有效括号长度,分两种情况)
核心思想:
dp[i]表示以s[i]结尾的最长有效括号长度。只有s[i] == ')'时才可能有有效括号,s[i] == '('时dp[i] = 0。- 情况一 :
s[i-1] == '(',即...(),直接和前一个配对,dp[i] = dp[i-2] + 2。 - 情况二 :
s[i-1] == ')',即...)),需要跳过s[i-1]前面已经匹配的那段长度dp[i-1],看s[i - dp[i-1] - 1]这个位置是不是(。如果是,配对成功,dp[i] = dp[i-1] + 2 + dp[i - dp[i-1] - 2](还要加上配对位置前面那段的长度)。 - 第二种情况的关键是"跳段" :不能只看
s[i-1],要跳过i-1位置已匹配的整段,看更前面的字符。
时间复杂度 : O ( n ) O(n) O(n),其中 n 为字符串长度。
空间复杂度 : O ( n ) O(n) O(n)。
java
class Solution {
public int longestValidParentheses(String s) {
int n = s.length();
int[] dp = new int[n]; // dp[i] = 以 s[i] 结尾的最长有效括号长度
int ans = 0;
for (int i = 1; i < n; i++) {
if (s.charAt(i) == ')') {
if (s.charAt(i - 1) == '(') {
// 情况一:...() 直接配对,加上配对前那段
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
} else {
// 情况二:...)) 跳过 i-1 已匹配的段,看前面那个字符
int j = i - dp[i - 1] - 1; // 跳过已匹配段后,往前看的位置
if (j >= 0 && s.charAt(j) == '(') {
// dp[i-1] + 2 是本次配对,dp[j-1] 是 j 前面那段的长度
dp[i] = dp[i - 1] + 2 + (j >= 1 ? dp[j - 1] : 0);
}
}
ans = Math.max(ans, dp[i]);
}
// s[i] == '(' 时 dp[i] 保持 0,不可能作为有效括号结尾
}
return ans;
}
}
思路二:栈(用栈底记录"最后一个没被匹配的右括号位置")
核心思想:
- 栈里存下标。遇到
(入栈;遇到)先弹栈,如果弹完栈不空,当前有效长度就是i - 栈顶,如果弹完栈空了,说明这个)没人配对,把它的下标入栈作为新的"分界点"。 - 栈底始终保持着"最后一个没被匹配的
)的位置" ,这样每次计算有效长度时只需要i - 栈顶。
时间复杂度 : O ( n ) O(n) O(n),其中 n 为字符串长度。
空间复杂度 : O ( n ) O(n) O(n)。
java
class Solution {
public int longestValidParentheses(String s) {
Deque<Integer> stack = new ArrayDeque<>();
stack.push(-1); // 哨兵:初始分界点(哨兵之后都是有效区间)
int ans = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '(') {
stack.push(i); // 左括号下标入栈
} else {
stack.pop(); // 先弹栈(匹配左括号或弹掉分界点)
if (stack.isEmpty()) {
stack.push(i); // 栈空说明这个 ) 没人配,作为新的分界点
} else {
ans = Math.max(ans, i - stack.peek()); // 当前有效长度
}
}
}
return ans;
}
}
总结:动态规划的共性是"定义状态、找转移方程、确定边界"。70.爬楼梯 和 118.杨辉三角 是入门级递推,198.打家劫舍 是经典的"选/不选"决策,279.完全平方数 和 322.零钱兑换 是完全背包 模板(
dp[i] = min(dp[i-coin]) + 1),139.单词拆分 是区间拆分,300.最长递增子序列 有 O ( n 2 ) O(n^2) O(n2) DP 和 O ( n log n ) O(n\log n) O(nlogn) 二分两种解法,152.乘积最大子数组 因为乘积有"负负得正"要同时维护最大和最小 ,416.分割等和子集 是01 背包 (倒序保证每个数用一次),32.最长有效括号 是最难的状态定义------关键在于第二种...))情况要跳过已匹配的段 。核心一句话:先想清楚dp[i]代表什么,转移方程自然就出来了。