动态规划难,不是因为代码一定长,而是因为状态定义一旦错了,后面的转移方程、初始化和遍历顺序都会跟着错。

面试里不要一上来就背模板。先问自己两个问题:这个问题能不能拆成子问题?当前答案是否依赖前面已经算过的答案?如果这两个问题都成立,再考虑 DP。

面试考察重点

面试考察重点

能说清 dp[i] 或 dp[i][j] 的含义。

dp[i]
dp[i][j]

能写出状态转移方程。

能处理初始化和遍历顺序。

能判断是否可以压缩空间。

能区分背包、子序列、区间等常见类型。

什么时候考虑动态规划?

什么时候考虑动态规划?

DP 不是看到“最值”就套。更靠谱的判断是看两个条件:

问题能不能拆成规模更小的同类问题。

子问题会不会被反复计算。

比如斐波那契数列,f(n) 依赖 f(n - 1) 和 f(n - 2),而 f(n - 2) 会在递归里被反复计算。把这些中间结果存下来,就是 DP。

f(n)
f(n - 1)
f(n - 2)
f(n - 2)

面试里可以先从暴力递归说起,再说明哪里重复计算,最后把递归改成记忆化搜索或表格递推。这个过程比直接背 dp 数组更容易让面试官相信你真的理解。

dp

DP 五步法

DP 五步法

定义状态:dp[i] 到底表示什么。

dp[i]

写转移:当前状态从哪些状态推出来。

做初始化:没有前置状态时答案是什么。

定遍历顺序:先算哪些状态,后算哪些状态。

检查样例:用一个小输入手推数组。

其中最重要的是第 1 步。dp[i] 的含义一旦含糊,后面的代码就会变成试出来的。

dp[i]

一个好的状态定义通常满足:

能覆盖题目要问的答案。

能从更小状态推出来。

维度尽量少,但不要为了省空间把含义写乱。

一维 DP 示例

一维 DP 示例

爬楼梯问题:

int climbStairs(int n) {
    if (n <= 2) {
        return n;
    }
    int prev2 = 1;
    int prev1 = 2;
    for (int i = 3; i <= n; i++) {
        int cur = prev1 + prev2;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}
int climbStairs(int n) {
    if (n <= 2) {
        return n;
    }
    int prev2 = 1;
    int prev1 = 2;
    for (int i = 3; i <= n; i++) {
        int cur = prev1 + prev2;
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}

状态含义:到第 i 阶有多少种走法。转移方程:dp[i] = dp[i - 1] + dp[i - 2]。

i
dp[i] = dp[i - 1] + dp[i - 2]

这题还可以从递归推出来:

到第 i 阶的最后一步,要么从 i-1 走 1 步上来,要么从 i-2 走 2 步上来。
到第 i 阶的最后一步,要么从 i-1 走 1 步上来,要么从 i-2 走 2 步上来。

所以 dp[i] 只依赖前两个状态,可以把数组压缩成两个变量。空间压缩的前提是你确认旧状态以后不会再用。

dp[i]

0-1 背包模板

0-1 背包模板

每个物品只能选一次:

int knapsack01(int[] weights, int[] values, int capacity) {
    int[] dp = new int[capacity + 1];
    for (int i = 0; i < weights.length; i++) {
        for (int j = capacity; j >= weights[i]; j--) {
            dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
}
int knapsack01(int[] weights, int[] values, int capacity) {
    int[] dp = new int[capacity + 1];
    for (int i = 0; i < weights.length; i++) {
        for (int j = capacity; j >= weights[i]; j--) {
            dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
}

容量要倒序遍历,避免同一个物品在一轮里被重复使用。

倒序遍历是 0-1 背包最容易被问的点。假设容量正序遍历,计算 dp[j] 时可能用到本轮刚更新过的 dp[j - weight],等于同一个物品被选了多次。这就变成完全背包了。

dp[j]
dp[j - weight]

0-1 背包的典型问法不一定直接叫背包,像“能否分成两个和相等的子集”,可以转成:能否从数组里选一些数,使它们的和等于总和的一半。

完全背包模板

完全背包模板

每个物品可以选多次:

int unboundedKnapsack(int[] weights, int[] values, int capacity) {
    int[] dp = new int[capacity + 1];
    for (int i = 0; i < weights.length; i++) {
        for (int j = weights[i]; j <= capacity; j++) {
            dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
}
int unboundedKnapsack(int[] weights, int[] values, int capacity) {
    int[] dp = new int[capacity + 1];
    for (int i = 0; i < weights.length; i++) {
        for (int j = weights[i]; j <= capacity; j++) {
            dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    return dp[capacity];
}

容量正序遍历,允许当前物品被重复使用。

完全背包里,正序遍历容量正是为了允许当前物品重复使用。比如零钱兑换,每种硬币可以用多次,计算更大金额时可以基于当前硬币已经参与过的状态继续转移。

如果题目问的是“组合数”还是“排列数”,遍历顺序也会变:

组合数:通常先遍历物品,再遍历容量。

排列数:通常先遍历容量,再遍历物品。

这块面试不一定问很深,但遇到零钱兑换 II 这类题时很关键。

常见题型

常见题型

题型状态设计代表题爬楼梯/打家劫舍dp[i] 表示前 i 个位置的最优值70、198背包dp[j] 表示容量为 j 时的最优值或方案数416、518、322子序列dp[i] 或 dp[i][j] 表示以某位置结尾或两个前缀的答案300、1143回文dp[i][j] 表示区间 [i, j] 是否满足条件或最优值647、516路径dp[i][j] 表示走到格子 (i, j) 的答案62、64

题型状态设计代表题

题型

状态设计

代表题

爬楼梯/打家劫舍dp[i] 表示前 i 个位置的最优值70、198

爬楼梯/打家劫舍

dp[i] 表示前 i 个位置的最优值

dp[i]
i

70、198

背包dp[j] 表示容量为 j 时的最优值或方案数416、518、322

背包

dp[j] 表示容量为 j 时的最优值或方案数

dp[j]
j

416、518、322

子序列dp[i] 或 dp[i][j] 表示以某位置结尾或两个前缀的答案300、1143

子序列

dp[i] 或 dp[i][j] 表示以某位置结尾或两个前缀的答案

dp[i]
dp[i][j]

300、1143

回文dp[i][j] 表示区间 [i, j] 是否满足条件或最优值647、516

回文

dp[i][j] 表示区间 [i, j] 是否满足条件或最优值

dp[i][j]
[i, j]

647、516

路径dp[i][j] 表示走到格子 (i, j) 的答案62、64

路径

dp[i][j] 表示走到格子 (i, j) 的答案

dp[i][j]
(i, j)

62、64

记忆化搜索和递推怎么选?

记忆化搜索和递推怎么选?

两种写法都在存子问题答案。

写法特点适合场景记忆化搜索从目标状态往下递归,按需计算状态转移复杂、递归更自然递推从小状态往大状态填表遍历顺序清楚、方便压缩空间

写法特点适合场景

写法

特点

适合场景

记忆化搜索从目标状态往下递归,按需计算状态转移复杂、递归更自然

记忆化搜索

从目标状态往下递归,按需计算

状态转移复杂、递归更自然

递推从小状态往大状态填表遍历顺序清楚、方便压缩空间

递推

从小状态往大状态填表

遍历顺序清楚、方便压缩空间

如果一开始想不清遍历顺序,可以先写记忆化搜索。等状态关系清楚后,再改成递推。很多树形 DP、区间 DP,用记忆化搜索更容易写对。

面试手写路径

面试手写路径

DP 题不建议直接从代码开始。面试手写时,可以先把下面 4 句话讲清楚:

dp 数组的含义是什么,答案最终落在哪个位置。

dp

当前状态依赖哪些旧状态,为什么这些旧状态已经算过。

初始化为什么这样写,尤其是 0、1、无穷大分别代表什么。

0
1

遍历顺序为什么不会提前使用未计算或不该重复使用的状态。

如果这 4 句话说不清,代码大概率是靠记忆写出来的,遇到变体就容易散。

代表题精讲:零钱兑换

代表题精讲:零钱兑换

  1. 零钱兑换 是完全背包里很适合面试的一题。题目给定硬币面额和目标金额,问凑成目标金额最少需要多少枚硬币,每种硬币可以使用无限次。

322. 零钱兑换

状态定义可以这样说:

dp[j] 表示凑成金额 j 所需的最少硬币数。
dp[j] 表示凑成金额 j 所需的最少硬币数。

初始化是这题的关键。dp[0] = 0,表示凑成金额 0 不需要硬币;其他金额先设成一个不可能的较大值,表示暂时不可达。

dp[0] = 0

代码里用到 Arrays.fill,需要导入 java.util.Arrays。

Arrays.fill
java.util.Arrays
int coinChange(int[] coins, int amount) {
    int max = amount + 1;
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, max);
    dp[0] = 0;

    for (int coin : coins) {
        for (int j = coin; j <= amount; j++) {
            dp[j] = Math.min(dp[j], dp[j - coin] + 1);
        }
    }

    return dp[amount] == max ? -1 : dp[amount];
}
int coinChange(int[] coins, int amount) {
    int max = amount + 1;
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, max);
    dp[0] = 0;

    for (int coin : coins) {
        for (int j = coin; j <= amount; j++) {
            dp[j] = Math.min(dp[j], dp[j - coin] + 1);
        }
    }

    return dp[amount] == max ? -1 : dp[amount];
}

为什么容量正序遍历?因为一枚硬币可以用多次。计算 dp[j] 时使用 dp[j - coin],如果 dp[j - coin] 已经在本轮被当前硬币更新过,就代表当前硬币可以继续被使用,这正好符合完全背包。

dp[j]
dp[j - coin]
dp[j - coin]

如果题目变成“每种硬币只能用一次”,容量就要倒序遍历。遍历方向不是格式问题,而是在控制同一件物品能不能重复参与转移。

状态定义对比

状态定义对比

DP 题经常不是不会写转移,而是状态含义选错。下面几组状态看起来接近,但写法完全不同:

题型状态含义常见转移关注点最长递增子序列dp[i] 表示以 nums[i] 结尾的 LIS 长度必须选 nums[i],向前找更小值打家劫舍dp[i] 表示前 i 间房子的最大金额第 i 间偷或不偷最长公共子序列dp[i][j] 表示两个前缀的 LCS 长度比较两个前缀最后一个字符回文子串dp[i][j] 表示区间 [i, j] 是否回文依赖内部区间 [i + 1, j - 1]

题型状态含义常见转移关注点

题型

状态含义

常见转移关注点

最长递增子序列dp[i] 表示以 nums[i] 结尾的 LIS 长度必须选 nums[i],向前找更小值

最长递增子序列

dp[i] 表示以 nums[i] 结尾的 LIS 长度

dp[i]
nums[i]

必须选 nums[i],向前找更小值

nums[i]

打家劫舍dp[i] 表示前 i 间房子的最大金额第 i 间偷或不偷

打家劫舍

dp[i] 表示前 i 间房子的最大金额

dp[i]
i

第 i 间偷或不偷

i

最长公共子序列dp[i][j] 表示两个前缀的 LCS 长度比较两个前缀最后一个字符

最长公共子序列

dp[i][j] 表示两个前缀的 LCS 长度

dp[i][j]

比较两个前缀最后一个字符

回文子串dp[i][j] 表示区间 [i, j] 是否回文依赖内部区间 [i + 1, j - 1]

回文子串

dp[i][j] 表示区间 [i, j] 是否回文

dp[i][j]
[i, j]

依赖内部区间 [i + 1, j - 1]

[i + 1, j - 1]

面试里可以主动说一句:这里的 dp[i] 是“以 i 结尾”,不是“前 i 个元素里的最优值”。这句话能避免很多子序列题写错。

dp[i]

过程示意和边界样例

过程示意和边界样例

以爬楼梯为例,n = 5 时的状态变化如下:

n = 5

idp[i - 2]dp[i - 1]dp[i]312342355358

idp[i - 2]dp[i - 1]dp[i]

i

i

dp[i - 2]

dp[i - 2]

dp[i - 1]

dp[i - 1]

dp[i]

dp[i]

3123

3

1

2

3

4235

4

2

3

5

5358

5

3

5

8

这张表要看的不是数字本身,而是状态只依赖前两个位置,所以可以压缩成两个变量。

DP 题建议检查这些边界:

输入重点n = 0 或空数组初始化是否覆盖只有 1 个元素是否越界访问 dp[1]无法组成目标初始值是否能表达“不可达”求方案数初始化和遍历顺序是否正确

输入重点

输入

重点

n = 0 或空数组初始化是否覆盖

n = 0 或空数组

n = 0

初始化是否覆盖

只有 1 个元素是否越界访问 dp[1]

只有 1 个元素

是否越界访问 dp[1]

dp[1]

无法组成目标初始值是否能表达“不可达”

无法组成目标

初始值是否能表达“不可达”

求方案数初始化和遍历顺序是否正确

求方案数

初始化和遍历顺序是否正确

常见错误写法:

for (int j = weights[i]; j <= capacity; j++) {
    dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]); // 0-1 背包中是错的
}
for (int j = weights[i]; j <= capacity; j++) {
    dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]); // 0-1 背包中是错的
}

0-1 背包中容量要倒序遍历,否则本轮刚更新的状态会被再次使用,相当于同一个物品被选了多次。

易错点

易错点

dp 含义不要频繁变化。

dp

初始化不是随便填 0,要看状态含义。

0-1 背包容量倒序,完全背包容量正序。

求方案数和求最值的初始化不同。

子序列题经常需要区分“以 i 结尾”和“前 i 个元素内”。

高频问题自测

高频问题自测

为什么 DP 的第一步一定是定义状态?

记忆化搜索和递推的区别是什么?什么时候先写记忆化更稳?

0-1 背包为什么容量要倒序遍历?

完全背包为什么容量可以正序遍历?

dp[i] 表示“以 i 结尾”和表示“前 i 个元素”时,转移有什么区别?

dp[i]

求最少次数、最大价值、方案数时,初始化分别要注意什么?

推荐练习题

推荐练习题

  1. 爬楼梯

70. 爬楼梯

  1. 打家劫舍

198. 打家劫舍

  1. 零钱兑换

322. 零钱兑换

  1. 分割等和子集

416. 分割等和子集

  1. 最长递增子序列

300. 最长递增子序列

  1. 最长公共子序列

1143. 最长公共子序列

写在最后

写在最后

如果内容对你有帮助的话,欢迎顺手给 JavaGuide 点一个免费的 Star 支持一下:GitHub | Gitee。

GitHub

Gitee

JavaGuide 已持续维护近七年,累计 6100+ 次提交,来自 620+ 位贡献者共同完善。你的 Star、反馈和 PR,都是这个项目继续更新的动力。

如果你正在准备后端/AI 应用开发面试,也可以了解一下我的知识星球,里面包括后端和 AI 实战项目、简历优化、一对一提问和高频考点资料,已经持续维护六年。

知识星球