LeetCode精选题之动态规划02

LeetCode精选题之动态规划02

参考资料:
1、CyC2018的LeetCode题解
2、liuyubobobo的LeetCode课程
3、动态规划之01背包问题(最易理解的讲解)
4、背包问题:0-1背包、完全背包和多重背包

背包问题

0-1背包问题的动态规划解法:
dp数组的含义:dp[i][j]表示将前i个物品放进容量为j的背包,所得到的最大价值(我这里定义的i是表示索引,和下面的代码相对应)最终的结果就是dp[n-1][C]表示将n个物品放进容量为C的背包得到的最大价值。

转移方程:

dp[i][j] = max(dp[i-1][j]), v(i)+dp[i-1][j-w(i)])

转移方程的解释:

  • dp[i-1][j])表示第i个物品不拿情况下的价值;
  • v(i)+dp[i-1][j-w(i)]表示第i个物品拿了情况下的价值

下面的动态规划基本解法,需要dp数组的含义,初始状态,转移方程的原理。之后的空间上的两种优化只需要明白可以使用较少空间的原理即可。

public class Knapsack01 {
    public int knapsack01(int[] w, int[] v, int C) {
        int n = w.length;
        if (n == 0) return 0;
        int[][] dp = new int[n][C+1];
        // 初始条件,只放第一个物品的时候
        for (int j = 1; j <= C; j++) {// j表示当前的背包容量
            if (w[0] <= j) {// 背包容量能够容得下物品,才能更新dp数组
                dp[0][j] = v[0];
            }
        }

        for (int i = 1; i < n; i++) {// 从第二个物品开始
            for (int j = 1; j <= C; j++) {// j表示当前的背包容量
                if (w[i] > j) {
                    dp[i][j] = dp[i-1][j];
                }else {
                    dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-w[i]]+v[i]);
                }
            }
        }
        return dp[n-1][C];
    }
}

0-1背包问题的空间优化

public class Knapsack01 {
    public int knapsack01(int[] w, int[] v, int C) {
        int n = w.length;
        if (n == 0) return 0;
        int[][] dp = new int[2][C+1];
        // 初始条件,只放第一个物品的时候
        for (int j = 1; j <= C; j++) {
            if (w[0] <= j) {
                dp[0][j] = v[0];
            }
        }

        for (int i = 1; i < n; i++) {// 从第二个物品开始
            for (int j = 1; j <= C; j++) {// j表示当前的背包容量
                // 因为只用两行保存结果,所以用 %2 的操作在两行之间切换
                if (w[i%2] > j) {
                    dp[i%2][j] = dp[(i-1)%2][j];
                }else {
                    dp[i%2][j] = Math.max(dp[(i-1)%2][j], dp[(i-1)%2][j-w[i%2]]+v[i%2]);
                }
            }
        }
        return dp[(n-1)%2][C];
    }
}

i行元素只依赖于第i-1行元素,只需要保存两行结果即可。空间复杂度:O(2 * C) = O©

空间上的继续优化:
下面的代码中使用一维dp数组,注意和上面代码的区别在于j的倒序遍历方式,即for (int j = C; j >= w[i]; j--)。解释:因为要取上一个状态,所以必须从后往前遍历。举例:当前在第i个物品,考虑dp[j]的时候,由于是从后往前更新,j之前的数据还停留在上一个状态,也就是dp[j-w[i]]就表示在第i-1个物品,容量为j-w[i]时的最大价值。这样就可以满足转移方程,同时又节约了空间。

public class Knapsack01 {
    public int knapsack01(int[] w, int[] v, int C) {
        int n = w.length;
        if (n == 0) return 0;
        int[] dp = new int[C+1];
        // 初始条件,只放第一个物品的时候
        for (int j = C; j >= 1; j--) {
            if (w[0] <= j) {
                dp[j] = v[0];
            }
        }

        for (int i = 1; i < n; i++) {// 从第二个物品开始
            for (int j = C; j >= w[i]; j--) {// j表示当前的背包容量
                if (j >= w[i]) { // 当前的背包容量能容纳下第i个物品
                    dp[j] = Math.max(dp[j], dp[j-w[i]]+v[i]);
                }
            }
        }
        return dp[C];
    }
}

0-1背包问题的变种:

  • 完全背包问题:每个物品可以无限使用。
  • 多重背包问题:每个物品不止1个,有num(i)个。
  • 多维费用背包问题:要考虑物品的体积和重量两个维度。
  • 物品间的约束:物品间可以互相排斥,也可以互相依赖。

