LeetCode - 9. 动态规划

刷题顺序来自:代码随想录

基础题目

70. 爬楼梯

到达第i层楼梯的方法数量等于到达第i-1层与第i-2层的方法数量之和。递推公式为:dp[i]=dp[i-1]+dp[i-2],答案实际上就是斐波那契数。

public int climbStairs(int n) {
    if(n <= 2) {
        return n;
    }

    int f1 = 1, f2 = 2, res = 3;
    for(int i = 0; i < n - 2; i++) {
        res = f1 + f2;
        f1 = f2;
        f2 = res;
    }
    return res;
}

746. 使用最小花费爬楼梯

递推公式为:dp[i] = Math.min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])

public int minCostClimbingStairs(int[] cost) {
    int[] dp = new int[cost.length+1];

    for(int i = 2; i < dp.length; i++) {
        dp[i] = Math.min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);
    }

    return dp[dp.length-1];
}

62. 不同路径

递推公式为:dp[i][j] = dp[i][j-1] + dp[i-1][j]

public int uniquePaths(int m, int n) {
    int[][] dp = new int[m][n];
	 
    // dp数组初始化
    for(int i = 0; i < m; i++) {
        dp[i][0] = 1;
    }
    for(int j = 0; j < n; j++) {
        dp[0][j] = 1;
    }
	
    // 计算dp数组
    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];
}

63. 不同路径 II

递推公式与上一题相同,但是需要注意不能直接初始化第一行和第一列,由于障碍物的存在,只能初始化第一个位置,然后根据第一个位置更新其他位置。

public int uniquePathsWithObstacles(int[][] obstacleGrid) {
    int m = obstacleGrid.length;
    int n = obstacleGrid[0].length;
	
    // dp数组初始化
    int[][] dp = new int[m][n];
    if(obstacleGrid[0][0] != 1) {
        dp[0][0] = 1;
    }

    // 计算dp数组
    for(int i = 0; i < m; i++) {
        for(int j = 0; j < n; j++) {
            if(obstacleGrid[i][j] != 1) {
                // 在计算时要考虑索引是否合法
                if(i - 1 >= 0) {
                    dp[i][j] += dp[i-1][j];
                }
                if(j - 1 >= 0) {
                    dp[i][j] += dp[i][j-1];
                }
            }

        }
    }

    return dp[m-1][n-1];
}

343. 整数拆分

dp[i]表示整数i被拆分成若干整数后的最大乘积。在计算dp[i]时,如果要将i拆分出整数j,那么最大乘积为dp[j]j的较大值乘以i-j,那么只需遍历2到i-1,找出使dp[i]最大的j即可。

public int integerBreak(int n) {
    int[] dp = new int[n+1];
	
    // dp数组初始化
    dp[2] = 1;
	
    // 计算dp数组
    for(int i = 3; i <= n ; i++) {
        // 计算dp[i]的值
        for(int j = 2; j <= i - 1; j++) {
            dp[i] = Math.max(dp[i], Math.max(j, dp[j]) * (i - j));
        }
    }

    return dp[n];
}

96. 不同的二叉搜索树

分析过程参考

计算dp[3]:以1为根节点的数量 + 以2为根节点的数量 + 以3为根节点的数量

  • 以1为根节点:dp[0]+dp[2]
  • 以2为根节点:dp[1]+dp[1]
  • 以3为根节点:dp[2]+dp[0]
public int numTrees(int n) {
    int[] dp = new int[n+1];
    
    // dp数组初始化
    dp[0] = 1;
    dp[1] = 1;
	
    // 计算dp数组
    for(int i = 2; i <= n; i++) {
        for(int j = 1; j <= i; j ++) {
            dp[i] += dp[j-1] * dp[i-j];
        }
    }

    return dp[n];
}

01背包问题

01背包理论基础,分别包括二维DP数组和一维DP数组的讲解

416. 分割等和子集

转化为01背包问题,即找出容量为sum/2的背包最多能装多少物品,如果恰好也为sum/2,说明能找到平均分割的子集。

  • 在本题中,物品的体积和价值相同
public boolean canPartition(int[] nums) {
    int sum = 0;
    for(int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
	
    // 必须是偶数才能平分
    if(sum % 2 != 0) {
        return false;
    }
	
    // dp数组
    int[] dp = new int[sum/2 + 1];

    for(int i = 0; i < nums.length; i++) {
        for(int j = dp.length-1; j >= nums[i]; j--) {
            dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);
        }
    }

    return dp[dp.length-1] == sum/2;
}

