动态规划好难!题单:https://leetcode.cn/discuss/post/3581838/fen-xiang-gun-ti-dan-dong-tai-gui-hua-ru-007o/

1. 入门DP

1.1. 爬楼梯

70. 爬楼梯

746. 使用最小花费爬楼梯

3693. 爬楼梯 II

377. 组合总和 Ⅳ

2466. 统计构造好字符串的方案数

1.2. 打家劫舍

198. 打家劫舍

213. 打家劫舍 II

2320. 统计放置房子的方式数

740. 删除并获得点数

1.3. 最大子数组和

53. 最大子数组和

2606. 找到最大开销的子字符串

考虑一下空字符串的价值为0,也可能为最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
private final Map<Character, Integer> mp = new HashMap<>();
public int maximumCostSubstring(String s, String chars, int[] vals) {
for (int i = 0; i < vals.length; i++) {
mp.put(chars.charAt(i), vals[i]);
}
int n = s.length();
int[] dp = new int[n + 1];
dp[0] = this.getValue(s.charAt(0));
int ans = Math.max(dp[0], 0); // 空字符串只需在此处考虑
for (int i = 1; i < n; i++) {
int val = this.getValue(s.charAt(i));
dp[i] = Math.max(val, dp[i - 1] + val);
ans = Math.max(dp[i], ans);
}
return ans;
}

private int getValue(char c) {
Integer val = mp.get(c);
return val == null ? (c - 'a' + 1) : val;
}
}

1749. 任意子数组和的绝对值的最大值

152. 乘积最大子数组

2. 网格图DP

待补充

3. 背包问题

背包问题不难,难的是边界的考虑。

3.1. 01背包

416. 分割等和子集

494. 目标和

3.2. 完全背包

322. 零钱兑换

首先递归方程和**原数据coins[]**的下标表示,用灵神的方法就是dp[i+1][j]表示coins取闭区间[0:i]和容量为j的情况,i就是从for(i=0;i<n;i++)开始遍历的,相当于在i的维度上整体右移了一格。
这样就有两个方向的边界需要考虑:

  1. 物品个数的维度i:dp[0][j]实际上在递推中是不被计算的,但在第一轮dp[i+1][j]=f(d[i][j])中会被用到,所以是需要初始化边界的
  2. 容量的维度j:dp[i][0]在递推中也是不被计算的,但也可能会被用到,例如说某个硬币面值刚好等于总金额dp[][j-coins[i]], j=coins[i]

那么怎么初始化边界呢?得看题目要我们求什么,这个题是求最少的硬币个数,所以上述两个边界问题可以这么考虑:

  1. dp[i][0](i>=0)表示用i个硬币凑出0,这必然有且仅有一种方案,就是啥也不选,但是此处并非初始化为1,因为要求的是硬币个数,所以应当初始化为0;该维度的初始化可以放在每次j的循环之前,但是dp[0][0]并不会被循环遍历到,所以还是要手动初始化至少1个值
  2. dp[0][j](j>0)表示用0个硬币凑出j>0的硬币个数,很显然这是不可能做到的,是非法的,我们不希望这类各自被后续递推时用到。再看递推方程是取Math.min()的,因此我们只需将其初始化为1个很大的值,让合法值与非法值相比时,永远取合法值即可;当然也可能是两个非法值取Math.min(),这样操作后仍为非法值,这是预期之内的;非法值的选择可以用最大值amount+1,因为不可能用amount+1个硬币来凑出amount
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public int coinChange(int[] coins, int amount) {
int n = coins.length;
// dp[i + 1][j]表示用闭区间[0,i]的硬币能否凑出j
int[][] dp = new int[n + 1][amount + 1];
// dp[0][]是边界,表示不用硬币凑出amount,需要无穷大个,用amount+1表示无穷大
Arrays.fill(dp[0], amount + 1);
dp[0][0] = 0; // 需要扣掉dp[0][0],这个操作无法再循环中完成,因为i+1至少是1
for (int i = 0; i < n; i++) {
dp[i + 1][0] = 0; // i维度的初始化可以放在循环之前
for (int j = 1; j <= amount; j++) {
// 1. 不选coins[i]
dp[i + 1][j] = dp[i][j];
// 2. 选coins[i]
if (coins[i] <= j) {
dp[i + 1][j] = Math.min(dp[i + 1][j], 1 + dp[i + 1][j - coins[i]]);
}
}
}
// 如果>amount,说明非法
return dp[n][amount] > amount ? -1 : dp[n][amount];
}
}

