美文网首页
经典算法——动态规划

经典算法——动态规划

作者: _冻茶 | 来源:发表于2022-05-05 15:42 被阅读0次

姓名:谭旭东 学号:19011210016

前言

  1. 动态规划是什么?
  • 动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。

  • 动态规划一般可分为线性动规,区域动规,树形动规,背包动规四类。

  • 动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。

  • 动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。

  1. 如何学习动态规划?
  • 多刷题,掌握该算法的整体思路
  • 在接下来的内容中,我将通过不同的【转移方程】将动态规划的题目进行分类,让我们对不同类型的题目有更好的认识

一、简单相加的状态转移

这类动态规划属于比较简单的情况,因为转移方程可以简单的直接得到

而且由于新状态转移只需要前几个状态,我们可以压缩状态来节省更多空间

1. 斐波那契数列

  • 题目出处:509. 斐波那契数
  • 转移方程:【F(n) = F(n - 1) + F(n - 2)】
  • 初始状态:【F(0) = 0,F(1) = 1】

不使用状态压缩:

    public int fib(int n) {
        if (n == 0) return 0;
        if ( n == 1 || n == 2) return 1;

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

        for (int i = 2; i<=n; i++) {
            dp[i] = f0 + f1;
        }
        return dp[n];
    }

状态压缩后:

    public int fib(int n) {
        if (n == 0) return 0;
        if ( n == 1 || n == 2) return 1;

        int f0 = 0;
        int f1 = 1;

        for (int i = 1; i<n; i++) {
            int newf = f0 + f1;

            f0 = f1;
            f1 = newf;
        }
        return f1;
    }

2. 第 N 个泰波那契数

直接上状态压缩之后的代码

    public int tribonacci(int n) {
        if ( n == 0 ) return 0;
        if ( n  <= 2) return 1;

        int t0 = 0;
        int t1 = 1;
        int t2 = 1;

        for (int i = 3; i <= n; i++) {
            
            int newT = t0 + t1 + t2;

            t0 = t1;
            t1 = t2;
            t2 = newT;
        }
        return t2;
    }

3. 爬楼梯

  • 题目出处:70. 爬楼梯
  • 转移方程:【dp[n] = dp[n-1] + dp[n-2]】
  • 初始状态:【dp[1] = 1,dp[2] = 2】

状态压缩后的代码:

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


        int dp1 = 1;
        int dp2 = 2;

        for (int i = 3; i <= n; i++) {
            int newDp = dp1 + dp2;
            dp1 = dp2;
            dp2 = newDp;
        }

        return dp2;
    }

二、取max/min的状态转移

这种状态转移方程需要在转移过程中选取最小/最大值

1. 最小花费爬楼梯

  • 题目出处:746. 使用最小花费爬楼梯
  • 状态转移:【dp[i] = min{ dp[i-1]+cost[i-1] , dp[i-2]+cost[i-2]}】
    • 分析:能够从第i-1个台阶爬到本台阶,也能从第i-2个台阶爬到本台阶;爬的时候分别加上对应的cost即可
  • 初始状态:【dp[0] = 0 , dp[1] = 1】

只需要前两个状态,所以还是可以状态压缩(注释掉的是没进行状态压缩的版本)

    public int minCostClimbingStairs(int[] cost) {

        int len = cost.length;

        if (len == 1) return 0;
        if (len == 2) return Math.min(cost[0],cost[1]);


        // int[] dp = new int[len + 1];

        int dp1 = 0;
        int dp2 = 0;

        for (int i = 2; i<=len; i++) {
            // dp[i] = Math.min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]);
            int newdp = Math.min(dp1+cost[i-2],dp2+cost[i-1]);

            dp1 = dp2;
            dp2 = newdp;

        }
        // return dp[len];
        return dp2;
    }

2. 打家劫舍

  • 题目出处:198. 打家劫舍
  • 转移方程:【dp[i] = max{dp[i-2] + nums[i] , dp[i-1]}】
    • 分析:因为不能连着抢劫,所以可以【抢第i-2家和第i家的,那么收益为dp[i-2] + nums[i]】,或者【第i家的不抢,那么收益仍未dp[i-1]】
  • 初始状态:【dp[0] = nums[0],dp[1] = max{nums[0],nums[1]}】

