3.21-3.24DP

DP五部曲:

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果

然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。

如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。

如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。

这样才是一个完整的思考过程,而不是一旦代码出问题,就毫无头绪的东改改西改改,最后过不了,或者说是稀里糊涂的过了

70. 爬楼梯 - 力扣(LeetCode)

思路:

DP五部曲:

  1. 确定dp数组(dp table)以及下标的含义

    dp[n]表示n种方法

  2. 确定递推公式

    n层可以由n-1 和 n-2层得来,故dp[n] = dp[n - 1] + dp[n - 2]

  3. dp数组如何初始化

    dp[2] = dp[1] + dp[0],有两种方法,故dp[1] = 1 , dp[0] = 1

  4. 确定遍历顺序

    从前往后

  5. 举例推导dp数组

    dp[1]=1 , dp[2] = 2 . dp[3] = dp[1] + dp[2] = 3

class Solution {
  public:
      int climbStairs(int n) {
          int dp[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];
      }
  };

空间优化:一旦算出 f[i],那么 f[i−2] 及其左边的状态就永远不会用到了。

这意味着每次循环,只需要知道「上一个状态」和「上上一个状态」的 f 值是多少,分别记作 f1 和 f0 。它俩的初始值均为 1,对应着 f[1] 和 f[0]。

每次循环,计算出新的状态 newF=f1+f0,那么对于下一轮循环来说:「上上一个状态」就是 f1,更新 f0 =f 。「上一个状态」就是 newF,更新 f1 =newF。
最后答案为 f1 ,因为最后一轮循环算出的 newF 赋给了 f1。

class Solution {
  public:
      int climbStairs(int n) {
          int f0 = 1 , f1 = 1;
          for (int i = 2; i <= n; i++) {
             int new_f = f1 + f0;
             f0 = f1;
             f1 = new_f;
          }
          return f1;
      }
  };