注:以上0-1背包问题的动态规划的代码参考自bobo老师。

15 分割等和子集–LeetCode416(Medium)

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

注意:

  1. 每个数组中的元素不会超过 100
  2. 数组的大小不会超过 200

示例 1:

输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].

示例 2:

输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.
class Solution {
    public boolean canPartition(int[] nums) {
        // 边界条件
        if (nums == null || nums.length == 0) {
            return false;
        }
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }
        if ((sum&1) == 1) {
            return false;//和是奇数的话,肯定不可能分割成两个等和子集
        }
        // 现在这个问题就转换成了容量为sum/2的0-1背包问题,能否用nums数组中的物品正好放满这个背包
        int cap = sum>>1;
        int n = nums.length;
        boolean[] dp = new boolean[cap+1];
        // 首先先放第一个物品
        for (int j = 1; j <= cap; j++) {// j表示当前的背包容量
            dp[j] = j == nums[0];// 注意:这里要正好放满才能为true
        }

        for (int i = 1; i < n; i++) {// 从第二个物品开始
            for (int j = cap; j >= nums[i]; j--) {// j表示当前的背包容量
                dp[j] = dp[j] || dp[j-nums[i]]; 
            }
        }
        return dp[cap];
    }
}

16 目标和–LeetCode494(Medium)

给定一个非负整数数组a1, a2, ..., an, 和一个目标数S。现在你有两个符号 +-。对于数组中的任意一个整数,你都可以从 +-中选择一个符号添加在前面。

返回可以使最终数组和为目标数 S的所有添加符号的方法数。

示例:

输入:nums: [1, 1, 1, 1, 1], S: 3
输出:5
解释:
-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3
一共有5种方法让最终目标和为3。

提示:

  • 数组非空,且长度不会超过 20 。
  • 初始的数组的和不会超过 1000 。
  • 保证返回的最终结果能被 32 位整数存下。

动态规划的分析思路:
dp数组的含义:根据背包问题的经验,可以将dp[i][j]定义为从数组nums中前i个元素(包含nums[i])进行加或减可以得到 j的方法数量。

状态转移方程:

dp[i][j] = dp[i-1][j-nums[i]] + dp[i-1][j+nums[i]]

可以理解为nums[i]这个元素可以加上,还可以减去,那么dp[i][j]的结果值就是加或减之后对应位置的和。

几个注意点:

  • 对边界的判断:如果S大于数组元素累加和sum的话,是没有解的。
  • dp数组第二维的大小,取的是2*sum + 1,如果元素前面的符号全是正号,和就是sum;如果元素前面的符号全是负号,和就是-sum,再加上一个0,总数就是2*sum + 1
  • 初始化的选择:如果nums[0]等于0,那么dp[0][sum]需要初始化为2,因为加减0都为0。这里为什么是sum这个位置,我一开始不太理解,sum可以理解为一个偏移量。因为数组中不能存储负数索引,所以要在-sum...0...sum的基础上加上一个sum的偏移量。也就是说dp[0][sum]中的sum对应的是-sum...0...sum中的0,这样就可以理解“加减0都为0”了。如果nums[0]不等于0,就可以根据转移方程来进行状态转移。
  • 状态转移中需要注意边界条件,所谓的边界条件可以理解为加上或减去nums[i]是否还在有效的容量之内0 ~ 2*sum + 1
class Solution {
    public int findTargetSumWays(int[] nums, int S) {
        int sum = 0;
        for (int num : nums) {
            sum += num;
        }

        // S的绝对值超过了数组元素的累加和, 是肯定没有解的
        if (Math.abs(S) > sum) {
            return 0;
        }

        int cap = 2*sum + 1;
        int n = nums.length;
        int[][] dp = new int[n][cap];
        // 初始化,sum是偏移量
        // 为什么要加个偏移量?因为数组没法存储负数下标  
        if (nums[0] == 0) {
            dp[0][sum] = 2;
        }else {
            dp[0][sum-nums[0]] = 1;
            dp[0][sum+nums[0]] = 1;
        }

        for (int i = 1; i < n; i++) {
            for (int j = 0; j < cap; j++) {
            	// 考虑边界
                int l = j-nums[i]>=0 ? dp[i-1][j-nums[i]] : 0;
                int r = j+nums[i]<cap ? dp[i-1][j+nums[i]] : 0;
                dp[i][j] = l + r;
            }
        }
        return dp[n-1][sum+S];
    }
}

参考题解:keepal的题解:动态规划思考全过程

17 一和零–LeetCode474(Medium)

在计算机界中,我们总是追求用有限的资源获取最大的收益。

