文章目录
六、动态规划
6.1动态规划核心框架
求解动态规划问题的本质是穷举,且此类问题还包含了以下三要素:
- 1.重叠子问题:动态规划的穷举中存在大量重叠的子问题,直接暴力穷举会使得效率低下,此时需要使用备忘录或DP数组来优化穷举过程,减少不必要的计算。
- 2.最优子结构:动态规划问题具备最优子结构,可通过子问题的解得到原问题的解。
- 3.状态转移方程:原问题与子问题之间存在一定关系,该关系可通过状态转移方程表示。此外,状态转移过程也能帮助正确穷举所有可行解。
动态规划算法框架:
python
# 初始化DP数组(假设是N维)
dp[0][0][...] = base
# 进行状态转移
for 状态1 in 状态1的所有值:
for 状态2 in 状态2的所有值:
for ...
dp[状态1][状态2][...] = 求最值(选择1, 选择2, ...)
java
class Solution {
public int fib(int n) {
if(n == 0 || n == 1)return n;
else return fib(n - 1) + fib(n - 2);
}
}
直接使用递归求解斐波那契数列问题是低效的。设n=20,查看递归树:

当计算原问题 f ( 20 ) f(20) f(20)时需先计算出子问题 f ( 19 ) 、 f ( 18 ) f(19)、f(18) f(19)、f(18),计算 f ( 19 ) f(19) f(19)时同理要计算出子问题 f ( 18 ) 、 f ( 17 ) f(18)、f(17) f(18)、f(17)。以此类推,最后计算到 f ( 2 ) 、 f ( 1 ) f(2)、f(1) f(2)、f(1)时结果已知即可直接返回结果,递归树也无需向下继续生长。递归算法的时间复杂度等于子问题个数乘以解决一个子问题所需的时间,该递归树树高为O(N)级别,因此节点总数为 O ( 2 n ) O(2^n) O(2n)级别,时间复杂度亦为 O ( 2 n ) O(2^n) O(2n)。
事实上,观察递归树可知此问题存在大量重复计算,如 f ( 18 ) f(18) f(18)被重复计算两次等等,这也是动态规划要素之一------重叠子问题。因此,可考虑使用一个数组充当备忘录,在计算出某个子问题的解后写放入备忘录中,之后再遇到该子问题时直接在该数组中查找,从而避免重复计算。
找出此问题的状态转移方程:

