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种情况:
- 当
i
和j
重合,则为true
- 当
i === j - 1
时,则为true
- 当
dp[i + 1][j - 1]
为true
时,则为true
此外,还要注意i
和j
的遍历顺序:
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);
};
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?