现在,假设你分别支配着 m 个 0 和 n 个 1。另外,还有一个仅包含 0 和 1 字符串的数组。

你的任务是使用给定的 m 个 0 和 n 个 1 ,找到能拼出存在于数组中的字符串的最大数量。每个 0 和 1 至多被使用一次。

注意:

  • 给定 0 和 1 的数量都不会超过 100。
  • 给定字符串数组的长度不会超过 600。

示例 1:

输入: Array = {"10", "0001", "111001", "1", "0"}, m = 5, n = 3
输出: 4  
解释: 总共 4 个字符串可以通过 5 个 0 和 3 个 1 拼出,即 "10","0001","1","0" 。

示例 2:

输入: Array = {"10", "0", "1"}, m = 1, n = 1
输出: 2  
解释: 你可以拼出 "10",但之后就没有剩余数字了。更好的选择是拼出 "0" 和 "1" 。

问题转换:把总共的 0 和 1 的个数视为背包的容量,每一个字符串视为装进背包的物品。这道题就可以使用 0-1 背包问题的思路完成。这里的目标值是能放进背包的字符串的数量。

动态规划的分析思路:

dp数组的含义:dp[k][i][j]表示考虑前k个元素,使用i个0和j个1能拼出的字符串的最大数量。【题目问什么,就可以考虑把状态定义成什么】

状态转移方程:

dp[k][i][j] = Math.max(dp[k-1][i][j], 1+dp[k-1][i-zeros][j-ones])

解释:dp[k-1][i][j]表示不选择当前考虑的字符串,至少是这个数值;1+dp[k-1][i-zeros][j-ones]表示选择当前考虑的字符串。zeros和ones表示当前字符串中’0’和’1’的个数。

参考题解:liweiwei1419的题解:动态规划(转换为 0-1 背包问题)

class Solution {
    public int findMaxForm(String[] strs, int m, int n) {
        if (strs == null || strs.length == 0 || m < 0 || n < 0) {
            return 0;
        }
        int N = strs.length;
        // dp[k][i][j]表示考虑前k个元素,使用i个0和j个1能拼出的字符串的最大数量
        int[][][] dp = new int[N+1][m+1][n+1];

        for (int k = 1; k <= N; k++) {
            int[] cnt = countZerosAndOnes(strs[k-1]);
            int zeros = cnt[0];
            int ones = cnt[1];// 当前字符串1的个数
            for (int i = 0; i <= m; i++) {
                for (int j = 0; j <= n; j++) {
                    dp[k][i][j] = dp[k-1][i][j];
                    
                    if (i >= zeros && j >= ones) {
                        dp[k][i][j] = Math.max(dp[k-1][i][j], 1+dp[k-1][i-zeros][j-ones]);
                    }
                }
            }
        }
        return dp[N][m][n];
    }

    private int[] countZerosAndOnes(String str) {
        int[] cnt = new int[2];
        for (char c : str.toCharArray()) {
            cnt[c - '0']++;
        }
        return cnt;
    }
}

注:空间上的优化暂时省略,我觉得最重要的是对转移方程的理解。

18 零钱兑换–LeetCode322(Medium)

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例 1:

输入: coins = [1, 2, 5], amount = 11
输出: 3 
解释: 11 = 5 + 5 + 1

示例 2:

输入: coins = [2], amount = 3
输出: -1

说明:你可以认为每种硬币的数量是无限的。

问题分析:因为硬币可以重复使用,因此这是一个完全背包问题。

动态规划的思路:

  • dp数组的含义:dp[i]表示总金额为i时的最少硬币数量。
  • 初始化:首先所有位置初始化为amount+1,对于面额为1的硬币需要amount个,所以amount+1是个取不到的值,用来表示无穷大(注:设置成无穷大也是可以的)。dp[0]=0表示总金额为零,不需要硬币。
  • 转移方程:dp[i] = Math.min(dp[i], 1+dp[i-coin])完全背包只需要将 0-1 背包的逆序遍历 dp 数组改为正序遍历即可。

补充:遍历顺序的分析。
0-1 背包问题中使用一维dp数组的逆序遍历。代码如下:

public class Knapsack01 {
    public int knapsack01(int[] w, int[] v, int C) {
        int n = w.length;
        if (n == 0) return 0;
        int[] dp = new int[C+1];
        // 初始条件,只放第一个物品的时候
        for (int j = C; j >= 1; j--) {
            if (w[0] <= j) {
                dp[j] = v[0];
            }
        }

        for (int i = 1; i < n; i++) {// 从第二个物品开始
            for (int j = C; j >= w[i]; j--) {// j表示当前的背包容量
                if (j >= w[i]) { // 当前的背包容量能容纳下第i个物品
                    dp[j] = Math.max(dp[j], dp[j-w[i]]+v[i]);
                }
            }
        }
        return dp[C];
    }
}