1049. 最后一块石头的重量 II

本题与上一题本质上相同,即寻找累加和最接近sum/2的子集,找到该子集之后,求出剩下子集的累加和与该子集的累加和之差即为答案。

public int lastStoneWeightII(int[] stones) {
    int sum = 0;
    for(int i = 0; i < stones.length; i++) {
        sum += stones[i];
    }
	
    // 注意dp数组的容量+1, 否则会忽略背包容量为sum/2时的情况
    int[] dp = new int[sum/2 + 1];

    for(int i = 0; i < stones.length; i++) {
        for(int j = dp.length - 1; j >= stones[i]; j--) {
            // 同样的, 物品体积和价值相同
            dp[j] = Math.max(dp[j], dp[j-stones[i]] + stones[i]);
        }
    }

	// 剩余子集累加和 - 当前子集累加和
    return sum - dp[dp.length-1] * 2;
}

494. 目标和

详细思路参考16.目标和

public int findTargetSumWays(int[] nums, int target) {
    int sum = 0;
    for(int i = 0; i < nums.length; i++) {
        sum += nums[i];
    }
	
    // 无法完成的情况
    if((target + sum) % 2 != 0 || (target + sum) < 0) {
        return 0;
    }
	
    // 初始化时注意dp[0]取1, 否则dp数组将全为0
    int[] dp = new int[(target + sum) / 2 + 1];
    dp[0] = 1;
	
    // 计算dp数组, 累加之间的结果
    for(int i = 0; i < nums.length; i++) {
        for(int j = dp.length - 1; j >= nums[i]; j--) {
            dp[j] += dp[j-nums[i]];
        }
    }

    return dp[dp.length - 1];
}

474. 一和零

和之前的01背包问题类似,不同的是,背包容量变成了两个维度:能装多少个'0'以及能装多少个'1'

public int findMaxForm(String[] strs, int m, int n) {
    int[] zeros = new int[strs.length];
    int[] ones = new int[strs.length];
	
    // 首先计算出strs数组中, 每个元素有多少0和1
    for(int i = 0; i < strs.length; i++) {
        for(int j = 0; j < strs[i].length() ; j++) {
            if(strs[i].charAt(j) == '0') {
                zeros[i]++;
            }
            else {
                ones[i]++;
            }
        }
    }
	
    // 初始化dp数组
    int[][] dp = new int[m+1][n+1];
    
    // 在计算时, 需要考虑两个维度, 能装多少0以及能装多少1
    for(int i = 0; i < strs.length; i++) {
        for(int j = m; j >= zeros[i]; j--) {
            for(int k = n; k >= ones[i]; k--) {
                dp[j][k] = Math.max(dp[j][k], dp[j-zeros[i]][k-ones[i]] + 1);
            }
        }
    }

    return dp[m][n];
}

完全背包问题

完全背包问题需要两层循环的顺序,例如当我们求组合数量时:

  • 先遍历物品,再遍历背包容量:将[1, 1, 3][1, 3, 1][3, 1, 1]视为同一个组合,计数为1
  • 先遍历背包容量,再遍历物品:将[1, 1, 3][1, 3, 1][3, 1, 1]视为不同的组合,计数为3

518. 零钱兑换 II

由于是求组合数,需要注意初始化和dp数组递推公式。组合内元素没有顺序,所以先遍历物品,再遍历背包容量。

public int change(int amount, int[] coins) {
    int[] dp = new int[amount+1];
    dp[0] = 1;  // 初始化
    
    for(int i = 0; i < coins.length; i++) {
        for(int j = coins[i]; j < dp.length; j++) {
            dp[j] += dp[j-coins[i]];  // 求组合数
        }
    }
    
    return dp[dp.length-1];
}

377. 组合总和 Ⅳ

组合内元素有顺序,所以先遍历背包容量,再遍历物品。

public int combinationSum4(int[] nums, int target) {
    int[] dp = new int[target+1];
    dp[0] = 1;

    for(int i = 0; i < dp.length; i++) {
        for(int j = 0; j < nums.length; j++) {
            if(i >= nums[j]) {
                dp[i] += dp[i-nums[j]];
            }

        }
    }

    return dp[target];
}

70. 爬楼梯

爬楼梯问题实际上也是完全背包问题,物品有1和2两种,并且组合元素区分顺序。如果一次性可以爬m个台阶,则修改为j <= m即可。