java
class Solution {
public int fib(int n) {
if(n == 0 || n == 1)return n;
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
for(int i = 2;i <= n;i ++)dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
}
【2.LeetCode322:零钱兑换】
此问题是动态规划问题,因为其具有最优子结构。例如,当求amount = 11的最少硬币数时(原问题),若已知amount = 10的最少硬币数(子问题)且包含面值为1的硬币,则只需在amount = 10的结果上加1即可。并且,由于硬币数量之间是没有限制的,因此子问题之间是没有限制、相互独立的。为列出状态转移方程,还需确定以下条件:
- base case :
amount = 0时所需硬币数为0,即无需任何硬币即可凑出目标金额。 - 状态 :原问题与子问题中会变化的量。由于硬币数量⽆限,硬币的⾯额也是给定的,只有目标金额不断地向
base case靠近,因此状态量即为目标金额amount。 - 选择:导致状态产生变化的行为。在选择硬币,每选择⼀枚硬币,就相当于减少了目标金额。因此硬币的面值,即是选择。
得到状态转移方程:

java
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for(int i = 1;i <= amount;i ++){
for(int coin : coins){
if(i - coin >= 0 && dp[i - coin] != Integer.MAX_VALUE)dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
return dp[amount] == Integer.MAX_VALUE ? -1 : dp[amount];
}
}
java
class Solution {
public int minFallingPathSum(int[][] matrix) {
int m = matrix.length;
//dp[i][j]:matrix[i][j]的下降路径最小和
int[][] dp = new int[m][m];
for(int i = 0;i < m;i ++)Arrays.fill(dp[i], Integer.MAX_VALUE);
for(int i = 0;i < m;i ++)dp[m - 1][i] = matrix[m - 1][i];
for(int i = m - 2;i >= 0;i --){
for(int j = 0;j < m;j ++){
if(j - 1 >= 0)dp[i][j] = Math.min(dp[i][j], matrix[i][j] + dp[i + 1][j - 1]);
if(j + 1 < m)dp[i][j] = Math.min(dp[i][j], matrix[i][j] + dp[i + 1][j + 1]);
dp[i][j] = Math.min(dp[i][j], matrix[i][j] + dp[i + 1][j]);
}
}
int res = dp[0][0];
for(int val : dp[0])res = Math.min(res, val);
return res;
}
}
6.2dp数组的遍历方向
常见的三种遍历方向:
java
// 正向遍历
int[][] dp = new int[m][n];
for(int i = 0;i < m;i ++){
for(int j = 0;j < n;j ++){
//计算dp[i][j]
}
}
//反向遍历
for(int i = m - 1;i >= 0;i --){
for(int j = n - 1;j >= 0;j --){
//计算dp[i][j]
}
}
//斜向遍历
for(int l = 2;l <= n;l ++){
for(int i = 0;i <= n - l;i ++){
int j = l + i - 1;
//计算dp[i][j]
}
}
遍历方向的选择应把握以下两点:
- 1.遍历过程中,所需的状态必须是已计算出来的。
- 2.遍历结束后,存储结果的那个位置必须已被计算出来。

LeetCode72:编辑距离的dp数组如上图所示,其base case确定为dp[...][0](第0列)和dp[0][...](第0行),最终答案是dp[m][n],通过状态转移方程可知dp[i][j]由dp[i-1][j-1]、dp[i-1][j]、dp[i][j-1]转移而来。因此,dp数组应为正向遍历:
java
for(int i = 1;i <= m;i ++){
for(int j = 1;j <= n;j ++){
//根据dp[i - 1][j]、dp[i - 1][j - 1]、dp[i][j - 1]计算dp[i][j]
}
}

LeetCode516:最长回文子序列的dp数组如上图所示,其base case确定为中间对角线,最终答案是dp[0][n-1],通过状态转移方程可知,dp[i][j]由dp[i][j-1]、dp[i+1][j]、dp[i+1][j-1]转移而来。因此,dp数组采用斜向遍历或反向遍历均可:

java
// 斜向遍历
for(int l = 2;l <= n ;l ++){
for(int i = 0;i <= n - l;i ++){
int j = l + i - 1;
//根据dp[i + 1][j]、dp[i + 1][j - 1]、dp[i][j - 1]计算dp[i][j]
}
}
// 反向遍历
for(int i = m - 1;i >= 0;i --){
for(int j = n - 1;j >= 0;j --){
//根据dp[i + 1][j]、dp[i + 1][j - 1]、dp[i][j - 1]计算dp[i][j]
}
}
6.3一维动态规划
解法一:动态规划
java
class Solution {
public int lengthOfLIS(int[] nums) {
//dp[i]:以第i个元素结尾的最长递增子序列长度
int[] dp = new int[nums.length + 1];
Arrays.fill(dp, 1);
for(int i = 1;i < dp.length;i ++){
for(int j = 1;j < i;j ++){
if(nums[i - 1] > nums[j - 1])dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
int res = 1;
for(int i = 1;i < dp.length;i ++)res = Math.max(res, dp[i]);
return res;
}
}
java
class Solution {
public int maxSubArray(int[] nums) {
//dp[i]:以第i个元素为结尾的最大子数组和(i>=1)
int[] dp = new int[nums.length + 1];
dp[0] = 0;
for(int i = 1;i <= nums.length;i ++)dp[i] = Math.max(dp[i - 1] + nums[i - 1], nums[i - 1]);
int res = Integer.MIN_VALUE;
//从1开始遍历,否则nums = {-1}时报错
for(int i = 1;i <= nums.length;i ++)res = Math.max(res, dp[i]);
return res;
}
}
java
class Solution {
public int jump(int[] nums) {
//dp[i]:跳到第i个位置所需的最少次数(i>=1)
int[] dp = new int[nums.length + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
dp[1] = 0;
for(int i = 1;i < dp.length;i ++){
for(int j = i - 1;j > 0;j --){
if(nums[j - 1] >= i - j)dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
return dp[nums.length];
}
}
【4.LeetCode55:跳跃游戏】
java
class Solution {
public boolean canJump(int[] nums) {
// dp[i]: 能否到达第i个下标
boolean[] dp = new boolean[nums.length];
Arrays.fill(dp, false);
dp[0] = true;
for(int i = 1;i < dp.length;i ++){
for(int j = 0;j < i;j ++){
if(dp[j] && nums[j] >= i - j) {
dp[i] = true;
break;
}
}
}
return dp[nums.length - 1];
}
}
【5.LeetCode70:爬楼梯】
java
class Solution {
public int climbStairs(int n) {
if(n == 1)return 1;
//dp[i]:第i个台阶的爬法数
int[] dp = new int[n + 1];
dp[0] = 1;
dp[1] = 1;
for(int i = 2;i <= n;i ++)dp[i] = dp[i - 1] + dp[i - 2];
return dp[n];
}
}
【6.LeetCode198:打家劫舍】
java
class Solution {
public int rob(int[] nums) {
//dp[i]:最后一次偷窃第i间房屋时所能偷窃的最大金额
int[] dp = new int[nums.length + 1];
for(int i = 0;i < nums.length;i ++)dp[i + 1] = nums[i];
dp[0] = 0;
for(int i = 1;i < dp.length;i ++){
int maxVal = dp[0];
for(int j = i - 2;j >= 0;j --)maxVal = Math.max(maxVal, dp[j]);
dp[i] += maxVal;
}
int res = nums[0];
for(int val : dp)res = Math.max(res, val);
return res;
}
}
可分为如下两种情况:
- 1.不偷第一间房屋时偷窃范围:
[1, n - 1]。 - 2.不偷最后一间房屋时的偷窃范围:
[0, n - 2]。
此时再调用打家劫舍中的方法即可。完整代码:
java
class Solution {
public int rob(int[] nums) {
if(nums.length == 1)return nums[0];
else if(nums.length == 2)return Math.max(nums[0], nums[1]);
// nums1[]: 不偷第一间屋子时, 偷取范围为[1, n - 1]
int[] nums1 = new int[nums.length - 1];
for(int i = 1;i < nums.length;i ++)nums1[i - 1] = nums[i];
// nums2[]: 不偷最后一间屋子时, 偷取范围为[0, n - 2]
int[] nums2 = new int[nums.length - 1];
for(int i = 0;i < nums.length - 1;i ++)nums2[i] = nums[i];
return Math.max(func(nums1), func(nums2));
}
public int func(int[] nums){
// dp[i]: 最后一次偷nums[i]时, 能偷的最大金额
int[] dp = new int[nums.length];
for(int i = 0;i < nums.length;i ++)dp[i] = nums[i];
for(int i = 1;i < dp.length;i ++){
for(int j = 0;j < i - 1;j ++){
dp[i] = Math.max(dp[i], dp[j] + nums[i]);
}
}
int ans = nums[0];
for(int val : dp)ans = Math.max(ans, val);
return ans;
}
}
java
class Solution {
Map<TreeNode, Integer> f;
Map<TreeNode, Integer> g;
public int rob(TreeNode root) {
// 选择当前节点时, 能偷窃的最大金额
f = new HashMap<>();
// 不选择当前节点时, 能偷窃的最大金额
g = new HashMap<>();
dfs(root);
return Math.max(f.getOrDefault(root, 0), g.getOrDefault(root, 0));
}
public void dfs(TreeNode root){
if(root == null)return ;
// 当前节点的值需由左右孩子推算而来
dfs(root.left);
dfs(root.right);
// 选择当前节点, 左右孩子一定不能选
f.put(root, root.val + g.getOrDefault(root.left, 0) + g.getOrDefault(root.right, 0));
// 不选择当前节点, 左右孩子可以选但不一定选, 且不一定同时选
g.put(root, Math.max(f.getOrDefault(root.left, 0), g.getOrDefault(root.left, 0)) + Math.max(f.getOrDefault(root.right, 0), g.getOrDefault(root.right, 0)));
}
}
6.4二维动态规划
定义dp数组:
java
// dp[i][j]:text1前i个字符与text2前j个字符的最长公共子序列
int[][] dp = new int[text1.length + 1][text2.length + 1];
注意,不应定义dp[i][j]表示text1[0~i]个字符与text2[0~j]个字符的最长公共子序列长度,因为此时不便于初始化dp数组。按照如上定义,当i = 0或j = 0时,表示空字符串和另外一个字符串的匹配,此时dp[i][j]可直接初始化为0。
状态转移方程:
- 1.
text1[i - 1] == text2[j - 1]:两子串最后一位字符相等,因此最长公共子序列长度在dp[i - 1][j - 1]的基础上加1。 - 2.
text1[i - 1] != text2[j - 1]:两子串最后一位字符不相等,此时应为dp[i - 1][j]与dp[i][j - 1]的最大值。

遍历方向:当i = 0或j = 0时,dp[i][j] = 0。因此i与j应从1开始遍历,结束位置为dp[text1.size()][text2.size()],遍历方式为正向遍历。完整代码:
java
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
//dp[i][j]:text1前i个元素与text2前j个元素的最长公共子序列长度
int[][] dp = new int[text1.length() + 1][text2.length() + 1];
for(int i = 1;i <= text1.length();i ++){
for(int j = 1;j <= text2.length();j ++){
if(text1.charAt(i - 1) == text2.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[text1.length()][text2.length()];
}
}
【2.最长公共子串】
给定两个字符串text1和text2,输出两个字符串的最长公共子串,如果最长公共子串为空,输出-1。与上一题不同,子序列不要求字符连续,因此遍历到两不相等字符时可取Math.max(dp[i - 1][j], dp[i][j - 1]),而子串要求必须是连续的字符,因此不相等时只能赋0。可类比LeetCode300:最长递增子序列与LeetCode53:最大子数组和,因为子序列不要求连续,因此前者可通过判断决定是否更新值,后者则必须进行更新。事实上,这是因为子串/子数组问题在dp数组定义时,dp[i]要求了当前子串/子数组必须以text[i]/nums[i]作为结尾元素,而子序列并无此要求。
完整代码:
java
class Solution{
public int func(String text1, String text2){
int m = text1.length();
int n = text2.length();
int res = 0;
//dp[i][j]:以text1[i - 1]结尾和以text2[j - 1]结尾的最长公共子串长度
int[][] dp = new int[m + 1][n + 1];
for(int i = 0;i <= n;i ++)dp[0][i] = 0;
for(int j = 0;j <= m;j ++)dp[j][0] = 0;
for(int i = 1;i <= m;i ++){
for(int j = 1;j <= n;j ++){
if(text1.charAt(i - 1) == text2.charAt(j - 1))dp[i][j] = dp[i - 1][j - 1] + 1;
else dp[i][j] = 0;
res = Math.max(dp[i][j]);
}
}
return res;
}
}
【3.LeetCode10:正则表达式】
注意,在本题中*用于决定前一个元素出现的次数,例如:
p = "a*", s = "aaa":此时*可让p串中的a出现三次,因此可以匹配。p = "aaab*", s = "aaa":此时*可让p串中的b出现零次,因此可以匹配。
因此,*出现时其前一位必须带有字符。
设dp数组:
dp[i][j]:p的前i个字符与s的前j个字符是否匹配。
递归终点为右下角,因此正向遍历即可。为便于叙述和写代码,为s和p在开头补上空格字符。当遍历到dp[i][j]时,有如下三种情况:
- 1.
p[i]==s[j]:p[i]是普通字符且与s[j]匹配,因此dp[i][j] = dp[i - 1][j - 1]。 - 2.
p[i] == ".":.可匹配任意字符,因此dp[i][j] = dp[i - 1][j - 1]。 - 3.
p[i] == "*":*与前一个字符相绑定,因此需判断p[i - 1]与s[j]是否匹配。有如下情况:- (1)
p[i - 1] == s[j]:匹配,但由于*可让p[i - 1]出现任意多次,因此需判断其应出现的次数。设p = a*,s = aaaa,当i指向*,j指向s中第三个a时由于*已确定其前一字符为a,因此只需考虑j的前一字符是否与之相匹配即可,因此i的位置不用变,仍是指向*,即dp[i][j] = dp[i][j - 1](在dp数组中表现为,同行中向右遍历)。 - (2)
p[i - 1] != s[j]:不匹配,此时*应让p[i - 1]出现零次,则dp[i][j] = dp[i - 2][j]。
- (1)
完整代码:
java
public class Solution {
public boolean isMatch(String s, String p) {
int m = s.length();
int n = p.length();
//前面加一个空格,模拟实际字符索引从1开始
s = " " + s;
p = " " + p;
//dp[i][j]:p的前i个字符是否能匹配s的前j个字符(此处的p和s不带空格)
//空格前缀+dp定义的好处:此时p.charAt(i)、s.charAt(j)能直接表示p第i个字符、s第j个字符
boolean[][] dp = new boolean[n + 1][m + 1];
dp[0][0] = true;
//初始化第一列:当s是空串时,p串中的*只能用于消除前一个字符,因此当p第i个字符为*时,转移方程为dp[i][0] = dp[i - 2][0];
for (int i = 1; i <= n; i++) {
if (p.charAt(i) == '*') {
if (i >= 2) {
dp[i][0] = dp[i - 2][0];
}
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (p.charAt(i) == s.charAt(j) || p.charAt(i) == '.') {
dp[i][j] = dp[i - 1][j - 1];
} else if (p.charAt(i) == '*') {
//判断*消除前一字符时是否能匹配
dp[i][j] = dp[i - 2][j];
//判断*不消除前一字符时是否能匹配,前提是前一字符应与s串当前字符相等或是'.'
if (p.charAt(i - 1) == s.charAt(j) || p.charAt(i - 1) == '.') {
//以上两种只要一种成功即可
dp[i][j] = dp[i][j] || dp[i][j - 1];
}
}
}
}
return dp[n][m];
}
}
【4.LeetCode62:不同路径】
java
class Solution {
public int uniquePaths(int m, int n) {
// dp[i][j]: 到达(i, j)的不同路径数
int[][] dp = new int[m][n];
for(int i = 0;i < m;i ++)dp[i][0] = 1;
for(int j = 0;j < n;j ++)dp[0][j] = 1;
for(int i = 1;i < m;i ++){
for(int j = 1;j < n;j ++){
dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
}
}
return dp[m - 1][n - 1];
}
}
【5.LeetCode64:最小路径和】
java
class Solution {
public int minPathSum(int[][] grid) {
// dp[i][j]: 到达grid[i][j]的最小路径和
int m = grid.length;
int n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0];
for(int i = 1;i < m;i ++)dp[i][0] = dp[i - 1][0] + grid[i][0];
for(int j = 1;j < n;j ++)dp[0][j] = dp[0][j - 1] + grid[0][j];
for(int i = 1;i < m;i ++){
for(int j = 1;j < n;j ++){
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
}
【6.LeetCode72:编辑距离】
设dp[][]数组定义为:
dp[i][j]:word1(原字符串)前i个字符变成和word2(目标字符串)前j个字符一样所需的编辑次数。
有如下情况(两字符串需先补充空格符以便于下标计算):
- 1.
word1[i] == word2[j]:dp[i][j] == dp[i - 1][j - 1]。 - 2.
word1[i] != word2[j]:dp[i][j] = Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]) + 1。dp[i - 1][j - 1] + 1:对word1执行替换操作。dp[i - 1][i - 1]表示word1前i - 1个字符已变换为和word2前j - 1个字符相同,若需要将word1[i]变换为word2[j],则直接将word1[i]替换为word2[j]即可(因此还要加1)。dp[i][j - 1] + 1:对word1执行插入操作。dp[i][i - 1]表示word1前i个字符已变换为和word2前j - 1个字符相同,由于word1[i]已被用于匹配word2[0 ~j - 1]的字符,因此只能插入新的字符来与word2[j]匹配(因此还要加1)。dp[i - 1][j] + 1:对word1执行删除操作。dp[i - 1][j]表示word1[0 ~ i - 1]的字符已变换为和word2前j个字符相同,即word2中已没有多余字符需要匹配,word1[i]是多余的,只能删除(因此还要加1)。
完整代码:
java
class Solution {
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
//dp[i][j]:word1前i个字符变换为word2前j个字符所需的次数
int[][] dp = new int[m + 1][n + 1];
word1 = " " + word1;
word2 = " " + word2;
//word1是空串时只能通过插入字符来变成word2
for(int i = 1;i <= n;i ++)dp[0][i] = dp[0][i - 1] + 1;
//word2是空串时,word1只能通过删除字符来变成word2
for(int i = 1;i <= m;i ++)dp[i][0] = dp[i - 1][0] + 1;
for(int i = 1;i <= m;i ++){
for(int j = 1;j <= n;j ++){
if(word1.charAt(i) == word2.charAt(j))dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = Math.min(dp[i - 1][j], Math.min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
}
}
return dp[m][n];
}
}
若设置一维dp数组,则会超出时间限制(本质就是暴力枚举):
java
class Solution {
public int maxProfit(int[] prices) {
//dp[i]:第i天买入股票所能获得的最大利润
int[] dp = new int[prices.length];
for(int i = 0;i < dp.length;i ++){
dp[i] = - prices[i];
for(int j = i + 1;j < dp.length;j ++){
dp[i] = Math.max(dp[i], prices[j] - prices[i]);
}
}
int res = 0;
for(int val : dp)res = Math.max(res, val);
return res;
}
}
完整代码:
java
class Solution {
public int maxProfit(int[] prices) {
int profit = 0;
int cost = Integer.MAX_VALUE;
for(int price : prices){
cost = Math.min(price, cost);
profit = Math.max(profit, price - cost);
}
return profit;
}
}
java
class Solution {
public int maxProfit(int[] prices) {
// dp[i][0]: 第i天结束时, 手中不持有股票所能获得的最大利润
// dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i])
// dp[i][1]: 第i天结束时, 手中持有股票所能获得的最大利润
// dp[i][1] = Math.max(dp[i - 1][0] - prices[i], dp[i - 1][1])
// 递归方向: 正向
// 终点: dp[n - 1][0]
int n = prices.length;
int[][] dp = new int[n][2];
// dp是n×2列数组, dp[i][j]由dp[i - 1][0]、dp[i - 1][1]转化而来, 需初始化dp[0][0]、dp[0][1]
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][0] - prices[i], dp[i - 1][1]);
}
return dp[n - 1][0];
}
}
其中,dp[i][1] = Math.max(dp[i - 1][0] - prices[i], dp[i - 1][1])里,dp[i][1]由dp[i - 1][1]而非dp[i - 1][1] - prices[i]转化而来,从而限制了最多只持有一股股票。
java
class Solution {
public int maxProfit(int[] prices) {
// dp[i][j][k]: 第i天结束时所能获得的最大利润
// dp[i][0][k]: 第i天结束且未持有股票、已进行k次购买时, 能获得的最大利润
// dp[i][0][k] = Math.max(dp[i - 1][0][k], dp[i - 1][1][k] + prices[i])
// dp[i][1][k]: 第i天结束且持有股票、已进行k次购买时, 能获得的最大利润
// dp[i][1][k] = Math.max(dp[i - 1][0][k - 1] - prices[i], dp[i - 1][1][k])
int n = prices.length;
// 由于k只能是0、1、2, 因此dp可看作是三个二维数组, 需依次初始化.
// 遍历方向为正向, 遍历终点为Math.max(dp[n - 1][0][0], Math.max(dp[n - 1][0][1], dp[n - 1][0][2]))
int[][][] dp = new int[n][2][3];
// 初始化k = 0对应的二维数组: dp[0][0][0] = 0, dp[0][1][0]不存在且需标记
dp[0][1][0] = Integer.MIN_VALUE;
// 初始化k = 1对应的二维数组: dp[0][0][1] = 0(当天买、当天卖), dp[0][1][1] = -prices[0]
dp[0][1][1] = -prices[0];
// 初始化k = 2对应的二维数组: dp[0][0][2]不存在且需标记(不允许连买两次), dp[0][1][2]不存在且需标记
dp[0][0][2] = Integer.MIN_VALUE;
dp[0][1][2] = Integer.MIN_VALUE;
for(int i = 1;i < n;i ++){
for(int k = 0;k <= 2;k ++){
// 买入
int sell;
if (dp[i - 1][1][k] != Integer.MIN_VALUE)sell = dp[i - 1][1][k] + prices[i];
else sell = Integer.MIN_VALUE;
dp[i][0][k] = Math.max(dp[i - 1][0][k], sell);
// 卖出
if (k == 0)dp[i][1][k] = Integer.MIN_VALUE;
else {
int buy;
if (dp[i - 1][0][k - 1] != Integer.MIN_VALUE)buy = dp[i - 1][0][k - 1] - prices[i];
else buy = Integer.MIN_VALUE;
dp[i][1][k] = Math.max(dp[i - 1][1][k], buy);
}
}
}
return Math.max(dp[n - 1][0][0], Math.max(dp[n - 1][0][1], dp[n - 1][0][2]));
}
}
本题与上一题思路并无区别,但若直接设状态数组 int[][][] dp = new int[n][k + 1][2];,则当k传入值较大时,会超出内存限制。事实上,一次交易由买入和卖出构成,若不考虑当天买入再卖出的无效情况,一次交易需占用两天时间,因此k ≤ n/2时才有效。而当k > n/2时,等价于LeetCode122:买卖股票的最佳时机II。完整代码:
java
class Solution {
public int maxProfit(int max_k, int[] prices) {
int n = prices.length;
if(max_k > n / 2)return maxProfit_inf(prices);
int[][][] dp = new int[n][max_k + 1][2];
// 情况等同于III
// dp[i][k][0] = Math.max(dp[i - 1][k][0], dp[i - 1][k][1] + prices[i])
// dp[i][k][1] = Math.max(dp[i - 1][k - 1][0] - prices[i], dp[i - 1][k][1])
// 终点: Math.max(dp[n - 1][k][0])
// 初始化:
// 第0天结束, 手中不持有股票时: dp[0][0][0] = 0, dp[0][1][0] = 0, dp[0][k][0] = Integer.MIN_VALUE
for(int i = 2;i <= max_k;i ++)dp[0][i][0] = Integer.MIN_VALUE;
// 第0天结束, 手中持有股票时: dp[0][0][1] = Integer.MIN_VALUE, dp[0][1][1] = -prices[0], dp[0][k][1] = Integer.MIN_VALUE
dp[0][0][1] = Integer.MIN_VALUE;
dp[0][1][1] = -prices[0];
for(int i = 2;i <= max_k;i ++)dp[0][i][1] = Integer.MIN_VALUE;
// 开始递推
for (int i = 1; i < n; i++) {
for (int k = 0; k <= max_k; k++) {
// 需避免Integer.MIN_VALUE参与运算, 否则最后会叠加至大于0的数而影响结果
int sell;
if (dp[i - 1][k][1] != Integer.MIN_VALUE)sell = dp[i - 1][k][1] + prices[i];
else sell = Integer.MIN_VALUE;
dp[i][k][0] = Math.max(dp[i - 1][k][0], sell);
// 同上
if (k == 0)dp[i][k][1] = Integer.MIN_VALUE;
else {
int buy;
if (dp[i - 1][k - 1][0] != Integer.MIN_VALUE)buy = dp[i - 1][k - 1][0] - prices[i];
else buy = Integer.MIN_VALUE;
dp[i][k][1] = Math.max(dp[i - 1][k][1], buy);
}
}
}
int ans = 0;
for(int k = 0;k <= max_k;k ++)ans = Math.max(ans, dp[n - 1][k][0]);
return ans;
}
public int maxProfit_inf(int[] prices) {
int n = prices.length;
int[][] dp = new int[n][2];
for (int i = 0; i < n; i++) {
if (i - 1 == -1) {
// base case
dp[i][0] = 0;
dp[i][1] = -prices[i];
continue;
}
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];
}
}
【11.LeetCode714:买卖股票的最佳时机含手续费】
只需将手续费从利润中减去即可。完整代码:
java
class Solution {
public int maxProfit(int[] prices, int fee) {
// dp[i][0]、dp[i][1]: 第i天结束时, 手中持有/不持有股票时的最大利润
int m = prices.length;
int[][] dp = new int[m][2];
// 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] - fee)
// 终点: dp[m - 1][0], 正向遍历
dp[0][0] = 0;
dp[0][1] = - prices[0] - fee;
for(int i = 1;i < m;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] - fee);
}
return dp[m - 1][0];
}
}
【12.LeetCode309:买卖股票的最佳时机含冷冻期】
java
class Solution {
public int maxProfit(int[] prices) {
int m = prices.length;
// dp[i][0]、dp[i][1]、dp[i][2]: 第i天结束时, 手中不持有且非冷冻期、不持有且冷冻期、持有股票时的最大利润(不持有股票时才能去买, 因此受冷冻期影响)
int[][] dp = new int[m][3];
// 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])
dp[0][0] = 0;
dp[0][1] = Integer.MIN_VALUE;
dp[0][2] = - prices[0];
for(int i = 1;i < m;i ++){
// 观察发现, Integer.MIN_VALUE不会传播到后续结果, 因此无需判断
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[m - 1][0], dp[m - 1][1]);
}
}
【13.LeetCode518:零钱兑换II】
错误解法:
java
// dp[i]: 凑出金额i的组合数
for(int i = 1;i <= amount;i ++)
for(int coin : coins)
if(i - coin >= 0)dp[i] += dp[i - coin];
例如,coins = [1, 2],amount = 3:
java
dp[0] = 1;
dp[1] = dp[0](coin = 1) = 1; 对应 [1, 0]
dp[2] = dp[1](coin = 1) + dp[0](coin = 2) = 2; 对应 [1, 1], [2, 0]
dp[3] = dp[2](coin = 1) + dp[1](coin = 2) = 3; 对应 [1, 1, 1], [1, 2, 0], [2, 1, 0]
可见,dp[3]中包含的[1, 2, 0], [2, 1, 0]实际同属一种情况。本题实际是背包问题的变种,正确解法:
java
class Solution {
public int change(int amount, int[] coins) {
int m = coins.length;
// dp[i][j]: 使用前i枚硬币凑出总金额j的组合数
// dp[i][j] = dp[i - 1][j] + dp[i][j - coin]
// 终点: dp[m][amount], 正向遍历
int[][] dp = new int[m + 1][amount + 1];
dp[0][0] = 1;
for(int i = 1;i <= m;i ++){
for(int j = 0;j <= amount;j ++)
if(j - coins[i - 1] >= 0)dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]];
else dp[i][j] = dp[i - 1][j];
}
return dp[m][amount];
}
}
【14.LeetCode416:分割等和子集】
此问题等价为,给定可装载重量为sum/2的背包和N个物品,每个物品重量为nums[i],问是否存在一种装法,能够恰好装满背包(因为总重量为sum/2,因此一定不会用到所有物品。背包里的物品是一个子集,剩下的物品即为另一子集)。大体思路与上题相同,但本题中每个物品只能使用一次,因此dp[i][j] = dp[i - 1][j - nums[i - 1]] || dp[i - 1][j];。即:
- 能使用第
i个物品时:dp[i][j] = dp[i - 1][j - coins[i - 1]] || dp[i - 1][j];,在能使用的情况下包含了使用与不使用两种选择。其中,dp[i - 1][j - coins[i - 1]]保证了物品只能使用一次。 - 不能使用第
i个物品时:dp[i][j] = dp[i - 1][j];。
java
class Solution {
public boolean canPartition(int[] nums) {
int sum = 0;
for(int val : nums)sum += val;
if(sum % 2 != 0)return false;
int target = sum / 2;
//dp[i][j]:使用前i个物品,能否将容量为j的背包装满
boolean[][] dp = new boolean[nums.length + 1][target + 1];
for(int i = 0;i <= nums.length;i ++)dp[i][0] = true;
for(int i = 1;i <= nums.length;i ++){
for(int j = 1;j <= target;j ++){
if(j < nums[i - 1])dp[i][j] = dp[i - 1][j];
else dp[i][j] = dp[i - 1][j - nums[i - 1]] || dp[i - 1][j];
}
}
return dp[nums.length][target];
}
}