在介绍0-1背包问题的一般问题时已经解释过。可以结合参考资料里面的图进一步的理解,参考资料:动态规划之01背包问题(最易理解的讲解)。总的来说:倒序遍历就是为了保证j前面的元素是上一个状态i-1所对应的元素。因为是倒序,前面的元素还没来得及更新,那不就是上一个状态的值了。

完全背包问题中使用一维dp数组的正序遍历,那么结合下面的代码,容量i前面的dp数值是已经更新过的值,"已经更新过"指的是前面的dp数值dp[0...i-1]已经考虑了当前的这个硬币coin。那么在后序dp数值dp[i...amount]继续更新值的过程中,就是在已经考虑了当前的这个硬币coin的基础上继续更新,这就体现出这个硬币可以使用多次

注意:结合图示、代码来理解会更能理解0-1背包和多重背包的区别。

参考资料:背包问题:0-1背包、完全背包和多重背包

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 coin : coins) {
            for (int i = 1; i <= amount; i++) {
                if (i >= coin) {
                    dp[i] = Math.min(dp[i], 1+dp[i-coin]);
                }
            }
        }
        return (dp[amount]==amount+1) ? -1 : dp[amount];
    }
}

19 零钱兑换II–LeetCode518(Medium)

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。

示例 1:

输入: amount = 5, coins = [1, 2, 5]
输出: 4
解释: 有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2:

输入: amount = 3, coins = [2]
输出: 0
解释: 只用面额2的硬币不能凑成总金额3。

示例 3:

输入: amount = 10, coins = [10] 
输出: 1

注意:你可以假设:

  • 0 <= amount (总金额) <= 5000
  • 1 <= coin (硬币面额) <= 5000
  • 硬币种类不超过 500 种
  • 结果符合 32 位符号整数

动态规划的思路:
dp数组的含义:dp[i]表示总金额为i的硬币组合数。
边界条件:dp[0] = 1这个边界条件很容易忽略。表示金额为零,不需要硬币,故可以看成是一种组合。

class Solution {
    public int change(int amount, int[] coins) {
        if (coins == null) {
            return 0;
        }
        //dp[i]表示总金额为i的硬币组合数
        int[] dp = new int[amount+1];
        dp[0] = 1;// 这个边界条件很容易忽略。表示金额为零,不需要硬币,故可以看成是一种组合
        
        for (int coin : coins) {
            for (int i = coin; i <= amount; i++) {
                dp[i] += dp[i-coin];
            }
        }
        return dp[amount];
    }
}

参考资料:liweiwei1419的题解:动态规划(套用完全背包问题模型)

20 单词拆分–LeetCode139(Medium)

给定一个非空字符串 s 和一个包含非空单词列表的字典 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

  • 拆分时可以重复使用字典中的单词。
  • 你可以假设字典中没有重复的单词。

示例 1:

输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"。

示例 2:

输入: s = "applepenapple", wordDict = ["apple", "pen"]
输出: true
解释: 返回 true 因为 "applepenapple" 可以被拆分成 "apple pen apple"。
     注意你可以重复使用字典中的单词。

示例 3:

输入: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
输出: false

思路:对于给定的字符串(s)可以被拆分成两个子串 s1 和 s2 。如果这两个子串是符合题目要求的,那么整个字符串 s 也是满足要求的。

dp数组的含义:dp[i]表示前i个字符组成的字符串是否符合题意。长度为n+1,其中 n是给定字符串的长度。

初始化: dp[0]为 true ,这是因为空字符串总是字典的一部分。 dp 数组剩余的元素都初始化为 false 。

转移方程

  • 使用 2 个下标指针 ij,其中i是当前字符串从头开始的子字符串(s′)的长度, j是当前子字符串(s′)的拆分位置,拆分成 s′(0,j)s′(j+1,i)两个子串。
  • 首先看 s′(0,j)是否满足题目要求,即dp[j]是否为true,
  • 如果满足,我们接下来检查 s′(j+1,i)是否在字典中。如果也满足,说明两个子串都是满足题意的,根据上面的思路,说明当前子字符串(s′)是满足题意的。那么dp[i]为true。
