动态规划之背包DP

动态规划之背包DP

文章目录

  • 动态规划之背包DP
  • 01背包问题
    • 分割等和子集
      • (动规)
      • (动规-空间优化)
    • [1049. 最后一块石头的重量 II](https://leetcode-cn.com/problems/last-stone-weight-ii/)
      • (动规)
      • (动规-空间优化)
    • 目标和
      • (递归)
      • (记忆化搜素)
      • (动规-加偏移量)
      • (动规-转化为01背包问题)
      • (动规-空间优化)
    • 一和零
      • (动规-01背包-二维费用背包-朴素版)
      • (动规-01背包-二维费用背包-空间优化)
  • 完全背包问题
    • [零钱兑换 II](https://leetcode-cn.com/problems/coin-change-2/)
      • (动规)
      • (动规-二维空间背包优化)
      • (动规-一维空间背包优化)
    • [组合总和 Ⅳ](https://leetcode-cn.com/problems/combination-sum-iv/)
      • (递归-TLE)
      • (记忆化搜索)
      • (动规-一维空间优化)
    • 爬楼梯
      • (动规-完全背包解法)
    • 零钱兑换
      • (递归-TLE)
      • (记忆化搜索)
      • (朴素动规-TLE)
      • (动规-完全背包优化)
      • (动规-一维空间优化)
    • 完全平方数
      • (动规-一维空间优化)
    • 数位成本和为目标值的最大数字
      • (动规)
      • (动规-一维空间)
      • (动规-完全背包分块求解)

01背包问题

分割等和子集

动态规划之背包DP_第1张图片

(动规)

本题是一个经典的01背包问题。

回顾一下01背包的状态转移方程,dp[i][j]表示从前i个数中体积不超过j容量的最大价值。

而本题最后需要求出的是否能从所有数中选取出恰好体积为sum / 2的情况。所以状态转移方程为dp[i][j]表示从i个数中选取能否可以体积恰好为j的情况,最后的答案就是dp[n][sum / 2]

两题的相同点在于:1.背包中物体都只能选取一次。

不同点在于:1.一个是求出的是不超过j的最大价值,一个是求出是否能恰好的到达j价值。

class Solution {
     
public:
    bool canPartition(vector<int>& nums) {
     
       int n = nums.size();
       int sum = 0;
       for (int num : nums) sum += num;
       if (sum & 1) return false; // 如果sum不能平分就可以直接return false了
       vector<vector<int>> dp(n + 1, vector<int>(sum + 1, false));
       for (int i = 0; i <= n; i ++) dp[i][0] = true;
       for (int i = 1; i <= n; i ++) {
     
           for (int j = 1; j <= sum; j ++) {
     
                if (j >= nums[i - 1]) {
     
                   	dp[i][j] |= dp[i - 1][j - nums[i - 1]] || dp[i - 1][j];
                } else {
     
                    dp[i][j] |= dp[i - 1][j];
                }
           }
       }
       return dp[n][sum / 2];
    }
};

(动规-空间优化)

同样的01背包问题的空间优化同样也适用于本题。

二维dp数组转化成为一维dp数组的关键就在于第二层的循环需要倒序枚举。

class Solution {
     
public:
    bool canPartition(vector<int>& nums) {
     
        int n = nums.size();
        int sum = 0;
        for (int num : nums) sum += num;
        if (sum & 1) return false; // 如果sum不能平分就可以直接return false了
        sum /= 2; // 只用到sum/2即可
        vector<bool> dp(sum + 1);
        dp[0] = true;
        for (int i = 1; i <= n; i ++) {
     
            for (int j = sum; j >= nums[i - 1]; j --) {
     
                dp[j] = dp[j] || dp[j - nums[i - 1]];
            }
        }
       return dp[sum];
    }
};

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

动态规划之背包DP_第2张图片

(动规)

本题的思路需要我们做出一个脑筋急转弯。我们每一次都需要跳出两块石头,并将两块石头相减的差值再放回石头堆中。为了最后使得留下的一个石头的重量最小,所以每一次都需要挑出两个石头重量最相近的石头,如果两个石头重量相等这样是最好,这样相减之后的之后就没有重量了。否则的话,两个重量相近的石头相减后得出的石头的重量也会相比其他情况要小。

**现在我们做一个思维的转换:我们不在拘泥于每一次只挑出两块重量最相近的石头,然后相减。我们从最终的目的来看,最后其实就是将石头堆分成两堆,然后两堆石头的重量相减,剩下的石头重量就是最终的答案。**为了让一堆石头分成两堆石头的重量最相近,所以我们可以求出一堆石头满足:从所有石头中选,并且重量不超过重量总和一半的最大重量。

这其实就是一个01背包问题,上面这种状态就是dp[n][sum / 2],我们求出了其中一堆的石头,那么另一堆石头的重量就是sum - dp[n][sum / 2]。最后的答案就是两堆石头相减而已,即sum - dp[n][sum / 2] -dp[n][sum / 2]

class Solution {
     
public:
    int lastStoneWeightII(vector<int>& stones) {
     
        int n = stones.size();
        int sum = 0;
        for (int s : stones) sum += s;
        vector<vector<int>> dp(n + 1, vector<int>(sum + 1));
        for (int i = 1; i <= n; i ++) {
     
            for (int j = 1; j <= sum; j ++) {
     
                if (j >= stones[i - 1]) {
     
                    dp[i][j] = max(dp[i - 1][j - stones[i - 1]] + stones[i - 1], dp[i - 1][j]);
                } else {
     
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        return sum - dp[n][sum / 2] * 2;
    }
};

(动规-空间优化)

当然还是可以使用01背包的一维dp数组的空间优化。

class Solution {
     
public:
    int lastStoneWeightII(vector<int>& stones) {
     
        int n = stones.size();
        int sum = 0;
        for (int s : stones) sum += s;
        vector<int> dp(sum + 1);
        for (int i = 0; i < n; i ++) {
     
            for (int j = sum; j >= stones[i]; j --) {
     
                dp[j] = max(dp[j - stones[i]] + stones[i], dp[j]);
            }
        }
        return sum - dp[sum / 2] * 2;
    }
};

目标和

动态规划之背包DP_第3张图片

本题有两种思想:1.枚举每一个数的两种符号2.将求出target的方案数转化为求出(sum - target) / 2的方案数。

我们先来了解第一种解法:即枚举出每一个数的两种方案

(递归)

第一种方法就是使用递归。

每一次利用sum + nums[index],即总和+当前数的方案数。还有每一次利用sum - nums[index],即总和-当前数的方案数。

这两种情况方案数的总和就是总的方案数。

class Solution {
     
public:
    int dfs(vector<int>& nums, int target, int index, int sum) {
     
        if (index == nums.size()) {
     
            if (sum == target) return 1;
            return 0;
        }
        int ans = 0;
        ans += dfs(nums, target, index + 1, sum + nums[index]);
        ans += dfs(nums, target, index + 1, sum - nums[index]);
        return ans;
    }

    int findTargetSumWays(vector<int>& nums, int target) {
     
        return dfs(nums, target, 0, 0);
    }
};

(记忆化搜素)

因为在递归的时候,中间会有很多重复计算的分支,所以利用一个unordered_map将每一次计算的结果保留下来,这样就可以一定程度上提高运行的效率。

注意:这里不能使用vector或者数组来保存计算的结果,因为sum - nums[index]可以会使得sum < 0,而数组的下标都是>0的,所以不能使用数组来保存计算的结果。

class Solution {
     
public:
    using PII = pair<int, int>;
    int dfs(vector<int>& nums, int target, int index, int sum, map<PII, int>& memo) {
     
        if (memo.count({
     index, sum})) return memo[{
     index, sum}];
        if (index == nums.size()) {
     
            if (sum == target) return 1;
            return 0;
        }
        int ans = 0;
        ans += dfs(nums, target, index + 1, sum + nums[index], memo);
        ans += dfs(nums, target, index + 1, sum - nums[index], memo);
        memo[{
     index, sum}] = ans;
        return ans;
    }

    int findTargetSumWays(vector<int>& nums, int target) {
     
        map<PII, int> memo;
        return dfs(nums, target, 0, 0, memo);
    }
};

(动规-加偏移量)

如果将记忆化搜索写成递推的形式就变成了动态规划。

注意点:

1.本题即使一个选择问题,就是在选择nums[i]的正负号,所以和01背包的递归公式很像。

2.因为sum - nums[i]可能会有负数出现,所以为了避免sum - nums[i] < 0而导致没有办法存放在dp数组中的问题,所以需要在所以的j上加上一个sum,这样原来target的范围在[-sum, sum]上,现在加上偏移量就可以将范围换成[0, 2 * sum]

动态规划解法:

1.状态定义

dp[i][j]表示从前i个数中选取数字,恰好总和为j的方案总数。

2.递推公式

因为每一个数都有两个选择,所以就有两个状态可以转移到dp[i][j]

情况1:如果nums[i] >= 0,需要将nums[i]减去,dp[i][j] += dp[i - 1][j - nums[i]]

情况2:如果nums[i] < 0,需要将nums[i]加上,dp[i][j] += dp[i - 1][j + nums[i]]

3.初始化

当不选取数组中的数并且target==0的时候,也可以是一种方法即dp[0][0] == 1,加上偏移量之后就变成了dp[0][sum] = 1

class Solution {
     
public:
    int findTargetSumWays(vector<int>& nums, int target) {
     
        int n = nums.size();
        int sum = 0;
        for (int num : nums) sum += num;
        if (target > sum || target < -sum) return 0;
         vector<vector<int>> dp(n + 1, vector<int>(2 * sum + 1));
         dp[0][sum] = 1;
         for (int i = 1; i <= n; i ++) {
     
             for (int j = 0; j <= 2 * sum; j ++) {
     
                 if (j - nums[i - 1] >= 0)
                     dp[i][j] += dp[i - 1][j - nums[i - 1]];
                 if (j + nums[i - 1] <= 2 * sum)
                     dp[i][j] += dp[i - 1][j + nums[i - 1]];
             }
         }
         return dp[n][target + sum];
    }
    	// 下面这种方式也是可以的
        // vector> dp(n + 1, vector(2 * sum + 1));
        // dp[0][sum] = 1;
        // for (int i = 1; i <= n; i ++) {
     
        //     for (int j = -sum; j <= sum; j ++) {
     
        //         if (j - nums[i - 1] >= -sum)
        //             dp[i][j + sum] += dp[i - 1][j - nums[i - 1] + sum];
        //         if (j + nums[i - 1] <= sum)
        //             dp[i][j + sum] += dp[i - 1][j + nums[i - 1] + sum];
        //     }
        // }
        // return dp[n][target + sum];

};

(动规-转化为01背包问题)

第二种思想就是转化一下问题。

原来求出的时候可以凑出target的最大方案数。现在假设所有数组中的数是前面添加-的绝对值的总和为n,而添加+的总和为m。此时m + n = sum,并且m - n = target。所以我们将两个式子相加得出m = (sum + target) / 2。或者n = (sum - target) / 2

所以我们现在只需要求出在所有数组中选取数字,并且数字的总和为(sum - target) / 2,或者(sum + target) / 2的方案数。

这就将问题转化为了01背包的选取数字的问题了。

class Solution {
     
public:
    int findTargetSumWays(vector<int>& nums, int target) {
     
        int n = nums.size();
        int sum = 0;
        for (int num : nums) sum += num;
        if (target > sum || target < -sum || (sum - target) & 1) return 0;
        target = (sum - target) / 2;
        vector<vector<int>> dp(n + 1, vector<int>(target + 1));//这里的target可以换成sum
        dp[0][0] = 1;
        for (int i = 0; i <= n; i ++) dp[i][0] = 1;
        for (int i = 1; i <= n; i ++) {
     
            for (int j = 0; j <= target; j ++) {
     
                dp[i][j] = dp[i - 1][j];
                if (j >= nums[i - 1])
                    dp[i][j] += dp[i - 1][j - nums[i - 1]];
            }
        }
        return dp[n][target];
    }
};

(动规-空间优化)

同样利用01背包的空间优化。

class Solution {
     
public:
    int findTargetSumWays(vector<int>& nums, int target) {
     
        int n = nums.size();
        int sum = 0;
        for (int num : nums) sum += num;
        if (target > sum || target < -sum || (sum - target) & 1) return 0;
        target = (sum - target) / 2;
        vector<int> dp(target + 1);
        dp[0] = 1;
        for (int i = 0; i < n; i ++) {
     
            for (int j = target; j >= nums[i]; j --) {
     
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[target];
    }
};

一和零

动态规划之背包DP_第4张图片

(动规-01背包-二维费用背包-朴素版)

本题的要求是:从数组中选取一个字符串,并且0的个数不超过m,1的个数不超过n,问最多可以包含的字符串的个数。其实本题就是一个选择问题:即到底选不选择strs[i]这个字符串,所以我们就可以将本题转化为01背包问题了。

要注意的是,以前的01背包问题都是只有一维的限制,即不超过背包的体积m,但是本题有两维的限制即0和1的个数,所以需要两维的数组来限制,再加上数组个数的限制,所以dp数组为dp[i][j][k]这三维空间。我们也成这种背包问题为「二维费用背包问题」。

动规解决方案:

1.状态定义

dp[i][j][k]表示:从前i个数中选取数字,并且0的个数不超过j1的个数不超过k的最多的字符串的个数。

2.递推公式

对于strs[i]字符串,我们有两种选择,即选或者不选。

情况1:如果选择了字符串strs[i],就需要将strs[i]的0和1的个数减去,即dp[i - 1][j - zero][k - one]

情况2:如果不选择字符串strs[i],就可以跳过strs[i],即dp[i - 1][j][k]

3.初始化

为了方便初始化,我们可以多开一行的数组,这样就可以不用单独地处理strs[0]。而dp[0][0][0]表示的是没有字符串可以选择的情况下,0的个数为0,1的个数为0中字符串最多有多少个,答案是没有字符串可以选,即dp[0][0][0] = 0

class Solution {
     
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
     
        int len = strs.size();
        vector<vector<vector<int>>> dp(len + 1, vector<vector<int>>(m + 1,
                                                           vector<int>(n + 1, 0)));
        // 计算字符串数组中每一个字符串的0和1的个数
        vector<vector<int>> cnt(len + 1, vector<int>(2, 0));
        for (int i = 1; i <= len; i ++) {
     
            for (int j = 0; j < strs[i - 1].size(); j ++)
                if (strs[i - 1][j] == '0') cnt[i][0] ++;
                else cnt[i][1] ++;
        }
        for (int i = 1; i <= len; i ++) {
     
            for (int j = 0; j <= m; j ++) {
     
                for (int k = 0; k <= n; k ++) {
     
                    dp[i][j][k] = dp[i - 1][j][k];
                    if (j >= cnt[i][0] && k >= cnt[i][1])
                        dp[i][j][k] = max(dp[i][j][k], 
                                        dp[i - 1][j - cnt[i][0]][k - cnt[i][1]] + 1);
                }
            }
        }
        return dp[len][m][n];
    }
};

(动规-01背包-二维费用背包-空间优化)

在上面的基础上利用01背包的优化方式,让第二层和第三层循环都逆序遍历就可以省去dp数组的第一维空间。

class Solution {
     
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
     
        int len = strs.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        dp[0][0] = 0;
        for (int i = 0; i < len; i ++) {
     
            int zero = 0, one = 0;
            for (auto ch : strs[i]) {
     
                if (ch == '0') zero ++;
                else one ++;
            }
            for (int j = m; j >= zero; j --) {
     
                for (int k = n; k >= one; k --) {
     
                    dp[j][k] = max(dp[j][k], dp[j - zero][k - one] + 1);
                }
            }
        }
        return dp[m][n];
    }
};

完全背包问题

零钱兑换 II

动态规划之背包DP_第5张图片

本题是一个选择问题,即从数组中选取一种面值,并且可以使用无限次。所以这种无限次的选择问题,我们就可以联想到完全背包问题。

(动规)

1.状态定义

dp[i][j]表示:从前i种数字当中选取,并且总金额恰好为j的方案的个数。

2.递推公式

我们根据递推的最后一步来看,对于第i个物品,我们可以选择也可以不选择,如果选择了这种面值的硬币,我们还要分情况讨论,要选择多少个该种面值的硬币。最终的答案就是将这些情况全部相加。

**假设当前选了k个该种面值的硬币。k的取值可以从0开始到k * coins[i] <= j为止。**每取一个该种面值的硬币,情况就多了一种,所以最终的答案就是dp[i][j] += dp[i - 1][j - k * coins[i]]

3.初始化

为了方便初始化,即不用特殊处理只有一个硬币的特殊情况,所以我们可以给dp数组多加一行,这样就可以dp[0][0]就表示:不从数组中选择硬币并且可以凑成0元的方案数,显然只能为1,所以dp[0][0] = 1

4.遍历顺序

这题的遍历顺序其实是一个难点,在完全背包的求解中,我们可以两种遍历顺序,即内循环背包,外循环物品和内循环物品,外循环背包。

但是本题要求出不同硬币的组合数,即2 + 2 + 11 + 2 + 2这两种硬币的组合是一种情况,所以循环的东西不同造成的影响是不同的。

情况一:内循环物品,外循环背包

for (int j = 0; j <= amount; j ++) {
     
    for (int i = 1; i <= n; i ++) {
     
        // ...
    }
}

同一种金额可以被有不同的组合,如amount = 10 coins = {2, 8},则{2, 8}, {8, 2}都被算进了方案中。所以这种循环方式是在计算方案数。

情况二:内循环背包,外循环物品

for (int i = 1; i <= n; i ++) {
     
    for (int j = 0; j <= amount; j ++) {
     
        //...
    }
}

同一种面值可以组合在不同的金额当中,所以每一种面值只会被使用一次,所以如果amount = 10, coins = {2, 8}的话,当使用2面值组成10的时候,此时8还没有使用,所以会直接跳过这种情况,当使用8面值的时候,此时2已经使用过了,所以此时可以组合成{8, 2}了,这种循环方式,保证了求出的是组合数而不是排列数。

class Solution {
     
public:
    int change(int amount, vector<int>& coins) {
     
        int n = coins.size();
        vector<vector<int>> dp(n + 1, vector<int>(amount + 1, 0));
        dp[0][0] = 1;
        for (int i = 1; i <= n; i ++) {
     
            for (int j = 0; j <= amount; j ++) {
     
                for (int k = 0; k * coins[i - 1] <= j; k ++) {
     
                    dp[i][j] += dp[i - 1][j - k * coins[i - 1]];
                }
            }
        }
        return dp[n][amount];
    }
};

(动规-二维空间背包优化)

更具完全背包的优化,可以将第三重循环省去,状态转移公式为dp[i][j] += d[i][j - coins[i - 1]]

class Solution {
     
public:
    int change(int amount, vector<int>& coins) {
     
        int n = coins.size();
        vector<vector<int>> dp(n + 1, vector<int>(amount + 1));
        dp[0][0] = 1;
        for (int i = 1; i <= n; i ++) {
     
            for (int j = 0; j <= amount; j ++) {
     
                dp[i][j] = dp[i - 1][j];
                if (j >= coins[i - 1])
                    dp[i][j] += dp[i][j - coins[i - 1]];
            }
        }
        return dp[n][amount];
    }
};

(动规-一维空间背包优化)

更具完全背包的优化方式,可以将第一维的空间省去,虽然时间效率没有变,但是空间可以得到优化。

class Solution {
     
public:
    int change(int amount, vector<int>& coins) {
     
        int n = coins.size();
        vector<int> dp(amount + 1, 0);
        dp[0] = 1;
        for (int i = 1; i <= n; i ++) {
     
            for (int j = coins[i - 1]; j <= amount; j ++) {
     
                dp[j] += dp[j - coins[i - 1]];
            }
        }
        return dp[amount];
    }
};

组合总和 Ⅳ

动态规划之背包DP_第6张图片

(递归-TLE)

每一次使用同一种硬币可以排列出不同的排列方式。所以可以使用递归的话,枚举所有可能组合成target的硬币排列方式。但是由于递归的重复计算太多,所以递归的方案会超时。

class Solution {
     
public:
    int dfs(vector<int>& nums, int target, int sum) {
     
        if (sum >= target) {
     
            if (sum == target) return 1;
            return 0;
        }
        int ans = 0;
        for (int num : nums) {
     
            ans += dfs(nums, target, sum + num);
        }
        return ans;
    }

    int combinationSum4(vector<int>& nums, int target) {
     
        return dfs(nums, target, 0);
    }
};

(记忆化搜索)

由于递归的方式会超时,所以可以使用记忆化搜索的方式优化递归中的重复计算。也就是使用一个容器,将每一次计算的结果都保存下来,这样就可以不用重复计算了。

class Solution {
     
public:
    int dfs(vector<int>& nums, int target, int sum, unordered_map<int, int>& memo) {
     
        if (memo.count(sum)) return memo[sum];
        if (sum >= target) {
     
            if (sum == target) return 1;
            return 0;
        }
        int ans = 0;
        for (int num : nums) {
     
            ans += dfs(nums, target, sum + num, memo);
        }
        memo[sum] = ans;
        return ans;
    }

    int combinationSum4(vector<int>& nums, int target) {
     
        unordered_map<int, int> memo;
        return dfs(nums, target, 0, memo);
    }
};

(动规-一维空间优化)

本题无论是因为从记忆化搜索可以写出动态规划,还是因为本题是一个无限次选择的完全背包问题,都是可以从动态规划的思想着手解决本题。

本题其实和「零钱兑换ll」很像,只不过「零钱兑换ll」求的是达成target的组合数,本题是求出达成target的排列数。所以只需要将循环的顺序交换一下,即外循环背包,内循环物品即可。

补充:如果想要写成二维dp的动规的话,dp[i][j]的定义需要改变一下,表示:序列长度为i的序列和为j的方案数。其中长度不同,则可以计算不同长度序列的不同排列数。

class Solution {
     
public:
    int combinationSum4(vector<int>& nums, int target) {
     
        int n = nums.size();
        vector<unsigned long long> dp(target + 1);
        dp[0] = 1;
        for (int j = 0; j <= target; j ++) {
     
            for (int i = 1; i <= n; i ++) {
     
                if (j >= nums[i - 1]) {
     
                    dp[j] += dp[j - nums[i - 1]];
                }
            }
        }
        return dp[target];
    }
};

爬楼梯

动态规划之背包DP_第7张图片

(动规-完全背包解法)

通过前面完全背包求方案数的学习,我们可以再回头看一次爬楼梯这道题目。

原来我们对于爬楼梯这道题目是和斐波那契数列数列相类比的,只是因为两题的动规的递推公式是一样的。

但是如果在看这道题目,**其实我们就是将{1, 2}放在一个数组当中,最终要求出通过nums中的数可以组合成target的方案数。**这样不就变成了「组合总和IV」这道题目了嘛,**也就是无限次选择的完全背包问题求出方案数。**所以可以直接套用上面的代码即可。

如果爬楼梯还有进阶的题目,如:每一次可以走{1, 2, 6, 9},这四种步数,我们也会求解。也就是将nums数组放入这4个数而已。

class Solution {
     
public:
    int climbStairs(int n) {
     
        vector<int> nums = {
     1, 2};
        vector<int> dp(n + 1);
        dp[0] = 1;
        for (int j = 1; j <= n; j ++) {
     
            for (int i = 0; i < 2; i ++) {
     
                if (j >= nums[i])
                    dp[j] += dp[j - nums[i]];
            }
        }
        return dp[n];
    }
};

零钱兑换

动态规划之背包DP_第8张图片

(递归-TLE)

第一种方法就是最暴力的方法就是使用递归的方式,将所有的可以组合成amount金额的硬币组合都求解出来,最后求出满足要求的最少的硬币个数。

class Solution {
     
public:
    int ans = INT_MAX;
    void dfs(vector<int>& coins, int amount, int count) {
     
        if (amount <= 0) {
     
            if (0 == amount) {
     
                ans = min(ans, count);
            }
            return ;
        }
        int ans = 0;
        for (int i = 0; i < coins.size(); i ++) {
     
            dfs(coins, amount - coins[i], count + 1);
        }
    }

    int coinChange(vector<int>& coins, int amount) {
     
        dfs(coins, amount, 0);
        return ans == INT_MAX ? -1 : ans;
    }
};

(记忆化搜索)

第二种方法就是递归的优化,即将所有已经计算过的答案保存下来。

递归函数的含义为:剩余的金额为amount,可以刚好组合成amount金额的最少的硬币个数。

所以递归函数需要使用返回值,当amount == 0的时候,不用使用任何的硬币。当amount < 0的时候,默认返回-1。每一次都将coins中的硬币都试一遍,如果最后的返回值在[0, ans)当中的话,ans = tmp + 1

class Solution {
     
public:
    unordered_map<int, int> memo;
    int dfs(vector<int>& coins, int amount, int count) {
     
        if (memo.count(amount)) {
     
            return memo[amount];
        }
        if (amount <= 0) {
     
            if (0 == amount) {
     
                return 0;
            }
            return -1;
        }
        int ans = INT_MAX;
        for (int i = 0; i < coins.size(); i ++) {
     
            int tmp = dfs(coins, amount - coins[i], count + 1);
            if (tmp >= 0 && tmp < ans) {
     
                ans = tmp + 1;
            }
        }
        memo[amount] = ans == INT_MAX ? -1 : ans;
        return memo[amount];
    }

    int coinChange(vector<int>& coins, int amount) {
     
        dfs(coins, amount, 0);
        return memo[amount];
    }
};

(朴素动规-TLE)

本题和前面「零钱兑换II」「组合总和IV」都是很类似的问题。都是给一个target或者amount代表目标值,然后再给一个nums,接着需要利用nums中的值组合出target。前面的题目都是问方案数,而本题问的是组成amount的最少个数。

但是既然已经知道本题是无限次选取的完全背包问题,那么就可以利用完全背包的思路求解。

如果要是用朴素的完全背包解法的话,就需要枚举coins中的硬币个数,这样也可以做出答案,但是只要数据稍微大一点的话,就会超时。

class Solution {
     
public:
    const int INF = 0x3f3f3f3f;
    int coinChange(vector<int>& coins, int amount) {
     
        int n = coins.size();
        vector<vector<int>> dp(n + 1, vector<int>(amount + 1, INF));
        for (int i = 0; i <= n; i ++) dp[i][0] = 0;
        for (int i = 1; i <= n; i ++) {
     
            for (int j = 0; j <= amount; j ++) {
     
                for (int k = 0; k * coins[i - 1] <= j; k ++) {
     
                    dp[i][j] = min(dp[i][j], dp[i - 1][j - k * coins[i - 1]] + k);
                }
            }
        }
        return dp[n][amount] == INF ? -1 : dp[n][amount];
    }
};

(动规-完全背包优化)

可以使用完全背包的解法将三重循环降为二重循环,这样的话就可以大大降低时间复杂度。但是这种方式的动规含义并不是很明确,只是对上面一种做法的时间优化而已。

class Solution {
     
public:
    const int INF = 0x3f3f3f3f;
    int coinChange(vector<int>& coins, int amount) {
     
        int n = coins.size();
        vector<vector<int>> dp(n + 1, vector<int>(amount + 1, INF));
        for (int i = 0; i <= n; i ++) dp[i][0] = 0;
        for (int i = 1; i <= n; i ++) {
     
            for (int j = 0; j <= amount; j ++) {
     
                dp[i][j] = dp[i - 1][j];
                if (j >= coins[i - 1])
                    dp[i][j] = min(dp[i][j], dp[i][j - coins[i - 1]] + 1);
            }
        }
        return dp[n][amount] == INF ? -1 : dp[n][amount];
    }
};

(动规-一维空间优化)

最后一种解法是上面一种解法的空间优化,但是其实动规的递推公式就是递归函数的等价变形。

1.状态定义

dp[i]表示:剩余i金额的时候,硬币组合刚好可以组合成i金额的最少的硬币个数。

2.递推公式

枚举每一种硬币,使其可以组合成为j金额。当可以使用一个硬币组合j金额的时候,组合j金额的硬币个数就+1。至于剩余的金额j - coins[i],就再由coins中的硬币重新的组合。

所以递推公式为dp[i] = min(dp[i], dp[i - coins[j]] + 1)

3.遍历顺序

本题求得是组合成amount金额的最少硬币个数,所以不考虑组合成该金额的硬币的排列和组合,所以内外循环为物品或者为物品都是可以的。

4.初始化

因为要求出最少的硬币个数,所以一开始dp数组需要都初始化为INF,而dp[0]表示金额为0的时候,最少可以组合0的硬币个数,当然也是为0的。即dp[0] = 0

class Solution {
     
public:
    const int INF = 0x3f3f3f3f;
    int coinChange(vector<int>& coins, int amount) {
     
        int n = coins.size();
        vector<int> dp(amount + 1, INF);
        dp[0] = 0;
        for (int i = 0; i < n; i ++) {
     
            for (int j = coins[i]; j <= amount; j ++) {
     
                dp[j] = min(dp[j], dp[j - coins[i]] + 1); 
            }
        }
        // 循环颠倒也是可以的
        // for (int i = 0; i <= amount; i ++) {
     
        //    for (int j = 0; j < n; j ++) {
     
        //        if (i >= coins[j]) {
     
        //            dp[i] = min(dp[i], dp[i - coins[j]] + 1);
        //        }
        //    }
        // }
        return dp[amount] == INF ? -1 : dp[amount];
    }
};

完全平方数

动态规划之背包DP_第9张图片

(动规-一维空间优化)

本题和上面的「零钱兑换」几乎一模一样,都是在问组合成amount的最少的数字的个数。只不过本题中“硬币”的面值一定是一个完全平方数而已。而且这个完全平方数并没有放在一个数组中,所以这些完全平方数需要我们手动的自己求出来。

要组合成n的一个完全平方数一定不会超过n,所以我们只需要枚举i * i <= n中的[1, i]中的所有数字即可。我们可以放在一个数组中,也可以直接在计算的时候,一边算一边求。

在解决了完全平方数的来源问题之后,上下的过程就是在求出「零钱兑换」的硬币个数了,可以使用动规,递归和记忆化搜索。

class Solution {
     
public:
    const int INF = 0x3f3f3f3f;
    int numSquares(int n) {
     
        vector<int> dp(n + 1, INF);
        dp[0] = 0;
        for (int i = 1; i * i <= n; i ++) {
     
            for (int j = i * i; j <= n; j ++) {
     
                dp[j] = min(dp[j], dp[j - i * i] + 1);
            }
        }
        return dp[n];
    }
};

数位成本和为目标值的最大数字

动态规划之背包DP_第10张图片

每当我们看到题目中有恰好成本为target或者是不超过target的最大值的时候,我们就会自动的想到这是一个背包问题。

(动规)

本题也算是一样的,在一个数组中选取数字,恰好可以组成target的对应的下标字符串为多少。这道题难就难在他并不是直接问,而是拐了一个弯,要求我们求出下标组成的最大字符串是什么。

所以我们的dp数组中就可以存放string,来存储最大的字符串。注意:最大答案要求出恰好可以组成target的对应下标的字符串,而不是不超过target的对应下标的最大字符串。所以我们需要对不能恰好组成target的组合标识一下。

最后我们可以看到每一个字符串是可以重复取用的,所以这时一个完全背包问题,所以可以使用递推公式**dp[i][j] = min(dp[i - 1][j], dp[i][j - cost[i]]),表示从前i个数字中选,恰好可以组成j - cost[i]的对应下标字符串是多少。因为虽然选择了第i个数字,但是还是可以从前i个中选取数字,就相当于可以将同一个数字重复取用了。**

1.状态定义

dp[i][j]表示:从前i个数字中选取数字恰好可以组成j,并且选取数字对应的下标拼接成字符串后字符串最大。

2.递推公式

如果在j > cost[i]并且dp[i][j - cost[i]]这个方程可以转移也就是恰好可以组成target的情况下,我们从dp[i - 1][j]to_string(i) + dp[i][j - cost[i]]中选择一个字符串长度最长的字符串,如果字符串的长度相同的话,则选取大的那一个字符串。这个判断需要写个函数来判断。所以dp[i][j] = cmp(dp[i - 1][j], to_string(i) + dp[i][j - cost[i]])

3.初始化

我们先将所有的情况都标志为#,即无效情况。当从数组的前i个数字中选取数字的时候,我们只要不选择数组中的数字,就一定可以组成target。所以dp[i][0]初始化为“”

class Solution {
     
public:
    string cmp(string a, string b) {
     
        if (a.size() != b.size()) return a.size() > b.size() ? a : b;
        else return max(a, b);
    }

    string largestNumber(vector<int>& cost, int target) {
     
        int n = cost.size();
        vector<vector<string>> dp(n + 1, vector<string>(target + 1, "#"));
        for (int i = 0; i <= n; i ++) dp[i][0] = "";
        for (int j = 0; j <= target; j ++) {
     
            for (int i = 1; i <= n; i ++) {
     
                dp[i][j] = dp[i - 1][j];
                if (j >= cost[i - 1] && dp[i][j - cost[i - 1]] != "#") {
     
                    dp[i][j] = cmp(dp[i][j], to_string(i) + dp[i][j - cost[i - 1]]);
                }
            }
        }
        return dp[n][target] == "#" ? "0" : dp[n][target];
    }
};

(动规-一维空间)

将上面二维的完全背包优化成一维可以这样写。

class Solution {
     
public:
    string cmp(string a, string b) {
     
        if (a.size() != b.size()) return a.size() > b.size() ? a : b;
        else return max(a, b);
    }

    string largestNumber(vector<int>& cost, int target) {
     
        int n = cost.size();
        vector<string> dp(target + 1, "#");
        dp[0] = "";
        for (int j = 0; j <= target; j ++) {
     
            for (int i = 1; i <= n; i ++) {
     
                if (j >= cost[i - 1] && dp[j - cost[i - 1]] != "#") {
     
                    dp[j] = cmp(dp[j], to_string(i) + dp[j - cost[i - 1]]);
                }
            }
        }
        return dp[target] == "#" ? "0" : dp[target];
    }
};

(动规-完全背包分块求解)

如果我们换一种思路,可以先将从数组中选取数字组合成target的数组最多的个数求出来。因为我们需要求出最大的字符串,所以字符串的长度越长就越大。

求出最大可以组成target的个数后,我们在反推回去,将所有这些可以组成target的数字都在拼接成为字符串进行比较,最后得出最大的字符串。而且要注意的是,我们在反推回去的时候,需要从数组的最后一个位置开始往回递推,这样才可以使得字符串的大小最大。

class Solution {
     
public:
    string largestNumber(vector<int>& cost, int target) {
     
        int n = cost.size();
        vector<int> dp(target + 1, INT_MIN);
        dp[0] = 0;
        for (int i = 1; i <= n; i ++) {
     
            for (int j = cost[i - 1]; j <= target; j ++) {
     
                dp[j] = max(dp[j], dp[j - cost[i - 1]] + 1);
            }
        }
        if (dp[target] < 0) return "0";
        string ans;
        for (int i = n; i >= 1; i --) {
     
            while (target >= cost[i - 1] && 
                   dp[target] == dp[target - cost[i - 1]] + 1) {
     
                ans += to_string(i);
                target -= cost[i - 1]; 
            }
        }
        return ans;
    }
};

你可能感兴趣的:(DP专题,LeetCode,动态规划)