空间优化:我感觉空间优化还是得先写出二维的之后,从代码的角度进行优化。代码中每次转移是依赖于左下角dp[i + 1][j - coins[i]]的,对于依赖左下角和右上角的状态转移方程,我们用正序;依赖右下角或左上角的则用倒序,例如01背包。
如果要从完全背包的角度看,那就是每个硬币能取无数次,状态dp[i + 1][j]需要转移到仍能取本次硬币的dp[i + 1][j - coins[i]],而非到无法取本次硬币的dp[i][j - coins[i]],这就是01背包的方法了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public int coinChange(int[] coins, int amount) {
int n = coins.length;
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
for (int i = 0; i < n; i++) {
dp[0] = 0;
for (int j = coins[i]; j <= amount; j++) {
dp[j] = Math.min(dp[j], 1 + dp[j - coins[i]]);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
}

518. 零钱兑换 II

这个题和上一题的区别完全只是换了个东西求,之前是求硬币的个数,现在是求方案数,所以说两类边界值的初始化值都变了。对于非法值要初始化为0,因为办不到就是0种方案;对于不取的情况要初始化为1,因为这本身就是一种方案。我就不贴二维数组的答案了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Solution {
public int change(int amount, int[] coins) {
int n = coins.length;
// dp[i + 1][j] 表示用[0,i]的硬币凑出j的方案数
int[][] dp = new int[n + 1][amount + 1];
// dp[0][j]表示不用硬币凑出j的方案数,如果j不是0,是不可能办到的
Arrays.fill(dp[0], 0); //非法值不能是尽量大了,而是0了,因为求总和数量
dp[0][0] = 1; // 不用硬币凑出0的方案数
for (int i = 0; i < n; i++) {
dp[i + 1][0] = 1; // 不用硬币凑出0,必然是1种
for (int j = 1; j <= amount; j++) {
dp[i + 1][j] = dp[i][j];
if (coins[i] <= j) {
dp[i + 1][j] += dp[i + 1][j - coins[i]];
}
}
}
return dp[n][amount];
}
}

class Solution {
// 这个题是组合数,而不是硬币数
public int change(int amount, int[] coins) {
int n = coins.length;
// dp[i + 1][j] 表示用[0,i]的硬币凑出j的方案数
int[] dp = new int[amount + 1];
// dp[0][j]表示不用硬币凑出j的方案数,如果j不是0,是不可能办到的
Arrays.fill(dp, 0); //非法值不能是尽量大了,而是0了,因为求总和数量
for (int i = 0; i < n; i++) {
dp[0] = 1; // 不用硬币凑出0,必然是1种
for (int j = coins[i]; j <= amount; j++) {
dp[j] += dp[j - coins[i]];
}
}
return dp[amount];
}
}

279. 完全平方数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public int numSquares(int n) {
int target = n;
n = (int) Math.sqrt(target) + 1;
// dp[i][j]表示用前[0,i]凑出j的最小数量,答案是dp[n][target]
int[][] dp = new int[n + 1][target + 1];

// 1. dp[][]并非对任意i,j都有解,例如对i=0,j>0无解
// Arrays.fill(dp[0], Integer.MAX_VALUE);
// dp[0][0] = 0; // 0个数就能组成0
// 2. 或者你也可以把dp[1][j]全部先算出来,后面不使用dp[0][j]
for (int j = 0; j <= target; j++) {
dp[1][j] = j;
}
for (int i = 2; i <= n; i++) {
// j = 0时,dp[i][j] 必然为 dp[i - 1][j],也就是0
for (int j = 0; j <= target; j++) {

dp[i][j] = dp[i - 1][j]; // 不选i
if (i * i <= j) { // 选i,如果用到dp[][0],也会自动+1的
dp[i][j] = Math.min(dp[i][j], dp[i][j - i * i] + 1);
}
}
}

return dp[n][target];
}
}

空间优化,这种无需额外变量的空间优化只要考虑清楚是正序还是逆序就行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public int numSquares(int n) {
int target = n;
n = (int) Math.sqrt(target) + 1;
int[] dp = new int[target + 1];
for (int j = 0; j <= target; j++) { dp[j] = j; }
for (int i = 2; i <= n; i++) {
for (int j = i * i; j <= target; j++) {
dp[j] = Math.min(dp[j], dp[j - i * i] + 1);
}
}
return dp[target];
}
}

4. 线性DP

4.1 最长公共子序列(LCS)

1143. 最长公共子序列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
char[] c1 = text1.toCharArray(), c2 = text2.toCharArray();
int n = c1.length, m = c2.length;
// dp[i][j]表示c1[:i]和c2[:j]这两个字符串的最长公共子序列
int[][] dp = new int[n + 1][m + 1];
// 边界是dp[i][0]和dp[0][j]表示和空字符串的最长公共子序列,显然为0
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (c1[i] == c2[j]) {
dp[i + 1][j + 1] = dp[i][j] + 1;
} else {
dp[i + 1][j + 1] = Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
}
return dp[n][m];
}
}