class Solution {
    public boolean wordBreak(String s, List<String> wordDict) {
        Set<String> set = new HashSet<>(wordDict);
        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++) {// 注意i和j表示的是字符串中的位置,表示的是长度,而不是索引
                if (dp[j] && set.contains(s.substring(j, i))) {
                    dp[i] = true;
                    break;
                }
            }
        }
        return dp[n];
    }
}

21 组合求和IV–LeetCode377(Medium)

给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。

示例:

nums = [1, 2, 3]
target = 4

所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)

请注意,顺序不同的序列被视作不同的组合。
因此输出为 7。

进阶:如果给定的数组中含有负数会怎么样?问题会产生什么变化?我们需要在题目中添加什么限制来允许负数的出现?

动态规划的思路:
dp数组的含义:dp[i]对于给定的由正整数组成且不存在重复数字的数组,和为 i的组合的个数。
状态转移方程:dp[i] = sum{dp[i - num], for num in nums and i >= num}
初始化:dp[0]=1,根据上面的dp数组的含义,这个初始条件表示和为零的组合有一个。但是数组中全是正整数,所以这个组合只能是空,即{}

class Solution {
    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];
    }
}

注意:这题要求顺序不同的序列被视作不同的组合,与【零钱兑换 II-LeetCode518】不同的地方就在这一点。但是解法上却很相像,区别在于两层遍历的遍历顺序。

参考资料:liweiwei1419的题解

股票交易系列问题

22 最佳买卖股票时机含冷冻期–LeetCode309(Medium)

给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。

设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

  • 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
  • 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。

示例:

输入: [1,2,3,0,2]
输出: 3 
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]

dp数组的含义:d[i][0]i天不持有股票的最大利润,dp[i][1]i天持有股票的最大利润。注意这里的i指的是数组的长度。有时候是长度有时候是索引是根据题目来的,我觉得根据题目情况来定会使代码易懂,这里涉及dp[i-2],所以定义成长度就可以在第二天就使用转移方程,不用在分类讨论。
初始状态:第一天的持股和不持股的状态。

  • dp[1][0]=0表示第一天不持股,收益为零;
  • dp[1][1]=-prices[0]表示第一天持股,买入股票,收益为第一天股票价格的负数;

转移方程:

  • 不持股可以由两种状态转换而来:(1)昨天不持股,今天什么都不操作,仍然不持股。(2)昨天持股,今天卖掉了。
  • 持股可以由两种状态转换而来:(1)昨天持股,今天什么都不操作,仍然持股;(2)昨天处在冷冻期,今天买了一股;
class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;
        // d[i][0]第i天不持有股票的最大利润,dp[i][1]第i天持有股票的最大利润
        int[][] dp = new int[n+1][2];
        // 第一天的情况
        dp[1][0] = 0;
        dp[1][1] = -prices[0];// 只能是第一天买入了股票

        for (int i = 2; i <= n; i++) {
            // prices[i-1]表示的是第i天的价格
            dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1]+prices[i-1]);
            dp[i][1] = Math.max(dp[i-1][1], dp[i-2][0]-prices[i-1]);
        }
        return dp[n][0];
    }
}

23 买卖股票的最佳时机含手续费–LeetCode714(Medium)

给定一个整数数组 prices,其中第 i个元素代表了第 i天的股票价格 ;非负整数 fee代表了交易股票的手续费用。 你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。返回获得利润的最大值。

注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。

示例 1:

输入: prices = [1, 3, 2, 8, 4, 9], fee = 2
输出: 8
解释: 能够达到的最大利润:  
在此处买入 prices[0] = 1
在此处卖出 prices[3] = 8
在此处买入 prices[4] = 4
在此处卖出 prices[5] = 9
总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8.

注意:

  • 0 < prices.length <= 50000.
  • 0 < prices[i] < 50000.
  • 0 <= fee < 50000.

动态规划的思路:
dp数组的含义:d[i][0]i天不持有股票的最大利润,dp[i][1]i天持有股票的最大利润。注意这里的i指的是索引。

初始状态:天数i从0开始,因此

  • dp[0][0]=0表示第一天不持股,收益为零;
  • dp[0][1]=-prices[0]表示第一天持股,买入股票,收益为第一天股票价格的负数;

状态转移:

  • i天不持股,有两种情况:前一天也不持股,到今天不操作,即dp[i-1][0];前一天持股,今天卖出,但这里要加上手续费fee,即dp[i-1][1]+prices[i]-fee
  • i天持股,也有两种情况:前一天也持股,到今天不操作,即dp[i-1][1];前一天不持股,今天买入,即dp[i-1][0]-prices[i]