public int climbStairs(int n) {
    int[] dp = new int[n+1];
    dp[0] = 1;

    for(int i = 0; i < dp.length; i++) {
        for(int j = 1; j <= 2; j++) {
            if(i >= j) {
                dp[i] += dp[i-j];
            }
        }
    }

    return dp[n];
}

322. 零钱兑换

需要将dp数组除了dp[0]以外的其他位置初始化为一个足够大的数,这里初始化为amount+1

public int coinChange(int[] coins, int amount) {
    // dp数组初始化, 除了dp[0] = 0之外, 其他位置初始化为amount + 1
    int[] dp = new int[amount+1];
    for(int i = 1; i <dp.length; i++) {
        dp[i] = amount + 1;
    }
	
    // 计算dp数组时取较小值
    for(int i = 0; i < coins.length; i++) {
        for(int j = coins[i]; j < dp.length; j++) {
            dp[j] = Math.min(dp[j], dp[j-coins[i]]+1);
        }
    }

    if(dp[amount] == amount+1) {
        return -1;
    }
    return dp[amount];
}

279. 完全平方数

public int numSquares(int n) {
    // dp数组初始化
    int[] dp = new int[n+1];
    for(int i = 0; i < dp.length; i++) {
        dp[i] = i;
    }
	
    int m = (int) Math.sqrt(n);  // 物品种类为1~m
    for(int i = 1; i <= m; i++) {
        for(int j = i*i; j < dp.length; j++) {
            dp[j] = Math.min(dp[j], dp[j-i*i] + 1);
        }
    }

    return dp[n];
}

139. 单词拆分

单词可以重复使用,属于完全背包问题;单词前后顺序有区分,所以先遍历背包容量再遍历物品。

    public boolean wordBreak(String s, List<String> wordDict) {
        // dp[i]表示长度为i的s的子串是否能被拆分
        int[] dp = new int[s.length() + 1];
        dp[0] = 1;

        for(int i = 1; i <= s.length(); i++) {
            for(int j = 0; j < wordDict.size(); j++) {
                String word = wordDict.get(j);
                if(i >= word.length() && dp[i-word.length()] == 1 
                    && word.equals(s.substring(i-word.length(), i))) {
                    dp[i] = 1;
                }
            }
        }

        return dp[dp.length - 1] == 1;
    }

打家劫舍

198. 打家劫舍

dp[i]表示如果打劫第i个房间,能盗窃的最高金额是多少。使用这个方法,dp[i]就一定会打劫第i个房间,dp[dp.length-1]也不是最大值。此时,dp数组的递推公式为dp[i] = nums[i] + maxValue(dp, i - 1)

public int rob(int[] nums) {
    int[] dp = new int[nums.length];
    dp[0] = nums[0];

    for(int i = 1; i < nums.length; i++) {
        dp[i] = nums[i] + maxValue(dp, i - 1);
    }

    return maxValue(dp, dp.length);
}

// 返回数组array从0到end中的最大值, 不包括end
int maxValue(int[] array, int end) {
    // 如果end为0, 返回0
    if(end == 0) {
        return 0;
    }

    int max = array[0];
    for(int i = 1; i < end; i++) {
        if(max < array[i]) {
            max = array[i];
        }
    }

    return max;
}

以下这种方法,dp数组表示到第i个房间为止,能盗窃的最多的钱,此时第i个房间可以不被盗窃。

public int rob(int[] nums) {
    // 考虑只有1个数字的情况
    if(nums.length == 1) {
        return nums[0];
    }
	
    // dp数组初始化
    int[] dp = new int[nums.length];
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);
	
    // dp数组计算
    for(int i = 2; i < nums.length; i++) {
        dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i]);
    }

    return dp[dp.length - 1];
}

213. 打家劫舍 II

修改上一题的逻辑,分别比较从第1家到倒数第2家能打劫到的钱与从第2家到倒数第1家能打劫到的钱。

public int rob(int[] nums) {
    if(nums.length == 1) {
        return nums[0];
    }
    return Math.max(robRange(nums, 0, nums.length - 1), robRange(nums, 1, nums.length));
}

// 与上一题逻辑相同
int robRange(int[] nums, int start, int end) {
    int length = end - start;
    if(length <= 1) {
        return nums[start];
    }

    int[] dp = new int[length];
    dp[0] = nums[start];
    dp[1] = Math.max(nums[start], nums[start+1]);

    for(int i = 2; i < dp.length; i++) {
        dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i+start]);
    }

    return dp[dp.length - 1];
}

