LeetCode Hot 100 · 动态规划篇
动态规划是面试中区分「背题党」和「真会党」的第一道分水岭。面试官看一个人写 DP,只看四件事:状态怎么定义、转移方程怎么列、初始化对不对、遍历顺序有没有错。本篇文章把 Hot 100 里的 9 道动态规划题全部拆成这套框架,每题都从暴力写法一路推到最优解,并给出复杂度逐步推导过程和 CodeTop 真实变体。
先记住这套「DP 四步走」:
- 状态定义:
dp[i]/dp[i][j]到底表示什么?这句话一定要能一字不差说出来。 - 转移方程:当前状态由哪些更小的子问题推出来?
- 初始化:
dp[0]是多少?边界下标会不会越界? - 遍历顺序:正序还是倒序?这决定了背包问题里物品能不能重复用。
下面逐题手撕。
#70 爬楼梯
题意
爬 n 级楼梯,每次可以爬 1 级或 2 级,返回到达楼顶的不同方法数。
输入:n = 3 输出:3 (1+1+1, 1+2, 2+1)
难点与易错点
- 本质识别:这题表面是「爬楼梯」,本质是斐波那契数列。到第 n 级的方法 = 从第 n-1 级跨 1 步 + 从第 n-2 级跨 2 步,即
dp[i] = dp[i-1] + dp[i-2]。没认出这个结构,就会去写暴力递归。 dp[0]的约定:有两种初始化流派——dp[0]=1, dp[1]=1(第 0 级视为 1 种空走法),或者直接n<=2 return n。混用会得到错误答案,面试时先说清自己的约定。- 溢出:n 很大时结果远超 int 范围(本质是斐波那契,n=45 左右就逼近 int 上限)。真实 LeetCode 约束 n ≤ 45,但面试官可能追问「n 到 100 怎么办」——答:换
long或 BigInteger。
解法一:暴力 / 直观
直接递归,把「爬 n 级」分解成「爬 n-1 级」+「爬 n-2 级」两个子问题。
class Solution {
public int climbStairs(int n) {
if (n <= 2) return n;
return climbStairs(n - 1) + climbStairs(n - 2);
}
}
复杂度:每次调用分裂成两个子问题,形成一棵高度为 n 的二叉树,节点总数约 2^n,所以时间 O(2^n),递归栈深度 O(n)。
瓶颈:大量重复计算——climbStairs(n-2) 会被 climbStairs(n-1) 和 climbStairs(n-3) 反复调用,指数级冗余。
解法二:优化 / 最优
状态定义:dp[i] = 爬到第 i 级的方法数。
转移方程:dp[i] = dp[i-1] + dp[i-2](最后一步跨 1 级来自 i-1,跨 2 级来自 i-2)。
初始化:dp[0] = 1(空走法)、dp[1] = 1。实际用滚动变量:prev2 = 1(到达第 1 级)、prev1 = 2(到达第 2 级)。
遍历顺序:i 从 3 到 n 正序,滚动更新。
class Solution {
public int climbStairs(int n) {
if (n <= 2) return n;
int prev2 = 1, prev1 = 2; // 到达第 1、2 级的方法数
for (int i = 3; i <= n; i++) {
int curr = prev1 + prev2; // dp[i] = dp[i-1] + dp[i-2]
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
}
复杂度逐步推导:循环变量 i 从 3 走到 n,共执行 n-2 次,每次 O(1),所以时间 O(n-2) = O(n)。滚动变量只用 prev2、prev1 两个额外 int,不随 n 增长,空间 O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 暴力递归 | O(2^n) | O(n) | 仅用于讲清子问题结构 |
| 记忆化递归 | O(n) | O(n) | 想用递归但避免重复计算 |
| 滚动 DP | O(n) | O(1) | 面试首选,无额外空间 |
这题约束下,滚动 DP 时间 O(n) 已经是最优(至少要看一遍输入规模),空间 O(1) 也无法更省,所以选滚动 DP。
CodeTop 变体
- 746. 使用最小花费爬楼梯(真实变体,字节/美团考过):每级台阶有 cost,每次爬 1 或 2 级,问到达顶部的最小花费。转移变成
dp[i] = min(dp[i-1], dp[i-2]) + cost[i]。 - 一次最多跨 m 级(腾讯变体):
dp[i] = dp[i-1] + dp[i-2] + ... + dp[i-m],需要前缀和优化到 O(n)。 - 509. 斐波那契数(同套路):一模一样的转移,只是起点不同。
#198 打家劫舍
题意
数组 nums 代表每间房的金额,相邻的房屋不能同时被打劫,返回能偷窃到的最大金额。
输入:nums = [2,7,9,3,1] 输出:12 (2+9+1)
难点与易错点
- 相邻约束的建模:这题本质是线性 DP 滚动变量:
dp[i] = max(dp[i-1], dp[i-2] + nums[i]),即「不偷第 i 间(继承 i-1 的最优)」vs「偷第 i 间(必须跳过 i-1,加 i-2 的最优)」。漏掉「不偷」这个分支是新手最常见的错。 - 数组长度边界:
n == 1时必须直接return nums[0],否则访问nums[1]会越界。 - 初始化
dp[1]:dp[1] = max(nums[0], nums[1]),不是nums[1]——前两间只能选一间偷,要取较大者。
解法一:暴力 / 直观
每个房子两种选择(偷 / 不偷),递归枚举。
class Solution {
public int rob(int[] nums) {
return dfs(nums, nums.length - 1);
}
private int dfs(int[] nums, int i) {
if (i < 0) return 0;
// 不偷 i(看 i-1),或偷 i(跳过 i-1,看 i-2)
return Math.max(dfs(nums, i - 1), dfs(nums, i - 2) + nums[i]);
}
}
复杂度:每个状态分裂成两个子问题,树高 n,时间 O(2^n),栈空间 O(n)。
瓶颈:dfs(i-2) 被 dfs(i-1) 和 dfs(i-3) 重复调用,指数级冗余,和爬楼梯同病。
解法二:优化 / 最优
状态定义:dp[i] = 打劫前 i+1 间房(下标 0..i)能拿到的最大金额。
转移方程:dp[i] = max(dp[i-1], dp[i-2] + nums[i])。
初始化:dp[0] = nums[0],dp[1] = max(nums[0], nums[1])。滚动变量 prev2 = nums[0]、prev1 = max(nums[0], nums[1])。
遍历顺序:i 从 2 到 n-1 正序,滚动更新。
class Solution {
public int rob(int[] nums) {
int n = nums.length;
if (n == 1) return nums[0];
int prev2 = nums[0]; // dp[0]
int prev1 = Math.max(nums[0], nums[1]); // dp[1]
for (int i = 2; i < n; i++) {
int curr = Math.max(prev1, prev2 + nums[i]); // 不偷 i vs 偷 i
prev2 = prev1;
prev1 = curr;
}
return prev1;
}
}
复杂度逐步推导:循环 i 从 2 到 n-1 共 n-2 次,每次 O(1),时间 O(n)。只有 prev2、prev1 两个变量,空间 O(1)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 暴力递归 | O(2^n) | O(n) | 讲清「偷/不偷」决策树 |
| 记忆化递归 | O(n) | O(n) | 保留递归结构 |
| 滚动 DP | O(n) | O(1) | 面试首选 |
时间 O(n) 是下界(每个房子至少看一次),空间 O(1) 最优,故选滚动 DP。
CodeTop 变体
- 213. 打家劫舍 II(真实变体,字节/阿里高频):房子首尾相连成环,不能同时偷第 0 间和第 n-1 间。做法:分别计算
rob(nums[0..n-2])和rob(nums[1..n-1]),取两者最大。 - 337. 打家劫舍 III(真实变体,进阶):房子排成二叉树,不能偷直接相连的父子节点,用树形 DP(每个节点返回「偷/不偷」两个状态)。
- 面试追问:如何输出具体偷了哪几间?做法:记录转移来源,从 dp[n-1] 回溯。
#279 完全平方数
题意
给你整数 n,返回和为 n 的完全平方数(1, 4, 9, 16…)的最少数量。
输入:n = 12 输出:3 (4+4+4)
难点与易错点
- 建模成完全背包:本质是完全背包 DP(物品可重复使用),物品是平方数
1,4,9,16...,每种无限个,目标是「恰好装满容量 n 且个数最少」。转移:dp[i] = min(dp[i - j*j] + 1)。 - 初始化为 MAX_VALUE 的陷阱:
dp[0] = 0,其余Integer.MAX_VALUE。更新时若dp[i - j*j]已经是 MAX_VALUE,直接 +1 会溢出成负数,必须判断!= Integer.MAX_VALUE(或初始化成n+1这种「不可能达到的大值」)。 j*j溢出:内层判断j*j <= i,j 大到一定程度j*j会 int 溢出变负数,导致死循环或错误,需写成(long) j * j <= i。- 遍历顺序:完全背包(物品无限)用正序遍历,与 0-1 背包的倒序相反。
解法一:暴力 / 直观
DFS 回溯:每次枚举一个平方数减去,递归找最小个数。
class Solution {
public int numSquares(int n) {
if (n == 0) return 0;
int res = Integer.MAX_VALUE;
for (int j = 1; j * j <= n; j++) {
res = Math.min(res, 1 + numSquares(n - j * j));
}
return res;
}
}
复杂度:每个状态分支最多 √n 个,递归深度最坏 n(全是 1 的情况),时间指数级 O(n^(n/2)) 量级,栈空间 O(n)。
瓶颈:同一剩余值 n - j*j 被反复递归计算,存在大量重叠子问题。
解法二:优化 / 最优
状态定义:dp[i] = 凑出整数 i 所需的最少完全平方数个数。
转移方程:dp[i] = min(dp[i], dp[i - j*j] + 1),对每个满足 j*j <= i 的 j 取最小。
初始化:dp[0] = 0,dp[1..n] = Integer.MAX_VALUE(表示「尚未找到」)。
遍历顺序:外层 i 从 1 到 n 正序,内层 j 枚举平方数(正序即可,因为这是「凑金额」型完全背包,i 从小到大天然保证 dp[i - j*j] 已算出)。
class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0; // 凑出 0 需要 0 个完全平方数
for (int i = 1; i <= n; i++) {
for (int j = 1; (long) j * j <= i; j++) {
if (dp[i - j * j] != Integer.MAX_VALUE) {
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
}
return dp[n];
}
}
复杂度逐步推导:外层 i 从 1 到 n 共 n 次;内层 j 从 1 到 √i,共 √i 次。总操作数 Σ(i=1..n) √i,这个和约为 (2/3)·n·√n(把离散和近似成 ∫√x dx),所以时间 O(n√n)。dp 数组长度 n+1,空间 O(n)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| DFS 回溯 | 指数级 | O(n) | 讲清「每次减一个平方数」的搜索树 |
| 完全背包 DP | O(n√n) | O(n) | 常规最优解 |
| 四平方和定理 | O(√n) | O(1) | 面试加分项,数学技巧 |
这题约束 n 可达 10^4,O(n√n) ≈ 10^6 可以接受;四平方和定理是拉格朗日定理的产物(任意正整数可表示为至多 4 个平方数之和),可以作为「如果面试官要求更快」的补充回答。
CodeTop 变体
- 322. 零钱兑换(同套路延伸):把「平方数面值」换成任意 coins 面值,转移完全一致,是最直接的姐妹题。
- 四平方和定理优化(数学变体):先判 n 是否为完全平方数、是否为两个平方数和、是否形如
4^k·(8m+7)(这种需要 4 个),可 O(√n) 解决,阿里/腾讯曾作为加分追问。 - 最少平方数并输出组合:在 dp 之外记录每个 i 的「最后一步用的平方数」,回溯得到具体拆分方案。
#322 零钱兑换
题意
给定一组硬币面值 coins 和总金额 amount,返回凑出 amount 所需的最少硬币数,无法凑出返回 -1(每种面值可重复使用)。
输入:coins = [1,5,11], amount = 15 输出:3 (5+5+5)
难点与易错点
- 为什么不能贪心:最经典的反例是
coins = [1,3,4], amount = 6。贪心先拿最大的 4,剩 2 再拿两个 1,共 3 枚;但最优是 3+3 共 2 枚。所以这题必须 DP,贪心只适用于「面值有整除关系」的特殊币值体系。 - 「无穷大」的表示:dp 初始化为
amount + 1(因为最坏情况全用 1 元硬币,也不会超过 amount 枚),这个值天然充当「不可达」标记,且+1不会溢出,比用 MAX_VALUE 更安全。 - 无法凑出的判断:最终
dp[amount] > amount说明从未被更新过,返回 -1;不要忘了这个兜底。 - 遍历顺序:完全背包,硬币无限,正序遍历 amount。
解法一:暴力 / 直观
DFS 递归:每次选一个面值减去,求最小硬币数。
class Solution {
public int coinChange(int[] coins, int amount) {
if (amount == 0) return 0;
int res = Integer.MAX_VALUE;
for (int coin : coins) {
if (coin <= amount) {
int sub = coinChange(coins, amount - coin);
if (sub != -1) res = Math.min(res, sub + 1);
}
}
return res == Integer.MAX_VALUE ? -1 : res;
}
}
复杂度:每个状态分支最多 coins.length 个,递归深度最坏 amount(全是面值 1),时间约 O(amount^coins.length),栈空间 O(amount)。
瓶颈:同一剩余金额被反复计算,重叠子问题严重。
解法二:优化 / 最优
状态定义:dp[i] = 凑出金额 i 所需的最少硬币数。
转移方程:dp[i] = min(dp[i], dp[i - coin] + 1),对每个 coin <= i 的面值取最小。
初始化:dp[0] = 0,dp[1..amount] = amount + 1(充当「无穷大」)。
遍历顺序:外层 i 从 1 到 amount 正序,内层遍历每个 coin(正序 = 硬币可无限用)。
class Solution {
public int coinChange(int[] coins, int amount) {
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 (coin <= i) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
}
复杂度逐步推导:外层 i 从 1 到 amount 共 amount 次;内层遍历 coins 共 coins.length 次。两层相乘,时间 O(amount × coins.length)。dp 数组长度 amount + 1,空间 O(amount)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 贪心 | 不一定正确 | O(1) | 仅币值有整除关系时可用 |
| DFS 回溯 | 指数级 | O(amount) | 讲清搜索树 |
| 完全背包 DP | O(amount × len) | O(amount) | 面试首选,唯一保证正确 |
这题约束下贪心会错(反例见难点),DFS 指数级不可行,只有 DP 既能保证正确性又是多项式时间,所以必须选 DP。
CodeTop 变体
- 518. 零钱兑换 II(同套路延伸,最经典变体):求凑出 amount 的组合数(不是最少枚数)。转移改成求和:
dp[i] += dp[i - coin],且外层遍历 coin、内层正序遍历 amount(保证「组合」而非「排列」)。 - 39. 组合总和 / 40. 组合总和 II(腾讯考过):返回所有具体组合,用回溯而非 DP。
- 面试追问:「如果每种硬币只能用一次怎么改?」——答案:改成 0-1 背包,内层倒序遍历。
#139 单词拆分
题意
给定字符串 s 和字典 wordDict,判断 s 是否能被拆分为字典中的单词(单词可重复使用)。
输入:s = "leetcode", wordDict = ["leet","code"] 输出:true
难点与易错点
- 状态定义的坑:
dp[i]表示s[0..i-1]能否被拆分(前 i 个字符),不是第 i 个字符。这个「长度前缀」定义容易和「下标」搞混,导致 substring 边界写错。 - substring 边界:转移条件
dp[j] && s[j..i-1] ∈ dict,切分点 j 从 0 到 i-1,substring 取s.substring(j, i)(左闭右开)。j 和 i 一个都不能差。 - 用 Set 加速:把 wordDict 放进
HashSet,把「查字典」从 O(词长) 降到 O(1) 均摊;否则整体多乘一个因子。 dp[0] = true:空串视为「可拆分」,这是转移的起点,漏掉会全错。
解法一:暴力 / 直观
DFS 回溯:从当前位置尝试所有以它为起点的字典词,能匹配就继续往下走。
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
return dfs(s, 0, new HashSet<>(wordDict));
}
private boolean dfs(String s, int start, Set<String> dict) {
if (start == s.length()) return true;
for (int end = start + 1; end <= s.length(); end++) {
if (dict.contains(s.substring(start, end)) && dfs(s, end, dict)) {
return true;
}
}
return false;
}
}
复杂度:每个位置都可能作为新的起点继续分支,最坏情况(如 s="aaaaa"、字典含 "a")每个位置有 O(n) 种切法,时间 O(2^n),栈空间 O(n)。
瓶颈:同一个 start 位置会被反复进入(不同路径到达同一 start),重叠子问题多。
解法二:优化 / 最优
状态定义:dp[i] = 字符串 s[0..i-1](前 i 个字符)能否被拆分成字典单词。
转移方程:dp[i] = true,当且仅当存在 j ∈ [0, i-1],使得 dp[j] == true 且 s[j..i-1] ∈ dict。
初始化:dp[0] = true(空串可拆分)。
遍历顺序:外层 i 从 1 到 n 正序,内层 j 从 0 到 i-1 正序。
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++) {
// dp[j] 可拆分 且 s[j..i-1] 在字典中
if (dp[j] && dict.contains(s.substring(j, i))) {
dp[i] = true;
break; // 找到一种方式就够了
}
}
}
return dp[n];
}
}
复杂度逐步推导:外层 i 从 1 到 n 共 n 次;内层 j 从 0 到 i-1 共 i 次;每次 s.substring(j, i) 拷贝长度 i-j 的字符串,最坏 O(L)(L 为字典最长单词长度,同时也是单次 substring 的上界)。总时间 Σ(i=1..n) Σ(j=0..i-1) O(L) = O(n² × L)。空间:dp 数组 O(n),HashSet 存字典 O(W)(W 为字典总字符数),合计 O(n + W)。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| DFS 回溯 | O(2^n) | O(n) | 讲清「按起点切词」的搜索 |
| 记忆化 DFS | O(n² × L) | O(n + W) | 保留递归结构 |
| 前缀 DP | O(n² × L) | O(n + W) | 面试首选,最直观 |
| Trie + DP | O(n²) | O(n + 字典) | 字典极大时优化 substring 查找 |
这题约束 n 可达 300,O(n² × L) ≈ 几十万次,完全可接受,且 DP 写法最直白、最不容易错,故选前缀 DP。
CodeTop 变体
- 140. 单词拆分 II(真实变体,字节考过):返回所有可能的拆分方案(如
"catsanddog"→["cats and dog", "cat sand dog"])。用记忆化回溯,每个 dp 位置存方案列表。 - Trie 优化(腾讯追问):单词数量很大时,把字典建成前缀树,内层 j 的 substring 查找改为沿 trie 边前进,省去 substring 拷贝。
- 最小拆分次数(变体):问最少拆成几段,把 boolean dp 换成 int dp 求 min。
#300 最长递增子序列
题意
给整数数组 nums,返回最长严格递增子序列的长度(子序列不一定连续)。
输入:nums = [10,9,2,5,3,7,101,18] 输出:4 (2,3,7,101)
难点与易错点
- 「子序列」不等于「子数组」:子序列可以跳着选,所以不能贪心地「一路往上加」。DP 定义
dp[i]为以 nums[i] 结尾的最长递增子序列长度,最后要遍历取 max(最长子序列不一定在末尾结束)。 - DP 的初始化:每个
dp[i]至少为 1(只含自己),不能默认 0。 - 二分解法的手写边界:贪心 + 二分维护
tails数组(tails[k]= 长度为 k+1 的递增子序列的最小末尾),要用「第一个>= num的位置」做 lower_bound,<和<=写反会破坏严格递增。 - 二分返回值:找到位置
pos后,pos == len说明 num 比所有末尾都大,序列变长;否则只是「替换」让末尾变小,长度不变。这个区分容易漏。
解法一:暴力 / 直观(DP O(n²))
状态定义:dp[i] = 以 nums[i] 结尾的最长严格递增子序列长度。
转移方程:dp[i] = max(dp[i], dp[j] + 1),其中 j < i 且 nums[j] < nums[i]。
初始化:所有 dp[i] = 1。
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);
}
}
ans = Math.max(ans, dp[i]);
}
return ans;
}
}
复杂度:外层 i 共 n 次,内层 j 共 i 次,总 Σi = n(n-1)/2 次比较,时间 O(n²),空间 O(n)。
瓶颈:对每个 i 都要向前扫一遍找「所有比它小且 dp 最大的 j」,这层 O(n) 的线性查找是瓶颈。
解法二:优化 / 最优(贪心 + 二分)
核心思想:维护 tails 数组,tails[k] = 所有长度为 k+1 的递增子序列中,末尾元素的最小值。末尾越小,后面越容易接上更大的数,这是贪心正确性的直觉来源。
三步主线:
- 遍历每个
num,在tails[0..len-1]中二分找第一个>= num的位置pos。 tails[pos] = num;若pos == len,说明 num 比所有末尾都大,len++。- 返回
len。
class Solution {
public int lengthOfLIS(int[] nums) {
int[] tails = new int[nums.length]; // tails[i] = 长度为 i+1 的 LIS 最小末尾
int len = 0;
for (int num : nums) {
// 二分查找第一个 >= num 的位置(lower_bound)
int l = 0, r = len;
while (l < r) {
int mid = l + ((r - l) >> 1);
if (tails[mid] < num) l = mid + 1;
else r = mid;
}
tails[l] = num; // 替换(更新最小末尾)
if (l == len) len++; // 序列长度增加
}
return len;
}
}
复杂度逐步推导:外层遍历 n 个元素共 n 次;每次在长度为 len ≤ n 的有序数组上做二分查找,比较次数 O(log len) = O(log n)。两者相乘,时间 O(n log n)。tails 数组长度 nums.length,空间 O(n)。
💭 思考:O(n²) 的 DP 为什么能优化到 O(n log n)?瓶颈在哪?——DP 版对每个
i都要向前扫一遍找「比它小且 dp 最大的 j」,这层 O(n) 线性查找就是病灶。换个视角:我们维护的不再是「以 i 结尾的 LIS 长度」,而是「每个长度对应的最小末尾」tails。末尾越小,后面越容易接上更大的数(贪心正确性来源),于是tails天然单调、可以用二分定位第一个>= num的位置,把「找插入点」从 O(n) 压到 O(log n)。看到「递增子序列 + 内层是线性查找」时,就该想到「换状态定义 + 二分」这个套路——代价是只能求长度,求具体序列还得回到 DP。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| DP O(n²) | O(n²) | O(n) | 直观,且能回溯具体序列 |
| 贪心 + 二分 | O(n log n) | O(n) | 面试首选,求长度最优 |
这题约束 n 可达 2500,O(n²) ≈ 625 万也能过;但 O(n log n) 更优且展示二分功底,面试官通常期待二分版。注意:贪心 + 二分只能求长度,求具体序列还得回到 DP 回溯。
CodeTop 变体
- 354. 俄罗斯套娃信封(真实变体,字节/阿里高频):二维信封 (w, h),问最多能套几层。做法:先按 w 升序、w 相同则 h 降序排序,再对 h 求 LIS(降序避免同宽信封被套)。
- 674. 最长连续递增子序列(真实变体,简单版):要求连续,一遍扫描 O(n) 即可。
- 求 LIS 具体序列(面试追问):DP 法记录每个位置的前驱下标,从最大值处回溯输出序列。
- 300 本身是美团/字节手撕高频,务必同时会 O(n²) 和 O(n log n) 两种写法。
#152 乘积最大子数组
题意
给你整数数组 nums,找到乘积最大的连续子数组,返回该子数组的乘积。
输入:nums = [2,3,-2,4] 输出:6 ([2,3])
难点与易错点
- 负数翻转:本质是同时维护最大值和最小值 DP。乘以一个负数会让「最大」变「最小」、「最小」变「最大」,所以必须同时追踪以当前元素结尾的最大值
maxProd和最小值minProd。这是和「53. 最大子数组和」最本质的区别。 - 更新顺序污染:更新
maxProd时会覆盖旧值,而minProd的计算还依赖旧的maxProd。所以必须先用prevMax保存旧maxProd,否则算出来的minProd是错的。 - 三种候选:每个位置的新极值来自三种情况——只取
num自身(切断前面的乘积)、延续prevMax * num、延续minProd * num(负负得正)。漏掉「只取自身」会在遇到 0 或负数开头时出错。 - 0 的处理:遇到 0 时乘积归 0,
maxProd/minProd变为 0,但result已经在之前记录过最大值,不会丢。
解法一:暴力 / 直观
枚举所有子数组,逐个累乘求最大。
class Solution {
public int maxProduct(int[] nums) {
int n = nums.length;
int ans = Integer.MIN_VALUE;
for (int i = 0; i < n; i++) {
int prod = 1;
for (int j = i; j < n; j++) {
prod *= nums[j];
ans = Math.max(ans, prod);
}
}
return ans;
}
}
复杂度:外层起点 i 共 n 个,内层终点 j 从 i 到 n-1,总子数组数 n(n+1)/2,时间 O(n²),空间 O(1)。
瓶颈:枚举了所有 O(n²) 个子数组,而很多子数组的信息其实可以复用(以同一元素结尾的子数组之间只差一个乘数)。
解法二:优化 / 最优
状态定义:maxProd = 以当前元素结尾的子数组的最大乘积,minProd = 以当前元素结尾的子数组的最小乘积(负数场景下有用)。
转移方程:maxProd = max(num, max(prevMax * num, minProd * num));minProd = min(num, min(prevMax * num, minProd * num))。
初始化:maxProd = minProd = result = nums[0]。
遍历顺序:i 从 1 到 n-1 正序,每轮先存 prevMax 再更新。
class Solution {
public int maxProduct(int[] nums) {
int maxProd = nums[0];
int minProd = nums[0];
int result = nums[0];
for (int i = 1; i < nums.length; i++) {
int num = nums[i];
// 因为 maxProd 和 minProd 会互相影响,先保存 maxProd
int prevMax = maxProd;
// 三种情况:仅 num 自身 / 延续最大 / 延续最小(负数翻转)
maxProd = Math.max(num, Math.max(prevMax * num, minProd * num));
minProd = Math.min(num, Math.min(prevMax * num, minProd * num));
result = Math.max(result, maxProd);
}
return result;
}
}
复杂度逐步推导:循环 i 从 1 到 n-1 共 n-1 次,每次只做常数次乘法与比较,时间 O(n)。只用了 maxProd、minProd、result、prevMax 四个变量,空间 O(1)。
💭 思考:为什么这题不能照搬「最大子数组和」的单状态 DP,而要同时维护 max 和 min?——因为乘法和加法不同:乘以一个负数会让「最大」变「最小」、「最小」变「最大」。如果只记
maxProd,遇到负数时它没法利用「之前最小的负积」翻盘成正的最大值。所以每个位置要保留「以当前元素结尾的最大积和最小积」两个状态,候选来自三种:只取自身、延续最大、延续最小(负负得正)。看到「乘积 + 含负数」这个信号,就该联想到「正负翻转需要双状态」,并且更新顺序上要先用prevMax存旧值,否则minProd会拿被污染的新值算错。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 讲清「连续子数组」枚举 |
| 双状态 DP | O(n) | O(1) | 面试首选,单遍扫描 |
时间 O(n) 是下界(每个元素至少看一次),空间 O(1) 最优,故选双状态 DP。
CodeTop 变体
- 53. 最大子数组和(同套路,加法版):只有加法、没有负数翻转问题,只需维护一个
maxSum,转移dp[i] = max(num, dp[i-1] + num)。 - 1567. 乘积为正数的最长子数组长度(腾讯/阿里考过):要求乘积为正的最长长度,需同时维护「正积长度」和「负积长度」两个状态,本质是本体的变体。
- 求具体子数组下标(面试追问):记录
maxProd更新时的起止位置,输出乘积最大的那段子数组。
#416 分割等和子集
题意
给定只包含正整数的非空数组 nums,判断是否可以将它分成两个子集使得两个子集的元素和相等。
输入:nums = [1,5,11,5] 输出:true ([1,5,5] 和 [11])
难点与易错点
- 转化为背包:本质是 0-1 背包判断是否能恰好装满
sum/2。每个元素只能用一次,问能否选出一个子集使其和恰好等于target = sum/2。想不通「分成两半 = 找一个子集和为总一半」这一步,这题就无从下手。 - 奇数直接判 false:
sum为奇数时不可能均分,必须先短路返回,否则target = sum/2会向下取整导致误判。 - 倒序遍历(0-1 背包关键):内层必须从
target到num倒序遍历。如果正序,dp[j - num]可能已经用过当前物品,导致同一物品被重复使用,就退化成了完全背包。这是背包问题最容易被问、最容易写错的地方。 dp[0] = true:空集和为 0,始终可行,是转移起点。
解法一:暴力 / 直观
DFS 回溯:每个元素选或不选,枚举所有子集。
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for (int x : nums) sum += x;
if (sum % 2 != 0) return false;
return dfs(nums, 0, sum / 2);
}
private boolean dfs(int[] nums, int idx, int target) {
if (target == 0) return true;
if (idx == nums.length || target < 0) return false;
// 选当前元素,或不选
return dfs(nums, idx + 1, target - nums[idx])
|| dfs(nums, idx + 1, target);
}
}
复杂度:每个元素两种选择,决策树高度 n,时间 O(2^n),栈空间 O(n)。
瓶颈:指数级枚举所有子集,n 稍大(如 200)就完全不可行。
解法二:优化 / 最优
状态定义:dp[j] = 能否从当前已遍历的元素中选出若干,使它们的和恰好为 j。
转移方程:dp[j] |= dp[j - num](不选当前 num,或选了 num 且剩余 j-num 可行)。
初始化:dp[0] = true。
遍历顺序:外层遍历每个 num,内层从 target 到 num 倒序(保证每个物品只用一次)。
class Solution {
public boolean canPartition(int[] nums) {
int sum = Arrays.stream(nums).sum();
if (sum % 2 != 0) return false; // 总和为奇数,不可能均分
int target = sum / 2;
boolean[] dp = new boolean[target + 1];
dp[0] = true; // 空集和为 0,始终可行
for (int num : nums) {
// 0-1 背包:倒序遍历,防止同一物品被重复使用
for (int j = target; j >= num; j--) {
dp[j] |= dp[j - num];
}
}
return dp[target];
}
}
复杂度逐步推导:外层遍历 n 个元素共 n 次;内层 j 从 target 倒序到 num,最多 target 次。两者相乘,时间 O(n × target),其中 target = sum/2,即 O(n × sum)。dp 数组长度 target + 1,空间 O(target)。
💭 思考:为什么「分成两半」等于「找一个子集和为 sum/2」,又为什么 0-1 背包必须倒序?——先想第一步:总和为
sum,要找两个和相等的子集,等价于找「一个子集和恰好为sum/2」,另一半自动凑齐,这就是「能否装满背包」。再想第二步:一维 DP 里dp[j]依赖dp[j - num],若j正序走,算dp[j]时dp[j - num]可能已经用上了「当前这个 num」,同一物品被用多次,就退化成了完全背包;倒序走保证dp[j - num]还是「上一层(不含当前物品)」的旧值,每个物品只用一次。看到「每个元素只能用一次 + 装满某容量」这个信号,就锁定 0-1 背包 + 倒序。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| DFS 回溯 | O(2^n) | O(n) | 讲清「选/不选」决策树 |
| 0-1 背包 DP | O(n × target) | O(target) | 面试首选 |
这题约束 sum 可达 2 × 10^4,O(n × sum/2) ≈ 几百万,可接受;DFS 的 2^n 在 n=200 时完全不可行。倒序一维数组已经是空间最优的 0-1 背包写法。
CodeTop 变体
- 494. 目标和(真实变体,字节/美团高频):给每个数加
+或-号,使总和等于 target,求方案数。转化为「选一个正子集使其和为(sum + target)/2」的 0-1 背包计数。 - 1049. 最后一块石头的重量 II(同套路延伸):把石头分成尽可能接近的两堆,本质还是「找子集和最接近 sum/2」的 0-1 背包。
- 输出具体分割方案(面试追问):把 boolean dp 换成「记录来源」的方式回溯出其中一个子集。
#32 最长有效括号
题意
给你只含 ( 和 ) 的字符串 s,返回最长有效括号子串的长度。
输入:s = ")()())" 输出:4 ("()()")
难点与易错点
- 「子串」必须连续:有效括号要连续成段,不能跳着匹配。
"(()"里最长有效是"()"(长度 2),不是整个串。 - 三种主流解法:DP(
dp[i]表示以s[i]结尾的最长有效长度)、单调栈(栈存下标,栈底维护「最后一个无效位置」哨兵)、双向扫描(左右各一遍计数,无需额外空间)。面试官常让你「用两种方法写」。 - 单调栈的哨兵:栈初始压入
-1,表示「最后一个无法匹配的位置」。每次弹栈后若栈空,说明当前)无法匹配,把自己作为新哨兵压入;否则len = i - stack.peek()。哨兵的意义是「给有效段一个起始基准」,理解不了这一步栈法就写不出来。 - DP 的越界:
s[i-1] == '('时要取dp[i-2],i-2可能为负,需判断i >= 2;第二种转移里的i - dp[i-1] - 2同样可能越界。
解法一:暴力 / 直观
枚举每个起点,向右扫描维护括号平衡,平衡为 0 时更新长度,平衡为负时提前终止。
class Solution {
public int longestValidParentheses(String s) {
int n = s.length();
int maxLen = 0;
for (int i = 0; i < n; i++) {
int balance = 0;
for (int j = i; j < n; j++) {
balance += (s.charAt(j) == '(' ? 1 : -1);
if (balance < 0) break; // 右括号过多,这段不可能再有效
if (balance == 0) maxLen = Math.max(maxLen, j - i + 1);
}
}
return maxLen;
}
}
复杂度:外层起点 i 共 n 个,内层终点 j 最多 n 个,时间 O(n²),空间 O(1)。
瓶颈:对每个起点都重新扫一遍,大量重复扫描,n 到 10^5 时 O(n²) 超时。
解法二:动态规划 O(n)
状态定义:dp[i] = 以 s[i] 结尾的最长有效括号长度(仅当 s[i] == ')' 时可能非 0)。
转移方程:
- 若
s[i-1] == '(':当前)和前一个(配对,dp[i] = dp[i-2] + 2。 - 若
s[i-1] == ')'且s[i - dp[i-1] - 1] == '(':当前)配对上「跳过前面一段有效子串后」的那个(,dp[i] = dp[i-1] + 2 + dp[i - dp[i-1] - 2]。
初始化:dp 全 0,maxLen = 0。
遍历顺序:i 从 1 到 n-1 正序(dp[i] 依赖 dp[i-1] 和更早位置,正序保证已算好)。
class Solution {
public int longestValidParentheses(String s) {
int n = s.length();
int[] dp = new int[n];
int maxLen = 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 if (i - dp[i - 1] - 1 >= 0
&& s.charAt(i - dp[i - 1] - 1) == '(') {
dp[i] = dp[i - 1] + 2
+ (i - dp[i - 1] - 2 >= 0 ? dp[i - dp[i - 1] - 2] : 0);
}
maxLen = Math.max(maxLen, dp[i]);
}
}
return maxLen;
}
}
复杂度逐步推导:单层循环 i 从 1 到 n-1 共 n-1 次,每次只做常数次下标判断和加法,时间 O(n)。dp 数组长度 n,空间 O(n)。
解法三:单调栈 O(n)(源材料最优解)
思路:栈存下标,栈底始终压着一个「最后一个无效位置」的哨兵,用 i - stack.peek() 直接得到当前有效段长度。
三步主线:
- 栈初始压入
-1(哨兵,表示最后一个无效下标)。 - 遇到
(:将下标压栈;遇到):弹出栈顶——- 若栈为空,压入当前下标作为新哨兵;
- 若栈非空,
len = i - stack.peek(),更新最大值。
- 返回最大有效长度。
class Solution {
public int longestValidParentheses(String s) {
Deque<Integer> stack = new ArrayDeque<>();
stack.push(-1); // 哨兵:最后一个"无效"位置的下标
int maxLen = 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 {
maxLen = Math.max(maxLen, i - stack.peek()); // 合法长度
}
}
}
return maxLen;
}
}
复杂度逐步推导:循环 i 从 0 到 n-1 共 n 次;每个下标最多入栈一次、出栈一次,均摊下来每步 O(1)。总时间 O(n)。栈最多存 n 个下标,空间 O(n)。
💭 思考:为什么栈法要在栈底压一个
-1当「哨兵」?——一步步想:有效子串的长度 = 「当前)的下标」减去「它前面最后一个无法匹配的位置」。栈里存的始终是「尚未匹配的(下标」和「最近的无效右括号位置」,弹栈后i - stack.peek()就是当前有效段长度。若没有哨兵,当开头就是)时栈会弹空,peek()就越界;放一个-1作为「虚拟的最后一个无效位置」,就统一了「有效段从哪开始算」这个基准。看到「括号匹配 + 求最长有效段」,先想到「用栈下标做差」,再补上「哨兵给段一个起始基准」这个关键细节。
多解法对比
| 解法 | 时间 | 空间 | 适用场景 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 讲清「连续有效段」定义 |
| 动态规划 | O(n) | O(n) | 面试常考,DP 思路清晰 |
| 单调栈 | O(n) | O(n) | 源材料最优解,最简洁 |
| 双向扫描 | O(n) | O(1) | 空间最优,进阶加分项 |
这题约束 n 可达 3 × 10^4,暴力 O(n²) 会超时,必须 O(n)。单调栈写法最短最不易错,是多数人的首选;若面试官再压空间,再讲双向扫描(左→右、右→左各扫一遍计数,遇 ) 多于 ( 就清零重来)。
CodeTop 变体
- 32 本身是字节/腾讯/阿里手撕高频,常要求「用两种不同方法实现」。
- 输出最长有效子串本身(追问):栈法记录匹配的起止下标,DP 法回溯 dp 段拼接。
- 301. 删除无效的括号 / 1249. 移除无效的括号(同套路延伸):前者求「删最少括号使串有效」的所有方案(BFS/回溯),后者是简单版(只删多余括号,栈标记)。
- 求有效括号的种数 / 判断单个串是否有效(20. 有效的括号):作为基础题提前热身。
以上 9 题覆盖了动态规划面试的四大高频模型:斐波那契型(70)、线性选择型(198、152)、背包型(279、322、416)、字符串/序列型(139、300、32)。把它们的状态定义、转移方程、初始化、遍历顺序各说清楚一遍,动态规划这一关基本就稳了。
章末提问
追问大多落在「为什么选这个解法」「状态怎么定」「复杂度怎么推」三个方向,每个都能用「结论先行 + 因为」一句话顶住:
-
怎么判断一道题能不能用 DP? —— 结论先行:看它是否同时满足「最优子结构」和「重叠子问题」。因为最优解能被拆成子问题最优解的组合(如打家劫舍的
max(dp[i-1], dp[i-2]+nums[i])),且暴力递归时同一子问题被反复计算(如爬楼梯的dfs(n-2)被多次调用),两者齐备才能把指数级降到多项式级。 -
dp[i]的状态到底怎么定义? —— 结论先行:先问「子问题的规模是什么」,下标就是规模、值就是该规模下的答案。因为状态定义错一句,转移和初始化就全错;要能一字不差说出dp[i]的语义——如最长递增子序列的dp[i]必须限定「以nums[i]结尾」,否则最后还要遍历取 max 才能得到全局答案。 -
为什么 0-1 背包要倒序遍历、完全背包要正序? —— 结论先行:倒序是为了保证每个物品只被用一次。因为正序遍历时
dp[j-num]可能已经包含「当前物品」,同一物品被反复累加就退化成完全背包(如分割等和子集);完全背包想允许物品重复,才故意正序(如零钱兑换、完全平方数)。 -
滚动数组为什么能省空间? —— 结论先行:因为
dp[i]只依赖固定数量的前序状态,旧状态用完就不再需要。所以只保留几个变量或一行数组即可,空间从O(n)降到O(1)(爬楼梯、打家劫舍、乘积最大子数组),代价是要小心「更新顺序污染」——乘积最大子数组必须先存prevMax再更新,否则minProd算错。 -
复杂度怎么一步步推出来? —— 结论先行:先数状态数、再数每个状态的转移代价,两者相乘。因为 DP 的时间就是「状态数 × 转移代价」,如零钱兑换有
amount个状态、每个扫coins个面值,得O(amount × len);完全平方数内层枚举√i个平方数,Σ√i 近似O(n√n)。空间数 dp 数组(或滚动变量)大小。