代码如下,状态压缩版本(注释掉的是非状态压缩版)

    public int rob(int[] nums) {
        
        int len = nums.length;

        if (len == 1) return nums[0];
        if (len == 2) return Math.max(nums[0],nums[1]);


        // int[] dp = new int[len];
        // dp[0] = nums[0];
        // dp[1] = Math.max(nums[0],nums[1]);

        int dp0 = nums[0];
        int dp1 = Math.max(nums[0],nums[1]);

        for (int i = 2; i < len; i++) {
            // dp[i] = Math.max(dp[i-1] , dp[i-2] + nums[i]);
            int newDp = Math.max(dp1 , dp0 + nums[i]);

            dp0 = dp1;
            dp1 = newDp;
        }

        // return dp[len-1];
        return dp1;

    }

3. 打家劫舍2

不同点在于:这里的房屋(数组)是首尾相连的,也就是说打劫了第一家,就不能打劫最后一家;打劫了最后一家,就不能打劫第一家

根据这种情况,我们将数组分为nums[0,len-1]和nums[1,len],然后分别通过上一题的方式进行求解,然后返回两个解的最大值即可

代码如下

    public int rob(int[] nums) {
        
        int n = nums.length;

        if (n == 1) return nums[0];
        if (n == 2) return Math.max(nums[0],nums[1]);


        int[] nums1 = Arrays.copyOfRange(nums , 0 , n - 1);
        int[] nums2 = Arrays.copyOfRange(nums , 1 , n);

        int ans1 = findMax(nums1);
        int ans2 = findMax(nums2);

        return Math.max(ans1 , ans2);
    }

    public int findMax(int[] nums) {

        int n = nums.length;

        if (n == 2) return Math.max(nums[0] , nums[1]);



        int dp0 = nums[0];
        int dp1 = Math.max(nums[0],nums[1]);

        for (int i = 2 ; i < n; i++) {
            int newdp = Math.max(dp1,dp0 + nums[i]);

            dp0 = dp1;
            dp1 = newdp;
        }

        return dp1;
    }

4. 删除并获得点数

  • 题目出处:740. 删除并获得点数

  • 状态转移:【dp[i] = max{ dp[i-1] , dp[i-2] + count[i]*i }】

    • ① 这里我们将题目转换一种思路,相当于打家劫舍的进阶版,也就是说我们不能够选择相邻的元素
    • ② 我们需要先得到nums[]数组中的最大值max,然后将nums[]数组转换成为count[]数组,统计每个数字出现的次数(count[]数组的大小为【max+1】,从1~max)
    • ③ 然后我们开始状态转移过程:可以从i-1状态转移到目前状态,由于不能够连着选数字,所以这种情况下收益为【dp[i-1]】;或者从i-2状态转移到目前状态,那么目前状态是可以选上的,所以当前收益为【dp[i-2] + count[i] * i】
  • 初始状态:

    • dp[1] = count[1] * 1
    • dp[2] = max{count[1] * 1,count[2] * 2}

代码如下:

    public int deleteAndEarn(int[] nums) {

        int max = 0;

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

        if (max == 1) return nums.length;

        int[] count = new int[max + 1];
        int[] dp = new int[max + 1];

        for (int i = 0; i < nums.length; i++) {
            ++count[nums[i]];
        }

        dp[1] = count[1];
        dp[2] = Math.max(count[1], count[2] * 2);

        for (int i = 3; i <= max; i++) {
            dp[i] = Math.max(dp[i - 2] + count[i] * i, dp[i - 1]);
        }

        return dp[max];
    }

三、嵌套的状态转移

这种DP的状态转移,会和之前的所有状态有关,不可进行状态压缩

1. 跳跃游戏2

  • 题目出处:45. 跳跃游戏 II
  • 状态转移:dp[i]表示跳到i位置所需的最小步数
    • ① 求dp[i]时,目前状态的值和之前的所有状态值都有关
    • ② 遍历之前所有的节点,如果不能跳到本节点,则跳过
    • ③ 如果j节点能够跳到本节点,即【j + nums[j] >= i】
    • ④ 对所有能够跳到本节点的前置j节点,推导得到【dp[i] = min{dp[i],dp[j]+1}】
  • 初始状态:
    • ① dp[0] = 0;
    • ② 其他节点假设不可达,设置成为Integer.MAX_VALUE