/*或者向以下写法:最后返回的是第一个数a,不管两数之和还是三数之和
	int a = 1 , b = 1 , sum;
	for (int i = 0; i < n; i++) { //这里没加=n
             int sum = a + b;
            a = b;
            b = sum;
          }
          return a;
          

复杂度分析

  • 时间复杂度:O(n)。
  • 空间复杂度:O(1)。仅用到若干变量。

746. 使用最小花费爬楼梯 - 力扣(LeetCode)

思路:

DP五部曲:

  1. 确定dp数组(dp table)以及下标的含义

    dp[n]表示最少花费爬到n

  2. 确定递推公式

    n层可以由n-1 和 n-2层得来,故dp[n] = min(dp[n - 1] + cost[n - 1], dp[n - 2] + cost[n - 2])

  3. dp数组如何初始化

    起始可以从0或1开始,dp[1] = dp[0] = 0

  4. 确定遍历顺序

    从前往后

  5. 举例推导dp数组

    输入:cost = [10,15,20]
    输出:15
    

    dp[0] = 0 , dp[1] = 0, dp[2] = min(cost[0] , cost[1]) = 10,

    dp[3] = min(cost[1] , dp[2] + cost[2]) = min(15 , 30) = 15

class Solution {
  public:
      int minCostClimbingStairs(vector<int>& cost) {
          int n = cost.size();
          int dp[n + 1];
          dp[0] = dp[1] = 0;
          for (int i = 2; i <= n; i++) {
             dp[i] = min(dp[i - 1] + cost[i - 1] , dp[i - 2] + cost[i - 2]);
          }
          return dp[n];
      }
  };

空间优化:

观察状态转移方程,发现一旦算出 f[i],那么 f[i−2] 及其左边的状态就永远不会用到了。这意味着每次循环,只需要知道「上一个状态」和「上上一个状态」的 f 值是多少,分别记作 f1 和 f0。它俩的初始值均为 0,对应着 f[1] 和 f[0]。

每次循环,计算出新的状态 newF=min(f1+cost[i−1],f0+cost[i−2]),那么对于下一轮循环来说:

  • 「上上一个状态」就是 f1,更新 f0=f1。
  • 「上一个状态」就是 newF,更新 f1=newF。

最后答案为 f1,因为最后一轮循环算出的 newF 赋给了 f1。

代码实现时,可以把 i 改成从 1 遍历到 n−1,这样 newF=min(f1+cost[i],f0+cost[i−1]),可以简化一点代码。

class Solution {
  public:
      int minCostClimbingStairs(vector<int> &cost) {
          int f0 = 0, f1 = 0;
          for (int i = 1; i < cost.size(); i++) {
              int new_f = min(f1 + cost[i], f0 + cost[i - 1]);
              f0 = f1;
              f1 = new_f;
          }
          return f1;
      }
  };

118. 杨辉三角 - 力扣(LeetCode)

思路:

DP五部曲:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i][j]表示值

  2. 确定递推公式

    dp[i][j] = dp[i- 1][j - 1] + dp[i - 1][j]

  3. dp数组如何初始化

    dp[n][n] = 0 , dp[n][0] = 1

  4. 确定遍历顺序

    从前往后,从上到下

  5. 举例推导dp数组

      dp[0][0] = 1
      dp[1][0] = 1 , dp[1][1] = 1
      dp[2][0] = 1 , dp[2][1] = 2 , dp[2][2] = 1
      ····
    

把杨辉三角的每一排左对齐:

[1]

[1,1]

[1,2,1]

[1,3,3,1]

[1,4,6,4,1]

设要计算的二维数组是 c,计算方法如下:

  • 每一排的第一个数和最后一个数都是 1,即 c[i][0]=c[i][i]=1。
  • 其余数字,等于左上方的数,加上正上方的数,即 c[i][j]=c[i−1][j−1]+c[i−1][j]。例如 4=1+3, 6=3+3 等。
class Solution {
public:
    vector<vector<int>> generate(int numRows) {
        vector<vector<int>> c(numRows);
        for (int i = 0; i < numRows; i++) {
            c[i].resize(i + 1, 1);
            for (int j = 1; j < i; j++) {
                // 左上方的数 + 正上方的数
                c[i][j] = c[i - 1][j - 1] + c[i - 1][j];
            }
        }
        return c;
    }
};

复杂度分析

  • 时间复杂度:O(numRows2)。
  • 空间复杂度:O(1)。返回值不计入。

附:如何理解这个式子

c[i][j]=c[i−1][j−1]+c[i−1][j]

本质上是一个组合数恒等式,其中 c[i][j] 表示从 i 个不同物品中选出 j 个物品的方案数。

如何理解上式呢?考虑其中某个物品选或不选

  • :问题变成从剩下 i−1 个不同物品中选出 j−1 个物品的方案数,即 c[i−1][j−1]。
  • 不选:问题变成从剩下 i−1 个不同物品中选出 j 个物品的方案数,即 c[i−1][j]。

二者相加(加法原理),得

c[i][j]=c[i−1][j−1]+c[i−1][j]


198. 打家劫舍 - 力扣(LeetCode)

思路:

DP五部曲:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i]表示到下标i能偷的最大值

  2. 确定递推公式

    dp[i] = max(dp[i - 2] + nums[i] , dp[i - 1])

  3. dp数组如何初始化

    dp[0] = nums[0] , dp[1] = max(nums[0] , nums[1]);

  4. 确定遍历顺序

    从前往后

  5. 举例推导dp数组

    输入:[1,2,3,1]
    输出:4
    

    dp[0] = 1 , dp[1] = 2 , dp[2] = max(dp[0] + nums[2] , dp[1]) =4

    dp[3] = max(dp[1] + nums[3] , dp[2]) = max(2 + 1 , 4) = 4

class Solution {
  public:
      int rob(vector<int>& nums) {
        int n = nums.size();
        vector<int> dp(n + 2);   
          dp[0] = nums[0] , dp[1] = max(nums[0] , nums[1]);

          for (int i = 2; i < n; i++) {
             dp[i] = max(dp[i - 2] + nums[i] , dp[i - 1]);
          }
          return dp[n - 1];
      }
  };

这个代码在处理数组长度为1时出现了越界问题。当nums的长度为1时,访问nums[1]会导致溢出。需要在代码开头处理边界情况。

class Solution {
  public:
      int rob(vector<int>& nums) {
        int n = nums.size();
        if (n == 0) return 0;
        if (n == 1) return nums[0];

        vector<int> dp(n);   
          dp[0] = nums[0] , dp[1] = max(nums[0] , nums[1]);

          for (int i = 2; i < n; i++) {
             dp[i] = max(dp[i - 2] + nums[i] , dp[i - 1]);
          }
          return dp[n - 1];
      }
  };

空间优化

class Solution {
public:
    int rob(vector<int>& nums) {
        int f0 = 0, f1 = 0;
        for (int x : nums) {
            int new_f = max(f1, f0 + x);
            f0 = f1;
            f1 = new_f;
        }
        return f1;
    }
};

复杂度分析

  • 时间复杂度:O(n)。其中 n 为 nums 的长度。
  • 空间复杂度:O(1)。仅用到若干额外变量。

740. 删除并获得点数 - 力扣(LeetCode)

思路:值域打家劫舍

DP五部曲:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i]表示点数小于等于i能获得的最大点数

  2. 确定递推公式

    dp[i] = max(dp[i - 2] + nums[i] , dp[i - 1])

  3. dp数组如何初始化

    dp[0] = nums[0] dp[1] = max(nums[0] , nums[1])

  4. 确定遍历顺序

    从前往后,从上到下

  5. 举例推导dp数组

    dp[0] = 3 , dp[1] = 4 , dp[2] =

看示例 2,nums=[2,2,3,3,3,4]。如果我们选了一个等于 3 的数,那么所有等于 2 和等于 4 的数都被删除,也就是都不能选。选了一个 3 后,剩下的 3 可以继续选。所以如果要选 3,所有的 3 都要选。

这种「相邻数字不能都选」联想到 198. 打家劫舍

把 nums 转换成一个值域数组 a,其中 a[i] 表示 nums 中的等于 i 的元素之和。上面的例子中,a=[0,0,4,9,4]。因为 nums 中有 3 个 3,所以 a[3]=3+3+3=9。

计算数组 a 的 198. 打家劫舍,即为答案。

class Solution {
    // 198. 打家劫舍
    int rob(vector<int>& nums) {
        int f0 = 0, f1 = 0;
        for (int x : nums) {
            int new_f = max(f1, f0 + x);
            f0 = f1;
            f1 = new_f;
        }
        return f1;
    }

public:
    int deleteAndEarn(vector<int>& nums) {
        int mx = ranges::max(nums);
        vector<int> a(mx + 1);
        for (int x : nums) {
            a[x] += x; // 统计等于 x 的元素之和
        }
        return rob(a);
    }
};

复杂度分析

  • 时间复杂度:O(n+U),其中 n 是 nums 的长度,U=max(nums)。
  • 空间复杂度:O(U)。

在提供的代码中,int mx = ranges::max(nums); 使用了 C++20 标准引入的 Ranges 库 中的 max 算法。以下是详细解释:


代码解释:

1. ranges::max 的作用

  • 功能:直接返回范围(如数组、容器)中的 最大值

  • 对比传统方法

    • 传统方法需要使用 std::max_element,它返回指向最大元素的迭代器,需解引用才能获取值:

      auto it = std::max_element(nums.begin(), nums.end());
      int mx = *it;
      
    • C++20 的 ranges::max 直接返回最大值,更简洁:

      int mx = std::ranges::max(nums); // 直接获取最大值
      

2. 为什么需要 ranges:: 前缀?

  • 命名空间ranges::max 属于 std::ranges 命名空间,是 C++20 标准库的一部分。

  • 正确用法

    • 需要包含头文件 <algorithm><ranges>(某些编译器可能需要)。

    • 若未使用 using namespace std;,则需完整限定:

      int mx = std::ranges::max(nums); // 标准写法

    • 如果代码中省略了 std::,可能是通过 using namespace std;using namespace std::ranges; 提前引入(但一般不推荐)。


3. 在代码中的实际作用

  • 目标:获取 nums 数组中的最大值 mx。因为nums数组不一定是按序排列的。

  • 用途:基于 mx 创建数组 a,其索引对应原数组中的数值,统计每个数值的总和:

    vector<int> a(mx + 1); // 索引范围覆盖所有可能的数值
    for (int x : nums) {
        a[x] += x; // 将相同数值的元素求和
    }
    
  • 示例

    • nums = [2,3,3,4],则 mx = 4,数组 a 长度为 5(索引 0~4)。

    • a[2] = 2, a[3] = 6, a[4] = 4,其余为 0。


4. 潜在问题

  • 空数组:若 nums 为空,ranges::max 会引发未定义行为。需先检查:

    if (nums.empty()) return 0;

  • C++20 兼容性:确保编译器支持 C++20 标准(如 GCC 10+、Clang 10+、MSVC 19.29+)。


修正后的代码(确保规范)

#include <vector>
#include <algorithm>  // 必须包含
#include <ranges>     // 某些编译器可能需要

class Solution {
    // 198. 打家劫舍(优化空间版)
    int rob(std::vector<int\>& nums) {
        int f0 = 0, f1 = 0;
        for (int x : nums) {
            int new_f = std::max(f1, f0 + x);
            f0 = f1;
            f1 =new_f;
        }
        return f1;
    }

public:
    int deleteAndEarn(std::vector<int>& nums) {
        if (nums.empty()) return 0; // 处理空输入
        int mx = std::ranges::max(nums); // 正确使用 std:: 限定
        std::vector<int> a(mx + 1);
        for (int x : nums) {
            a[x] += x;
        }
        return rob(a);
    }
};

关键点总结

  • ranges::max 是 C++20 的特性,直接返回范围的最大值。

  • 需确保编译器支持 C++20 并包含正确头文件。

  • 始终处理边界情况(如空输入)。


279. 完全平方数 - 力扣(LeetCode)

思路:

可能刚看这种题感觉没啥思路,又平方和的,又最小数的。

我来把题目翻译一下:完全平方数就是物品(可以无限件使用),凑个正整数n就是背包,问凑满这个背包最少有多少物品?

把 1,4,9,16,⋯ 这些完全平方数视作物品体积,物品价值都是 1。由于每个数(物品)选的次数没有限制,所以本题是一道标准的完全背包问题。

动规五部曲分析如下:

  1. 确定dp数组(dp table)以及下标的含义

dp[j]:和为j的完全平方数的最少数量为dp[j]

  1. 确定递推公式

dp[j] 可以由dp[j - i * i]推出, dp[j - i * i] + 1 便可以凑成dp[j]

\(f[i][j]=\left\{\begin{array}{ll} f[i-1][j], & j<i^{2} \\ \min \left(f[i-1][j], f[i]\left[j-i^{2}\right]+1\right), & j \geq i^{2} \end{array}\right.\)

初始值 f[0][0]=0, f[0][j]=∞ (j>0),翻译自递归边界 dfs(0,0)=0 和 dfs(0,j)=∞ (j>0)。

答案为 f[⌊n⌋][n],翻译自递归入口 dfs(⌊n⌋,n)。

此时我们要选择最小的dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j]);

  1. dp数组如何初始化

dp[0]表示 和为0的完全平方数的最小数量,那么dp[0]一定是0。

有同学问题,那0 * 0 也算是一种啊,为啥dp[0] 就是 0呢?

看题目描述,找到若干个完全平方数(比如 1, 4, 9, 16, ...),题目描述中可没说要从0开始,dp[0]=0完全是为了递推公式。

非0下标的dp[j]应该是多少呢?

从递归公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要选最小的,所以非0下标的dp[j]一定要初始为最大值,这样dp[j]在递推的时候才不会被初始值覆盖

  1. 确定遍历顺序

我们知道这是完全背包

  • 如果求组合数就是外层for循环遍历物品,内层for遍历背包。

  • 如果求排列数就是外层for遍历背包,内层for循环遍历物品。

动态规划:322. 零钱兑换 (opens new window)中我们就深入探讨了这个问题,本题也是一样的,是求最小数!

所以本题外层for遍历背包,内层for遍历物品,还是外层for遍历物品,内层for遍历背包,都是可以的!

代码:

class Solution {
  public:
      int numSquares(int n) {
          vector<int> dp(n + 1 , INT_MAX);
          dp[0] = 0;

          for (int i = 0; i <= n; i++) {  // 遍历背包1~n
             for (int j = 1; j * j <= i; j++) { // 遍历物品,填入数字1~sqrt(i)
                dp[i] = min(dp[i - j * j] + 1 , dp[i]);
             }
          }
          return dp[n];
      }
  };
  • 时间复杂度:$ O(n * \sqrt{n})$
  • 空间复杂度:$ O(n)$

同样我在给出先遍历物品,在遍历背包的代码,一样的可以AC的。

// 版本二
class Solution {
public:
    int numSquares(int n) {
        vector<int> dp(n + 1, INT_MAX);
        dp[0] = 0;
        for (int i = 1; i * i <= n; i++) { // 遍历物品,数字从1开始一直可以填到sqrt(n)
            for (int j = i * i; j <= n; j++) { // 遍历背包,背包容量从i*i开始一直到n
                dp[j] = min(dp[j - i * i] + 1, dp[j]);
            }
        }
        return dp[n];
    }
};

322. 零钱兑换 - 力扣(LeetCode)

思路:

完全背包问题,同上一题。

DP五部曲:

  1. 确定dp数组(dp table)以及下标的含义

    dp[j]表示和为j的最小的零钱数

  2. 确定递推公式

    dp[i][j] = min(dp[i - 1][j] , dp[i][j - coins[i]] + 1)简化为dp[j] = min(dp[j] , dp[j - coins[i]] + 1)

  3. dp数组如何初始化

    dp[0] = 0,判断:如果dp[j - coins[i]]是初始值INT_MAX则跳过,因为如果不能兑换则到不了dp[0],只有dp[0]被更新为0 。

  4. 确定遍历顺序

    求组合数,外层for循环遍历物品(i :coins),内层for遍历背包(j : amounts)。

  5. 举例推导dp数组

class Solution {
  public:
      int coinChange(vector<int>& coins, int amount) {
          vector<int> dp(amount + 1 , INT_MAX);
          dp[0] = 0;
         
          for (int i : coins) {// 遍历物品
              for (int j = i; j <= amount; j++) {// 遍历背包
                if(dp[j - i] != INT_MAX){// 如果dp[j - coins[i]]是初始值则跳过,因为如果不能兑换则到不了dp[0],只有dp[0]被更新为0                 
  						dp[j] = min(dp[j - i] + 1 , dp[j]);
                }
              }
          }
          if(dp[amount] == INT_MAX)  return -1;
          return dp[amount];
      }
  };

139. 单词拆分 - 力扣(LeetCode)

动规五部曲分析如下:

  1. 确定dp数组以及下标的含义

dp[i] : 字符串长度为i的话,dp[i]为true,表示可以拆分为一个或多个在字典中出现的单词。

  1. 确定递推公式

如果确定dp[j]true,且 [j, i] 这个区间的子串出现在字典里,那么dp[i]一定是true。(j < i )。

所以递推公式是 if([j, i] 这个区间的子串出现在字典里 && dp[j]是true) 那么 dp[i] = true

  1. dp数组如何初始化

从递推公式中可以看出,dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递推的根基,dp[0]一定要为true,否则递推下去后面都都是false了。

那么dp[0]有没有意义呢?

dp[0]表示如果字符串为空的话,说明出现在字典里。

但题目中说了“给定一个非空字符串 s” 所以测试数据中不会出现i为0的情况,那么dp[0]初始为true完全就是为了推导公式。

下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词

  1. 确定遍历顺序

题目中说是拆分为一个或多个在字典中出现的单词,所以这是完全背包。

还要讨论两层for循环的前后顺序。

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

我在这里做一个总结:

求组合数:动态规划:518.零钱兑换II (opens new window)求排列数:动态规划:377. 组合总和 Ⅳ (opens new window)动态规划:70. 爬楼梯进阶版(完全背包) (opens new window)求最小数:动态规划:322. 零钱兑换 (opens new window)动态规划:279.完全平方数(opens new window)

而本题其实我们求的是排列数,为什么呢。 拿 s = "applepenapple", wordDict = ["apple", "pen"] 举例。

"apple", "pen" 是物品,那么我们要求 物品的组合一定是 "apple" + "pen" + "apple" 才能组成 "applepenapple"。

"apple" + "apple" + "pen" 或者 "pen" + "apple" + "apple" 是不可以的,那么我们就是强调物品之间顺序

所以说,本题一定是 先遍历 背包,再遍历物品

  1. 举例推导dp[i]

代码:

class Solution {
  public:
      bool wordBreak(string s, vector<string>& wordDict) {
          unordered_set<string> wordSet(wordDict.begin() , wordDict.end());
          vector<bool> dp(s.size() + 1 , false);
          dp[0] = true;

          for (int i = 1; i <= s.size(); i++) {
              for (int j = 0; j < i; j++) {
                 string word = s.substr(j , i - j);

                 if(wordSet.find(word) != wordSet.end() && dp[j])  dp[i] = true;
              }
          }
          return dp[s.size()];
      }
  };

以下为01背包专题:


416. 分割等和子集 - 力扣(LeetCode)

思路:

可以计算出nums数组的sum,相当于找子集和为sum/2即可分割。

只有确定了如下四点,才能把01背包问题套到本题上来。

  • 背包的体积为sum / 2
  • 背包要放入的商品(集合里的元素)重量为 元素的数值,价值也为元素的数值
  • 背包如果正好装满,说明找到了总和为 sum / 2 的子集。(return dp[sum / 2] == sum / 2)
  • 背包中每一个元素是不可重复放入。

动规五部曲分析如下:

  1. 确定dp数组以及下标的含义

本题中nums[i]既是重量,也是价值。

dp[j]表示背包总容量(所能装的总重量)是j,放进物品后,背的最大价值为dp[j]

那么如果背包容量为target = sum / 2, dp[target]就是装满背包之后的重量,所以 当 dp[target] == target 的时候,背包就装满了。

那还有装不满的时候?拿输入数组 [1, 5, 11, 5],举例, dp[7] 只能等于 6,因为 只能放进 1 和 5。而dp[6] 就可以等于6了,放进1 和 5,那么dp[6] == 6,说明背包装满了。

  1. 确定递推公式

01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

本题,相当于背包里放入数值,那么物品i的重量是nums[i],其价值也是nums[i]。

所以递推公式:dp[j] = max(dp[j], dp[j - nums[i] ] + nums[i]);

  1. dp数组如何初始化
  • 从dp[j]的定义来看,首先dp[0]一定是0。

  • 如果题目给的价值都是正整数那么非0下标都初始化为0就可以了

  • 如果题目给的价值有负数,那么非0下标就要初始化为负无穷。

这样才能让dp数组在递推的过程中取得最大的价值,而不是被初始值覆盖了

本题题目中只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。

// 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
// 即sum不会大于20000,背包最大只需要其中一半,所以10001大小就可以了
vector<int> dp(10001, 0);//或者 vector<int> dp(sum / 2 + 1 , 0);
  1. 确定遍历顺序

如果使用一维dp数组,外层物品,内层体积,且内层for循环倒序遍历!

// 开始 01背包
for(int i = 0; i < nums.size(); i++) {
 for(int j = target; j >= nums[i]; j--) { // 每一个元素一定是不可重复放入,所以从大到小遍历
     dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
 }
}
  1. 举例推导dp数组

dp[j]的数值一定是小于等于j的。

如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j,理解这一点很重要。

代码:

class Solution {
  public:
      bool canPartition(vector<int>& nums) {
          int sum = 0;
          for(int i : nums)  sum += i;
          int n = nums.size();
          vector<int> dp(10001 , 0);

         for (int i = 0; i < n; i++) {
              for (int j = sum / 2; j >= nums[i]; j--) {
                 dp[j] = max(dp[j] , dp[j - nums[i]] + nums[i]);
              }
         }
         return dp[sum / 2] == sum / 2;
      }
  };

1049. 最后一块石头的重量 II - 力扣(LeetCode)

思路:

和上一题类似,尽量分成两份和相近的子集,最后返回sum - dp[sum / 2] * 2即可。

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

          return sum - dp[sum / 2] * 2;
      }
  };

494. 目标和 - 力扣(LeetCode)

思路:

问题转化:

nums数组所有元素和设为S,其中正数和设为a,那么负数应该为S-a,满足题目条件,则a-(s-a) = target(记为t)。移项可得 a = (s + t) / 2, s、t均已知,化为01背包问题。

先排除边界情况:

 if((target + s) % 2 || abs(target) > s) return 0;

这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少

本题则是装满有几种方法。其实这就是一个组合问题

  1. 确定dp数组以及下标的含义

dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法

  1. 确定递推公式

有哪些来源可以推出dp[j]呢?

只要搞到nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。

例如:dp[j],j 为5,

  • 已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包。
  • 已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包。
  • 已经有一个3(nums[i]) 的话,有 dp[2]种方法 凑成 容量为5的背包
  • 已经有一个4(nums[i]) 的话,有 dp[1]种方法 凑成 容量为5的背包
  • 已经有一个5 (nums[i])的话,有 dp[0]种方法 凑成 容量为5的背包

那么凑整dp[5]有多少方法呢,也就是把所有的 dp[j - nums[i]] 累加起来。

所以求组合类问题的公式,都是类似这种:

dp[j] += dp[j - nums[i]]
  1. dp数组如何初始化

从递推公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递推结果将都是0。

这里有录友可能认为从dp数组定义来说 dp[0] 应该是0,也有录友认为dp[0]应该是1。

其实不要硬去解释它的含义,咱就把 dp[0]的情况带入本题看看应该等于多少。

如果数组[0] ,target = 0,那么 bagSize = (target + sum) / 2 = 0。 dp[0]也应该是1, 也就是说给数组里的元素 0 前面无论放加法还是减法,都是 1 种方法。

所以本题我们应该初始化 dp[0] 为 1。

可能有同学想了,那 如果是 数组[0,0,0,0,0] target = 0 呢。

其实此时最终的dp[0] = 32,也就是这五个零子集的所有组合情况,但此dp[0]非彼dp[0],dp[0]能算出32,其基础是因为dp[0] = 1 累加起来的。

dp[j]其他下标对应的数值也应该初始化为0,从递推公式也可以看出,dp[j]要保证是0的初始值,才能正确的由dp[j - nums[i]]推导出来。

  1. 确定遍历顺序

对于01背包问题一维dp的遍历,外物品内容量,nums放在外循环,target在内循环,且内循环倒序。

  1. 举例推导dp数组

略。

如果仅仅是求个数的话,就可以用dp

但如果要求的是把所有组合列出来,还是要使用回溯法暴搜的。见回溯算法:39. 组合总和 (opens new window)

代码:

class Solution {
  public:
      int findTargetSumWays(vector<int>& nums, int target) {
          int s = 0;
          for(int i : nums) s += i;
          if((target + s) % 2 || abs(target) > s) return 0;

          int a = (s + target) / 2;
          vector<int> dp(a + 1 , 0);
            dp[0] = 1;
          for (int i = 0; i < nums.size(); i++) {
             for (int j = a; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
             }
          }
          return dp[a];
      }
  };

474. 一和零 - 力扣(LeetCode)

思路:

本题其实是01背包问题。只不过这个背包有两个维度,一个是m 一个是n,而不同长度的字符串就是不同大小的待装物品。

动规五部曲:

  1. 确定dp数组(dp table)以及下标的含义

dp[i] [j]:最多有i个0 和 j个1的strs的最大子集的大小为dp[i] [j]。

  1. 确定递推公式

dp[i] [j] 可以由前一个strs里的字符串推导出来,strs里的字符串有zeroNum个0,oneNum个1。

dp[i] [j] 就可以是 dp[i - zeroNum] [j - oneNum] + 1

然后我们在遍历的过程中,取dp[i] [j]的最大值。

所以递推公式:dp[i] [j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);

此时大家可以回想一下01背包的递推公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

对比一下就会发现,字符串的zeroNumoneNum相当于物品的重量(weight[i]),字符串本身的个数相当于物品的价值(value[i])。

这就是一个典型的01背包! 只不过物品的重量有了两个维度而已。

  1. dp数组如何初始化

01背包的dp数组初始化为0就可以。

因为物品价值不会是负数,初始为0,保证递推的时候dp[i] [j]不会被初始值覆盖。

  1. 确定遍历顺序

外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历。

那么本题也是,物品就是strs里的字符串,背包容量就是题目描述中的m和n。

循环部分代码如下:

for (string str : strs) { // 遍历物品
 int oneNum = 0, zeroNum = 0;
 for (char c : str) {
     if (c == '0') zeroNum++;
     else oneNum++;
 }
 for (int i = m; i >= zeroNum; i--) { // 遍历背包容量且从后向前遍历!
     for (int j = n; j >= oneNum; j--) {
         dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1);
     }
 }
}

遍历背包容量的两层for循环先后循序有没有什么讲究?

没讲究,都是物品重量的一个维度,先遍历哪个都行!

  1. 举例推导dp数组

    略。

代码:

class Solution {
  public:
      int findMaxForm(vector<string>& strs, int m, int n) {
          vector<vector<int>> dp(m + 1 , vector<int>(n + 1 , 0));

          for(string str : strs){
            int zero = 0 , one = 0;
            for(char c : str){
              if(c == '0') zero ++;
              else one ++;
            }

            for (int i = m; i >= zero; i--) {
               for (int j = n; j >= one; j--) {
                  dp[i][j] = max(dp[i][j] , dp[i - zero][j - one] + 1);
               }
            }
          }
          return dp[m][n];
      }
  };
posted @ 2025-03-24 22:47  七龙猪  阅读(5)  评论(0)    收藏  举报
-->