注意:题目中说买入卖出的过程只需要计算一次手续费即可,这里我自己理解的是在卖出的时候加上手续费,当然也可以在买入的时候加上手续费,因为最后的结果肯定是不持股的状态。

class Solution {
    public int maxProfit(int[] prices, int fee) {
        int n = prices.length;
        int[][] dp = new int[n][2];

        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]-fee);
            dp[i][1] = Math.max(dp[i-1][1], dp[i-1][0]-prices[i]);
        }
        return dp[n-1][0];
    }
}

24 买卖股票的最佳时机 III–LeetCode123(Hard)

给定一个数组,它的第 i个元素是一支给定的股票在第 i天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。

注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: [3,3,5,0,0,3,1,4]
输出: 6
解释: 在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。
     随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3 。

示例 2:

输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。   
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。   
     因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

示例 3:

输入: [7,6,4,3,1] 
输出: 0 
解释: 在这个情况下, 没有交易完成, 所以最大利润为 0。

动态规划的思路:
dp数组的含义:用 dp[i][k]表示前i天(包括第i天)最多交易k次的最高利润。

状态转移方程:第i天有两种情况:

  • 如果第 i天什么都不操作,那么这一天的最高收益就等于前一天的最高收益,即dp[i][k] = dp[i-1][k]
  • 为了获得最大利润,第 i天不可能买入,因为买入会使利润降低。那么第 i天只能选择卖出,相应地在0到 i-1天就要选择一天买入。这就构成了一次交易,那么在上面所述的买入之前就已经进行了 k-1次交易。

上述第二种情况中,要在0到 i-1天选择一天买入,假如在第 j天买入,收益就是 prices[i] - prices[j] + dp[j-1][k-1],最后一项是j前一天进行 k-1次交易的最大收益。因为在同一天买入和卖出对收益是没有影响的,所以最后一项也可以改成加上第j天当天的最大收益,收益就是 prices[i] - prices[j] + dp[j][k-1]

在0到 i-1天选择一天买入,找到哪一天买入使得收益最大,然后与第i天什么都不操作比较,就是dp[i][k]的值了。

dp[i][k] = Max(dp[i-1][k], prices[i] - prices[j] + dp[j][k-1]),j 取 0,...,i

参考资料:windliang的题解

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;
        int K = 2;
        int[][] dp = new int[n][K+1];

        for (int k = 1; k <= K; k++) {
            for (int i = 1; i < n; i++) {
                int min = Integer.MAX_VALUE;
                // 找到0到i-1天中 prices[j]-dp[j][k-1] 的最小值
                for (int j = 0; j < i; j++) {
                    min = Math.min(min, prices[j]-dp[j][k-1]);
                }
                // 比较不操作 和 选择一天买入 哪个更大
                dp[i][k] = Math.max(dp[i-1][k], prices[i] - min);
            }
        }
        
        return dp[n-1][K];
    }
}

时间超慢,因为每次都要找出0 到 i-1天中prices[j]-dp[j][k-1]的最小值。使用额外的空间来存储第 1 天到第iprices[i]-dp[i][k-1]的最小值,对时间复杂度进行优化。此时是一行一行对二维数组进行更新。

class Solution {
    public int maxProfit(int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;
        int K = 2;
        int[][] dp = new int[n][K+1];
        int[] min = new int[K+1];

        // 初始化,从第二天开始初始化,值为第一天的价格
        // 第一天的价格其实是取不到的,因为min[k] = prices[i] - dp[i][k-1]
        for (int i = 1; i <= K; i++) {
            min[i] = prices[0];
        }

        for (int i = 1; i < n; i++) {
            for (int k = 1; k <= K; k++) {
                // 找出第 1 天到第 i 天 prices[i]-dp[i][k-1] 的最小值
                min[k] = Math.min(min[k], prices[i] - dp[i][k-1]);
                // 比较不操作 和 选择一天买入 哪个更大
                dp[i][k] = Math.max(dp[i-1][k], prices[i] - min[k]);
            }
        }
        return dp[n-1][K];
    }
}

25 买卖股票的最佳时机 IV–LeetCode188(Hard)

给定一个数组,它的第 i个元素是一支给定的股票在第i天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 k笔交易。

注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: [2,4,1], k = 2
输出: 2
解释: 在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2 。

示例 2:

输入: [3,2,6,5,0,3], k = 2
输出: 7
解释: 在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4 。
     随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。

思路同上一题一样。