动态规划版本的代码如下:


    public int jump(int[] nums) {

        int len = nums.length;

        if (len == 1) return 0;


        int[] dp = new int[len];
        Arrays.fill(dp, Integer.MAX_VALUE);

        dp[0] = 0;

        for (int i = 1; i < len; i++) {

            for (int j = 0; j < i; j++) {

                if (j + nums[j] >= i)
                    dp[i] = Math.min(dp[i], dp[j] + 1);
            }
        }

        return dp[len - 1];
    }

其实该题目还能用BFS去做,不断对节点松弛,有点类似于SPFA算法,代码如下:

    public int jump(int[] nums) {
        int len = nums.length;
        int[] d = new int[len];
        Arrays.fill(d, Integer.MAX_VALUE);
        d[len - 1] = 0;
        Queue<Integer> queue = new LinkedList<>();
        queue.offer(len - 1);

        while (!queue.isEmpty()) {
            int cur = queue.poll();
            for (int i = 0; i < len; i++) {
                if (nums[i] >= cur - i) {       // 判断能否到达
                    if (d[cur] + 1 < d[i]) {    // 是否松弛成功,成功才入队
                        d[i] = d[cur] + 1;
                        queue.add(i);
                    }
                }
            }
        }
        return d[0];
    }

四、连续子数组状态转移

这类题目往往要求连续子数组的最大/最小和值,我们在遍历数组时通过一个sum(子数组和)值不断更新最大/最小值,最后可以得到结果

1. 最大子序和

  • 题目出处:53. 最大子序和

  • 状态转移:

    • ① 设置一个sum值,作为我们目前的子数组和
    • ② 遍历数组,我们需要求的是最大值,那么我们可以很简单的分析出,最大子数组肯定不是以负数开头或者结尾的(除非只有负数)
    • ③ 再进一步推导,如果前置的某个子数组为负数,我们可以将它合并,那么它对我们接下来需要寻找的最大值是没有一点正面的帮助的,需要舍弃掉,继续从当前位置开始往下找
    • ④ 也就是说,如果【sum <= 0】,那么我们从当前位置往下找即【sum = nums[i]】
    • ⑤ 如果【sum > 0】,那么不管【nums[i]】是正还是负,我们都可以把它加到我们的子数组中去
    • ⑥ 为什么负数还要加进去?是因为可能出现一个很小的负数,但是前面的正数却很大的情况,如[100,-1,100];如果遇到负数我们就重新开始的话,是有可能出现捡了芝麻丢了西瓜的情况的
    • ⑦ 如果前面的正数很小,而负数却很大的话,如[-100,1,1];那么这种情况我们通过④就能够切断掉前面的很大的负数

要用语言表达思路,实在是...
还是直接上代码把,要去品...

    public int maxSubArray(int[] nums) {

        int ans = Integer.MIN_VALUE;
        int sum = 0;

        for(int i=0; i< nums.length; i++) {
            if (sum <= 0) {
                sum = nums[i];
            } else {
                sum += nums[i];
            }

            ans = Math.max(ans , sum);
        }
        return ans;
    }

2. 环形子数组的最大和

思路:其实和上题基本一致,但是由于这里是首尾相连,我们需要分情况讨论

  • (1)最大和子数组出现在中间:
    • ① 这种情况,包含了首尾都不选(首尾都为负),或者首尾只选一个的情况(首尾其中一个为负)
    • ② 如果全都是正数,那么这种情况还包含了首尾都选的情况
    • ③ 我们可以直接通过上一题的思路,求出这种情况下的最大值max
  • (2)最大子数组和出现在两端:
    • ① 这种情况的意思是,最大子数组同时包含了首尾元素,还可能往数组中间延申
    • ② 假设整个数组所有数的和为sum
    • 如果两端的和是最大的,那么中间的和就是最小的!
    • ④ 我们只需要按照上一题的逻辑,在数组中间求出连续数组的最小值和值min,最后【sum - min】就是数组剩下元素的最大和值