空间优化:需要用到2个额外变量来存储即将被覆盖的dp[j+1];对比完全背包,其实完全背包只有i这一个递推的维度,只需要i+1的上一个dp[i][j],而这个题还需要j+1的上一个,这个值会在前一次计算被覆盖掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
char[] c1, c2; // c2尽可能短
if (text1.length() > text2.length()) {
c1 = text1.toCharArray(); c2 = text2.toCharArray();
} else {
c2 = text1.toCharArray(); c1 = text2.toCharArray();
}
int n = c1.length, m = c2.length;
// dp[i][j]表示c1[:i]和c2[:j]这两个字符串的最长公共子序列
int[] dp = new int[m + 1];
// 边界是dp[i][0]和dp[0][j]表示和空字符串的最长公共子序列,显然为0
int upLeft;
for (int i = 0; i < n; i++) {
upLeft = dp[0]; // 这里要先存上一轮的再初始化为0,虽然不初始化也没啥事,因为默认值就是0,但下一题编辑距离就需要考虑清楚了
dp[0] = 0;
for (int j = 0; j < m; j++) {
// 现在是dp[j+1],下一轮是dp[j],马上要被覆盖了
int tmp = dp[j + 1];
if (c1[i] == c2[j]) {
dp[j + 1] = upLeft + 1;
} else {
dp[j + 1] = Math.max(dp[j], dp[j + 1]);
}
upLeft = tmp; // 这一轮被覆盖的dp[j + 1]下一轮还要用
}
}
return dp[m];
}
}

72. 编辑距离

非常典的一个题,这里的边界初始化只能用循环,注意n和m都要取等号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public int minDistance(String word1, String word2) {
// 插入和删除的操作是重复的,如果不考虑转化的方向的话
char[] w1 = word1.toCharArray(), w2 = word2.toCharArray();
int n = w1.length, m = w2.length;
// dp[i+1][j+1]表示w1[:i]和w2[:j]的编辑距离
int[][] dp = new int[n + 1][m + 1];
// 边界:dp[i][0]和dp[0][j],注意此处i和j是要到n和m的
for (int i = 0; i <= n; i++) { dp[i][0] = i; }
for (int j = 0; j <= m; j++) { dp[0][j] = j; }

for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (w1[i] == w2[j]) {
dp[i + 1][j + 1] = dp[i][j];
} else {
dp[i + 1][j + 1] = 1 + Math.min(dp[i][j], Math.min(dp[i + 1][j], dp[i][j + 1]));
}
}
}
return dp[n][m];
}
}

空间优化:也是要用到额外的两个变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public int minDistance(String word1, String word2) {
// 插入和删除的操作是重复的,如果不考虑转化的方向的话
char[] w1 = word1.toCharArray(), w2 = word2.toCharArray();
int n = w1.length, m = w2.length;
// dp[i+1][j+1]表示w1[:i]和w2[:j]的编辑距离
int[] dp = new int[m + 1];
// 边界:dp[i][0]和dp[0][j],注意此处i和j是要到n和m的
for (int j = 0; j <= m; j++) { dp[j] = j; }

for (int i = 0; i < n; i++) {
int upLeft = dp[0]; // 先存储上一轮的,再初始化这一轮的
dp[0] = i + 1; // 换成1维后,不要忘记初始化边界值
for (int j = 0; j < m; j++) {
int tmp = dp[j + 1];
if (w1[i] == w2[j]) {
dp[j + 1] = upLeft;
} else {
dp[j + 1] = 1 + Math.min(upLeft, Math.min(dp[j], dp[j + 1]));
}
upLeft = tmp;
}
}
return dp[m];
}
}

583. 两个字符串的删除操作

4.2 最长递增子序列(LIS)

300. 最长递增子序列

这个题有两种做法,前者直接使用动态规划,复杂度为O(n^2),后者贪心+二分,可以将时间复杂度优化到O(nlogn)

方法一:直接DP,还是非常容易理解的,用dp[i]表示以nums[i]结尾的最长递增子序列,然后取dp[]中最大的元素,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 = 1; i < n; i++) {
int max = 0;
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
max = Math.max(dp[j], max);
}
}
dp[i] = max + 1;
ans = Math.max(ans, dp[i]);
}
return ans;
}

方法二:贪心+二分,用dp[i]记录长度为i的IS的最小末尾值
注意Arrays.binarySearch的特性,它并不是找第一个大于等于num的元素,而仅仅是寻找等于;如果找不到等于的,就会返回负数。但是这个返回的负数取补码后的值,标记了第一个大于num的元素的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length, len = 0;
int[] dp = new int[n]; // dp[i]表示长度为i的IS的最小末尾值
for (int i = 0; i < n; i++) {
// 判断nums[i]是否处在某个dp[j-1]和dp[j]之间
// 如果是的话,则可以优化掉dp[j]为nums[i];相等的情况相当于啥也不做
int j = Arrays.binarySearch(dp, 0, len, nums[i]); // 在[0,len)范围搜索
if (j < 0) { j = ~j; } // 注意Arrays.binarySearch的特性
dp[j] = nums[i]; // dp[j]是第一个大于等于nums[i]的值;或者是数组的长度,即dp[0:len)全部小于nums[i]
if (j == len) { len++; }
}
return len;
}
}

3825. 按位与结果非零的最长上升子序列

这是2026-1-31的双周赛,思想就是LIS,但强制要用O(nlogn)算法,因为数据量比较大,注意Integer自带的一些处于二进制的函数,例如.toBinaryString()是将一个数转化为二进制字符串。具体见我的周赛博客。

5. 划分DP

5.1 判定能否划分

一般定义f[i]表示长为i的前缀a[:i]能否划分,在进行状态转移时,枚举i左边的端点,例如左端点为L,判断a[L:i]的划分是否满足要求。

139. 单词拆分

注意内层循环在进行状态转移时的下标。