LeetCode 14 - 动态规划
棋盘路径问题#
931. 下降路径最小和#
给定一个二维数组,计算从第一行下降到最后一行所经过路径的最小和。每次下降,可以向正下方、左下方或者右下方移动一格。比如二维数组为 [[2,1,3],[6,5,4],[7,8,9]]
,那么有两条最小路径:1,5,7
和 1,4,8
。
三要素的确定:
dp[i][j]
:从第一行下降到当前位置的最小路径和。base case
:第一行每个位置的最小路径和就是每个元素自身。- 状态转移:
dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i-1][j+1])
。
int minFallingPathSum(int[][] matrix) {
int rows = matrix.length, cols = matrix[0].length;
int[][] dp = new int[rows][cols];
// base case
for (int i = 0; i < cols; i++) {
dp[0][i] = matrix[0][i];
}
// 状态转移
for (int i = 1; i < rows; i++) {
for (int j = 0; j < cols; j++) {
int temp = dp[i - 1][j];
if (j - 1 >= 0) {
temp = Math.min(temp, dp[i - 1][j - 1]);
}
if (j + 1 < rows) {
temp = Math.min(temp, dp[i - 1][j + 1]);
}
dp[i][j] = temp + matrix[i][j];
}
}
// 在最后一行中找出下降路径最小的
int minSum = dp[rows - 1][0];
for (int i = 1; i < cols; i++) {
minSum = Math.min(minSum, dp[rows - 1][i]);
}
return minSum;
}
64. 最小路径和#
给定一个包含非负整数的 m x n 网格 grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。
三要素的确定:
dp[i][j]
:从左上角到位置(i,j)
的最小路径和。- Base Case:第一行、第一列的每个位置只有一条路径。
- 状态转移:一个格子可能是从左边或者上边的格子移动过来的,所以
dp[i][j] = matrix[i][j] + min(dp[i-1][j], dp[i][j-1])
。
int minPathSum(int[][] matrix) {
int rows = matrix.length, cols = matrix[0].length;
int[][] dp = new int[rows][cols];
// base case
dp[0][0] = matrix[0][0];
for (int i = 1; i < cols; i++) {
dp[0][i] = dp[0][i - 1] + matrix[0][i];
}
for (int i = 1; i < rows; i++) {
dp[i][0] = dp[i - 1][0] + matrix[i][0];
}
// 状态转移
for (int i = 1; i < rows; i++) {
for (int j = 1; j < cols; j++) {
dp[i][j] = matrix[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[rows - 1][cols - 1];
}
62. 不同路径#
一个机器人位于一个 m x n 网格的左上角,机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。问总共有多少条不同的路径?
方法:动态规划
dp[i][j]
表示到达(i,j)
位置的路径数量。- base case:第一列、第一行的所有位置都只有一条路径。
- 状态转移:每个位置可以从上面或者左边转移过来,也就是说:
dp[i][j] = dp[i-1][j] + dp[i][j-1]
。
int pathNum(int m, int n) {
int[][] dp = new int[m][n]
// 第一行
for(int i = 0; i < n; i++)
dp[0][i] = 1;
// 第一列
for(int i = 0; i < m; i++)
dp[i][0] = 1;
// 状态转移
for(int i = 1; i < m; i++) {
for(int j = 1; j < n; j++) {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
63. 不同路径 II#
一个机器人位于一个 m x n 网格的左上角 。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角。现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?网格中的障碍物和空位置分别用 1 和 0 来表示。
方法:动态规划
如果某个位置有障碍物,则到达该位置的路径数量为 0. 其他和上一题完全一样,包括 dp 数组定义、base case、状态转移方程。
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int rows = obstacleGrid.length, cols = obstacleGrid[0].length;
int[][] dp = new int[rows][cols];
for (int i = 0; i < cols; i++) {
if (obstacleGrid[0][i] == 1) break;
dp[0][i] = 1;
}
for (int i = 0; i < rows; i++) {
if (obstacleGrid[i][0] == 1) break;
dp[i][0] = 1;
}
for (int i = 1; i < rows; i++) {
for (int j = 1; j < cols; j++) {
if (obstacleGrid[i][j] == 1) dp[i][j] = 0;
else dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[rows - 1][cols - 1];
}
最长子序列 / 子串问题#
这类问题的 dp[i]
一般为两种定义方式之一:
- 定义为 以
sequence[i]
结尾 的所有子串中所求的最值。(适用于子串、子数组情况,连续) - 定义为 前缀串
sequence[0:i]
中所求的最值。(适用于子序列情况,不要求连续)
1143. 最长公共子序列#
三要素的确定:
dp[i][j]
:两前缀串s1[0:i]
,s2[0:j]
的最长公共子序列,[0:0]
表示空串。base case
:dp[0][i]=0, dp[j][0]=0
- 状态转移:
s1[i]==s2[j]
时,dp[i][j]=dp[i-1][j-1] + 1
;- 否则,
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
。
int LCS(String s1, String s2) {
int len1 = s1.length(), len2 = s2.length();
int[][] dp = new int[len1 + 1][len2 + 1];
// base case
for (int i = 0; i <= len1; i++) {
dp[i][0] = 0;
}
for (int i = 0; i <= len2; i++) {
dp[0][i] = 0;
}
// 状态转移
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[len1][len2];
}
583. 两个字符串的删除操作#
给定两个字符串 s1,s2,求使得两个字符串相同所需的最少步数——每一步可以删除一个字符串的一个字符。例如 sea, eat,需要两步:删除 s、删除 t。
删除之后两个字符串变成了什么样子——变成了它俩的 最长公共子序列。那么删除的步数就是原来两个字符串的总长度减去两倍的 LCS 的长度。
712. 两个字符串的最小ASCII删除和#
将两个字符串删除一些字符后变成相同的两个字符串,问所需删除的字符的 ASCII 码值之和最小是多少。
求出最长公共子序列的 ASCII 码之和 x,然后用两个字符串的 ASCII 码之和减去两倍的 x 即可。
int minAsciiSum(String s1, String s2) {
int len1 = s1.length(), len2 = s2.length();
int sum = 0;
for (int i = 0; i < len1; i++) sum += s1.charAt(i);
for (int j = 0; j < len2; j++) sum += s2.charAt(j);
return sum - 2 * maxAsciiSumCommonSubsequence(s1, s2);
}
int maxAsciiSumCommonSubsequence(String s1, String s2) {
int len1 = s1.length(), len2 = s2.length();
int[][] dp = new int[len1 + 1][len2 + 1];
for (int i = 1; i <= len1; i++) {
for (int j = 1; j <= len2; j++) {
if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + s1.charAt(i - 1);
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[len1][len2];
}
718. 最长重复子数组#
给两个整数数组 nums1
和 nums2
,返回两个数组中 公共的 、长度最长的子数组的长度 。
输入:nums1 = [1,2,3,2,1], nums2 = [3,2,1,4,7]
输出:3
解释:长度最长的公共子数组是 [3,2,1] 。
方法:动态规划
用 dp[i][j]
表示以 nums1[i]
结尾的 nums1
的所有子数组,和 以 nums2[j]
结尾的 nums2
的所有子数组 之间 最长公共数组的长度,则最终答案为 dp
数组中的最大值。
初始化:第一行、第一列为 Base Case:
- 如果
nums1[0] == nums2[j]
,则dp[0][j] = 1
; - 如果
nums1[i] == nums2[0]
,则dp[i][0] = 1
。
状态转移:
- 如果
nums1[i] == nums2[j]
,则dp[i][j] = dp[i-1][j-1] + 1
; - 否则
dp[i][j] = 0
。
public int findLength(int[] nums1, int[] nums2) {
int len1 = nums1.length, len2 = nums2.length;
int[][] dp = new int[len1][len2];
int maxLen = 0;
for (int i = 0; i < len1; i++) {
for (int j = 0; j < len2; j++) {
if (nums1[i] == nums2[j]) {
if (i == 0 || j == 0) { // Base case
dp[i][j] = 1;
} else { // 状态转移
dp[i][j] = dp[i - 1][j - 1] + 1;
}
maxLen = Math.max(maxLen, dp[i][j]);
}
}
}
return maxLen;
}
编辑距离问题#
115. 不同的子序列#
给定一个字符串 s
和一个字符串 t
,计算在 s
的子序列中 t
出现的个数。
方法:动态规划
dp[i][j]
定义为在 s
的前缀串 s[0:i]
的所有子序列中 t
的前缀串 t[0:j]
出现的次数,s[0:0]
表示空串。则最终答案为 dp[len_s][len_t]
。
Base Case 为 dp[0][0] = 1, dp[0][j] = 0, j >= 1
,以及 dp[i][0] = 1
,因为空串是每个字符串的子序列。
状态转移:
- 如果
s[i] != t[j]
,则说明子序列t[0:j]
只能出现在s[0:i-1]
中,所以dp[i][j] = dp[i-1][j]
。 - 否则,可以让
s[i]
对应t[j]
,那么在前面s[0:i-1]
的子序列中找到t[0:j-1]
的个数即可;也可以不用s[i]
,在前面s[0:i-1]
的子序列中找到t[0:j]
的个数。这两种情况之和就是s[0:i]
的子序列中t[0:j]
出现的次数:dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
。
public int numDistinct(String s, String t) {
int len_s = s.length(), len_t = t.length();
// dp[i][j]: 前缀串 s[0:i] 的子序列中 t[0:j] 出现的次数
// s[0:0] 表示空串
int[][] dp = new int[len_s + 1][len_t + 1];
// base case
for (int i = 0; i <= len_s; i++) {
dp[i][0] = 1;
}
// 状态转移
for (int i = 1; i <= len_s; i++) {
for (int j = 1; j <= len_t; j++) {
if (s.charAt(i - 1) == t.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[len_s][len_t];
}
583. 两个字符串的删除操作#
给定两个字符串 s1,s2
,求使得两个字符串相同所需的最少步数——每一步可以删除一个字符串的一个字符。例如 sea, eat
,需要两步:删除 s
、删除 t
。
方法一:直接进行动态规划
dp[i][j]
定义为 s1
的前缀子串 s1[0:i]
与 s2
的前缀子串 s2[0:j]
的最小删除步数,s[0:0]
表示空串。最终答案就是 dp[len1][len2]
。
Base Case:s1
或 s2
为空串时,另一个变成空串的最小删除步数就是它的长度。dp[i][0] = i, dp[0][j] = j
。
状态转移:
-
如果
s1[i] == s2[j]
,则不用删除当前字符,所以dp[i][j] = dp[i-1][j-1]
。 -
否则,有三种选择:
- 删除
s1[i]
,此时最小删除步数为1
+s1[0:i-1]
和s2[0:j]
的最小删除步数。 - 删除
s2[j]
,此时最小删除步数为1
+s1[0:i]
和s2[0:j-1]
的最小删除步数。 - 同时删除
s1[i]
和s2[j]
,此时最小删除步数为2
+s1[0:i-1]
和s2[0:j-1]
的最小删除步数。
所以:
dp[i][j] = min(1 + dp[i-1][j], 1 + dp[i][j-1], 2 + dp[i-1][j-1])
。 - 删除
方法二:通过求 LCS 间接求解
删除之后两个字符串变成了什么样子——变成了它俩的 最长公共子序列。那么删除的步数就是原来两个字符串的总长度减去两倍的 LCS 的长度。
72. 编辑距离#
问题:给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:插入一个字符、删除一个字符、替换一个字符。
方法:动态规划
我们可以对任意一个单词进行三种操作:
- 插入一个字符;
- 删除一个字符;
- 替换一个字符。
题目给定了两个单词,设为 A 和 B,这样我们就有六种操作方法。
但我们可以发现,如果我们有单词 A 和单词 B:
- 对单词 A 删除一个字符和对单词 B 插入一个字符是等价的。例如当单词 A 为 doge,单词 B 为 dog 时,我们既可以删除单词 A 的最后一个字符 e,得到相同的 dog,也可以在单词 B 末尾添加一个字符 e,得到相同的 doge;
- 同理,对单词 B 删除一个字符和对单词 A 插入一个字符也是等价的;
- 对单词 A 替换一个字符和对单词 B 替换一个字符是等价的。例如当单词 A 为 bat,单词 B 为 cat 时,我们修改单词 A 的第一个字母 b -> c,和修改单词 B 的第一个字母 c -> b 是等价的。
这样以来,本质不同的操作实际上只有三种:
- 在 A 中删除一个字符;
- 在 B 中删除一个字符;
- 替换 A 的一个字符。
要素的确定:
- dp 数组定义:
dp[i][j]
表示s1[0..i], s2[0..j]
的最小编辑距离,s[0:0]
表示空串。 - base case:当任一字符串为空串时,另一个字符串变成空串的最小编辑距离就是它的长度。
- 状态转移:
- 如果
s1[i]==s2[j]
,那么不用进行编辑,即dp[i][j] = dp[i-1][j-1]
。 - 否则,取上面三种不同操作的最小值。
- 删除
s1[i]
,那么编辑距离就是s1[0:i-1]
与s2[0:j]
的距离加一。 - 删除
s2[j]
,那么编辑距离就是s1[0:i]
与s2[0:j-1]
的距离加一。 - 替换
s1[i]
为s2[j]
字符,那么编辑距离就是s1[0:i-1]
与s2[0:j-1]
的距离加一。
- 删除
- 如果
- 确定遍历顺序:第一列和第一行是 base case,每个未知格子的计算需要知道它上面、左边和左上角的三个值,所以可以从上到下、从左往右遍历。
int minDistance(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
// base case
for (int i = 1; i < m + 1; i++) dp[i][0] = i; // 第一列
for (int j = 1; j < n + 1; j++) dp[0][j] = j; // 第一行
for (int i = 1; i < m + 1; i++) {
for (int j = 1; j < n + 1; j++) {
// 状态转移
if (s1.charAt(i) == s2.charAt(j))
dp[i][j] = dp[i - 1][j - 1];
else
dp[i][j] = 1 + min(
dp[i - 1][j], // 删除 s1
dp[i][j - 1], // 删除 s2
dp[i - 1][j - 1] // 替换 s1
);
}
}
return dp[m][n];
}
打家劫舍问题#
198. 打家劫舍#
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
方法:动态规划
-
dp[i]
表示前i
间房子能偷窃的最大金额; -
base case:如果只有一间房子,则
nums[0]
就是答案;如果有两间房子,则较大那个就是答案。 -
状态转移:对于第 k 间房子(k > 2),有偷和不偷两种选择:
- 偷它,则不能偷第 间房子,偷窃总金额为前 间房子的偷窃金额与第 间房子的金额之和;
- 不偷它,则偷窃总金额为前 间房子的偷窃金额。
于是状态转移方程为:
int steal(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int len = nums.length;
// base case
if (len == 1) return nums[0];
int[] dp = new int[len];
dp[0] = nums[0]; // dp[i] 表示前 i+1 间
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < len; i++) dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
return dp[len - 1];
}
因为 dp 数组中每个位置只依赖于前面相邻两个位置的值,所以可以利用滚动数组思想进行空间优化,不创建 dp 数组,只用两三个变量滚动地计算 dp 数组中每个位置的值,最后返回 dp[n-1]
。
int steal(int[] nums) {
int n = nums.length;
if (n == 1) return nums[0];
if (n == 2) return Math.max(nums[0], nums[1]);
// prepre 相当于 dp[0],pre 相当于 dp[1]
int prepre = nums[0], pre = Math.max(nums[0], nums[1]);
int cur = 0;
for (int i = 2; i < n; i++) {
cur = Math.max(prepre + nums[i], pre);
pre = prepre;
pre = cur;
}
return cur;
}
213. 打家劫舍 II#
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
方法:动态规划
和上一题的不同在于,第一间房子和最后一间房子是相邻的,因此这两间不能在同一天偷窃。那么如何保证这一点呢?
- 如果偷了第一间,则不能偷最后一间——偷窃范围是
[0,n-2]
; - 如果偷了最后一间,这不能偷第一间——偷窃范围是
[1, n-1]
。
这两个范围可以直接用上题的方法来求出答案,然后返回较大那个即可。
int steal(int[] nums) {
int len = nums.length;
if (len == 1) return nums[0];
if (len == 2) return Math.max(nums[0], nums[1]);
return Math.max(stealRange(nums, 0, len - 2), stealRange(nums, 1, len - 1));
}
这里求解 dp 数组时,因为规定了 start 和 end,所以下标 i 的确定有些复杂,所以不如直接使用滚动数组方法进行空间优化,反而更好理解。因为 dp 数组中每个位置的值只依赖于前两个位置,所以使用两个变量存储这两个位置的值,然后不断更新这两个值。
int stealRange(int[] nums, int start, int end) {
int first = nums[start], second = Math.max(nums[start], nums[start + 1]);
for (int i = start + 2; i <= end; i++) {
int temp = second;
// 先更新 second
second = Math.max(first + nums[i], temp);
first = temp;
}
}
337. 打家劫舍 III#
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root
。这个地方的所有房屋的排列类似于一棵二叉树。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root
。返回在不触动警报的情况下 ,小偷能够盗取的最高金额 。
方法:基于 DFS 的动态规划(树结构中的动态规划)
这个问题实际上是说:给定一棵二叉树,每个结点有两种状态——选择和不选择,问:在不能同时选中有父子关系的结点的情况下,能选择的结点的最大权值之和为多少?
用 表示选择结点 的情况下,以 为根的子树能选择的结点权值之和的最大值; 表示在不选择 的情况下,以 为根的子树能选择的结点权值之和的最大值。同时用 分别表示 的左右孩子。则状态转移关系为:
- 当 被选择时, 都不能被选择,所以此时 。
- 当 不被选择时, 可以都不选择,也可以只选择一个,也可以两个都选择。各种情况下的最大值即为所求: 。
可以用哈希表存储 的函数值,用 DFS 后序遍历这棵树,就可以得到每个结点的 。 就是答案。
Map<TreeNode, Integer> f = new HashMap<>();
Map<TreeNode, Integer> g = new HashMap<>();
int rob(TreeNode root) {
dfs(root);
return Math.max(f.getOrDefault(root, 0), g.getOrDefault(root, 0));
}
void dfs(TreeNode node) {
if(node == null) return;
// 后序遍历
dfs(node.left);
dfs(node.right);
f.put(node, node.val + g.getOrDefault(node.left, 0)
+ g.getOrDefault(node.right, 0));
g.put(node, Math.max(f.getOrDefault(node.left, 0), g.getOrDefault(node.left, 0))
+ Math.max(f.getOrDefault(node.right, 0), g.getOrDefault(node.right, 0)));
}
买卖股票问题#
121. 买卖股票的最佳时机#
给定一个数组,元素 i
表示第 i
天的股票价格。你可以在某一天买入,在以后的某一天卖出,求出最大的获利。(只有一次买卖操作)
方法一:动态规划
- dp 数组定义:
dp[i][0]
表示第i
天结束时持有股票所得最大收益。dp[i][1]
表示第i
天结束时不持有股票所得最大收益。
- Base Case:
dp[0][0] = -prices[0]
,dp[0][1] = 0
。 - 状态转移:
dp[i][0]
:可以从两种情况转移而来 —— 前一天就持有股票,今天没有将它卖出(dp[i-1][0]
);前一天不持有股票,今天买入(prices[i]
)。dp[i][0] = max(dp[i-1][0], -prices[i])
dp[i][1]
:两种情况:前一天就不持有,今天没有买入(dp[i-1][1]
);前一天持有,今天将它卖出(dp[i-1][0] + prices[i]
)。dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i])
- 最终答案:
dp[n-1][1]
方法二:记录历史最低点(效率更高)
用 minPrice
表示以前的最低价格,同时用 maxProfit
表示之前的最大获利,则当前的最大获利为 max(nums[i] - minPrice, maxProfit)
。
只需要遍历价格数组一遍,记录历史最低点,然后在每一天考虑这么一个问题:如果我是在历史最低点买进的,那么我今天卖出能赚多少钱?当考虑完所有天数之时,我们就得到了最好的答案。
这个方法实际上也是动态规划(一维),
dp[i]
表示前i
天所能获得的最大收益,则需要记录前面每天中的最小值,据此来判断今天卖出的最大收益与之前所能获得最大收益的大小关系。状态转移为:dp[i] = max(dp[i-1], prices[i] - minPrice)
。
int maxProfit(int[] nums) {
int minPrice = nums[0], maxProfit = 0;
for (int i = 1; i < nums.length; i++) {
maxProfit = Math.max(maxProfit, nums[i] - minPrice);
if (nums[i] < minPrice) minPrice = nums[i];
}
return maxProfit;
}
122. 买卖股票的最佳时机 II#
给定一个数组 prices
,其中 prices[i]
表示股票第 i
天的价格。
在每一天,你可能会决定购买和/或出售股票。你在任何时候最多只能持有一股股票。你也可以购买它,然后在同一天出售。返回你能获得的最大利润 。
方法:动态规划
dp 数组定义
定义 dp[i][0]
表示第 i
天交易完成后 手里没有股票 的最大利润,dp[i][1]
表示第 i
天交易完成后 手里持有股票 的最大利润。
考虑 dp[i][0]
的状态转移。如果这一天结束时手里没有股票,那么可能的转移状态是:
- 前一天结束时手里已经没有股票(交易完成),即
dp[i-1][0]
; - 或者,前一天结束时手里持有股票,即
dp[i-1][1]
,然后将其在第二天卖出,获利prices[i]
。
那么,为了让第 i
天的收益最大化,状态转移方程为:
然后考虑 dp[i][1]
的状态转移。如果 这一天结束时手里持有股票,那么可能的状态转移是:
- 前一天结束时已经持有股票,即
dp[i-1][1]
; - 前一天结束时没有股票,在第
i
天花费prices[i]
购入股票,即dp[i-1][0]-prices[i]
。
那么,状态转移方程为:
base case
dp[0][0]=0
;dp[0][1]=-prices[0]
。
最终答案
最后一天交易结束后,所获最大利润为 dp[n-1][0]
。
int maxProfit(int[] prices) {
int n = prices.length;
int[][] dp = new int[n][2];
// base case
dp[0][0] = 0;
dp[0][1] = -prices[0];
// 状态转移
for (int i = 1; i < n; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
}
// 答案
return dp[n - 1][0];
}
123. 买卖股票的最佳时机 III#
给定一个数组,它的第 i
个元素是一支给定的股票在第 i
天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
方法:动态规划
最多只能进行两笔交易,所以每天可能处于这两笔交易中的五个阶段(状态)之一:
- 不曾买入股票
- 持有第一支股票
- 卖出了第一支股票,不持有股票
- 持有第二支股票
- 卖出了第二支股票
作为对比,在买卖股票 II 问题中,每天只有 持股 / 不持股 两种状态。
所以 dp 数组的定义如下:
dp[i][0]
:第i
天结束时不曾买入股票的情况下,所得最大收益。dp[i][1]
:第i
天结束时持有第一支股票的情况下,所得最大收益。dp[i][2]
:第i
天结束时卖出第一支股票且不持有股票的情况下,所得最大收益。dp[i][3]
:第i
天结束时持有第二支股票的情况下,所得最大收益。dp[i][4]
:第i
天结束时卖出第二支股票的情况下,所得最大收益。
状态转移:
dp[i][0]
:只能从dp[i-1][0]
转移而来。dp[i][1]
:可以从dp[i-1][1]
转移而来;也可能是当天才买入了第一支股票:dp[i-1][0] - prices[i]
。dp[i][2]
:可以从dp[i-1][2]
转移而来,也可能是当天才卖出第一支股票:dp[i-1][1] + prices[i]
。dp[i][3] = max(dp[i-1][3], dp[i-1][2] - prices[i])
dp[i][4] = max(dp[i-1][4], dp[i-1][3] + prices[i])
最终答案:dp[n-1][4]
初始化:
dp[0][0] = 0
dp[0][1] = -prices[0]
dp[0][2] = 0
dp[0][3] = -prices[0]
dp[0][4] = 0
188. 买卖股票的最佳时机 IV#
给定一个整数数组 prices
,它的第 i
个元素 prices[i]
是一支给定的股票在第 i
天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 k
笔交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
方法:动态规划
和买卖股票 III 问题类似,为每对买 / 卖操作创建两个状态,所以一共需要创建 2 * k +1
个状态,除了表示最初的 未曾买入股票 状态,依次是 买入 / 卖出第 j
支股票 的状态。
状态转移也类似,每天的状态可以从前一天的状态转移而来,例如:
dp[i][1]
:可以是前一天就已经处于状态1
,今天不做任何操作;也可以是前一天处于状态0
,今天买入第一支股票。dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])
dp[i][2]
:可以是前一天就已经处于状态2
,今天不做任何操作;也可以是前一天处于状态1
,今天卖出了第一支股票。dp[i][2] = max(dp[i-1][2], dp[i-1][1] + prices[i])
总的来说,买入股票时需要 -prices[i]
,卖出股票时需要 +prices[i]
,且下标为奇数的状态对应买入操作,为偶数对应卖出操作。所以状态转移为:
for (int j = 1; j <= 2 * k; j++) {
if (j % 2 != 0) {
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-1] - prices[i]);
} else {
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-1] + prices[i]);
}
}
初始化也同样和 买卖股票 III 类似,奇数下标为 -prices[0]
,偶数为 0
。
714. 买卖股票的最佳时机含手续费#
给定一个整数数组 prices
,其中 prices[i]
表示第 i
天的股票价格 ;整数 fee
代表了交易股票的手续费用。(买 + 卖称为一个交易)
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
方法:动态规划
和买卖股票 II 唯一的不同在于,需要为每笔交易支付一次手续费。手续费可以在买入时支付,也可以在卖出时支付,两种支付时机对应的初始化和状态转移稍有不同,但是 dp[i][0] / dp[i][1]
的定义相同,分别表示第 i
天结束时手上不持有 / 持有股票的情况下,最大收益。
- 买入时支付手续费:
- 初始化:
dp[0][0] = 0, dp[0][1] = -prices[0] - fee
(买入有手续费) - 状态转移:
- 不持有(卖出):
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
- 持有(买入有手续费):
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i] - fee)
- 不持有(卖出):
- 初始化:
- 卖出时支付手续费:
- 初始化:
dp[0][0] = 0, dp[0][1] = -prices[0]
- 状态转移:
- 不持有(卖出有手续费):
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i] - fee)
- 持有:
dp[i][1] = dp[i-1][1], dp[i-1][0] - prices[i]
- 不持有(卖出有手续费):
- 初始化:
309. 最佳买卖股票时机含冷冻期#
给定一个整数数组prices
,其中第 prices[i]
表示第 i
天的股票价格 。设计一个算法计算出最大利润。注意:
- 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
- 你必须在再次购买前出售掉之前的股票。
方法:动态规划
还是按照 持有 / 不持有 的思路来确定状态,因为加入了冷冻期,在前一天有卖出操作的情况下,今天不能进行任何操作,所以在记录 不持有 状态的同时,还要知道是否进行了卖出操作。所以不持有状态需要细分为两种:继承而来的不持有状态,以及因为卖出导致的不持有状态。
- 未持有:
dp[i][0]
: 第i
天结束时不持有股票,且当天没有进行卖出操作;dp[i][1]
: 第i
天结束时不持有股票,且当天进行了卖出操作。
- 持有:
dp[i][2]
: 第i
天结束时持有股票。
状态转移:
dp[i][0]
:今天不持有,说明前一天结束时未持有,(前一天可能有也可能没有卖出操作)。所以有:dp[i][0] = max(dp[i-1][0], dp[i-1][1])
。dp[i][1]
:今天进行了卖出操作,说明昨天结束时持有股票,所以有:dp[i][1] = dp[i-1][2] + prices[i]
。dp[i][2]
:今天结束时持有股票——可能是今天买入的(昨天不能卖出),也可能是昨天就有的:dp[i][2] = max(dp[i-1][2], dp[i-1][0] - prices[i])
Base Case:
dp[0][0] = 0
dp[0][1] = 0
相当于今天买入又卖出dp[0][2] = -prices[0]
int maxProfit(int[] prices) {
int n = prices.length;
int[][] dp = new int[n][4];
// base case
dp[0][0] = 0;
dp[0][1] = 0;
dp[0][2] = -prices[0];
// 状态转移
for (int i = 1; i < n; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1]);
dp[i][1] = dp[i - 1][2] + prices[i];
dp[i][2] = Math.max(dp[i - 1][2], dp[i - 1][0] - prices[i]);
}
// 结果为最后一天 未持有 情况下的最大利润
return Math.max(dp[n - 1][0], dp[n - 1][1]);
}
回文串问题#
647. 回文子串#
给你一个字符串 s
,请你统计并返回这个字符串中 回文子串 的数目。具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
方法一:动态规划
dp 数组定义:dp[i][j]
表示子串 s[i:j]
是否 为一个回文串。
状态转移:
i == j
时,dp[i][j]=true
;i+1 == j && s[i]==s[j]
时,dp[i][j]=true
;- 子串长度大于 2 时,
dp[i+1][j-1] && s[i]==s[j]
时,dp[i][j]=true
。
int countSubstring(String s) {
int result = 0;
int n = s.length();
// dp[i][j]: 子串 s[i:j] 是否为回文串
boolean[][] dp = new boolean[n][n];
// base case: 长度为 1, 2 的子串
for (int i = 0; i < n; i++) {
dp[i][i] = true;
result++;
if (i < n - 1 && s.charAt(i) == s.charAt(i + 1)) {
dp[i][i + 1] = true;
result++;
}
}
// 状态转移:遍历顺序 —— 从下往上、从左向右
for (int i = n - 3; i >= 0; i--) {
for (int j = i + 2; j < n; j++) {
if (dp[i + 1][j - 1] && s.charAt(i) == s.charAt(j)) {
dp[i][j] = true;
result++;
}
}
}
return result;
}
方法二:中心扩展法
在向两边扩展的同时,记录回文串的个数。
int countSubstring(String s) {
int result = 0;
for(int i = 0; i < s.length(); i++) {
result += expandFromCenter(s, i, i);
result += expandFromCenter(s, i, i+1);
}
return result;
}
int expandFromCenter(String s, int left, int right) {
int count = 0;
while(left >= 0 && right < s.length()
&& s.charAt(left) == s.charAt(right)) {
left--;
right++;
count++;
}
return count;
}
516. 最长回文子序列#
三要素的确定:
dp[i][j]
:子串s[i:j]
的最长回文子序列。- Base Case:
dp[i][i]=0
。 - 状态转移:
- 如果
s[i] == s[j]
,则dp[i-1][j+1] = 2 + dp[i][j]
。 - 否则:
dp[i-1][j+1] = max(dp[i-1][j], dp[i][j+1])
。
- 如果
确定 base case 的值之后,dp 数组变成下面这种状态:
每个位置的值依赖于左边、下边、左下三个位置的值,最终要求的值是右上角的 dp[0][n-1]
,所以需要斜着进行遍历,或者反向遍历,来逐渐求出最终值。
int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
// base case
for (int i = 0; i < n; i++) {
dp[i][i] = 1;
}
// 状态转移(注意遍历顺序)
// 从下往上、从左往右计算
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = 2 + dp[i + 1][j - 1]; // 依赖左下角元素
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); // 依赖左边、下边元素
}
}
}
return dp[0][n - 1];
}
5. 最长回文子串#
方法一:动态规划
- dp 数组定义:
dp[i][j]
定义为s[i:j]
是否为回文串。 - base case:
i==j
时dp[i][j]=true
,j==i+1
时,dp[i][j]
的值取决于s[i]
和s[j]
是否相等。 - 状态转移:
s[i+1:j-1]
为回文串(即dp[i+1][j-1]==true
)且s[i]==s[j]
时,dp[i][j]==true
。
从图中可见,每个位置依赖于左下角位置的值,所以可以有两种遍历顺序。
下面代码的遍历顺序是向斜下方遍历:
String longestPalindrome(String s) {
int n = s.length();
boolean[][] dp = new boolean[n][n];
// base case
for (int i = 0; i < n; i++) {
dp[i][i] = true;
if (i < n-1) {
dp[i][i+1] = s.charAt(i) == s.charAt(i+1);
}
}
// 状态转移(注意遍历顺序)
for (int i = n-3; i >= 0; i--) {
for (int j = i + 2; j < n; j++) {
dp[i][j] = dp[i+1][j-1] && (s.charAt(i) == s.charAt(j));
}
}
// 从所有 true 元素中找出 `j-i` 最大的
int resultStart = 0, maxLen = 0;
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
if (dp[i][j] && j - i > maxLen) {
maxLen = j - i;
resultStart = i;
}
}
}
return s.substring(resultStart, resultStart + maxLen + 1);
}
方法二:中心扩展
可以从每一种边界情况进行扩展,最终得到所有状态对应的答案。
- 边界情况有两种类型:长度为 1 和 2。
- 从每一种边界情况对应的子串开始,向两边扩展,如果两边字母相同,则继续扩展;不同则停止扩展,意味着再扩展下去也不可能是回文了。
String longestPalindrome(String s) {
int maxLen = 1, start = 0;
for (int i = 0; i < s.length(); i++) {
int oddLen = expandAroundCenter(s, i, i);
int evenLen = expandAroundCenter(s, i, i + 1);
int len = Math.max(oddLen, evenLen);
if (len > maxLen) {
maxLen = len;
start = i - (len - 1) / 2;
}
}
return s.substring(start, start + maxLen);
}
int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
// 向两边扩展
left--;
right++;
}
return right - left - 1; // 回文串长度
}
1312. 让字符串成为回文串的最少插入次数#
输入一个字符串,可以在任意位置插入任意字符,要把字符串变成回文串,最少要进行多少次插入?
三要素的确定:
-
dp[i][j]
:让子串s[i:j]
成为回文串的最少插入次数。 -
Base Case:
dp[i][i] = 0
。 -
状态转移:在求
dp[i][j]
时,s[i+1:j-1]
已经可以看作是回文串了,所以:-
如果
s[i] == s[j]
,dp[i][j] = dp[i+1][j-1]
。 -
否则,首先考虑下面两种情况的最小插入次数:
- 将
s[i:j-1]
变成回文串(dp[i][j-1]
); - 将
s[i+1:j]
变成回文串(dp[i+1][j]
)。
然后再考虑将
s[i:j]
变成回文串,在上一步基础上——- 如果
s[i:j-1]
已经成回文串,那么在s[i]
之前插入字符s[j]
即可让s[i:j]
成为回文串。 - 如果
s[i+1:j]
已经成回文串,那么在s[j]
之后插入s[i]
即可。
- 将
-
int minInsertions(String s) {
int n = s.length();
int[][] dp = new int[n][n];
// 默认初始化已经包含了 Base Case
// 状态转移:注意遍历顺序——从下往上、从左往右
for (int i = n - 2; i >= 0; i--) {
for (int j = i + 1; j < n; j++) {
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i + 1][j - 1];
} else {
dp[i][j] = 1 + Math.min(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
背包问题总结#
背包问题分类
- 根据每个物品的数量来分类,重点是0-1背包和完全背包。
🛠️ 模板
- 遍历顺序
- 0-1 背包:外层循环物品 num,内层循环背包容量 target,dp 数组为一维时,内层循环从大到小(从 target 到 num)。
- 完全背包:外层循环物品 num,内层循环背包容量 target,dp 数组为一维时,内层循环从小到大(从 num 到 target)。
- 完全背包-组合问题:
- 不考虑组合中元素的顺序:外层循环物品,内层循环背包容量,dp 数组为一维时,内层循环从小到大。(零钱兑换 II)
- 考虑组合中元素的顺序(组合背包):外层循环背包容量,内层循环物品,dp 数组为一维时,内层循环从小到大。(组合总和 IV)
- 状态转移
- 最大 / 小值问题:
- 求个数:
dp[i] = max/min(dp[i], dp[i-num] + 1)
- 求价值:
dp[i] = max/min(dp[i], dp[i-num] + num)
- 求个数:
- 存在性问题:
dp[i]=dp[i]||dp[i-num]
- 组合数量问题:可以选出多少种组合,其和为指定值。
dp[i] += dp[i-num]
- 最大 / 小值问题:
0-1 背包问题#
0-1 背包问题的解法#
有 n
件物品和一个最多能背重量为 w
的背包。第 i
件物品的重量是 weight[i]
,得到的价值是 value[i]
。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
例子:
物品重量:weight = {1, 3, 4};
物品价值:value = {15, 20, 30};
背包容量:w = 4
- dp 数组定义:dp 数组为二维的,
dp[i][j]
表示背包容量为j
,从前i
个物品中任意挑选装入背包,能够得到的最大价值。
- Base Case:
- 当背包容量为 0 时,最大价值为 0 ——
dp[i][0] = 0
;(第一列) - 选择物品 0 时,最大价值为
value[0]
或者0
. (第一行)
- 当背包容量为 0 时,最大价值为 0 ——
- 状态转移:对于
dp[i][j]
——- 如果当前容量
j
小于当前物品的重量weight[i]
,则不能放入当前物品i
,只能从前i-1
个物品中选,且因为没有放入物品,容量就不变。所以dp[i][j] = dp[i-1][j]
。 - 否则,可以选择放入物品
i
,也可以不放入,两种选择的最大值就是dp[i][j]
的结果。即:dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i])
。(依赖于上一行的某些位置)
- 如果当前容量
int weightBag(int[] weight, int[] value, int capacity) {
int m = weight.length, n = capacity;
int[][] dp = new int[m][n + 1];
// base case (Java 对数组元素默认初始化为 0,所以不必显式处理值为 0 的位置)
for (int j = weight[0]; j <= n; j++) { // 初始化第一行(从第一个物品中选择)
dp[0][j] = value[0];
}
// 状态转移
for (int i = 1; i < m; i++) { // 从前 i 个物品中选择
for (int j = 1; j <= n; j++) { // 背包容量为 j
if (j < weight[i]) { // 盛不下,则只能从前 i-1 个物品中选取
dp[i][j] = dp[i - 1][j];
} else { // 盛得下,则可选也可不选当前物品
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
return dp[m - 1][n];
}
空间优化
dp 数组可以变成一维数组,反复利用上一轮数组中的值更新当前轮次数组中的值,且方向是从后往前,这样防止上一轮还未使用的值被覆盖。此时 dp[j]
表示容量为 j
的背包能够装下的物品的最大价值,状态转移为:dp[j] = max(dp[j], value[j] + dp[j-value[j]])
,其中右边的 dp[j]
是上一轮的值。 Base Case 为 dp[j]=0
,这会在创建数组时被 Java 默认初始化,无需显式赋值。
int[] dp = new int[bagWeight + 1];
for (int weight : weights) {
for (int j = bagWeight; j >= weight; j--) { // 从后往前遍历到 j==weight 处
dp[j] = Math.max(dp[j], dp[j - weight] + weight);
}
}
return dp[bagWeight];
416. 分割等和子集#
给你一个 只包含正整数 的 非空 数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
方法:动态规划
这个问题可以转化为:选取一部分数字,使得它们的和等于整个数组和的一半。这属于 0-1 背包问题。(数字相当于物品,数组和的一半相当于背包容量,不考虑物品价值)
假设数组长度和数组之和分别为 n
和 2*target
,那么 dp 数组可以定义为:
- dp 数组包含
n
行target+1
列,为每个元素创建一行,为0
到target
之间的每个整数创建一列。 dp[i][j]
表示从子数组nums[0:i]
中选取一部分数字,是否存在其和等于j
的情况。
边界情况为:
- 对于每个元素,如果什么也不选,和就为
0
,所以有dp[i][0] = true
; - 当
i
为0
时,只能从nums[0:0]
中做选择,即只能选取num s[0]
这个正整数,所以dp[0][nums[0]] = true
。
状态转移为:
- 如果
j >= nums[i]
,说明当前数字nums[i]
可选也可不选,两种情况只要有一个为true
,dp[i][j]
就为true
。即dp[i][j] = dp[i-1][j-nums[i]] || dp[i-1][j]
。 - 如果
j < nums[i]
,说明当前数字不能选,于是dp[i][j] = dp[i-1][j]
。
最后的 dp[n-1][target]
就是这个问题的答案。
例子: 给定数组 [1,5,11,5]
,dp 表如下,空白位置均为 false
。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | true | true | ||||||||||
5 | true | true | true | true | ||||||||
11 | true | true | true | true | true | |||||||
5 | true | true | true | true | true | true |
public boolean canPartition(int[] nums) {
int n = nums.length;
if (n < 2) return false;
int sum = 0, maxNum = 0;
for (int num : nums) {
sum += num;
maxNum = Math.max(maxNum, num);
}
// 和必须为偶数
if (sum % 2 != 0) return false;
// 最大数大于和的一半,说明无解
int target = sum / 2;
if (maxNum > target) return false;
// 开始动态规划
boolean[][] dp = new boolean[n][target + 1];
// base case
for (int i = 0; i < n; i++) dp[i][0] = true;
dp[0][nums[0]] = true;
// 状态转移
for (int i = 1; i < n; i++) {
int cur = nums[i];
for (int j = 1; j < target + 1; j++) {
if (j >= cur) {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - cur];
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[n - 1][target];
}
空间优化
在计算 dp 的过程中,每一行的值只与上一行有关,因此可以将二维数组变成一维数组,此时的转移方程成为:dp[j] = dp[j] || dp[j-nums[i]]
。(反复刷新上面 dp 表中同一行的值)
需要注意的是,第二层的循环需要从大到小计算,因为 dp[j]
依赖于上一行的dp[j-nums[i]]
,如果从小到大计算,当前行的 dp[j-nums[i]]
会覆盖掉上一行这个值。
boolean[] dp = new boolean[target + 1];
dp[0] = true;
for(int i = 0; i < n; i++) {
int cur = nums[i];
for(int j = target; j >= cur; j--)
dp[j] = dp[j] || dp[j-cur];
}
return dp[target];
494. 目标和#
给你一个整数数组 nums
和一个整数 target
。向数组中的每个整数前添加 '+'
或 '-'
,然后串联起所有整数,可以构造一个 表达式 :
- 例如,
nums = [2, 1]
,可以在2
之前添加'+'
,在1
之前添加'-'
,然后串联起来得到表达式"+2-1"
。
返回可以通过上述方法构造的、运算结果等于 target
的不同 表达式 的数目。
方法:动态规划 (0-1 背包问题)
设元素总和为 sum
,添加负号的元素之和为 neg
,则添加正号的元素之和为 sum-neg
,那么得到的式子的结果为 (sum - neg) - neg = target
,即 neg = (sum - target) / 2
。
那么问题转化为:选取一部分元素(为它们添加符号),它们的和为 (sum - target) / 2
,求这种选取的方案数。
bagWeight 为
(sum - target) / 2
,物品为各个数,因为各个数不重复,且每个数只能选一次,所以这是 0-1 背包问题。
- dp 数组定义:定义二维数组
dp
,其中dp[i][j]
表示在前i
个数中选取元素,满足元素和为j
的方案数。如果数组长度为n
,则最终答案为dp[n][neg]
。 - 边界情况:当
i==0
时,表示可选子数组为空,那么没有元素可选,元素和只能是0
,所以在j=0
时有dp[0][j] = 1
,其他情况下,dp[0][j] = 0
。 - 状态转移:对于数组中第
i
个元素nums[i]
:- 如果
j < nums[i]
,则不能选该元素,所以dp[i][j] = dp[i-1][j]
; - 如果
j >= nums[i]
,则可选可不选该元素,所以dp[i][j] = dp[i-1][j-nums[i]] + dp[i-1][j]
。
- 如果
由于
dp
的每一行的计算只和上一行有关,因此可以使用滚动数组的方式,去掉第一个维度,将空间复杂度优化到 。
实现时,内层循环需采用倒序遍历的方式,这种方式保证转移来的是 中的元素值。
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for(int num : nums)
sum += num;
int diff = sum - target;
// 注意这两个条件
if(sum < target || diff % 2 != 0)
return 0;
int n = nums.length, neg = diff / 2;
// dp[i][j] 表示从前 i 个元素中找到和为 j 的子数组的方案数
// int[][] dp = new int[n+1][neg+1];
// dp[0][0] = 1; // base case
// // 状态转移
// for(int i = 1; i <= n; i++) {
// int cur = nums[i-1];
// for(int j = 0; j <= neg; j++) {
// if(cur <= j) {
// // 可选可不选
// dp[i][j] = dp[i-1][j-cur] + dp[i-1][j];
// } else {
// // 不能选
// dp[i][j] = dp[i-1][j];
// }
// }
// }
// return dp[n][neg];
// 空间优化
int[] dp = new int[neg+1];
dp[0] = 1;
for(int num : nums) {
for(int j = neg; j >= num; j--)
dp[j] += dp[j-num];
}
return dp[neg];
}
}
1049. 最后一块石头的重量 II#
有一堆石头,用整数数组 stones
表示。其中 stones[i]
表示第 i
块石头的重量。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x
和 y
,且 x <= y
。那么粉碎的可能结果如下:
- 如果
x == y
,那么两块石头都会被完全粉碎; - 如果
x != y
,那么重量为x
的石头将会完全粉碎,而重量为y
的石头新重量为y-x
。 - 最后,最多只会剩下一块石头。返回此石头最小的可能重量 。如果没有石头剩下,就返回
0
。
方法:动态规划(0-1 背包问题)
实际上就是要让分出的两堆石头的重量尽量接近 sum/2
,这样碰撞之后留下的重量最小。因为第一堆石头重量越接近 sum/2
的时候,第二堆的重量也会随之越接近 sum/2
,这样两堆石头的重量就越接近。
问题进一步转化为:向一个可以装下 sum/2
重量的袋子里装入一堆石头,最多可以装入石头的总重量 maxWeight
。变成了 0-1 背包问题。最终剩下的石头重量为 sum - 2 * maxWeight
。
要素确定:
- dp 数组定义:
dp[i][j]
表示在前i
个石头中选取部分石头装入容量为j
的袋子,最多可以装入多大重量。(j
取值范围为[0, bagWeight]
,i
是每个石头) - Base Case:
- 第一列都为 0;
- 第一行:
j ≥ stones[0]
时,dp[0][j] = stones[0]
。(因为每一行的值依赖于上一行的某些值,所以需要先初始化第一行)
- 状态转移:
- 如果
stones[i] > j
,则不能选择当前石头,dp[i][j] = dp[i-1][j]
; - 否则,可选也可不选当前石头,
dp[i][j] = Math.max(dp[i-1][j-stones[i]] + stones[i], dp[i-1][j]
- 如果
public int lastStoneWeightII(int[] stones) {
int n = stones.length;
if (n == 0) return 0;
if (n == 1) return stones[0];
int sum = Arrays.stream(stones).sum();
int target = sum / 2;
int[][] dp = new int[n][target+1];
// Base Case(第一行)
for (int j = stones[0]; j <= target; j++) {
dp[0][j] = stones[0];
}
// 状态转移
for (int i = 1; i < n; i++) { // 遍历石头
for (int j = 0; j <= target; j++) { //遍历背包容量
if (stones[i] > j) {
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-stones[i]] + stones[i]);
}
}
}
return sum - 2 * dp[n-1][target];
}
空间优化:二维数组变成一维数组,从后往前不断刷新同一行的值。
public int lastStoneWeightII(int[] stones) {
int n = stones.length;
if (n == 0) return 0;
if (n == 1) return stones[0];
int sum = Arrays.stream(stones).sum();
int target = sum / 2;
int[] dp = new int[target + 1];
// Base Case:dp[i]=0,已经被默认初始化了
// 状态转移
for (int stone : stones) {
for (int j = target; j >= stone; j--) { // 从后往前遍历到 j==stone 位置
dp[j] = Math.max(dp[j], stone + dp[j-stone]);
}
}
return sum - 2 * dp[target];
}
474. 一和零#
给你一个二进制字符串数组 strs
和两个整数 m
和 n
。
请你找出并返回 strs
的最大子集的长度,该子集中最多 有 m
个 0 和 n
个 1。
方法:动态规划
这也是一个背包问题:
- 背包容量:m 个 1 和 n 个 0(两个维度)
- 物品:各个字符串
要素的确定:(空间优化版本)
- dp 数组定义:
dp[i][j]
表示 0 和 1 的数量最多分别为i
和j
的时候,子集的最大长度。 - Base Case: 0-1 背包问题的 Base Case 都是将 dp 数组初始化为 0。
- 状态转移:当前字符串中 0 和 1 的数量同时满足
zeros <= i && ones <= j
时,dp[i][j] = max(dp[i][j], 1 + dp[i-zeros][j-ones])
。
public int findMaxForm(String[] strs, int m, int n) {
int len = strs.length;
int[][] dp = new int[m + 1][n + 1];
for (String str : strs) {
int[] num0_num1 = getNumOf0and1(str);
int num0 = num0_num1[0], num1 = num0_num1[1];
for (int j = m; j >= num0; j--) {
for (int k = n; k >= num1; k--) {
dp[j][k] = Math.max(dp[j][k], 1 + dp[j - num0][k - num1]);
}
}
}
return dp[m][n];
}
int[] getNumOf0and1(String str) {
int num_0 = 0, num_1 = 0;
for (char c : str.toCharArray()) {
if (c == '0') num_0++;
if (c == '1') num_1++;
}
return new int[] {num_0, num_1};
}
完全背包问题#
完全背包问题的解法#
问题描述:
有 n 件物品和一个最多能背重量为 W
的背包。第 i
件物品的重量是 weight[i]
,得到的价值是 value[i]
。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
和 0-1 背包的唯一不同点在于:每件物品可以多次选择。
dp 数组定义和状态转移:
同样地,dp[i][j]
表示在前 i
件物品中选取部分物品放入容量为 j
的背包可以获得的最大价值。每件物品可以被选择多次,因此 dp[i][j]
为下面所有情况的最大值:
- 第
i
件物品选择 0 个:dp[i-1][j]
- 第
i
件物品选择 1 个:dp[i-1][j-weight[i]] + value[i]
- 第
i
件物品选择 2 个:dp[i-1][j - 2 * weight[i]] + 2 * value[i]
- ……
- 第
i
件物品选择 k 个:dp[i-1][j - k * weight[i]] + k * value[i]
当然,能放入 k 件的前提为 k * weight[i] <= j
。
所以,状态转移方程为:
可以按照类似 0-1 背包的遍历方式,但是对每件物品都要枚举所有可行的选择数量 k
(0-1 背包只是选 1 个)。这种方式效率较低,可以对这个状态转移方程进行优化,得到更为简洁的表达。
将这个状态转移方程展开:
对于 dp[i][j-w_i]
,有:
将(2)式代入(1)式,得到:
这里 max
函数的第二个参数是第 i
行的值,而在 0-1 背包中这一项是第 i-1
行的值,这就导致了编程时状态更新顺序的不同。
遍历方式:同样是先遍历物品,再遍历背包容量。
int n = weight.length;
int[][] dp = new int[n][bagWeight+1];
for (int i = 0; i < n; i++) { // 遍历物品
for (int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (weight[i] <= j) {
dp[i][j] = Math.max(dp[i-1][j], value[i] + dp[i][j-weight[i]]);
} else {
dp[i][j] = dp[i-1][j];
}
}
}
空间优化:每个 dp[i][j]
值只和正上方的 dp[i-1][j]
和当前行左边的 dp[i][j-x]
这两个值有关。所以可以基于滚动数组的思想将 dp 数组变成一维的:
借助上图可以看到,在更新 dp[j]
时,用到了 dp[j]
上一轮的值,以及当前行已经更新过的值 dp[j-w]
,所以从左到右刷新 dp
数组的值即可。
0-1 背包问题中内层循环是从大到小计算的;
完全背包问题中内层循环是从小到大计算的。
int n = weight.length;
int[] dp = new int[bagWeight +1];
for (int item : items) { // 遍历物品
for (int j = item.weight; j <= bagWeight; j++)
dp[j] = Math.max(dp[j], dp[j-item.weight] + item.value);
}
322. 零钱兑换#
给定 k
种面值的硬币,面值分别为 c1, c2, ..., ck
,每种数量不限,再给定一个总金额 amount
,问 最少需要几枚硬币 能凑出这个金额。如果凑不出,则返回 -1.
方法:动态规划(完全背包问题)
这实际上是一个完全背包问题:
- 物品:硬币,且每种面值的硬币可以选择多个。
- 背包容量:
amount
。
确定三要素:(空间优化版本,dp 数组为一维的)
- dp 数组定义:
dp[i]
表示凑出i
金额的钱最少需要多少枚硬币; - base case:
i==0
时返回 0;i<0
时返回 -1; - 状态转移:
dp[i] = min(dp[i], dp[i-coins[i]] + 1)
。
在这类问题中,求最小值时,dp 数组初始化方式(Base Case)为:
dp[0] = 0
dp[i] = 不可能取到的一个较大值
。
int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1);
dp[0] = 0; // base case
for (int coin : coins) { // 遍历物品
for (int i = coin; i <= amount; i++) // 遍历背包容量,从小到大
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
if (dp[amount] == amount + 1) return -1;
return dp[amount + 1];
}
279. 完全平方数#
给定一个数 n
,求出它至少能由几个完全平方数相加得到。
方法:动态规划(完全背包问题)
用 dp[i]
表示数字 i
最少能由几个完全平方数得相加得到。这些完全平方数一定落在 区间,可以枚举这个区间中的数,假设枚举到 j
,那么为了相加得到 i
,还需要凑出 ——问题规模变小了,符合动态规划要求。于是得到状态转移方程:
int numSquares(int n) {
int[] dp = new int[n + 1];
for (int i = 1; i <= n; i++) {
int minn = n;
for (int j = 1; j * j <= i; j++)
minn = Math.min(minn, dp[i - j * j]);
dp[i] = minn + 1;
}
return dp[n];
}
或者按照完全背包问题来解。
因为本问题需要从一些完全平方数中选择一部分出来,其和要等于 n
,所以是一个背包问题:
- 物品:从
1
到sqrt(n)
的各个数的平方。 - 背包容量:
n
。
且因为每个数不限制选择次数,所以是完全背包问题。
那么,按照模板:
dp[i]
定义为和为i
的完全平方数的最少个数;- 初始化:
dp[0] = 0
,其他位置都为最大值n
(因为要求的是最小值); - 状态转移为
dp[i] = min(dp[i], dp[i-num*num] + 1)
。
int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, n);
dp[0] = 0;
for (int i = 0; i * i <= n; i++) { // 遍历物品
for (int j = i * i; j <= n; j++) {
dp[j] = Math.min(dp[j], 1 + dp[j - i * i]);
}
}
return dp[n];
}
518. 零钱兑换 II#
给你一个整数数组 coins
表示不同面额的硬币,另给一个整数 amount
表示总金额。
请你计算并返回 可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
方法:完全背包-组合问题(不考虑组合中元素的顺序)
容易看出这是一个完全背包问题,但是不是求最值,而是求组合个数。
按照完全背包的思路,还是先遍历硬币,再遍历背包容量,dp 数组正序计算。
- dp 数组初始化为
0
,但dp[0]
初始化为1
,因为什么也不选也是一种组合。 - 给定硬币面值
coin
,对于dp[i]
,当dp[i-coin] > 0
时,说明dp[i-coin]
能够被凑出来,那么dp[i]
可以从dp[i-coin]
转移而来,dp[i] += dp[i-coin]
。
public int change(int amount, int[] coins) {
int[] dp = new int[amount+1];
dp[0] = 1;
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
if (dp[i-coin] > 0) dp[i] += dp[i-coin];
}
}
return dp[amount];
}
组合背包问题#
377. 组合总和 Ⅳ#
方法:完全背包-组合问题(考虑组合中元素的顺序)(即组合背包问题)
- 先遍历背包容量,再遍历物品,且
target >= num
; - 状态转移为
dp[i] += dp[i-num]
。
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target+1];
dp[0] = 1;
for (int i = 1; i <= target; i++) {
for (int num : nums) {
if (i >= num) dp[i] += dp[i-num];
}
}
return dp[target];
}
其他问题#
343. 整数拆分#
给定一个正整数 n
,将其拆分为 k
个 正整数 的和( k >= 2
),并使这些整数的乘积最大化。
方法:动态规划
-
dp 数组定义:
dp[i]
表示将整数i
拆分为至少两个整数后,这些整数的最大乘积。 -
Base case:
dp[0]=0, dp[1]=0
(0,1都不能拆分) -
状态转移:对于整数
i
,可以将其拆分为j
和i-j
两个数,,那么对于每一个j
,dp[i]
可以从下面两种情况转移而来:- 如果
i-j
不再拆分,则此时拆分的结果乘积为j * (i-j)
; - 如果
i-j
继续拆分,则此时拆分的结果乘积为j * dp[i-j]
。
dp[i]
就是遍历所有j
之后,各种拆分情况下的最大值: - 如果
-
最终结果:
dp[n]
public int integerBreak(int n) {
int[] dp = new int[n+1];
dp[0] = dp[1] = 0;
for (int i = 2; i <= n; i++) {
for (int j = 1; j < i; j++) {
dp[i] = Math.max(dp[i], Math.max(j * (i-j), j * dp[i-j]));
}
}
return dp[n];
}
221. 最大正方形#
在一个由 '0'
和 '1'
组成的二维矩阵内,找到只包含 '1'
的最大正方形,并返回其面积。
方法一:暴力
过程如下:
-
遍历矩阵中每个元素,每次遇到
1
,将该元素作为正方形的左上角。 -
确定左上角后,根据它的行列号计算可能的最大正方形的边长,在这个可能的最大正方形的范围内(从这个左上角位置延伸到最后一行/列的范围)寻找只包含 1 的最大正方形。
-
每次判断新增的一行和一列是否都为 1,:
- 先判断下一行和下一列的交叉点是否为 1,如果不是则直接跳出循环;
- 是则进一步判断下一行和下一列的其他位置是否都为 1,如果是则说明找到了一个正方形,更新最大正方形的边长。
int maxSquare(int[][] matrix) {
int maxEdge = 0;
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) return 0;
int rows = matrix.length, cols = matrix[0].length;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
// 遇到一个1,作为正方形左上角
if (matrix[i][j] == 1) {
maxEdge = Math.max(maxEdge, 1);
int possibleMaxEdge = Math.min(rows - i, cols - j);
// 判断新增的一行一列是否都为 1
for (int k = 1; k < possibleMaxEdge; k++) {
boolean flag = true;
if (matrix[i + k][j + k] == 0) break;
for (int m = 0; m < k; m++) {
if (matrix[i + k][j + m] == 0 || matrix[i + m][j + k] == 0) {
flag = false;
break;
}
}
if (flag) maxEdge = Math.max(maxEdge, k + 1);
else break;
}
}
}
}
int maxSquare = maxEdge * maxEdge;
return maxSquare;
}
方法二:动态规划
用 dp[i][j]
表示以 (i,j)
位置为右下角的最大正方形边长。每个位置的值取决于其左边、上边和左上边三个位置的值,状态转移方程为:
int maxSquare(int[][] matrix) {
int maxEdge = 0;
if (matrix == null || matrix.length == 0 || matrix[0].length == 0) return 0;
int rows = matrix.length, cols = matrix[0].length;
int[][] dp = new int[ros][cols];
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (matrix[i][j] == 1) {
if (i == 0 || j == 0) dp[i][j] = 1; // 第一行和第一列
else dp[i][j] = myMin(dp[i - 1][j], dp[i - 1][j - 1], dp[i][j - 1]);
// 更新最大边长
maxEdge = Math.max(maxEdge, dp[i][j]);
}
}
}
return maxEdge * maxEdge;
}
53. 最大子数组和#
给定一个整数数组 nums
,找出一个具有最大和的连续子数组,返回其最大和。
三要素的确定:
dp[i]
:表示以nums[i]
结尾的子数组的最大和。base case
:dp[0]=nums[0]
。- 状态转移:考虑当前元素是单独成段还是归入前面的段,如果
dp[i-1]>0
,那么就归入前面的段,否则单独成段(因为是以nums[i]
结尾的子数组):dp[i]=max(nums[i], dp[i-1] + nums[i])
。
int maxSubArray(int[] nums) {
int len = nums.length;
int[] dp = new int[len];
dp[0] = nums[0];
for (int i = 1; i < len; i++) {
dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]);
}
// 找到最大和
int result = dp[0];
for (int i = 1; i < len; i++) {
result = Math.max(result, dp[i]);
}
return result;
}
152. 乘积最大子数组#
给你一个整数数组 nums
,请你找出数组中乘积最大的非空连续子数组,并返回该子数组所对应的乘积。
方法:动态规划
按照「最大子序列和」的经验,可以针对「最大子序列积」推导出这样的状态转移方程:
但这其实是错的,因为乘积有「负负得正」这种情况,所以不满足最优子结构条件,换句话说,当前位置的最优解未必是由前一个位置的最优解转移得到的。
所以需要根据正负性进行分类讨论。
- 维护 的同时,
- 另外维护 。
这样,状态转移方程为:
最后遍历 数组,找出最大值即为最终结果。
int maxProduct(int[] nums) {
int len = nums.length;
int[] f_max = new int[len];
int[] f_min = new int[len];
f_max[0] = nums[0];
f_min[0] = nums[0];
for(int i = 1; i < len; i++) {
f_max = my_max(f_max[i-1] * nums[i],f_min[i-1] * nums[i], nums[i]));
f_min = my_min(f_max[i-1] * nums[i],f_min[i-1] * nums[i], nums[i]));
}
int result = f_max[0];
for(int i = 1; i < len; i++)
result = Math.max(result, f_max[i]);
return result;
}
int my_max(int a, int b, int c) {
return Math.max(a, Math.max(b ,c));
}
由于每个状态只和前一个状态相关,所以可以利用滚动数组的思想,只用两个变量来维护每个时刻的状态,以此来优化空间复杂度:
int maxProduct(int[] nums) {
int len = nums.length;
int result = nums[0], _max = nums[0], _min = nums[0];
for (int i = 1; i < len; i++) {
int temp_max = _max, temp_min = _min;
_max = my_max(temp_max * nums[i], temp_min * nums[i], nums[i]);
_min = my_min(temp_max * nums[i], temp_min * nums[i], nums[i]);
result = Math.max(result, _max);
}
return result;
}
96. 不同的二叉搜索树#
给定一个整数 n
,求出由结点值分别为 1-n
的这 n
个结点组成的互不相同的二叉搜索树有几种。
方法:动态规划
给定有序序列 ,为了构建二叉搜索树,可以遍历每个数字 ,将其作为根,将其左边的子序列作为左子树,右边的作为右子树。继而可以用同样的方式递归构建左右子树。
定义两个函数:
- :长度为 n 的有序序列能构成的不同二叉搜索树的个数。
- :以 i 为根、序列长度为 n 的不同二叉搜索树的个数。
那么有:
base case 是 。
给定序列 ,以 i 为根的所有二叉搜索树的集合是左子树集合与右子树集合的笛卡尔积。(即左子树集合与右子树集合两两组合)即:
则有:
int numTrees(int n) {
int[] G = new int[n+1];
G[0] = 1;
G[1] = 1;
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= i; j++)
G[i] += G[j-1] * G[i-j]; // 求 G[i]
}
return G[n];
}
139. 单词拆分#
给定一个单词列表和一个字符串,判断这个字符串是否能由单词列表中的单词组成。
方法:动态规划
用 表示子串 能否由单词列表组成。具体来说,需要遍历子串中的每一个分割点 ,判断 和 是否都可由单词列表组成,都是的话 才会为 true
,所以状态转移方程为:。
boolean wordBreak(String s, List<String> wordDict) {
Set<String> wordDictSet = new HashSet<>(wordDict);
boolean[] dp = new boolean[s.length()+1];
dp[0] = true; // 表示空串为合法情况
for(int i = 1; i <= s.length(); i++) {
// 遍历每一个分割点,试图寻找一个使得子串合法的分割点
for(int j = 0; j < i; j++) {
if(dp[j] && wordDictSet.contains(s.substring(j, i))) {
dp[i] = true;
break;
}
}
}
return dp[s.length()];
}
300. 最长递增子序列#
示例:[10,9,2,5,3,7,101,19]
的最长上升子序列为 [2,5,7,101]
,长度为 4。
三要素的确定:
-
dp[i]
:以nums[i]
结尾的最长递增子序列的长度。 -
base case
:dp[0]=1
。 -
状态转移:
for(int j = 0; j < i; j++) { if(nums[j] < nums[i]) { dp[i] = Math.max(dp[i], dp[j] + 1); } }
int lengthOfLIS(int[] nums) {
int len = nums.length;
int[] dp = new int[len];
Arrays.fill(dp, 1);
for (int i = 1; i < len; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], 1 + dp[j]);
}
}
}
// dp 数组中的最大值就是结果
int maxLen = 0;
for (int i = 0; i < len; i++) {
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义