代码:注意,求第二种情况需要去掉首尾元素!

    public int maxSubarraySumCircular(int[] nums) {

        if (nums.length == 1) return nums[0];

        int max = Integer.MIN_VALUE;
        int min = Integer.MAX_VALUE;

        int sum1 = 0;
        int sum2 = 0;

        int sum = 0;

        for (int i = 0; i < nums.length; i++) {

            sum += nums[i];

            if (sum1 <= 0)
                sum1 = nums[i];
            else
                sum1 += nums[i];

            max = Math.max(max, sum1);
        }


        for (int i = 1; i < nums.length - 1; i++) {

            if (sum2 >= 0)
                sum2 = nums[i];
            else
                sum2 += nums[i];

            min = Math.min(min, sum2);
        }

        return Math.max(sum - min, max);
    }

3. 乘积最大的子数组

  • 题目出处:152. 乘积最大子数组

  • 转移方程:【preMax = max{preMax * nums[i],preMin * nums[i]}】,【preMin = min{preMax * nums[i],preMin * nums[i]}】

    • ① 由于数组中的元素有正有负,负数的存在可能会导致最大值max变最小值min,最小值min变最大值
    • ② 所以在寻找子序列的过程中,维护最大值preMax和最小值preMin,表示前缀最大/最小值
    • ③ 还需要考虑一种情况即【nums[i] = 0】,后面的乘积都为0了,需要从下一个数开始继续取,这种情况下我们下一次的preMax和preMin直接从nums[i+1]开始
  • 初始状态:【preMax = nums[0]】【preMin = nums[0]】

    public int maxProduct(int[] nums) {

        int max = nums[0];

        // 前置最大最小乘积
        int preMax = nums[0];
        int preMin = nums[0];

        int len = nums.length;

        for (int i = 1; i < len; i++) {


            /**
             * 当前的最大最小值
             * 可能由前置的最大最小值乘以正数/负数得到
             */
            int curMax = Math.max(preMax * nums[i], preMin * nums[i]);
            int curMin = Math.min(preMax * nums[i], preMin * nums[i]);

            /**
             * 是0的情况,会从nums[i+1]重新开始
             * 所以我们需要加入以下语句,以免curMax和curMin都为0
             * 之后乘积就全部为0了
             */
            preMax = Math.max(nums[i], curMax);
            preMin = Math.min(nums[i], curMin);

            max = Math.max(preMax, max);
        }
        return max;
    }

4. 乘积为正数的最长子数组

  • 题目出处:1014. 最佳观光组合

  • 转移方程:

    • ① 乘积要为正数,那么同上一题一样,根据nums[i]的不同情况分类,我们需要维护两个数组postive[i]和negative[i],分别表示以nums[i]结尾的子数组,前面连续最长的正数/负数乘积为多少
    • ② 如果【nums[i] = 0】,那么子数组需要从下一个开始进行计算,即【postive[i] = 0】【negative[i] = 0】
    • ③ 如果【nums[i] > 0】,那么【postive[i] = postive[i-1] + 1】,【negative[i] = negative[i-1] + 1】(特殊情况:如果negative[i-1] = 0,说明前面子数组长度为0,那么【negative[i] = 0】)
    • ④ 如果【nums[i] < 0】,那么【negative[i] = negative[i-1] + 1】,【positive[i] = negative[i-1] + 1】(特殊情况:如果negative[i-1] = 0,说明前面子数组长度为0,那么【positive[i] = 0】)
  • 初始状态:

    • nums[0]大于0时,将positive[0]置1
    • nums[0]小于0时,将negative[0]置1
    public int getMaxLen(int[] nums) {

        int len = nums.length;

        int[] pos = new int[len];
        int[] neg = new int[len];

        // 初始化
        if (nums[0] > 0) pos[0] = 1;
        else if (nums[0] < 0) neg[0] = 1;

        int max = pos[0];

        for (int i = 1; i < len; i++) {
            if (nums[i] > 0) {
                pos[i] = pos[i - 1] + 1;
                neg[i] = neg[i - 1] == 0 ? 0 : neg[i - 1] + 1;
            } else if (nums[i] < 0) {
                pos[i] = neg[i - 1] == 0 ? 0 : neg[i - 1] + 1;
                neg[i] = pos[i - 1] + 1;
            } else {
                pos[i] = 0;
                neg[i] = 0;
            }
            max = Math.max(max, pos[i]);
        }

        return max;
    }