337. 打家劫舍 III

后序遍历,为了防止多次递归,需要存储当前的值。map.get(root)表示root节点能打劫到的最大金额,可以不打劫root

HashMap<TreeNode, Integer> map = new HashMap<>();

public int rob(TreeNode root) {
    if(root == null) {
        return 0;
    }

    int left = rob(root.left);
    int right = rob(root.right);

    int leftChildren = 0;
    int rightChildren = 0;

    if(root.left != null) {
        leftChildren = check(root.left.left) + check(root.left.right);
    }
    if(root.right != null) {
        rightChildren = check(root.right.left) + check(root.right.right);
    }
	
    // 考虑是否要打劫root
    map.put(root, Math.max(left + right, root.val + leftChildren + rightChildren));
    return map.get(root);
}

int check(TreeNode root) {
    return map.getOrDefault(root, 0);
}

股票问题

121. 买卖股票的最佳时机

  • 贪心算法
public int maxProfit(int[] prices) {
    int profit = 0;
    int min = prices[0];  // 记录当前日期之前的价格的最小值

    for(int i = 1; i < prices.length; i++) {
        profit = Math.max(profit, prices[i] - min);
        min = Math.min(min, prices[i]);  // 更新最小价格值
    }

    return profit;
}
  • 动态规划:dp[i][0]表示第i天持有股票的最大收益,dp[i][0]表示第i天不持有股票的最大收益
public int maxProfit(int[] prices) {
    int[][] dp = new int[prices.length][2];
    dp[0][0] = -prices[0];  // 持有股票

    for(int i = 1; i < prices.length; i++) {
        // 不持有股票: 前一天持有股票, 保持原状; 前一天不持有股票, 今天买入股票
        dp[i][0] = Math.max(dp[i-1][0], -prices[i]);
        // 持有股票: 前一天持有股票, 今天卖出; 前一天不持有股票, 保持原状
        dp[i][1] = Math.max(dp[i-1][0] + prices[i], dp[i-1][1]);
    }

    return dp[dp.length - 1][1];
}

122. 买卖股票的最佳时机 II

  • 贪心算法
public int maxProfit(int[] prices) {
    int profit = 0;

    for(int i = 1; i < prices.length; i++) {
        if(prices[i] - prices[i-1] > 0) {
            profit += prices[i] - prices[i-1];
        }
    }

    return profit;
}
  • 动态规划:和上一题不同的地方是,dp[i][0]在更新时,需要考虑前一天不持有股票的收益 (因为在上一题中,一定是0,但本题支持多次买卖)
public int maxProfit(int[] prices) {
    int[][] dp = new int[prices.length][2];
    dp[0][0] = -prices[0];

    for(int i = 1; i < prices.length; 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[dp.length-1][1];
}

714. 买卖股票的最佳时机含手续费

与前面类似,注意卖出时有手续费。

public int maxProfit(int[] prices, int fee) {
    int[][] dp = new int[prices.length][2];
    dp[0][0] = -prices[0];

    for(int i = 1; i < dp.length; 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] - fee, dp[i-1][1]);
    }

    return dp[dp.length - 1][1];
}

309. 最佳买卖股票时机含冷冻期

与前面类似,需要注意当第i天买入时, 需要保证第i-1天是没有卖出的, 所以考虑第i-2天不持有股票,即dp[i-2][1] - prices[i]

public int maxProfit(int[] prices) {
    if(prices.length == 1) {
        return 0;
    }
	
    // 初始化时需要初始化前两天
    int[][] dp = new int[prices.length][2];
    dp[0][0] = -prices[0];
    dp[1][0] = Math.max(-prices[0], -prices[1]);
    dp[1][1] = Math.max(0, -prices[0] + prices[1]);
	
    for(int i = 2; i < dp.length; i++) {
        // 唯一不同的地方是, 当第i天买入时, 需要保证第i-1天是没有卖出的, 所以考虑第i-2天不持有股票
        dp[i][0] = Math.max(dp[i-1][0], dp[i-2][1] - prices[i]);
        dp[i][1] = Math.max(dp[i-1][0] + prices[i], dp[i-1][1]);
    }

    return dp[dp.length - 1][1];
}

123. 买卖股票的最佳时机 III

子序列问题

300. 最长递增子序列

