0
点赞
收藏
分享

微信扫一扫

《动态规划入门》刷题笔记(更新中)

琛彤麻麻 2022-04-21 阅读 39

《动态规划》刷题笔记

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;
}

正常做法:经历循环求得的最大值、不经历循环求得的最大值,两种情况比大小

  • 经历循环求得的最大值 = 原数组的和 - 子数组的最小和(动态规划)
  • 不经历循环求得的最大值 = 子数组的最大和(动态规划)

别的地方偷过来的图片,秒懂:
sum - minSubarraySum

易理解版代码:分别求出数组和、最大子数组和、最小子数组和,分情况讨论

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];
}
举报

相关推荐

0 条评论