5. 最佳观光组合

  • 1014. 最佳观光组合

  • 状态转移:观光得分:【values[i] + values[j] + i - j】

    • ① 将上述得分拆分成为两个部分,对于j位置来说,我们只需要知道前置最大的【values[i] + i】即可
    • ② 通过记录max{values[i] + i},然后遍历数组,我们可以只进行一次遍历就求得结果
  • 初始状态:【maxVi = values[0] + 0】

其实这题的总体思路就是在遍历过程中维护一个最大值
代码如下:

    public int maxScoreSightseeingPair(int[] values) {
        int ans = 0;
        int maxVi = values[0] + 0;

        for (int j = 1; j < values.length; j++) {
            ans = Math.max(ans , maxVi + values[j] - j);

            if (values[j] + j > maxVi)
                maxVi = (values[j] + j);
        }
        return ans;
    }

五、 买卖股票问题

这类题目模拟股票的买入和卖出,其实就是要我们对股票的状态进行一个模拟,实现完整的过程并且将所有结果模拟出来进行对比

1. 买卖股票的最佳时机

  • 状态转移:维护二维数组dp[prices.length][2]
    • ① 第一层状态i表示到第i天能够获得的最大收益
    • ② 第二层状态标识该天股票的持有情况,0表示未持有,1表示已持有
    • ③ 当前未买入状态【dp[i][0]】可以由两种状态转移而来,取两者最大值
      • 前一天也未持有:【dp[i-1][0]】
      • 前一天已经持有,今天卖出:【dp[i-1][1] + price[i]】
    • ④ 当前买入状态【dp[i][1]】可以由两种状态转移而来
      • 前一天已经持有:【dp[i-1][1]】
      • 前一天未持有,今天买入:【dp[i-1][0] - price[i]】(需要更改)
      • 注意:<font color=red>这里由于我们只能单次买入卖出股票,所以买入股票这个操作是无前效性的,我们需要更改最后一种状态为【-price[i]】</font>
  • 初始状态:
    • 【dp[0][0] = 0】
    • 【dp[0][1] = -prices[0]】(第一天就买入)

代码:

    public int maxProfit(int[] prices) {

        int len = prices.length;

        int[][] dp = new int[len][2];

        dp[0][0] = 0;
        dp[0][1] = -prices[0];

        for (int i = 1; i<len; 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] , -prices[i]);
        }

        return dp[len - 1][0];
    }