class Solution {
    public int maxProfit(int k, int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;
        int[][] dp = new int[n][k+1];
        int[] min = new int[k+1];

        for (int j = 1; j <= k; j++) {
            min[j] = prices[0];
        }

        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= k; j++) {
                min[j] = Math.min(min[j], prices[i]-dp[i][j-1]);
                dp[i][j] = Math.max(dp[i-1][j], prices[i]-min[j]);
            }
        }
        return dp[n-1][k];
    }
}

但是提交结果显示超出内存限制,dp数组改成一维的,仍然超出内存限制。抛出异常时的k是一个很大的数,一次交易至少需要 2 天,一天买,一天卖。因此如果 k 很大,大到大于等于 len / 2,就相当于股票系列的第 II 题,此时使用贪心算法来求解。

class Solution {
    public int maxProfit(int k, int[] prices) {
        int n = prices.length;
        if (n == 0) return 0;

        // 如果最高交易次数超过了数组长度的一半,使用贪心法求解
        if (k >= (n>>1)) {
            return greedy(prices, n);
        }

        int[] dp = new int[k+1];
        int[] min = new int[k+1];

        for (int j = 1; j <= k; j++) {
            min[j] = prices[0];
        }

        for (int i = 1; i < n; i++) {
            for (int j = 1; j <= k; j++) {
                min[j] = Math.min(min[j], prices[i]-dp[j-1]);
                dp[j] = Math.max(dp[j], prices[i]-min[j]);
            }
        }
        return dp[k];
    }

    private int greedy(int[] prices, int n) {
        int res = 0;
        for (int i = 1; i < n; i++) {
            if (prices[i-1] < prices[i]) {
                res += (prices[i]-prices[i-1]);
            }
        }
        return res;
    }
}

字符串编辑问题

26 两个字符串的删除操作–LeetCode583(Medium)

给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。

示例:

输入: "sea", "eat"
输出: 2
解释: 第一步将"sea"变为"ea",第二步将"eat"变为"ea"

提示:

  • 给定单词的长度不超过500。
  • 给定单词中的字符只含有小写字母。

动态规划的思路:
dp数组的含义:dp[i][j]表示 word1 中的前i个字符组成的子串和 word2 中的前j个字符组成的子串,通过删除操作变成相同所需的最小步数。
初始条件:考虑的是 word1 或者word2 中有一个为空串的情况。
转移方程:

  • 如果word1 中的第i个字符和 word2 中的第j个字符相等。这两个字符已经相等了就不需要考虑了,只需要考虑前面的子串即可。故dp[i][j] = dp[i-1][j-1]
  • 如果word1 中的第i个字符和 word2 中的第j个字符不相等。说明这两个字符不可能共存,也就是需要删除某一个,所以存在两种情况:删除word1 中的第i个字符,考虑剩下的;删除word2 中的第j个字符,考虑剩下的。取两种情况中步数较少的,故转移方程dp[i][j] = 1 + Math.min(dp[i][j-1], dp[i-1][j]),等号后面的 1 表示的就是删除操作。

代码如下:

class Solution {
    public int minDistance(String word1, String word2) {
        int m = word1.length();
        int n = word2.length();
        int[][] dp = new int[m+1][n+1];
        // 初始条件
        for (int i = 1; i <= m; i++) {
            dp[i][0] = i;
        }
        for (int j = 1; j <= n; j++) {
            dp[0][j] = j;
        }

        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (word1.charAt(i-1) == word2.charAt(j-1)) {
                    dp[i][j] = dp[i-1][j-1];
                }else {
                    dp[i][j] = 1 + Math.min(dp[i][j-1], dp[i-1][j]);
                }
            }
        }

        return dp[m][n];
    }
}

27 编辑距离–LeetCode72(Hard)

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

  1. 插入一个字符
  2. 删除一个字符
  3. 替换一个字符

示例 1:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

示例 2:

输入:word1 = "intention", word2 = "execution"
输出:5
解释:
intention -> inention (删除 't')
inention -> enention (将 'i' 替换为 'e')
enention -> exention (将 'n' 替换为 'x')
exention -> exection (将 'n' 替换为 'c')
exection -> execution (插入 'u')