dp数组初始化为1,递推公式为:dp[i] = max(dp[i], dp[j] + 1)。注意dp[i]的意义,到i个元素为止的最长递增子序列的长度,且子序列必须包括元素i,所以dp[dp.length-1]不一定是最终结果。

public int lengthOfLIS(int[] nums) {
    // dp数组初始化为1
    int[] dp = new int[nums.length];
    Arrays.fill(dp, 1);
    
    // 记录最长的递增子序列长度
    int result = 1;

    for(int i = 1; i < nums.length; i++) {
        for(int j = 0; j < i; j++) {
            if(nums[i] > nums[j]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        result = Math.max(result, dp[i]);
    }

    return result;
}

674. 最长连续递增序列

public int findLengthOfLCIS(int[] nums) {
    int result = 1;  // 记录最长连续递增子序列长度

    int count = 1;  // 记录当前连续递增子序列长度
    for(int i = 1; i < nums.length; i++) {
        if(nums[i] > nums[i-1]) {
            count++;
            result = Math.max(result, count);
        }
        else {
            count = 1;
        }
    }

    return result;
}

718. 最长重复子数组

public int findLength(int[] nums1, int[] nums2) {
    int result = 0;
    int[][] dp = new int[nums1.length + 1][nums2.length + 1];

    for(int i = 1; i <= nums1.length; i++) {
        for(int j = 1; j <= nums2.length; j++) {
            if(nums1[i-1] == nums2[j-1]) {
                dp[i][j] = dp[i-1][j-1] + 1;
            }
            result = Math.max(result, dp[i][j]);
        }
    }

    return result;
}

1143. 最长公共子序列

与上一题类似,不同点在于,由于子序列不需要连续,所以dp数组需要继承之前的最长子序列长度。

public int longestCommonSubsequence(String text1, String text2) {
    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][j-1], dp[i-1][j]);
            }
        }
    }

    return dp[text1.length()][text2.length()];
}

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

本质上与上一题类似,求最大公共子序列。

public int minDistance(String word1, String word2) {
    int[][] dp = new int[word1.length() + 1][word2.length() + 1];

    for(int i = 1; i <= word1.length(); i++) {
        for(int j = 1; j <= word2.length(); j++) {
            if(word1.charAt(i - 1) == word2.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]);
            }
        }
    }

    int common = dp[word1.length()][word2.length()];
    return word1.length() + word2.length() - common * 2;
}

1035. 不相交的线

53. 最大子数组和

public int maxSubArray(int[] nums) {
    int result = nums[0];
    int sum = nums[0];  // 记录当前累加和

    for(int i = 1; i < nums.length; i++) {
        sum = Math.max(sum + nums[i], nums[i]);
        result = Math.max(result, sum);
    }

    return result;
}

392. 判断子序列

采用双指针法更简单。

public boolean isSubsequence(String s, String t) {
    if(s.length() == 0) {
        return true;
    }

    int index = 0;  // s串的下标

    for(int i = 0; i < t.length(); i++) {
        if(s.charAt(index) == t.charAt(i)) {
            index++;
        }
        if(index == s.length()) {
            return true;
        }
    }

    return false;
}

其他问题

5. 最长回文子串

dp[i][j]表示字符串的子串s.slice(i, j + 1)是否是回文子串。首先,dp[i][j]true的前置条件是s[i] === s[j],在此条件下,递推公式分3种情况:

  • ij重合,则为true
  • i === j - 1时,则为true
  • dp[i + 1][j - 1]true时,则为true

此外,还要注意ij的遍历顺序:

var longestPalindrome = function(s) {
    let len = s.length;
    if (!len) {
        return 0;
    }
	
    // 初始化大小为 len * len 的二维dp数组
    let dp = Array(len).fill(0);
    dp.forEach((_, i) => dp[i] = Array(len).fill(false));

    // 记录当前最长回文子串的长度
    let res = 1, left = 0, right = 0;

    for (let j = 0; j < len; j++) {
        for (let i = 0; i <= j; i++) {
            // i j 相邻, i j重叠, 或者i j中间的子串是回文串
            if (s[i] === s[j] && (i === j || i === j - 1 || dp[i + 1][j - 1])) {
                dp[i][j] = true;
            }
			
            // 如果当前子串最长回文串, 则收集结果
            if (dp[i][j] && j - i + 1 > res) {
                res = j - i + 1;
                [left, right] = [i, j];
            }
        }
    }

    return s.slice(left, right + 1);
};
posted @   lv6laserlotus  阅读(22)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示