2. 买卖股票的最佳时机2

  • 题目出处:122. 买卖股票的最佳时机 II

  • 状态转移:一次只能持有一支股票,可以多次买卖

    • 上一题已经分析得到这个的转移状态方程了
    • 【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])】
    public int maxProfit(int[] prices) {

        int len = prices.length;

        int[][] dp = new int[len][2];

        dp[0][0] = 0;
        dp[0][1] = -prices[0];

        for (int i = 1; i<len; 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]);
        }

        return dp[len - 1][0];
    }

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

  • 题目出处:309. 最佳买卖股票时机含冷冻期

  • 状态转移:加入冷冻期,时长为1天,也就是说前一天卖出股票,后一天无法再买入;一次最多持有一支股票,可以多次买入卖出

    • ① 维护转移数组dp[n][2],n为天数,0表示未持有非冷冻期,1表示持有,2表示未持有但是处在冷冻期内
    • ② 今天不持有股票且非冷冻期:前一天也不持有,分为两种情况,前一天是冷冻期和前一天不是冷冻期;所以【dp[i][0] = max{dp[i-1][0],dp[i-1][2]}】
    • ③ 今天持有股票:前一天也持有股票,或者前一天不持有股票,今天买入股票;所以【dp[i][1] = max{dp[i-1][1],dp[i-1][0]-price[i]}】
    • ③ 今天持有股票且在冷冻期内:前一天持有股票,且卖出;所以【dp[i][2] = dp[i-1][1] + price[i]】
  • 初始状态:第一天肯定是没有冷冻期的

    • dp[0][0] = 0
    • dp[0][1] = -price[0]
    • dp[0][2] = 0
    public int maxProfit(int[] prices) {

        int len = prices.length;

        int[][] dp = new int[len][3];

        dp[0][0] = 0;
        dp[0][1] = -prices[0];
        dp[0][2] = 0;

        for (int i = 1; i < len; i++) {
            dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][2]);
            dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
            dp[i][2] = dp[i - 1][1] + prices[i];
        }

        return Math.max(dp[len - 1][0], Math.max(dp[len - 1][1], dp[len - 1][2]));
    }

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

  • 题目出处:714. 买卖股票的最佳时机含手续费

  • 转移方程:一次最多购买一支股票,可以多次买入卖出;每次交易(买入+卖出)都需要支付一定的手续费,其实也可以理解为卖出要收钱

    • ① 正常转移,加上手续费即可
    • ② 【dp[i][0] = max{dp[i-1][0],dp[i-1][1] + price[i] - fee}】
    • ③ 【dp[i][1] = max{dp[i-1][1],dp[i-1][0] - price[i]}】
  • 初始状态:dp[0][0] = 0,dp[0][1] = -price[0]

    public int maxProfit4(int[] prices, int fee) {

        int len = prices.length;

        int[][] dp = new int[len][2];

        dp[0][0] = 0;
        dp[0][1] = -prices[0];

        for (int i = 1; i < len; 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[len - 1][0];
    }

六、0-1背包问题

基本概念:

  • 最基本的背包问题就是 01 背包问题:一共有 N 件物品,第 i(i 从 1 开始)件物品的重量为 w[i],价值为 v[i]。在总重量不超过背包承载上限 W 的情况下,能够装入背包的最大价值是多少?
    • <font color=red>如果是 01 背包,即数组中的元素不可重复使用,外循环遍历 arrs,内循环遍历 target,且内循环倒序</font> (因为这里nums中一个元素只能用一次,循环过了就相当于取用过了,不再选了)

1. 分割等和子集

  • 题目出处:416. 分割等和子集

  • 思路:只要找到一个子集,其和刚好为sum/2即可

    • ① 我们维护一个【dp[n+1]】数组,表示从0~n的数是否可以由nums数组中的数组成(这里n=sum/2)
    • ② 外层循环nums,嵌套内循环倒序遍历dp[i],在nums中找能够组成i的集合是否存在;也就说说我们一个一个数取出来,然后看这个数在范围 [0-target]内,能和前面已存在的结果构成什么新的结果。
    • ③ 【dp[i] = dp[i] || dp[i - num]】,之所以要或上一个dp[i],是因为结果具有前效性,也就是说如果早就拼凑出来了这个子集,要覆盖后面的结果使其为true
    public boolean canPartition(int[] nums) {
        int len = nums.length;
        int sum = 0;

        for (int i = 0; i < len; i++) sum += nums[i];

        if (sum % 2 == 1) return false;     // 奇数无法二分

        int target = sum / 2;

        boolean[] dp = new boolean[target + 1];
        dp[0] = true;
    
    
        for (int num : nums) {
            for (int i = target; i >= num; --i) {
                if (i - num >= 0) {
                    dp[i] = dp[i] || dp[i - num];
                }
            }
        }
        return dp[target];
    }   

2. 目标和

  • 题目出处:494. 目标和

    • 题目的不同之处在于:选择数字的时候可以进行加或者减,所以我们维护的数组长度需要大于target了,至少得是数组和【sum】
  • 思路1:我们想办法把这一题转换为上一题的思路,分析一下有什么不同

    • ① 首先,所有的数字都一定要用上一次,去构建目标和
    • ② 其次,使用该数字的时候可以选择其前面的符号为正或者负
    • ③ 我们假设【target = x - y】,即x为正数部分总和,y为负数部分总和
    • ④ 同时可以得到【sum = x + y】,也就是整体总和
    • ⑤ 【(target + sum)/2 = x】,<font color=red>也就是说我们只要在数组中找到一个子数组,其和为x,构成正数部分,那么剩下的和就为y,构成负数部分</font>
    • ⑥ 经过上述分析,我们将target转换为x,就可以套用上一题的思路
    public int findTargetSumWays(int[] nums, int target) {

        int len = nums.length;

        int sum = 0;
        for (int i = 0; i < len; i++) sum += nums[i];

        if (target > sum || target < -sum || (target + sum) % 2 == 1) return 0;
        int x = (sum + target) / 2;

        int[] dp = new int[x + 1];
        dp[0] = 1;                      // 目标和为0,可以不选,故总共有一种方案

        for (int num : nums) {
            for (int i = x; i >= num; --i) {
                dp[i] += dp[i - num];
            }
        }
        return dp[x];
    }

  • 思路2:当然还有一种做法,即扩大范围进行统计[-sum,sum],然后每次状态转移时加上正负两种情况
    • ① 维护二维数组dp[i][j],其中i表示只考虑前i个数数字(数字需要全部用完),j表示能够构成结果为j的方案数量
    • ② 其中i的范围为[0,len+1],j的范围为[-sum,sum]
    • ③ 由于是0-1背包问题,所以外层循环取num,内层循环更新方案数量,需要同时考虑加减两种方案
    public int findTargetSumWays2(int[] nums, int target) {
        int len = nums.length;

        int sum = 0;

        for (int i = 0; i < len; i++) sum += nums[i];

        if (target > sum || target < -sum) return 0;

        // i表示使用了前几个数组num
        // j表示能够构成的结果,范围为-sum到sum,下面使用时需要加入偏移量
        int[][] dp = new int[len + 1][sum * 2 + 1];
        dp[0][sum] = 1;     // 使用0个元素构成0,方案有一种

        for (int i = 1; i <= len; i++) {
            int num = nums[i - 1];
            for (int j = -sum; j <= sum; j++) {
                if (j + sum - num >= 0)
                    dp[i][j + sum] += dp[i - 1][j + sum - num];
                if (j + sum + num <= 2 * sum)
                    dp[i][j + sum] += dp[i - 1][j + sum + num];
            }
        }

        // 所有数都用上,能构成target的方案数量
        return dp[len][target + sum];
    }

七、完全背包问题

基本概念:

  • 完全背包与 01 背包不同就是每种物品可以有无限多个:一共有 N 种物品,每种物品有无限多个,第 i(i 从 1 开始)种物品的重量为 w[i],价值为 v[i]。在总重量不超过背包承载上限 W 的情况下,能够装入背包的最大价值是多少?
  • 一般就是要我们在arr[]数组中找出能够组成target的组合
    • (1)如果是完全背包,即数组中的元素可重复使用并且不考虑元素之间顺序,arrs 放在外循环(保证 arrs 按顺序),target在内循环。且内循环正序
    • (2)如果组合问题需考虑元素之间的顺序,需将 target 放在外循环,将 arrs 放在内循环,且内循环正序

1. 单词拆分

  • 题目出处:139. 单词拆分

  • 思路:判断一个字符串s是否能够拆分成为一个或者多个在字典wordDict中出现的单词

    • ① 维护一个数组dp[i],表示字符串i以及其之前的字串能否由wordDict中的串组成,i的范围为[0,s.len]
    • ② 外层循环遍历目标字符串s,内层循环遍历字典wordDict(字典中元素可重复使用)
    • ③ dp[i] = dp[i-len] || dp[i](这里的len为字典中某个字符的长度)
    public boolean wordBreak(String s, List<String> wordDict) {
        int len = s.length();

        boolean[] dp = new boolean[len + 1];
        dp[0] = true;

        for (int i = 1; i <= len; i++) {
            for (String curS : wordDict) {
                int size = curS.length();

                if (i - size >= 0 && s.substring(i - size, i).equals(curS))
                    dp[i] = dp[i] || dp[i - size];
            }
        }
        return dp[len];
    }

2. 完全平方数

  • 题目出处:279. 完全平方数

  • 思路:将题目转换成为从数组nums[1,sqrt(n)]中,选出平方数能够组成target的组合,并且使组合中数量个数最小

    • ① 维护一个dp[i]数组,i组成的结果,dp[i]表示组成结果i需要的最少数量
    • ② 数组nums中的元素是可以重复使用的,所以为完全背包问题
    • ③ 外层循环遍历nums取数,内层循环找不同的组合方案(从0-target中任意一个数转换到target都可以使方案+1)
    • ④ 初始状态dp[0] = 0,表示结果为0的完全平方数和组合为0个,其余都为无穷大(方便取min)
    • ⑤ 【dp[i] = min{dp[i],dp[i-num*num] + 1}】
    public int numSquares(int n) {

        int[] dp = new int[n + 1];
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0] = 0;

        for (int num = 1; num <= Math.sqrt(n); num++) {
            int squareNum = num * num;
            for (int i = 0; i <= n; i++) {
                if (i - squareNum >= 0)
                    dp[i] = Math.min(dp[i], dp[i - squareNum] + 1);
            }
        }
        return dp[n];
    }