动态规划的思路:
dp数组的含义:dp[i][j]表示 word1 中的前i个字符组成的子串转换成 word2 中的前j个字符组成的子串的最少操作数。
初始条件:考虑的是 word1 或者word2 中有一个为空串的情况。题目要求是将 word1 转换成 word2,所以如果word1为空,这种转换是插入操作,如果word2为空,这种转换是删除操作。
转移方程:

  • 如果word1 中的第i个字符和 word2 中的第j个字符相等。这两个字符已经相等了就不需要考虑了,只需要考虑前面的子串即可。故dp[i][j] = dp[i-1][j-1]
  • 如果word1 中的第i个字符和 word2 中的第j个字符不相等。有三种操作:插入、删除、替换。转移方程dp[i][j] = 1 + Math.min(Math.min(dp[i][j-1], dp[i-1][j]), dp[i-1][j-1]),等号后面的 1 表示的就是当前做出的操作,三种操作和转移方程的对应关系如下:
    • dp[i][j-1]:插入
    • dp[i-1][j]:删除
    • dp[i-1][j-1]:替换

如果不好理解,不妨在例子中理解转移方程:word1=a,word2=eat,指针i指向word1中的a,指针j指向word2中的t,那么word1的三种选择分别是:

  1. 插入。在a的前面插入一个t,此时问题转化成了:at 和 eat 求最少步数,也就是 a 和 ea 求最少步数。为什么可以这样转化,思路参考上述转移方程的第一种情况。
  2. 删除。删去word1中的字符a,此时问题转化成了:“” 和 eat 求最少步数。
  3. 替换。将word1中的字符a替换成t,此时问题转化成了:t 和 eat 求最少步数,也就是 “” 和 ea 求最少步数。
class Solution {
    public int minDistance(String word1, String word2) {
        int m = word1.length();
        int n = word2.length();

        int[][] dp = new int[m+1][n+1];

        // 初始条件
        for (int i = 1; i <= m; i++) {
            dp[i][0] = i;// 删除操作
        }
        for (int j = 1; j <= n; j++) {
            dp[0][j] = j;// 插入操作
        }

        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (word1.charAt(i-1) == word2.charAt(j-1)) {
                    dp[i][j] = dp[i-1][j-1];
                }else {
                    dp[i][j] = 1 + Math.min(Math.min(dp[i][j-1], dp[i-1][j]), dp[i-1][j-1]); 
                }
            }
        }
        return dp[m][n];
    }
}

28 只有两个键的键盘–LeetCode650(Medium)

最初在一个记事本上只有一个字符 ‘A’。你每次可以对这个记事本进行两种操作:

  1. Copy All (复制全部) : 你可以复制这个记事本中的所有字符(部分的复制是不允许的)。
  2. Paste (粘贴) : 你可以粘贴你上一次复制的字符。

给定一个数字 n 。你需要使用最少的操作次数,在记事本中打印出恰好 n 个 ‘A’。输出能够打印出 n 个 ‘A’ 的最少操作次数。

示例 1:

输入: 3
输出: 3
解释:
最初, 我们只有一个字符 'A'。
第 1 步, 我们使用 Copy All 操作。
第 2 步, 我们使用 Paste 操作来获得 'AA'。
第 3 步, 我们使用 Paste 操作来获得 'AAA'。

说明:n 的取值范围是 [1, 1000] 。

动态规划的思路:

  • 当 n 为质数,比如 2 3 5 7 ,那么只能一个个进行粘贴,即先拷贝 ,再进行 n - 1 次粘贴,加上原本的 A,总共 n 个 A,刚好 进行 n 次操作,即 dp[n] = n
  • 当 n 为非质数,则分解它的因子,得到最小的步数。

dp数组的含义:dp[i]表示,通过复制粘贴操作,得到 i个字符的最少操作步数。
初始条件:dp[1] = 0;当i>=2时,dp[i] = i
状态转移方程:dp[i] = Math.min(dp[i], dp[j] + dp[i / j])

怎么理解转移方程?
比如n = 12时, 此时dp[6] = 5dp[i / j]=dp[2] = 2,意思是先算出得到 6 个 A 需要5步,然后需要 2 个这样的 6 个 A,才能组成12个A,那么相当于把前面的这 6 个 A 看作一个整体,这个过程需要2步,最终就是7步。

为什么代码中找到一个因子就可以直接break?

因为是自下而上, j 从最小开始向上增加,如果 j 越小,则 i/j 则越大。 理想化的情况是 n/2, 那现在我们从2开始,就是最理想的情况,所以可以立刻break。

参考资料:
suan-tou-wang-ba的评论
sussica的评论

代码如下:

class Solution {
    public int minSteps(int n) {
        int[] dp = new int[n+1];
        for (int i = 2; i <= n; i++) {
            dp[i] = i;// 初始状态,也就是一个一个复制
            for (int j = 2; j <= Math.sqrt(i); j++) {
                if (i % j == 0) {
                    dp[i] = dp[j]+dp[i/j];
                    break;
                }
            }
        }
        return dp[n];
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值