《动态规划》刷题笔记
1. 斐波那契数
题目:509. 斐波那契数
let fib = function (n: number): number {
// dp[i] - 第 i 个斐波那契数
let dp = new Array(n + 1)
dp[0] = 0
dp[1] = 1
for (let i = 2; i <= n; i++)
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
};
2. 第 N 个泰波那契数
题目:1137. 第 N 个泰波那契数
public int tribonacci(int n) {
if (n <= 1) return n;
// dp[i] - 第 i 个泰波那契数
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
dp[2] = 1;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 3] + dp[i - 2] + dp[i - 1];
}
return dp[n];
}
3. 爬楼梯
题目:70. 爬楼梯
public int climbStairs(int n) {
// 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];
}
4. 使用最小花费爬楼梯
题目:746. 使用最小花费爬楼梯
标准 DP:初始化值为 0
public int minCostClimbingStairs(int[] cost) {
// dp[i] - 爬到第 i 个台阶的最小花费
int n = cost.length;
int[] dp = new int[n + 1];
for (int i = 2; i <= n; i++) {
dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[n];
}
滚动数组优化: DP 数组的值只与前两项有关, 使用两个变量滚动
public int minCostClimbingStairs(int[] cost) {
int n = cost.length;
int first = 0, second = 0, tmp;
for (int i = 2; i <= n; i++) {
tmp = second;
second = Math.min(first + cost[i - 2], second + cost[i - 1]);
first = tmp;
}
return second;
}
初始化方法不同的 DP:使用 cost 数组进行初始化
public int minCostClimbingStairs(int[] cost) {
// dp[i] - 爬到第 i 个台阶的最小花费
int n = cost.length;
int[] dp = new int[n];
dp[0] = cost[0];
dp[1] = cost[1];
for (int i = 2; i < n; i++) {
dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];
}
return Math.min(dp[n - 1], dp[n - 2]);
}
5. 打家劫舍
题目:198. 打家劫舍
标准 DP:
public int rob(int[] nums) {
int[] dp = new int[nums.length + 1];
dp[0] = 0;
dp[1] = nums[0];
for (int i = 2; i <= nums.length; i++) {
dp[i] = Math.max(dp[i - 2] + nums[i - 1], dp[i - 1]);
}
return dp[nums.length];
}
6. 打家劫舍 II
题目:213. 打家劫舍 II
核心思路:将环拆成两个队列,一个从 0 到 n-1,另一个从 1 到 n
class Solution {
public int rob(int[] nums) {
if (nums.length == 1) return nums[0];
return Math.max(
myRob(Arrays.copyOfRange(nums, 0, nums.length - 1)),
myRob(Arrays.copyOfRange(nums, 1, nums.length)));
}
public int myRob(int[] nums) {
int n = nums.length;
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = nums[0];
for (int i = 2; i <= n; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}
return dp[n];
}
}
7. 删除并获得点数
题目:740. 删除并获得点数
将这题转化一下就成了 “打家劫舍”:
[2, 2, 3, 3, 3, 4] -> [0, 0, 4, 9, 4]
class Solution {
public int deleteAndEarn(int[] nums) {
int[] trans = new int[10001];
for (int i = 0; i < nums.length; i++) {
trans[nums[i]] += nums[i];
}
return rob(trans);
}
// 打家劫舍
public int rob(int[] nums) {
int n = nums.length;
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = nums[0];
for (int i = 2; i <= n; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}
return dp[n];
}
}
8. 跳跃游戏
题目:跳跃游戏
不确定这个是不是动规,感觉就是正常的模拟:
public boolean canJump(int[] nums) {
// 只有一个下标, 一定可以到达
if (nums.length == 1) return true;
// 不止一个下标, 且第一步为0, 一定不能到达
if (nums[0] == 0) return false;
// res[i] 能否到达坐标 i
boolean[] res = new boolean[nums.length];
res[0] = true; // 初始下标一定能到
for (int i = 0; i < nums.length; i++) {
// 如果能到达当前下标, 才能继续跳
if (!res[i]) continue;
// 计算当前能跳到的下标
for (int j = 1; j <= nums[i] && i + j < nums.length; j++) {
res[i + j] = true;
}
}
// 能否到达最后一个下标
return res[nums.length - 1];
}
只维护一个能跳到的最远距离:
public boolean canJump(int[] nums) {
int k = 0; // 能跳到的最远距离
for (int i = 0; i < nums.length; i++) {
if (i > k) return false;
k = Math.max(i + nums[i], k);
}
return true;
}
9. 跳跃游戏 II
题目:跳跃游戏 II
标准的 DP:
public int jump(int[] nums) {
if (nums.length == 1) return 0;
// dp[i] 跳到 i 位置需要的最少跳跃次数
int[] dp = new int[nums.length];
Arrays.fill(dp, nums.length + 1); // 填充最大值
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (j + nums[j] >= i) {
dp[i] = Math.min(dp[i], dp[j] + 1);
}
}
}
return dp[nums.length - 1];
}
优化 DP:
public int jump1(int[] nums) {
int[] dp = new int[nums.length];
Arrays.fill(dp, nums.length + 1);
dp[0] = 0;
for (int i = 0; i < nums.length; i++) {
for (int j = 1; j <= nums[i]; j++) {
if (i + j >= nums.length) {
return dp[nums.length - 1];
}
dp[i + j] = Math.min(dp[i + j], dp[i] + 1);
}
}
return dp[nums.length - 1];
}
注:此题最优解应该是 贪心
10. 最大子数组和
题目:53. 最大子数组和
标准 DP:
public int maxSubArray(int[] nums) {
// dp[i] i 位置的最大子数组和
// 也就是 dp[nums.length - 1] 不是最后答案, max{dp[i]} 才是
int[] dp = new int[nums.length];
dp[0] = nums[0];
int max = dp[0];
for (int i = 1; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
max = Math.max(max, dp[i]);
}
return max;
}
优化 DP:将空间复杂度优化成常量级别(速度也变快了)
public int maxSubArray(int[] nums) {
int cur = nums[0], max = cur;
for (int i = 1; i < nums.length; i++) {
cur = Math.max(cur + nums[i], nums[i]);
max = Math.max(max, cur);
}
return max;
}
11. 环形子数组的最大和
题目:918. 环形子数组的最大和
暴力做法(会超时):将数组拼接成 2 个,然后对其中每连续的 nums.length
个元素去寻找最大子数组和
public int maxSubarraySumCircular(int[] nums) {
int[] doubleNums = new int[nums.length * 2];
// 拼接数组:[1,2,3] ---> [1,2,3,1,2,3]
System.arraycopy(nums, 0, doubleNums, 0, nums.length);
System.arraycopy(nums, 0, doubleNums, nums.length, nums.length);
// 对拼接后的数组,每 nums.length 个元素找最大子数组和
int res = Integer.MIN_VALUE;
for (int i = 0; i < nums.length; i++) {
res = Math.max(res, maxSubarray(Arrays.copyOfRange(doubleNums, i, i + nums.length)));
}
return res;
}
// 子数组的最大和
int maxSubarray(int[] nums) {
int cur = nums[0], max = cur;
for (int i = 1; i < nums.length; i++) {
cur = Math.max(cur + nums[i], nums[i]);
max = Math.max(max, cur);
}
return max;
}
正常做法:经历循环求得的最大值、不经历循环求得的最大值,两种情况比大小
- 经历循环求得的最大值 = 原数组的和 - 子数组的最小和(动态规划)
- 不经历循环求得的最大值 = 子数组的最大和(动态规划)
别的地方偷过来的图片,秒懂:
易理解版代码:分别求出数组和、最大子数组和、最小子数组和,分情况讨论
public int maxSubarraySumCircular(int[] nums) {
int sum = Arrays.stream(nums).sum(); // 数组和
// 情况一:循环, 数组和 - 最小子数组和
int max1 = sum - minSubarray(nums);
// 情况二:未循环, 最大子数组和
int max2 = maxSubarray(nums);
// max1 == 0 说明整个数组都是负数
return max1 == 0 ? max2 : Math.max(max1, max2);
}
// 最小子数组和
int minSubarray(int[] nums) {
int cur = nums[0], min = cur;
for (int i = 1; i < nums.length; i++) {
cur = Math.min(cur + nums[i], nums[i]);
min = Math.min(min, cur);
}
return min;
}
// 最大子数组和
int maxSubarray(int[] nums) {
int cur = nums[0], max = cur;
for (int i = 1; i < nums.length; i++) {
cur = Math.max(cur + nums[i], nums[i]);
max = Math.max(max, cur);
}
return max;
}
优化后的代码:就是将求最大子数组和、最小子数组和、数组和放到一个循环中完成
public int maxSubarraySumCircular(int[] nums) {
int sum = nums[0]; // 数组和
int minDp = nums[0], min = minDp; // 求子数组的最小和
int maxDp = nums[0], max = maxDp; // 求子数组的最大和
for (int i = 1; i < nums.length; i++) {
// 求子数组的最小和
minDp = Math.min(minDp + nums[i], nums[i]);
min = Math.min(min, minDp);
// 求子数组的最大和
maxDp = Math.max(maxDp + nums[i], nums[i]);
max = Math.max(max, maxDp);
// 求子数组的和
sum += nums[i];
}
// 情况一:经历循环, 最大和 = 数组和 - 最小子数组和
int max1 = sum - min;
// 情况二:未经历循环, 最大和 = 最大子数组和
int max2 = max;
// max1 == 0 说明整个数组都是负数
return max1 == 0 ? max2 : Math.max(max1, max2);
}
12. 乘积最大子数组
题目:152. 乘积最大子数组
相比最大子数组的和,这题主要是多了负数的处理。
如果有了负数,当前的最大乘积有两种情况:
- nums[i] 是正数,当前的最大乘积 = 前面的最大乘积 * nums[i]
- nums[i] 是负数,当前的最大乘积 = 前面的最小乘积 * nums[i]
所以可以维护两个 dp 数组,分别计算前面的 最小乘积 和 最大乘积:
maxDp[i] = Math.max(nums[i], Math.max(maxDp[i - 1] * nums[i], minDp[i - 1] * nums[i]))
minDp[i] = Math.min(nums[i], Math.min(maxDp[i - 1] * nums[i], minDp[i - 1] * nums[i]))
比较标准的动态规划写法:(其实 res 已经是将 dp 数组优化掉的结果,最标准的应该还有一个 DP 数组)
public int maxProduct(int[] nums) {
int[] maxDp = new int[nums.length];
int[] minDp = new int[nums.length];
int res = nums[0]; // 最大值
maxDp[0] = minDp[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
maxDp[i] = Math.max(nums[i], Math.max(maxDp[i - 1] * nums[i], minDp[i - 1] * nums[i]));
minDp[i] = Math.min(nums[i], Math.min(maxDp[i - 1] * nums[i], minDp[i - 1] * nums[i]));
res = Math.max(res, maxDp[i]);
}
return res;
}
将两个 DP 数组优化掉,用 两个变量 来保存 ‘前面的最大乘积’,‘前面的最小乘积’
- 这里有个细节,需要备份一下 imax,具体看注释
public int maxProduct(int[] nums) {
int res = nums[0], imax = 1, imin = 1;
for (int i = 0; i < nums.length; i++) {
// 需要备份一下 imax, 因为下一行会将 imax 更新成当前的值, 计算 imin 时用的 imax 应当是前面的最大乘积
int tempImax = imax;
imax = Math.max(nums[i], Math.max(imax * nums[i], imin * nums[i]));
imin = Math.min(nums[i], Math.min(tempImax * nums[i], imin * nums[i]));
res = Math.max(res, imax);
}
return res;
}
评论区的答案其实是这么来的,并不应该是直接写出来的:(至少我不能)
将上面那个备份 imax 操作再次优化掉(也不能叫优化,两者效率相当,就是换种实现手段)
public int maxProduct(int[] nums) {
int max = Integer.MIN_VALUE, imax = 1, imin = 1;
for (int i = 0; i < nums.length; i++) {
if (nums[i] < 0) {
int temp = imax;
imax = imin;
imin = temp;
}
imax = Math.max(nums[i], imax * nums[i]);
imin = Math.min(nums[i], imin * nums[i]);
max = Math.max(max, imax);
}
return max;
}
再附上一种其他思路:从前往后乘找最大,从后往前乘找最大,比较两者的最大
public int maxProduct(int[] nums) {
int res = nums[0], max = 1;
// 从前往后乘积最大值
for (int i = 0; i < nums.length; i++) {
max = (max == 0) ? nums[i] : max * nums[i];
res = Math.max(max, res);
}
// 从后往前乘积最大值
max = 1;
for (int i = nums.length - 1; i >= 0; i--) {
max = (max == 0) ? nums[i] : max * nums[i];
res = Math.max(max, res);
}
return res;
}
上面的两个循环可以 “优化” 成一个循环:
(为什么 “优化” 加双引号,因为根据测试,速度反而变慢了,原因是指令执行数量反而更多了??)
public int maxProduct1(int[] nums) {
int len = nums.length;
// maxF - 从前往后乘的最大值, maxB - 从后往前乘的最大值
int res = nums[0], maxF = 1, maxB = 1;
for (int i = 0; i < len; i++) {
maxF = (maxF == 0) ? nums[i] : maxF * nums[i];
maxB = (maxB == 0) ? nums[len - i - 1] : maxB * nums[len - i - 1];
res = Math.max(res, Math.max(maxF, maxB));
}
return res;
}
13. 乘积为正数的最长子数组长度 TODO
题目:1567. 乘积为正数的最长子数组长度
这题还存疑。。。。
14. 最佳观光组合
题目:1014. 最佳观光组合
这题最直观做法是两层 for 循环的暴力做法:(这个应该也算 DP 吧)
Java 这么写但是会超时。(但是 JavaScript 不会。。。)
public int maxScoreSightseeingPair(int[] values) {
// dp[j] 到 j 为止的观光景点的最高分
int[] dp = new int[values.length];
for (int j = 1; j < values.length; j++) {
// 寻找最大值
int tempMax = -1;
for (int i = 0; i < j; i++) {
tempMax = Math.max(tempMax, values[i] + values[j] + i - j);
}
dp[j] = Math.max(dp[j - 1], tempMax);
}
return dp[values.length - 1];
}
转换思路:将题目给出的公式中的 i 和 j 分组考虑,会发现其实只有 i 的状态在变化。
(每次遍历时都可以拿到当前的 values[j] - j
,只需要维护 values[i] + i
的最大值即可)
// values[i] + values[j] + i - j
// 上式等价于 (values[i] + i) + (values[j] -j)
// 由于已经确定 i < j
// 相当于我们只要遍历 j, 每次能拿到当前的 (values[j] - j)
// 同时不停的更新 (values[i] + i) 的最大值即可
public int maxScoreSightseeingPair(int[] values) {
int maxI = -1, res = -1;
for (int j = 1; j < values.length; j++) {
// i < j, 所以 i 最多只能维护到 j 的前一位
maxI = Math.max(maxI, values[j - 1] + j - 1);
res = Math.max(res, maxI + values[j] - j);
}
return res;
}
15. 买卖股票的最佳时机
题目:121. 买卖股票的最佳时机
标准的动态规划:
public int maxProfit(int[] prices) {
// dp[i] 在第 i 天卖出股票可以获得的最大利润
// 也就是说 dp[i] 不一定大于 dp[i - 1]
// 递推结束后再求出 dp[i] 的最大值才是结果
int[] dp = new int[prices.length];
// min 第 i 天[之前]的股票的最低价格
int min = prices[0];
for (int i = 1; i < prices.length; i++) {
dp[i] = prices[i] - min > 0 ? prices[i] - min : 0;
min = Math.min(min, prices[i]); // 更新股票的最低价格
}
// 求 dp[i] 的最大值
return Arrays.stream(dp).max().getAsInt();
}
优化时间:一轮循环
public int maxProfit(int[] prices) {
// dp[i] 在第 i 天卖出股票可以获得的最大利润
int[] dp = new int[prices.length];
// min 第 i 天之前的股票的最低价格, res = max(dp[i])
int min = prices[0], res = 0;
for (int i = 1; i < prices.length; i++) {
dp[i] = prices[i] - min > 0 ? prices[i] - min : 0;
res = Math.max(res, dp[i]); // 更新最大值
min = Math.min(min, prices[i]); // 更新股票的最低价格
}
return res;
}
优化空间:不开辟数组
public int maxProfit(int[] prices) {
// min 第 i 天之前的股票的最低价格, res = max(dp[i])
int min = prices[0], res = 0;
for (int i = 1; i < prices.length; i++) {
res = Math.max(res, prices[i] - min); // 更新最大值
min = Math.min(min, prices[i]); // 更新股票的最低价格
}
return res;
}
16. 买卖股票的最佳时机 II
题目:122. 买卖股票的最佳时机 II
自己写出来的垃圾模拟代码:(也是贪心的思想,但是代码比较恶心)
public int maxProfit(int[] prices) {
// min - 局部的最低股价
// max - 当前手持的股票可以获取的最大利润
// profit - 已经卖掉拿到的利润
int min = prices[0], max = 0, profit = 0;
for (int i = 1; i < prices.length; i++) {
if (prices[i] >= prices[i - 1]) {
// 今日股票价格比昨日的高, 继续手持股票更新利润
max = Math.max(max, prices[i] - min);
} else {
// 今日股票价格低于昨日股票价格, 将昨日股票卖掉
profit += max; // 更新目前利润
max = 0; // 当前手持股票从利润0开始
min = prices[i]; // 从当前开始维护最低股价, 前面的最低价已经没用
}
}
// 最终的利润 = 已经卖掉拿到的利润 + 手持股票可以获取的利润
return profit + max;
}
贪心理解角度1:当 prices[i] > prices[i - 1]
则直接卖掉,然后再次开始买入
public int maxProfit(int[] prices) {
int res = 0;
for (int i = 1; i < prices.length; i++) {
if (prices[i] > prices[i - 1]) {
res += prices[i] - prices[i - 1];
}
}
return res;
}
贪心理解角度2:相当于每天都在做买卖,如果当天利润为正,则加入总利润
public int maxProfit(int[] prices) {
int res = 0; // 总利润
for (int i = 1; i < prices.length; i++) {
int tmp = prices[i] - prices[i - 1];
if (tmp > 0) res += tmp;
}
return res;
}
17. 最佳买卖股票时机含冷冻期
题目:309. 最佳买卖股票时机含冷冻期
其实总共就 4 种状态,不需要考虑冷冻期:
两种大状态:
- 未持有股票
- 今天没有卖出 —
dp[i][0]
- 是今天卖出的 —
dp[i][1]
- 今天没有卖出 —
- 持有股票
- 是今天买入的 —
dp[i][2]
- 不是今天买入的 —
dp[i][3]
- 是今天买入的 —
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length][4];
dp[0][0] = 0; // 不持有股票, 今天没卖出
dp[0][1] = 0; // 不持有股票, 今天卖出的
dp[0][2] = -prices[0]; // 持有股票, 今天买入的
dp[0][3] = -prices[0]; // 持有股票, 非今天买入
for (int i = 1; i < prices.length; i++) {
// 今天不持股的情况 = 昨天不持股的两种情况的最大值
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1]);
// 今天卖出股票的情况 = 昨天持有的股票的最大值 + 今天的价格
dp[i][1] = Math.max(dp[i - 1][2], dp[i - 1][3]) + prices[i];
// 今天买入股票的情况:昨天一定没有卖出股票,也没有买入
dp[i][2] = dp[i - 1][0] - prices[i];
// 今天没有买股票, 却持有股票, 昨天承过来的
dp[i][3] = Math.max(dp[i - 1][2], dp[i - 1][3]);
}
// 最大值一定是处于不持有股票的状态
return Math.max(dp[prices.length - 1][0], dp[prices.length - 1][1]);
}
18. 买卖股票的最佳时机含手续费
题目:714. 买卖股票的最佳时机含手续费
public int maxProfit(int[] prices, int fee) {
int[][] dp = new int[prices.length][2];
dp[0][0] = -prices[0]; // 第 i 天手上持有股票的最大利润
dp[0][1] = 0; // 第 i 天手上没有股票的最大利润
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][1], dp[i - 1][0] + prices[i] - fee);
}
return dp[prices.length - 1][1];
}