3. 组合总和

  • 题目出处:377. 组合总和 Ⅳ

  • 思路:nums中的数字可以重复选取,不同的顺序组成的组合是不一样的

    • ① 维护数组dp[i],表示组成和为i的组合数量
    • ② 在这个题目中,由于不同的选择顺序认作不同的结果,所以我们在外层遍历dp[]数组,在内层遍历nums数组,这样的话可以表示不同的组合顺序
    • ③ 【dp[i] = dp[i] + dp[i-num]】

4. 零钱兑换

  • 题目出处:322. 零钱兑换

  • 思路:每种硬币是无限的,需要返回能够构成指定amount额度的最小硬币数量

    • ① 维护dp[i]数组,表示构成额度i所需的最少硬币数量
    • ② 由于硬币可以无限使用,我们将dp[]放在外层循环,将coin放在内层循环
    public int coinChange(int[] coins, int amount) {
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, 100000);        // 如果填充MAX_VALUE,可能需要考虑反转问题,所以随便找一个大数填入
        dp[0] = 0;      // 没有面额为0的硬币

        for (int i = 1; i <= amount; i++) {

            for (int coin : coins) {
                if (i - coin >= 0) {
                    dp[i] = Math.min(dp[i], dp[i - coin] + 1);
                }
            }
        }

        return dp[amount] == 100000 ? -1 : dp[amount];
    }

5. 零钱兑换2

  • 题目出处:518. 零钱兑换 II

  • 思路:这题和上一题不一样之处在于,在本题目中重复的组合是需要排除掉的

    • ① 维护dp[i]数组,表示构成额度i的组合数量有多少
    • ② 我们只需要改变一下遍历的顺序,外层循环遍历coins[]数组,内层循环遍历dp[]数组
    • ③ 初始状态,dp[0] = 1,表示组成总额度为0的组合有1种(不选)
    public int change(int amount, int[] coins) {
        int[] dp = new int[amount + 1];
        dp[0] = 1;

        for (int coin : coins) {
            for (int i = 1; i <= amount; i++) {
                if (i - coin >= 0)
                    dp[i] += dp[i - coin];
            }
        }
        return dp[amount];
    }

``

···java

相关文章

  • 4. 动态规划算法

    1. 动态规划算法总结2. 漫画:什么是动态规划?3.算法之动态规划4. 动态规划-算法

  • 经典算法——动态规划

    姓名:谭旭东 学号:19011210016 前言 动态规划是什么? 动态规划(dynamic pro...

  • Swift 算法实战:动态规划

    Swift 算法实战:动态规划 Swift 算法实战:动态规划

  • 程序员算法基础——动态规划

    程序员算法基础——动态规划 程序员算法基础——动态规划

  • 动态规划

    --tags: 算法,动态规划 动态规划解题 引入:动态规划 和贪心法 都是算法的思想方法 贪心算法——像 第一类...

  • 动态规划 Dynamic Programming

    从运筹学和算法的角度综合介绍动态规划 算法分类总结动态规划与静态规划的关系浅析静态规划和动态规划动态规划解非线性规...

  • 动态规划-js

    动态规划 参考:算法分析与设计-贪心&动归 漫画:什么是动态规划? 【数据结构与算法】 DP 动态规划 介绍 介绍...

  • 背包问题

    介绍 学习动态规划算法的经典例题。动态规划算法一般有3个特征1、最优化原理:最优解所包含的子问题的解也是最优的。2...

  • 动态规划

    动态规划(Dynamic Programming) 本文包括: 动态规划定义 状态转移方程 动态规划算法步骤 最长...

  • 2018-11-14

    今天学了动态规划一些知识,着重看了一下动态规划之背包算法(主要是0-1算法和部分算法),关于动态规划的问题重点是建...

网友评论

      本文标题:经典算法——动态规划

      本文链接:https://www.haomeiwen.com/subject/mhxoyrtx.html