【ONE·基础算法 || 动态规划(四)】

总言

主要内容:编程题举例,熟悉理解动态规划类题型(背包问题、似包非包、卡特兰数)。

文章目录

9、01背包问题

9.0、概述

背包问题(Knapsack Problem)是一种经典的组合 优化NP完全问题,它考验着我们在给定约束条件下 做出最优选择的能力。

背包问题可以描述为: 给定一组物品,每种物品都有自己的重量和价格(或价值)。在限定的总重量(容量)内,我们如何选择物品,才能使得所选物品的总价格最高。

根据物品的数量和特性,背包问题可以分为以下几类:

  • 01背包问题:每个物品只有一个,即每个物品要么被选择,要么不被选择。
  • 完全背包问题:每个物品有无限多个,即每种物品可以被选择任意多次。
  • 多重背包问题:每件物品最多有si个,即每种物品有一个固定的数量限制。
  • 混合背包问题:物品可能同时具有上述三种情况中的任意一种或多种。
  • 分组背包问题:物品被分为n组,每组物品里有若干个,但每组里最多只能选择一个物品。
  • ......

根据背包是否必须装满,背包问题还可以分为两类:

  • 不一定装满背包:背包的容量不一定需要被完全利用。
  • 背包一定装满:背包的容量必须被完全利用,不能留下任何空闲空间。

根据限定条件的个数,背包问题还可以进一步分类:

  • 限定条件只有一个:如只有体积(容量)限制,这是最常见的背包问题。
  • 限定条件有两个:如同时考虑体积和重量(或其他两个维度)的限制,这类问题通常被称为二维费用背包问题。

为了求解背包问题,我们可以采用多种优化方案:

1)、空间优化(滚动数组):可以利用滚动数组来减少空间复杂度。

2)、单调队列优化:在某些情况下,我们可以利用单调队列来优化动态规划的状态转移过程。

3)、贪心优化:虽然背包问题通常是NP完全问题,但在某些特殊情况下,贪心策略可以得到最优解。

根据不同的求解目标,背包问题还可以分为以下几类:

1)、输出方案:要求输出具体的选择方案,即哪些物品被选择了。

2)、求方案总数:要求计算满足条件的选择方案有多少种。

3)、最优方案:要求找到总价值最高的选择方案。

4)、方案可行性:判断是否存在满足条件的选择方案。

9.1、01背包(medium)(模板!重点!)

题源:链接

9.1.1、题解

9.1.1.1、思路分析(第一问:背包不一定装满)

1)、思路分析

1、确定状态表示:

根据题目,背包问题本质上还是一个线性dp (是否选择某个物品),因此,容易想到的状态表示是:用dp[i]来表示从前i个物品中挑选,在所有选法中,能挑选出来的最大价值。然而,由于背包容量限制,这种表示方法忽略了挑选出的物品的总体积,无法准确地进行状态转移。

因此,我们需要引入此时背包的容量信息。修改状态表示为dp[i][j],其中i表示前i个物品j表示当前背包的总体积 不超过j。这样,dp[i][j]就表示 ,从前i个物品中挑选,总体积不超过j时,所有选法中,能挑选出来的最大价值。

注意理解这里的"不超过"的含义 ,第一问中背包不一定装满,因此选出的物品的体积可以≤j

2、推导状态转移方程: 线性dp状态转移方程的分析,一般都是根据"最后一步"的状况分情况讨论。

本题中,根据是否选择第 i 个物品,来分情况讨论:

1)、如果我们不选择第 i 个物品 。此时需要在前 i-1 个物品中挑选,保证总体积不超过 j ,获得此时的最大价值。这种情况下,最大价值就是 dp[i-1][j]

2)、如果我们选择第 i 个物品 。由于第 i 个物品的体积为 v[i],在前 i-1 个物品中进行挑选时,要保证总体积不超过 j - v[i],获得这种情况下的最大价值,然后再加上当前第 i 个物品的价值w[i]即可。因此有 dp[i-1][j - v[i]] + w[i]

需要注意:这里j - v[i]可能为负数 ,也就是说,第2)种情况是否存在,有条件限制。

既然有两种选法,根据dp[i][j]的定义,需要选其中价值最大的情况,则需要对两种情况求一个max。得到背包问题的状态转移方程如下:

cpp 复制代码
dp[i][j] = dp[i-1][j];// 不选择 i 处物品
if(j >= v[i])// 选择 i 处物品,有前提条件
	dp[i][j] = max(dp[i][j], dp[i-1][j- v[i]] + w[i]);

3、初始化: 为了方便初始化,引入虚拟行列 。这里,由于是ACM模式,输入值需要我们自个创建变量存储,那么直接一步到位,让物品从 i = 1下标开始存储。 如此便省去了下标映射问题,只需要注意虚拟行列的填值(填表从 i = 1,j =1开始)。

PS:这里,也可以选择只引入虚拟行。那么填表时,从 i =1, j =0开始填表(两种写法,思路一致,细节有些差异而已)。

4、填表顺序: 根据状态方程,填表顺序仅需满足从上往下即可(这里左右不做要求)。

5、返回值: 根据状态表示,我们应该返回dp[n][V]的值,表示从 n 个物品中挑选,总体积不超过V时的最大价值。

9.1.1.2、思路分析(第二问:背包一定装满)

对于背包恰好装满的情况,可以根据第一问中状态表示和状态转移方程进行微调。需要注意,存在凑不出体积为 j 的情况,因此,这里我们把不合法的状态设置为-1

1、确定状态表示: dp[i][j],表示从前i个物品中挑选,总体积恰好为j时,所能获得的最大价值。

2、推导状态转移方程: 同样根据是否选择 i 位置的物品,分情况讨论:

1)、不选择第i个物品: 此时,dp[i][j] = dp[i-1][j],即从前i-1个物品中挑选,且总体积恰好为j时的最大价值。

①、注意这里要求总体积恰好为 j ,存在选不出的情况 。按理来说应该做一下判断:if( dp[i-1][j] != -1),但直接赋值含义也是正确的。在选不出来是,会把dp[i][j]赋值为 -1,这是可以的。因为前 i-1个物品选不出总体积恰好为 j 的情况 。在当前这种条件下(前 i 个物品,但不选择 i 处物品),自然还是保持选不出来的情况 ,那么dp[i][j]=-1是合理的。

2)、选择第i个物品: 此时,dp[i][j] = dp[i-1][j - v[i]] + w[i]。但这种情况有前提条件:

①、首先 j - v[i] >= 0(也就是 j >= v[i])。如果 j < v[i],说明不能选择第i个物品,因为背包无法容纳它。

②、与1)①同理,即使j - v[i] >= 0 坐标合法,仍旧存在dp[i-1][j - v[i]] = -1 选不出来的情况。但相比于1)①,这里一定要显示地判断一下:dp[i-1][j - v[i]] != -1 。这是因为状态转移方程的要求,dp[i][j] = max(dp[i-1][j], dp[i-1][j - v[i]] + w[i]),如果此处不做判断,在求 max 时,有可能因为dp[i-1][j - v[i]]加上了 w[i])后,导致结果为正数被选中,从而获得一个错误的结果。

cpp 复制代码
dp[i][j] = dp[i-1][j];// 不选择 i 处物品
if(j >= v[i] && dp[i-1][j-v[i]] != -1)// 选择 i 处物品,需要注意条件
	dp[i][j] = max(dp[i][j], dp[i-1][j-v[i]] + w[i]);

理解这里的状态方程,才能理解下面第二问中的初始化,为什么填入-1,就能和第一问区分开。

3、初始化:

4、填表顺序: 根据状态方程,填表顺序仅需满足从上往下即可(这里左右不做要求)。

5、返回值: 根据状态表示,我们应该返回dp[n][V]的值,表示从 n 个物品中挑选,总体积正好为 V 时的最大价值。注意,存在凑不出体积为 V的情况,最后返回时需要判断一下。

9.1.1.3、题解(未优化时)

根据上述分析,代码如下:

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

// 为什么使用全局变量?全局变量默认初始化为0,方便。
// 不会浪费空间吗?编程题,主要是解题,一星半点的空间可忽略。(如果是工程项目中,需要考虑)
const int N = 1010;// 题目给定数值也不大,直接开辟了。
int n;// 物品数列(i)
int V;// 背包体积(j)
int v[N],w[N];// 每行的物品体积、价值
int dp[N][N];// dp表
// 这些都可以写一行解决,这里是为了标注注释。

int main()
{
    // 1、根据题目,获取输入信息
    cin >> n >> V;//第一行输入:两个整数n和V,表示物品个数和背包体积。
    for(int i = 1; i <= n; ++i)// 接下来n行,每行两个数,表示第i个物品的体积和价值。
        cin >> v[i] >> w[i];

    // 2、第一问:背包不一定装满
    // 2.1、建表并初始化(已完成)
    // 2.2、填表:从上往下
    for(int i = 1; i <= n; ++i)
    {
        for(int j = 1; j <= V; ++j)
        {
            dp[i][j] = dp[i-1][j];// 不选择 i 处物品
            if(j >= v[i])// 选择 i 处物品,需要注意条件
                dp[i][j] = max(dp[i][j], dp[i-1][j- v[i]] + w[i]);
        }
    }
    // 2.3、输出
    cout << dp[n][V] << endl;

    // 3、第二问:背包一定装满
    // 3.1、建表并初始化
    memset(dp,0,sizeof dp);// 清空一下dp表,进行复用
    for(int j = 1; j <= V; ++j)
        dp[0][j] = -1;
    // 3.2、填表:
    for(int i = 1; i <= n; ++i)
    {
        for(int j = 1; j <= V; ++j)
        {
            dp[i][j] = dp[i-1][j];// 不选择 i 处物品
            if(j >= v[i] && dp[i-1][j-v[i]] != -1)// 选择 i 处物品,需要注意条件
                dp[i][j] = max(dp[i][j], dp[i-1][j-v[i]] + w[i]);
        }
    }
    // 3.3、输出:存在无解情况,需要判断
    cout << (dp[n][V] == -1 ? 0 : dp[n][V]) << endl;
    
    return 0;
}

9.1.2、引入优化(滚动数组)

利用滚动数组做空间优化,一维dp表中的滚动方式之前章节介绍过,这里来看二维dp表如何滚动。

dp[i][j]表示考虑前i个物品且背包容量为j时的最大价值。注意到在更新dp[i][j]时,我们仅依赖于dp[i-1][...]的值,即前一行(或前一个阶段)的状态。这意味着我们只需要在更新当前行的每个元素时,能够访问到前一行的对应元素即可

1)、原始优化策略

以下是最原始的滚动数组使用策略:

2)、改进版

实际上,我们可以直接只用一个数组做到滚动。

总结一下:

1、利用滚动数组做空间上的优化

2、直接在原始的代码上稍加修改:

①、删除所有的横坐标

②、修改纵坐标 j 的遍历顺序

代码如下:

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

const int N = 1010;// 题目给定数值也不大,直接开辟了。
int n;// 物品数列(i)
int V;// 背包体积(j)
int v[N], w[N]; // 每行的物品体积、价值
int dp[N];// dp表:优化后,只需要一维dp表即可

int main() {
    // 1、根据题目,获取输入信息
    cin >> n >> V;//第一行输入:两个整数n和V,表示物品个数和背包体积。
    for (int i = 1; i <= n; ++i) // 接下来n行,每行两个数,表示第i个物品的体积和价值。
        cin >> v[i] >> w[i];

    // 2、第一问:背包不一定装满
    // 2.1、建表并初始化(已完成)
    // 2.2、填表:
    for (int i = 1; i <= n; ++i) //从上往下填每一行
    {
        for (int j = V; j >= v[i]; --j) //加入优化后,每行需要从右往左
        {
            dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
        }
    }
    // 2.3、输出
    cout << dp[V] << endl;

    // 3、第二问:背包一定装满
    // 3.1、建表并初始化
    memset(dp, 0, sizeof dp); //清空一下dp表,进行复用
    for(int j = 1; j <= V; j++) dp[j] = -1;
    // 3.2、填表:
    for (int i = 1; i <= n; ++i) {
        for (int j = V; j >= v[i]; --j) {
            if (dp[j - v[i]] != -1)
                dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
        }
    }
    // 3.3、输出:存在无解情况,需要判断
    cout << (dp[V] == -1 ? 0 : dp[V]) << endl;

    return 0;
}

注意事项:

1、模板题里面的分析思路 ,可以用到很多题里面(我们要学的是这种思路过程 ,并非指模板题可以原模原样照搬不动)。

2、不要去强行去记忆解释优化后的状态表示以及状态转移方程。

9.2、分割等和子集(medium)

题源:链接

9.2.1、题解

1)、思路分析

分析此题, 如果数组能够被分成两个相同元素之和相同的子集,那么原数组有以下几个特征:

a. 总和为偶数: 数组 nums 的所有元素之和必须是一个偶数,因为两个子集的和要相等,所以总和必须是偶数才能被平均分配

b. 最大元素限制: 数组中的最大元素应该小于或等于所有元素总和的一半(这个条件不是严格必需的,但可以帮助我们快速排除一些不可能的情况,如果最大元素已经超过了总和的一半,那么显然无法分割成两个和相等的子集)。

因此,问题就转化为了在数组 nums 中选择一些元素 (每个元素只有一次选择),使得这些元素的和恰好等于数组总和的一半

往我们熟悉的题型转换,这里很容易就想到01背包问题。其中,数组内的元素就是物品重量,总和就是背包容量。

cpp 复制代码
a. 数组中的元素只能选择一次;
b. 每个元素⾯临被选择或者不被选择的处境;
c. 选出来的元素总和要等于所有元素总和的一半。(我们要判断的就是,这种背包装满的情况是否存在)

1、确定状态表示: 根据上述分析,dp[i][j]表示,在前 i 个元素中选择一些数,是否存在一种选法,使得这些数的和恰好为 j

换句话说,这是一个bool类型的dp表,dp表里只需要判断truefalse即可,而非像9.1中背包问题一样需要存储判断最大价值。

2、推导状态转移方程: 老规矩,根据最后一个元素,分情况讨论。(本题中,指是否选择最后一个元素)

1)、如果不选择最后一个元素 nums[i] 。我们就需要在前 i-1 个元素中选数,看看是否能凑出元素和 j 的情况。此时,状态转移方程为 dp[i][j] = dp[i-1][j]

2)、如果选择了最后一个元素 nums[i]。此时需要在前 i-1 个元素中选数,看看是否能凑出元素和为 j - nums[i] 的情况。注意,这种选法要成立,则要保证 j >= nums[i],因为背包的容量(即目标和)不能小于我们要放入的物品的重量(即 nums[i])。此时,状态转移方程为 dp[i][j] = dp[i-1][j - nums[i]]

综合上述两种情况,只要满足其中一种情况,dp[i][j] 的状态就是 true,表示存在一种选法使得前 i 个元素的和恰好为 j。如果两种情况都不满足,则 dp[i][j] 的状态为 false

cpp 复制代码
	dp[i][j] = dp[i - 1][j];
	if(nums[i - 1] <= j) 
		dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i]];

3、初始化: 这里,根据状态方程,为了防止填表时下标越界,可以引入虚拟行列/虚拟节点。需要注意两个事项:

①、下标映射关系(填表时注意)

②、虚拟节点填值要保证后续状态转移方程正确。

4、填表顺序: 根据状态方程,填表满足从上往下即可(左右不做要求)

5、返回值: 根据题目,返回dp[n][sum/2] 的值。

2)、题解

未优化版本:

cpp 复制代码
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;
        for(auto n : nums) sum += n;
        if(sum % 2) return false;// 奇数,无法平分成两个集合

        // 1、建表
        int m = nums.size();// dp表的i值
        int aim = sum /2;// dp表的j值
        vector<vector<bool>> dp(m+1,vector<bool>(aim+1, false));
        // 2、初始化:dp[i][0]第一列
        for(int i = 0; i <= m; ++i)
            dp[i][0] = true;
        // 3、填表:从上往下
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 1; j <= aim; ++j)
            {
                dp[i][j] = dp[i-1][j];// 不选 i 值
                if(j >= nums[i-1]) // 选 i 值:注意这里的下标映射
                    dp[i][j] = dp[i][j] || dp[i-1][j - nums[i-1]];// 满足其中一种情况即可(因此使用或等)
            }
        }
        // 4、返回
        return dp[m][aim];
    }
};

滚动数组优化版:

cpp 复制代码
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum = 0;
        for(auto n : nums) sum += n;
        if(sum % 2) return false;// 奇数,无法平分成两个集合

        // 1、建表
        int m = nums.size();// i 值
        int aim = sum /2;// j值
        vector<bool> dp(aim+1, false);
        // 2、初始化
        dp[0] = true;
        // 3、填表:从上往下,从右往左
        for(int i = 1; i <= m; ++i)
        {
            for(int j = aim; j >= nums[i-1]; --j)//注意这里的下标映射
                    dp[j] = dp[j] || dp[j - nums[i-1]];// 满足其中一种情况即可(因此使用或等)
        }
        // 4、返回
        return dp[aim];
    }
};

9.3、目标和(medium)

题源:链接

9.2.1、递归(DFS回溯剪枝)

相关链接

9.2.2、动态规划

9.2.2.1、解法一

1)、思路分析

分析此题,无非是将非负整数数组 nums 分成两个集合(设这两个集合元素和分别为ab),使得两个集合元素和的差为 target

如此,有:

cpp 复制代码
a - b = target (1)
a + b = sum (2)
将(1)、(2)式合并得, 2a = (target + sum)
					a = (target + sum) / 2

aim = (target + sum) / 2 ,那么,问题就转换成了,从非负整数数组 nums 中选数,使得这些元素和为 aim ,一共有多少种选法。有没有觉得很熟悉?这与 "9.2、分割等和子集" 的分析思路一致。

这里有几个关键的边界条件需要考虑: 其中,①②是使用这种动态规划必须考虑的边界情况,③是基于整个问题分析后的特殊情况。

①、aim 必须非负: 因为 aim ,也就是上述的集合a,代表的是我们想要找到的和(即子集的和)。根据题目给定的元素值,它显然不能是负数,如果我们计算得负数,说明是 target为负数且其绝对值小于sum和。

②、sum + target 必须为偶数: 这是因为我们需要找到一个整数 a, 使得 2a = sum + target。如果 sum + target 是奇数,那么就不存在这样的整数 a。也就不存在这样的选法。

③、target 的绝对值不能超过 sum ①中只考虑了这里的其中一种情况,实则我们可以对两种情况都做处理。因为 |target| > sum,那么不可能存在两个不相交的子集,它们的差正好是 target(除非允许子集为空集,但在本题中,sum.length至少为1)。

1、确定状态表示: 根据上述,dp[i][j]表示,在数组的前 i 个元素中选数,使得元素和正好为 j 时,一共存在多少种选法。

2、推导状态转移方程: 根据最后一个位置的元素,分情况讨论。(本题指,是否选择最后一个元素)

1)、如果不选择 nums[i],那么就需要从前 i-1 个元素元素中选数,使得选出的元素和恰好为 j。因此,dp[i][j] 至少应该等于 dp[i-1][j]

2)、如果选择了 nums[i],那么在前 i-1 个元素中选数时,只需要使得选出的元素和恰好为 j - nums[i-1] 即可。此使有 dp[i][j] =dp[i-1][j - nums[i]]。(这里需要注意两点 :①这种选法要成立,需要保证 j >= nums[i]。②总的方法种类,不会因为选了nums[i]就发生变化,需要理解这一点。

综上所述,两种情况如果存在的话,应该要累加在一起。因此,状态转移方程为:

cpp 复制代码
	dp[i][j] = dp[i - 1][j]
	if(nums[i - 1] <= j) 
		dp[i][j] = dp[i][j] += dp[i - 1][j - nums[i - 1]]

3、初始化: 引入虚拟行里,需要注意这里初始化的细节,注意学习理解为什么列可以不做处理。

4、填表顺序: 根据状态方程,这种不做优化处理时,满足从上往下填表即可。

5、返回值: 根据状态表示,返回dp[m][aim]。这里,m 指nums数组的元素个数,aim 指最初我们推导的那一个式子,(target + sum) / 2,也就是我们要凑出的目标和。

2)、题解

不做优化时的写法如下:

cpp 复制代码
class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int m = nums.size();// i下标
        int sum = 0;
        for(auto n :nums) sum +=n;
        if(sum < abs(target)) return 0;// 处理边界条件1:target目标值过大或过小
        int aim = (sum + target) / 2;// j 下标
        if(aim < 0 || (sum + target) % 2 ) return 0;// 处理边界条件2
 

        // 1、创建dp表并初始化
        vector<vector<int>> dp(m+1, vector<int>(aim+1, 0));
        dp[0][0] = 1;// 首列可以不单独拎出初始化,用状态转移方程获得

        // 2、填表
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 0;j <= aim; ++j)// 因为我们没有对j=0的情况初始化,因此j=0也需要使用状态转移方程填表获得
            {
                dp[i][j] += dp[i-1][j];
                if(j >= nums[i-1])// 注意下标映射关系
                    dp[i][j] += dp[i-1][j - nums[i-1]];
            }
        }

        // 3、返回
        return dp[m][aim];
    }
};

使用滚动数组做优化的写法如下: 在原先的代码基础上做修改即可得到。

①、删掉第一维;

②、修改第二层循环的遍历顺序:从右往左。

cpp 复制代码
class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int m = nums.size();
        int sum = 0;
        for(auto n :nums) sum += n;
        if(sum < abs(target)) return 0;// 处理边界条件1:target目标值过大或过小
        int aim = (sum + target) / 2;
        if(aim < 0 || (sum + target) % 2 ) return 0;// 处理边界条件2
 

        // 1、创建dp表并初始化
        vector<int> dp(aim+1, 0);
        dp[0] = 1;

        // 2、填表
        for(int i = 1; i <= m; ++i)
        {
            for(int j = aim;j >= nums[i-1]; --j)// 优化后,填表需要满足从右往左。
                dp[j] += dp[j - nums[i-1]];// 注意下标映射关系
        }

        // 3、返回
        return dp[aim];
    }
};

9.2.2.2、解法二

1)、思路分析

上述解法是通过数学公式转化,使得我们只用考虑其中一个子集a即可,以下是另一种思考方法,但相对来说注意细节较多。

1、确定状态表示: 定义一个二维数组 dp,其中 dp[i][j] 表示在前 i 个元素中选数,经过系列操作(每个元素前可以加 +-),使得它们的和等于 j 时,存在的选法 。

需要注意,dp[i][j]数组下标是从0开始的。给定的nums数组为非负数,其在进行+-操作时,总和可以在{-maxSum, +maxSum}之间。这就表明,代表元素和下标 j 有可能为负数,但负数是不能作为dp表数组下标的,这需要我们进行下标映射 。将所有可能的和映射到一个非负整数范围内,通常是通过加上一个足够大的数(比如数组元素绝对值之和 maxSum)来实现的。

也就是说,dp[i][j]的大小为(n+1) x (2*maxSum + 1)

cpp 复制代码
n+1 :给定的nums数组一共有 n 个元素。
2*maxSum + 1 : 需要处理从 -maxSum 到 maxSum 的所有和,并映射到非负整数索引上。-maxSum、0、maxSum,注意这里的列数。

2、推导状态转移方程: 仍旧以最后一个元素分情况讨论。对 i 位置的元素 nums[i],可以选择 +nums[i]-nums[i]
  dp[i][j]是用整数来表示有多少种方式可以达到这个和。因此,状态方程应该写成:

cpp 复制代码
dp[i][j + maxSum] += dp[i-1][j+ maxSum - nums[i-1] ]  // 选择+nums[i-1]
dp[i][j + maxSum] += dp[i-1][j+ maxSum + nums[i-1] ]  // 选择-nums[i-1]

// PS:可以用一个变量表示 j 映射后的下标,如此就方便了上述简写:
int j_idx = j + maxSum

3、初始化: 需要初始化dp[0][0] = 1,表示没有元素,元素和为 0,有且仅有一种方式(即不选择任何元素)。经过下标映射后为,dp[0][maxSum] = 1。

对其它dp[0][j]:不存在这种选法。

对列:无需引入虚拟列,因为状态转移方程做了越界判断。

4、填表顺序: 从上往下

5、返回值: 返回值是 dp[n][target + maxSum],其中 n 是数组的长度,target 是目标值,maxSum 是数组中所有元素绝对值之和(用于偏移和)。

2)、题解

cpp 复制代码
class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int n = nums.size(); // 数组 nums 的长度

        // 计算数组中所有元素的绝对值之和,这将是我们偏移的基础
        int maxSum = 0;
        for (int num : nums) {
            maxSum += std::abs(num);
        }

        if(maxSum < abs(target)) return 0;// 边界情况:直接返回

        // 1、创建一个二维动态规划数组 dp,大小为 (n+1) x (2*maxSum + 1)
        //    n+1 是因为我们考虑从 0 到 n-1 ,共 n 个元素的情况
        //    2*maxSum + 1 是因为我们需要处理从 -maxSum 到 maxSum 的所有和,并映射到非负整数索引上
        std::vector<std::vector<int>> dp(n + 1,vector<int>(2 * maxSum + 1, 0));

        // 2、初始化: dp[0][0] = 1,表示没有选择任何元素时,和为 0有且仅有一种方式(j = 0映射后变成了 maxSum)
        dp[0][0 + maxSum] = 1;

        // 3、填表:从上往下
        for (int i = 1; i <= n; ++i) 
        {
            int num = nums[i - 1]; // 注意dp表与原表的下标隐射关系

            for (int j = -maxSum; j <= maxSum; ++j)  // 遍历所有可能的元素和,j 可以在{-maxSum,+maxSum}之间
            {
                int j_idx = j + maxSum; // 因为 j 可能是负数 ,填dp表时需要进行下标偏移,使用 j + maxSum 作为 dp 数组的索引

                // 选择 +num 或 -num 来更新 dp[i][j]
                if(j_idx - num >=0 ) dp[i][j_idx] += dp[i-1][j_idx - num];// 选择 +num[i]
                if(j_idx + num <= 2*maxSum) dp[i][j_idx] += dp[i-1][j_idx + num];// 选择 -num[i]
            }
        }

        // 返回 dp[n][target+maxSum],即考虑所有 n 个元素时,和为 target(映射后变成了 target+maxSum)的方式数
        return dp[n][target + maxSum];
    }
};

9.4、最后一块石头的重量II

题源:链接

附:最后一块石头的重量(easy),使用优先级队列,题解链接

9.4.1、题解

1)、思路分析

先来分析题目:

由此,我们将石头(数组中的元素)分为两个集合,设集合的元素和分别为 a、b,且 a ≤ b。那么,我们所要求的就是两堆石头的重量差(b - a)最小。

这其实就是一道数学问题,a+b=sum,要是的b - a差值尽可能小,则a、b两数的值要尽可能接近,极端情况下,有 a = b = sum / 2。于是问题就转换成了:在数组中选择一些数,让这些数的和尽量接近sum / 2

如果把数看成物品,每个数的值看成体积和价值,这就变成了01背包问题: 设定背包的容量为sum / 2,在不超过背包容量的情况下,选择哪些石头(物品)能使得背包的总重量(即所选石头的总重量)最大。

1、确定状态表示: 根据上述分析,dp[i][j]表示从前 i 个数中选数,总和不超过 j 时的元素最大和。

2、推导状态转移方程: 通常根据"最后一个位置"的元素分情况讨论。本题为,是否选择最后一个元素 stones[[i] 时,总和不超过 j 时的元素最大和。

1)、不选择 stones[[i] ,此时,需要在前 i-1 个元素中选数,求使得总和不超过 j 时的最大和。因此有 dp[i][j] = dp[i-1][j]

2)、选择 stones[[i],此时,只能从前 i - 1 个数中选数,看是否能凑成总和不超过 j - stones[[i]。因此有 dp[i][j] = dp[i - 1][j - stones[i]] + stones[i](需要注意,这里有条件,那就是 stones[i] ≤ j

综上,我们需要的是最大和:

cpp 复制代码
dp[i][j] = dp[i - 1][j];
if(j >= stones[i]) 
 	dp[i][j] = dp[i][j] + dp[i - 1][j - stones[i]] + stones[i];

3、初始化: 引入了虚拟行列,在9.2.2.1中我们曾讲过,列是无需初始化的,因为状态转移方程中已经做出了判断,保证填表不会发生越界。对于虚拟行,dp[0][j]i = 0时表示没有元素时选数,使得元素和不超过 j,那么此时怎么选, 最大和都是0。

4、填表顺序: 根据状态转移方程,从上往下填表。

5、返回值: 根据状态表示,先找到最接近sum / 2的最大和dp[n][sum / 2] (这个值是不会超过sum/2 的)。由于我们要的是两堆石子的差,因此返回sum - 2*dp[n][sum / 2]

2)、题解

不做优化的写法:

cpp 复制代码
class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        int n = stones.size();
        int sum = 0;
        for(auto num : stones) sum += num;
        int aim = sum / 2;

        // 1、创建dp表并初始化
        vector<vector<int>> dp(n+1,vector<int>(aim+1,0));

        // 2、填表:从上往下
        for(int i = 1; i <= n; ++i)
        {
            for(int j = 0; j <= aim; ++j)//j = 0我们没有特殊处理,因此需要从j = 0开始
            {
                dp[i][j] = dp[i-1][j];
                if(j >= stones[i-1])// 注意下标映射
                    dp[i][j] = max(dp[i][j], dp[i-1][j - stones[i-1]] + stones[i-1]);
            }
        }

        // 3、返回
        return sum - 2*dp[n][aim];
    }
};

滚动数组优化版本:

cpp 复制代码
class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        int n = stones.size();
        int sum = 0;
        for(auto num : stones) sum += num;
        int aim = sum / 2;

        // 1、创建dp表并初始化
       vector<int> dp(aim+1,0);

        // 2、填表:从上往下
        for(int i = 1; i <= n; ++i)
        {
            for(int j = aim; j >= stones[i-1]; --j)// 注意下标映射
            {
                dp[j] = max(dp[j], dp[j - stones[i-1]] + stones[i-1]);
            }
        }

        // 3、返回
        return sum - 2*dp[aim];
    }
};

10、完全背包问题

10.1、完全背包(medium)

题源:链接

10.1.1、题解

完全背包等各种背包问题,都是建立在01背包的基础上 的,它们的思考分析方式大同小异。

与之区别的是物品数量限制

cpp 复制代码
01背包:每种物品只有一个。要么选,要么不选。
完全背包:每种物品有无限个。理论上可以选0、1、2、......、任意次数(实际会受到背包容量限制)

10.1.1.1、思路分析(第一问:背包不一定装满)

1)、思路分析

1、确定状态表示: 与01背包同,dp[i][j]表示 ,从前i个物品中挑选,总体积不超过j时,所有选法中,能挑选出来的最大价值。

2、推导状态转移方程: 线性dp的状态转移方程,一般都是根据最后一步的状况,来分情况讨论。根据本题是完全背包问题, 每种物品可以选任意次。对最后一个物品同理,可以有很多种选法,因此我们的需要分很多情况:

对最后一处位置的物品 i,其价值为 w[i],体积为 v[i]

1)、选 0 个。此时需要在前 i - 1 个物品中挑选,获得总体积不超过 j 时的最大价值。即:dp[i-1][j]

2)、选 1 个。此时需要在前 i - 1 个物品中挑选,获得总体积不超过 j - v[i]时的最大价值,然后再加上当前挑选出的1i 物品的价值总量。即:dp[i-1][j - v[i]] + w[i]

3)、选 2 个。此时需要在前 i - 1 个物品中挑选,获得总体积不超过 j - 2*v[i]时的最大价值,然后再加上当前挑选出的2i 物品的价值总量。即:dp[i-1][j - 2*v[i]] + 2*w[i]

4)、依此类推。选 k 个。此时需要在前 i - 1 个物品中挑选,获得总体积不超过 j - k*v[i]时的最大价值,然后再加上当前挑选出的ki 物品的价值总量。即:dp[i-1][j - k*v[i]] + k*w[i]

由此可得:

cpp 复制代码
(1)式:
	dp[i][j] = max(dp[i-1][j], dp[i-1][j - v[i]]+ w[i], dp[i-1][j - 2*v[i]] + 2*w[i] ,  ...... , dp[i-1][j - k*v[i]] + k*w[i])
其中,k*v[i] <= j,不得超过背包容量。

可以发现,计算某一个状态时(dp[i][j]),需要一个循环才能搞定。自然,我们会想到能否优化掉这个循环,用一个或者两个状态来表示这一堆的状态。

根据先前的经验(通配符匹配正则表达式匹配),通常就是用数学的方式做一下等价替换 。 观察发现,第二维是有规律的变化的,因此让 j = j - v[i],代入原公式,看看dp[i][j - v[i]] 这个状态(PS:注意理解这里,如何选择是公式替换的基础)。

cpp 复制代码
(2)式:
	dp[i][j-v[i]] = max(dp[i-1][j-v[i]], dp[i-1][j - 2*v[i]] + w[i], dp[i-1][j - 3*v[i]] + 2*w[i], ......, dp[i-1][j - (k+1)*v[i]] + k*w[i])
其中,(k+1)*v[i]<= j,不得超过背包容量。

观察比较会发现,把(2)式中的dp[i][j-v[i]]左右两边同时加上w[i] ,正好和(2)式dp[i][j]中,除了第一项以外的全部一致:

cpp 复制代码
dp[i][j-v[i]] + w[i] = max(dp[i-1][j-v[i]] + w[i], dp[i-1][j - 2*v[i]] + 2*w[i], dp[i-1][j - 3*v[i]] + 3*w[i], ...... , dp[i-1][j - (k+1)*v[i]] + (k+1)*w[i])

因此我们可以修改原先的状态转移方程为:

cpp 复制代码
dp[i][j] = max(dp[i-1][j], dp[i][j-v[i]] + w[i])

//引入条件限制后:j-v[i] >= 0
dp[i][j] = dp[i-1][j]
if(j >= v[i])
	dp[i][j] = max(dp[i][j], dp[i][j-v[i]] + w[i])

3、初始化: 初始化是为了防止填表时下标越界。与先前一样,引入虚拟行列。由于我们做了判断,虚拟列dp[i][0]无需特意拎出初始化,可以在填表时直接使用状态方程。对虚拟行dp[0][j],表示不选择任何物品时,体积不超过 j 时的最大价值,当然就是 0。

4、填表顺序: 根据状态转移方程,从上到下填每一行,每一行从左到右。

5、返回值: 根据我们的状态表示,需要返回dp[n][V],表示从 n 个物品中挑选,总体积不超过V时的最大价值。

10.1.1.2、思路分析(第二问:背包一定装满)

1)、思路分析

思路与背包不一定装满时完全一致,只是在细节需要注意。

1、确定状态表示: dp[i][j],表示从前i个物品中挑选,总体积恰好为 j时,所能获得的最大价值。

注意事项:

①、强调:要求背包的总体积必须严格等于j ,不允许有多余的空间。

②、在实际操作中,可能会遇到无法用前i个物品组合出总体积为j的情况 。为了应对这种情况,我们特别规定:当无法凑出体积为j的组合时,将对应的状态dp[i][j]设置为-1。(需要注意,这里的规定是我们自己设置的,其值不一定要是-1,你也可以用INT_MIN-0x3f3f3f3f等来表示。这些细节规定,会影响到状态方程的细节表示 。)

③既然可以自己规定,不存在的情况也可以设置为0吗? 回答:不可以 。因为 0在背包问题中具有特定的含义 。在背包问题中,0通常代表不选择任何物品时的价值,即当背包为空或没有物品可选时,所能获得的最大价值自然为0。因此,如果我们将不可行状态也设置为0,就会与这种"不选物品"的合法状态产生混淆 ,从而导致问题的复杂化。

2、推导状态转移方程:

①、与"背包不一定装满"时的情况一致。但因为我们规定了不存在的状况为-1,这里dp[i][j-v[i]] + w[i] 运算后有可能为正数,导致max求值选择了非法情况。因此需要做判断:dp[i][j-v[i]] != -1

cpp 复制代码
dp[i][j] = dp[i-1][j]
if(j >= v[i] && dp[i][j-v[i]] != -1)
	dp[i][j] = max(dp[i][j], dp[i][j-v[i]] + w[i])

②、问题:不做判断可以吗?

回答:当然可以,只要保证在max求值时,不会选中非负情况即可。那么我们可以规定凑不出体积的状态为INT_MIN-0x3f3f3f3f等,这些值远超于题目给定的数值。这种设置下,就不用特别做判断处理。

cpp 复制代码
dp[i][j] = dp[i-1][j]
if(j >= v[i])
	dp[i][j] = max(dp[i][j], dp[i][j-v[i]] + w[i])

3、初始化: 列不用初始化,对于行dp[0][0] = 0dp[0][j] = -1

4、填表顺序: 根据状态转移方程,从上到下填每一行,每一行从左到右。

5、返回值: 因为存在凑不出的情况,所以需要对dp[n][V]判断一下。

10.1.1.3、题解

第二问中,不存在的情况设为 -1的写法:注意条件判断与返回值。

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

// 使用全局变量:默认初始化为0
const int N = 1010;//数值上限
int n,V;// 物品个数、背包体积
int v[N],w[N];// 物品的体积、价值
int dp[N][N];

int main()
{
    // 1、将数据输入
    cin >> n >> V;
    for(int i = 1; i <= n; ++i)
        cin >> v[i] >> w[i];

    // 2、解决第一问
    // 2.1、创建dp表并初始化(已完成)
    // 2.2、填表:从上到下,从左到右
    for(int i = 1; i <= n; ++i)
    {
        for(int j = 0; j <= V; ++j)
        {
            dp[i][j] = dp[i-1][j];
            if(j >= v[i])
                dp[i][j] = max(dp[i][j],dp[i][j - v[i]]+w[i]);
        }
    }
    // 2.3、返回(输出结果)
    cout << dp[n][V] << endl;


    // 3、解决第二问
    // 3.1、创建dp表并初始化:这里设不存在的情况为-1
    memset(dp,0,sizeof dp);
    for(int j = 1; j <= V; ++j)
        dp[0][j] = -1;

    // 3.2、填表:从上往下,从左到右
    for(int i = 1; i <= n; ++i)
    {
        for(int j = 0; j <= V; ++j)
        {
            dp[i][j] = dp[i-1][j];
            if(j >= v[i] && dp[i][j- v[i]]!= -1)
                dp[i][j] = max(dp[i][j],dp[i][j- v[i]] + w[i]);
        }
    }
    // 3.3、返回
    cout << ((dp[n][V] == -1) ? 0: dp[n][V]) << endl;

    return 0;
}

第二问中,不存在的情况设为-0x3f3f3f的写法:注意条件判断与返回值。

cpp 复制代码
    // 3、解决第二问
    // 3.1、创建dp表并初始化:这里设不存在的情况为-0x3f3f3f3f
    memset(dp,0,sizeof dp);
    for(int j = 1; j <= V; ++j)
        dp[0][j] = -0x3f3f3f3f;

    // 3.2、填表:从上往下,从左到右
    for(int i = 1; i <= n; ++i)
    {
        for(int j = 0; j <= V; ++j)
        {
            dp[i][j] = dp[i-1][j];
            if(j >= v[i])
                dp[i][j] = max(dp[i][j],dp[i][j- v[i]] + w[i]);
        }
    }
    // 3.3、返回
    cout << ((dp[n][V] < 0) ? 0: dp[n][V]) << endl;

10.1.2、优化(滚动数组)

注意区别:使用滚动数组做优化,在01背包中,填表要求从右到左。在完全背包中,填表仍旧保持从左到右的顺序不变。

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;

// 使用全局变量:默认初始化为0
const int N = 1010;//数值上限
int n,V;// 物品个数、背包体积
int v[N],w[N];// 物品的体积、价值
int dp[N];

int main()
{
    // 1、将数据输入
    cin >> n >> V;
    for(int i = 1; i <= n; ++i)
        cin >> v[i] >> w[i];

    // 2、解决第一问
    // 2.1、创建dp表并初始化(已完成)
    // 2.2、填表:从上到下,从左到右
    for(int i = 1; i <= n; ++i)
    {
        for(int j = v[i]; j <= V; ++j)
            dp[j] = max(dp[j],dp[j - v[i]]+w[i]);
    }
    // 2.3、返回(输出结果)
    cout << dp[V] << endl;


    // 3、解决第二问
    // 3.1、创建dp表并初始化:这里设不存在的情况为-1
    memset(dp,0,sizeof dp);
    for(int j = 1; j <= V; ++j)
        dp[j] = -1;

    // 3.2、填表:从上往下,从左到右
    for(int i = 1; i <= n; ++i)
    {
        for(int j = v[i]; j <= V; ++j)
        {
            if(dp[j- v[i]]!= -1)
                dp[j] = max(dp[j],dp[j- v[i]] + w[i]);
        }
    }
    // 3.3、返回
    cout << ((dp[V] == -1) ? 0: dp[V]) << endl;

    return 0;
}

10.2、零钱兑换(medium)

题源:链接

10.2.1、题解

1)、思路分析

分析此题,可以发现它能转换为完全背包问题:

cpp 复制代码
物品(硬币):coins 数组中的每个元素。
背包容量(总金额):amount。
目标:找到"最少的物品(硬币)数量",使得总金额"恰好为" amount。

那么,就是完全背包问题中,背包一定装满的情况。我们可以借助10.1中的分析思路。

1、确定状态表示: dp[i][j],表示在前 i 枚硬币(元素)中挑选,使其总金额恰好为 j 时,所需要的最小硬币数量。

需要注意,存在凑不出总金额恰为 j 的情况 ,因为这里要求的是最小值,我们规定 dp[i][j]不存在时,设为0x3f3f3f

2、推导状态转移方程: 线性dp,一般根据"最后一步"的状况,来分情况讨论。在本题中,由于最后一处位置的硬币不限选择次数,因此存在多种情况:

1)、对最后一枚硬币,选 0 个。此时需要在前 i - 1 枚硬币中挑选,获得总金额恰好为 j 时的最小硬币数量。即:dp[i-1][j]

2)、对最后一枚硬币,选 1 个,金额为coins[i]。此时需要在前 i - 1 枚硬币中挑选,获得总金额恰好为 j - coins[i]时的最小硬币数量,然后再加上当前 i 位置挑选出的硬币总数。则有:dp[i-1][j - coins[i]] + 1

3)、依此类推。对最后一枚硬币,选 k 个,金额为k*coins[i]。此时需要在前 i - 1 枚硬币中挑选,获得总金额恰好为 j - k*coins[i]时的最小硬币数量,然后再加上当前 i 位置挑选出的硬币总数。即:dp[i-1][j - k*coins[i]] + k

由于dp[i][j]要选金额最小的情况,则有:

cpp 复制代码
dp[i][j] = min(dp[i-1][j], dp[i-1][j - coins[i]] + 1, dp[i-1][j - 2*coins[i]] + 2, dp[i-1][j - k*coins[i]] + k)

j = j - coins[i],代入上式得:

cpp 复制代码
dp[i][j- coins[i]] = min(dp[i-1][j- coins[i]], dp[i-1][j - 2*coins[i]] + 1, dp[i-1][j - 3*coins[i]] + 2, dp[i-1][j - (k+1)*coins[i]] + k)

在等式左右两边+1,得:

cpp 复制代码
dp[i][j- coins[i]] + 1 = min(dp[i-1][j- coins[i]] + 1, dp[i-1][j - 2*coins[i]] + 2, dp[i-1][j - 3*coins[i]] + 3, dp[i-1][j - (k+1)*coins[i]] + (k+1))

由此,整理可得:

cpp 复制代码
dp[i][j] = min(dp[i-1][j],dp[i][j- coins[i]] + 1)

//加入有条件限制:
dp[i][j] = dp[i-1][j];
if(j >= coins[i])
	dp[i][j] = min(dp[i][j], dp[i][j - coins[i]] + 1);

3、初始化: 根据状态方程,引入虚拟行列初始化,需要注意两点。

1)、下标映射关系(原coins表,dp表)

2)、虚拟行列的初始化要保证使用状态转移方程时,填表正确。①根据之前的经验,虚拟列不用初始化。②虚拟行dp[0][j],表示没有硬币时,要是的总金额为 j 时的最少硬币数目。只有 dp[0][0] = 0成立,i = 0时,其它 j > 0 的情况,均不存在这种选法,可设为正无穷(0x3f3f3f)。

4、填表顺序: 根据状态转移方程,从上往下,从左往右。

5、返回值: 返回dp[n][amount]

2)、题解

不做优化的写法:

cpp 复制代码
class Solution {
    const int default_max = 0x3f3f3f;

public:
    int coinChange(vector<int>& coins, int amount) {
        int n = coins.size();
        // 1、创建dp表:i物品, j总金额
        vector<vector<int>> dp(n + 1, vector<int>(amount + 1, 0));
        // 2、初始化
        for (int j = 1; j <= amount; ++j)
            dp[0][j] = default_max;
        // 3、填表:从上往下,从左往右
        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);
            }
        }
        // 4、返回
        return (dp[n][amount] >= default_max) ? -1 : dp[n][amount];
    }
};

使用滚动数组优化的写法:

cpp 复制代码
class Solution {
    const int default_max = 0x3f3f3f;

public:
    int coinChange(vector<int>& coins, int amount) {
        int n = coins.size();
        // 1、创建dp表并初始化:优化为一维数组
        vector<int> dp(vector<int>(amount + 1, default_max));
        dp[0] = 0;//少循环一次

        // 2、填表:从上往下,从左往右
        for (int i = 1; i <= n; ++i) {
            for (int j = coins[i - 1]; j <= amount; ++j)
            {
                dp[j] = min(dp[j], dp[j - coins[i-1]] + 1);//注意映射关系
            }
        }
        // 3、返回
        return (dp[amount] >= default_max) ? -1 : dp[amount];
    }
};

10.3、零钱兑换II(medium)

题源:链接

10.3.1、题解

1)、思路分析

此题和10.2思路一样,同属于完全背包问题。只不过10.2中我们求的是 "凑出目标金额时的最小硬币数",这里求的是 "凑出目标金额时,存在多少种选法/凑法"。

1、确定状态表示: dp[i][j],表示在前 i 枚硬币(元素)中挑选,使其总金额恰好为 j 时,一共存在多少种选法。

需要注意,存在凑不出总金额恰为 j 的情况 ,那么干脆把 选法设为0 即可。

2、推导状态转移方程: 线性dp,一般根据"最后一步"的状况,来分情况讨论。在本题中,由于最后一处位置的硬币不限选择次数,因此存在多种情况:

1)、对最后一枚硬币,选 0 个。此时需要在前 i - 1 枚硬币中挑选,看看总金额恰好为 j 时,一共存在多少种选法。则有:dp[i-1][j]

2)、对最后一枚硬币,选 1 个。此时需要在前 i - 1 枚硬币中挑选,看看总金额恰好为 j - coins[i]时,一共存在多少种选法。则有:dp[i-1][j - coins[i]] (注意理解,选法总数不变)

3)、依此类推。对最后一枚硬币,选 k 个。此时需要在前 i - 1 枚硬币中挑选,看看总金额恰好为 j - k*coins[i]时,一共存在多少种选法。则有:dp[i-1][j - k*coins[i]]

dp[i][j]处存在的选法,等于上述所有选法累加:

cpp 复制代码
dp[i][j] = dp[i-1][j] + dp[i-1][j-coins[i]] + dp[i-1][j-2*coins[i]] + ...... 

j = j - coins[i],代入上式得:

cpp 复制代码
dp[i][j-coins[i]] = dp[i-1][j-coins[i]]+ dp[i-1][j - 2*coins[i]] + dp[i-1][j-3*coins[i]]...... 

由此,整理可得:

cpp 复制代码
dp[i][j] = dp[i-1][j]+ dp[i][j- coins[i]]

//加入有条件限制:
dp[i][j] = dp[i-1][j];
if(j >= coins[i])
	dp[i][j] += dp[i][j - coins[i]];

3、初始化: 根据状态方程,引入虚拟行列初始化,需要注意两点。

1)、下标映射关系(原coins表,dp表)

2)、虚拟行列的初始化要保证使用状态转移方程时,填表正确。

4、填表顺序: 根据状态转移方程,从上往下,从左往右。

5、返回值: 返回dp[n][amount] 。但是要特判⼀下,因为有可能凑不到目标金额。

2)、题解

不做优化的写法:

cpp 复制代码
class Solution {
public:
    int change(int amount, vector<int>& coins) {
        int n = coins.size();
        // 1、创建dp表并初始化
        vector<vector<unsigned long long>> dp(n+1,vector<unsigned long long>(amount+1,0));
        dp[0][0] = 1;// 没有硬币,总金额恰好为 0 时,存在1种选法(就是什么都不选)
        // 2、填表:从上往下,从左往右
        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]];
            }
        }
        // 3、返回
        return dp[n][amount];
    }
};

使用滚动数组优化的写法:

cpp 复制代码
class Solution {
public:
    int change(int amount, vector<int>& coins) {
        int n = coins.size();
        
        // 1、创建dp表并初始化
        vector<unsigned long long> dp(amount+1,0);
        dp[0] = 1;

        // 2、填表:从上往下,从左往右
        for(int i = 1; i <= n; ++i)
        {
            for(int j = coins[i-1]; j <= amount; ++j)
                 dp[j] += dp[j - coins[i-1]];
        }
        // 3、返回
        return dp[amount];
    }
};

优化后也可以直接使用范围for:

cpp 复制代码
class Solution {
public:
    int change(int amount, vector<int>& coins) {

        // 1、创建dp表并初始化
        vector<unsigned long long> dp(amount+1,0);
        dp[0] = 1;

        // 2、填表:
        for(auto x : coins)
        {
            for(int j = x; j <= amount; ++j)
                dp[j] += dp[j - x];
        }
        // 3、返回
        return dp[amount];
    }
};

10.4、完全平方数(medium)

题源:链接

10.4.1、题解

1)、思路分析

分析题目,题目要求我们从一堆完全平方数:{ 1 2 1^2 12、 2 2 2^2 22、 3 2 3^2 32、 ... ... ...... ......} 中选数,每个数不限选择次数,求选数的数和恰好为 n 时,所需要的最小的元素个数。

这就是完全背包问题,且背包一定要装满的情况。

细节分析:

1)、由于有 1 2 1^2 12的存在,在允许同一个数被选择多次的情况下,题目保证有解。

2)、实际上,我们只需要考虑那些 i 2 ≤ n i^2 ≤ n i2≤n 的整数即可。这些整数对应的平方数就是我们的物品集合。

1、确定状态表示: dp[i][j],表示从前 i 个完全平方数中挑选,使得总和恰好为 j 时,所有选法中,"元素个数最小"的情况。

2、推导状态转移方程: 一般根据"最后一步"的状况,来分情况讨论。在本题中,根据选择 i 2 i^2 i2不限选择次数,因此存在多种情况:

1)、选 0 个 i 2 i^2 i2。此时需要在前 i - 1 个数中挑选,获得总和恰好为 j 时的最小元素数量。即:dp[i-1][j]

2)、选 1 个 i 2 i^2 i2。此时需要在前 i - 1 个数中挑选,获得总和恰好为 j - i*i时的最小元素数量,然后再加上当前 i 位置选择的 i 2 i^2 i2 的总数。则有:dp[i-1][j - i*i] + 1

3)、依此类推。选 k 个 i 2 i^2 i2。此时需要在前 i - 1 个数中挑选,获得总和恰好为 j - k* i*i时的最小元素数量,然后再加上当前 i 位置选择的 i 2 i^2 i2 的总数。即:dp[i-1][j - k*i*i]] + k

在多种情况中,选择其中数量最小的情况,由此得:

cpp 复制代码
dp[i][j] = min(dp[i-1][j],dp[i][j -  i*i] + 1)//注意条件

PS:实际上这里仅需将选 1 个 的情况,dp[i-1][j - i*i] + 1 中横坐标i-1改为i即可,简单记一下可以不用一步步推导,但仅适用于完全背包的问题。如果不能确定,那就用公式推导验证。

3、初始化: 列无需单独拎出初始化,对行,dp[0][j],仅当dp[0][0] = 0成立,表示不存在元素时,总和 j 为0,此时有一种选法,那就是不选任何数,因此最小数量为0。其它 j > 0 的情况,不存在,为了防止min求值选择错误情况,可将不存在的情况初始化为0x3f3f3f

4、填表顺序: 根据状态方程,从上往下,从左往右。

5、返回值: 根据之前分析,返回 d p [ n ] [ n ] dp[\sqrt{n}][n] dp[n ][n]

2)、题解

不做优化的写法:

cpp 复制代码
class Solution {
    int defmax = 0x3f3f3f;
public:
    int numSquares(int n) {
        int m = sqrt(n);
        // 1、建表并初始化
        vector<vector<int>> dp(m+1,vector<int>(n+1,0));
        for(int j = 1; j <= n; ++j)
            dp[0][j] = defmax;
        // 2、填表
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 0; j <= n; ++j)
            {
                dp[i][j] = dp[i-1][j];
                if(j >= i*i)
                    dp[i][j] = min(dp[i][j],dp[i][j - i*i]+1);

            }
        }
        // 3、返回
        return dp[m][n];
    }
};

使用滚动数组优化:

cpp 复制代码
class Solution {
    int defmax = 0x3f3f3f;
public:
    int numSquares(int n) {
        int m = sqrt(n);
        // 1、建表并初始化
        vector<int> dp(n+1,defmax);
        dp[0] = 0;
        // 2、填表
        for(int i = 1; i <= m; ++i)
        {
            for(int j = i*i; j <= n; ++j)
                dp[j] = min(dp[j],dp[j - i*i]+1);
        }
        // 3、返回
        return dp[n];
    }
};

11、二维费用的背包问题

11.0、概述

二维费用的背包问题是01背包问题的一种变体 ,也是一类经典的组合优化问题。在这个问题中,每件物品具有两个不同的属性,通常被称为"费用"或"资源限制",以及一个价值 。问题的目标是在给定的两种资源限制下,选择一组物品,使得它们的总价值最大。

一、问题描述

设有N件物品,每件物品有两种不同的费用(或资源限制),分别记为a[i]b[i](或v[i]u[i]),以及一个价值w[i]。同时,有两种费用(或资源)可付出的最大值(背包容量),分别记为V和U。要求确定选择哪些物品放入背包,使得在不超过这两种费用限制的情况下,背包中物品的总价值最大。

二、解题思路

状态定义:dp[i][j][k]表示前i件物品在两种费用分别为jk的情况下可获得的最大价值。

初始状态为dp[0][0][0]=0,表示没有物品时,背包的价值为0

状态转移方程: 对于第i件物品,有两种选择:放入背包或不放入背包。

1)、如果放入背包,则dp[i][j][k] = dp[i-1][j-a[i]][k-b[i]] + w[i](当j >= a[i]k >= b[i]时)。

2)、如果不放入背包,则dp[i][j][k] = dp[i-1][j][k]

因此,状态转移方程为:

cpp 复制代码
dp[i][j][k] = max(dp[i-1][j][k], dp[i-1][j-a[i]][k-b[i]] + w[i]) //后者有限定条件:j >= a[i]且k >= b[i]

边界条件:i=0时,dp[0][j][k] = 0,表示没有物品时的背包价值。

优化: 由于dp[i][j][k]只与dp[i-1][...]有关,因此可以使用滚动数组优化空间复杂度,将三维数组降为二维数组。

11.1、一和零(medium)

题源:链接

11.1.1、题解

1)、思路分析

分析题目,将问题转化成我们熟悉的题型。

1)、在一些物品中"挑选"一些出来,然后在满足某个"限定条件" 下,解决一些问题,大概率是背包模型;

题目要求在给定的二进制字符串数组 strs 中,找出一个最大子集,该子集中最多有 m 个 0 和 n 个 1。这个问题可以转化为在有限制条件(即最多 m 个 0 和 n 个 1)下,从一系列物品(即字符串)中选择一些物品,使得选择的物品数量最大化。

2)、由于每个字符串(或物品)只能被选择一次,因此这是一个01背包问题。

但是,与传统的01背包问题不同,本题有两个限制条件:最多 m 个 0 和 n 个 1。因此,它是一个二维费用的01背包问题。那么我们定义状态表示的时候,来一个三维dp表,把第二个限制条件加上即可。

1、确定状态表示: dp[i][j][k],表示从前 i 个字符串中挑选,字符 0 的个数不超过j,字符1的个数不超过k,所有的选法中,最大的长度。

注意理解题目中的"子集长度",指的是选出的子集的个数,也就是字符串的个数,而非字符长度。

cpp 复制代码
输入:strs = ["10", "0001", "111001", "1", "0"], m = 5, n = 3
输出:4
解释:最大子集是 {"10","0001","1","0"} ,因此最大子集的长度就是 4 。

2、推导状态转移方程: 线性dp的状态转移方程分析方式,一般都是根据"最后一步"的状况,分情况讨论。这里,我们记第 i 个字符中,字符0的个数为a,字符1的个数为b

1)、不选第 i 个字符串。此时,需要去前 i-1个字符串中挑选,保证字符 0 的个数不超过 j,字符 1 的个数不超过k,获得此时的最大长度。因此有:dp[i][j][k] = dp[i -1][j][k] ;

2)、选择第 i 个字符串。先前我们设过第 i 个字符串的01字符个数分别为 ab,因此在选择第 i 个字符串的情况下,仅需在前 i-1 个字符串里面,挑选出来字符 0 的个数不超过j-a,字符 1 的个数不超过k-b的最长长度,然后在这个长度后面加上字符串i的即可。此时dp[i][j][k]=dp[i-1][j-a][k-b]+1

注意,这种状态不一定存在,因此需要特判一下:j >= a && k >= b

题目求的是最大子集长度,因此我们需要选两种情况下的最大值,状态转移方程为:

cpp 复制代码
dp[i][j][k] = max(dp[i - 1][j][k], dp[i - 1][j - a][k - b] + 1);

3、初始化: i = 0时,表示没有字符串时,要挑选出 "字符 0 的个数不超过 j,字符1的个数不超过 k"的子集,自然只有空集,子集长度当然为0。

4、填表顺序: 根据状态转移方程,填写 i 面的值时,仅需用到 i-1 面的值,因此保证第一维的从小到大即可。

5、返回值: 根据状态表示,返回dp[len][m][n]。其中,len表示字符串数组的长度。

2)、题解

不做优化的写法:

cpp 复制代码
class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        int len = strs.size();
        // 1、建表并初始化
        vector<vector<vector<int>>> dp(len+1,vector<vector<int>>(m+1,vector<int>(n+1,0)));

        // 2、填表
        for(int i = 1; i <= len; ++i)
        {
            // 统计当前 strs[i-1](映射)字符串中的0和1的个数
            int a = 0,b = 0;
            for(auto ch : strs[i-1])
            {
                if(ch == '0') a++;
                else b++;
            }

            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 >= a && k >= b)
                        dp[i][j][k] = max(dp[i][j][k],dp[i-1][j-a][k-b]+1);
                }
            }
        }

        // 3、返回
        return dp[len][m][n];
    }
};

优化为二维的写法:与01背包一致,①删掉第⼀维;②修改第⼆层以及第三层循环的遍历顺序即可。(原先不做要求,优化后要求从大到小遍历)

cpp 复制代码
class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        int len = strs.size();
        // 1、建表并初始化
        vector<vector<int>> dp(m+1,vector<int>(n+1,0));

        // 2、填表
        for(int i = 1; i <= len; ++i)
        {
            // 统计当前 strs[i-1](映射)字符串中的0和1的个数
            int a = 0,b = 0;
            for(auto ch : strs[i-1])
            {
                if(ch == '0') a++;
                else b++;
            }

            for(int j = m; j >= a; --j)
            {
                for(int k = n; k >= b; --k)
                {
                    dp[j][k] = max(dp[j][k],dp[j-a][k-b]+1);
                }
            }
        }

        // 3、返回
        return dp[m][n];
    }
};

11.2、盈利计划(hard)

题源:链接

11.2.1、题解

1)、思路分析

分析题意,题目要求我们从给定的工作中选择若干项,每项工作有对应的需求人数(group[i])和产生的利润(profit[i])。我们的目标是找出所有满足以下条件的计划的总数:

cpp 复制代码
成员总数限制:参与工作的员工总数不超过 n。
利润下限:所有选定的工作产生的总利润不少于 minProfit。

由于每个工作只能被选择一次(即员工一旦参与了某项工作,就不能再参与其他工作),这个问题实际上是01背包问题的一个变种,具体来说是二维费用的背包问题,因为我们需要同时考虑两个限制条件:员工人数和利润。

1、确定状态表示: 根据上述分析,dp[i][j][k]表示,从前 i 份工作中挑选, 找出员工人数不超过 j ,利润不少于 k 时,一共存在多少种选法。

注意,本题中出现了一个"不少于",和我们之前做过的背包问题不一样,我们在分析状态转移方程的时候要结合实际情况考虑一 下

2、推导状态转移方程: 根据"最后一个位置"的元素(即第 i 个工作)的情况,分情况讨论:

1)、不选择第 i 个工作: 在这种情况下,我们只能从前 i-1 个工作中挑选计划,使得总人数不超过 j,总利润至少为 k。此时一共有 dp[i-1][j][k] 种选法。

2)、选择第 i 个工作: 在这种情况下,从前 i-1 个工作中挑选计划时,总人数和总利润会受到第 i 个工作的影响。实际需要保证总人数不超过 j - group[i],总利润至少为 k - profit[i]

来理解一下这里:dp[i-1][j - group[i]][k - profit[i]]

①、j - group[i] >= 0要进行条件判断吗? 如果 j - group[i] < 0,则说明第 i 个工作所需的人数过多,无法在当前状态下选择它,因此这个状态是不合法的,需要判断。

②、k - profit[i] >= 0要进行条件判断吗? 如果k - profit[i] < 0,则说明第 i 个工作的利润已经足够大,足够满足要求"利润至少k "的要求了。但是,由于dp表是数组形式,其下标不能是负数 。虽然k - profit[i] < 0是成立的,但为了使得数组下标不为负数,我们要做处理,将 k - profit[i] 0 取一个 max,即 max(0, k -profit[i] ),这表示,我们只需要在前 i-1 个工作中找到一种方案,使得总利润至少为 0(但实际上可能会更多,因为我们要加上第 i 个工作的利润),然后再加上第 i 个工作的利润,就能满足至少 k 的利润需求。

因此有:

cpp 复制代码
dp[i][j][k] = dp[i - 1][j][k] + dp[i - 1][j - g[i - 1][max(0, k - p[i - 1])]

3、初始化: 根据状态表示,当i = 0没有任务时,无论人数限制 j 是多少,此时仅有一种方案,即选择一个"空集",总利润自然是 0。因此,我们应该将 dp[0][j][0] 初始化为 1 (0 <= j <= n ),表示在没有任何任务的情况下,存在一种方案(即空集)使得总利润为 0,且这个方案对于任何人数限制 j 都是有效的。

4、填表顺序: 根据"状态转移方程", 我们保证 i 从小到大即可。

5、返回值: 根据"状态表示",返回dp[m][n][minProfit] 。其中:

cpp 复制代码
m:表示group.size(),等同于profit.size()
n:题目给定的员工限制总人数
minProfit:题目给定的最小利润

2)、题解

未优化的写法:

cpp 复制代码
class Solution {
    const int MOD = 1e9+7;// 题目说明
public:
    int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
        int m = group.size();
        // 1、建表, i 表示工作,j 表示员工人数,k 表示利润
        vector<vector<vector<int>>> dp(m+1,vector<vector<int>>(n+1,vector<int>(minProfit+1,0)));
        // 2、初始化:dp[0][j][0]
        for(int j = 0; j <= n; ++j)
            dp[0][j][0] = 1;
        // 3、填表
        for(int i = 1; i <= m; ++i)
        {
            for(int j = 0; j <=n; ++j)
            {
                for(int k = 0; k <= minProfit; ++k)
                {
                    dp[i][j][k] += dp[i-1][j][k];
                    if(j >=  group[i-1])//注意下标映射
                        dp[i][j][k] +=  dp[i-1][j-group[i-1]][max(0,k - profit[i-1])];
                    dp[i][j][k] %= MOD;//数值很大,不要忘记取模
                }
            }
        }
        // 4、返回
        return dp[m][n][minProfit];
    }
};

使用滚动数组优化的写法:

cpp 复制代码
class Solution {
    const int MOD = 1e9+7;// 题目说明
public:
    int profitableSchemes(int n, int minProfit, vector<int>& group, vector<int>& profit) {
        int m = group.size();
        // 1、建表
        vector<vector<int>> dp(n+1,vector<int>(minProfit+1,0));
        // 2、初始化:
        for(int j = 0; j <= n; ++j)
            dp[j][0] = 1;
        // 3、填表
        for(int i = 1; i <= m; ++i)
        {
            for(int j = n; j >=  group[i-1]; --j)// 记得修改遍历顺序
            {
                for(int k = minProfit; k >=0 ; --k)
                {
                    dp[j][k] +=  dp[j-group[i-1]][max(0,k - profit[i-1])];
                    dp [j][k] %= MOD;// 数值很大,不要忘记取模
                }
            }
        }
        // 4、返回
        return dp[n][minProfit];
    }
};

12、似包非包

12.1、组合总数IV(medium)

题源:链接

12.1.1、题解

1)、思路分析

需要注意:背包问题是用于解决限制条件下的组合问题 ,而本题,示例1表明,"顺序不同的序列"也是一种有效解。我们都知道数学上的"排列、组合",这种元素顺序也考虑进入的情况,实则求的是"排列数",因此,将其视为背包问题进行求解,而需要采用普通的动态规划的思想解题即 可。

1、确定状态表示: 对于常规的动态规划,根据分析问题的过程中,发现重复子问题,抽象出来一个状态表示

当我们想要计算总和为 target 的数的排列方式有多少种时,我们可以考虑这样一个思路:对于构成这个总和的最后一个数字,如果我们从给定的数组 nums 中选择一个数字 x,那么剩下的数字之和就应该是 target - x。接下来,我们就需要去找出总和为 target - x 的数字有多少种排列方式。

基于这个思路,状态表示 dp[i] 表示的是:当总和为 i 时,所有可能的排列方式的数量。

2、推导状态转移方程: 对于 dp[i],根据最后一个位置划分,可以选择数组中的任意一个元素nums[j](其中 0 <= j <= n-1)。

nums[j] <= i(即 nums[j] 不大于当前的总和 i)时,我们可以将 nums[j] 加到某个总和为 i - nums[j] 的排列的末尾,从而得到一个总和为 i 的新排列。

因此,状态转移方程可以表示为:

cpp 复制代码
if(i >= nums[j])
	dp[i] += dp[i-nums[i]] // 0 <= j <= n - 1

3、初始化: dp[0] 应该被初始化为 1,因为总和为 0 的排列只有一种,即空排列(不包含任何数字)。

4、填表顺序: 根据状态表示,我们需要从小到大遍历 i,并且在每个 i 上遍历 nums的所有元素。

在实际编程中,这个状态转移方程可以通过两层循环来实现:外层循环遍历总和 i,内层循环遍历数组 nums 中的每个数字 nums[j]。在每次内层循环中,我们根据状态转移方程更新 dp[i] 的值。

5、返回值: 根据题意,返回dp[target]处的值。

2)、题解

cpp 复制代码
class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        int n = nums.size();
        // 1、创建dp表并初始化
        vector<double> dp(target+1);//double是防止数值越界
        dp[0] = 1;
        // 2、填表:从左到右
        for(int i = 1; i <= target; ++i)
        {
            for(auto x : nums)
                if(i >= x) dp[i] += dp[i - x];
        }
        // 3、返回
        return dp[target];
    }
};

13、卡特兰数

13.1、不同的二叉搜索树(medium)

题源:链接

扩展: 这道题属于"卡特兰数"的一个应用,同样能解决的问题还有"合法的进出栈序列"、"括号匹配的括号序列"、"电影购票"等等。

13.1.1、题解

1)、思路分析

1、确定状态表示: 对于常规的动态规划,根据分析问题的过程中,发现重复子问题,抽象出来一个状态表示

对二叉搜索树,其左子树节点< 根节点 < 右子树节点。且左右子树也是二叉搜索树。

当我们在求个数为 n 的二叉搜索树的个数时,如果确定了一个根节点,则左右子树的结点个数也就确定了。此时左右子树就会变成相同的子问题:

因此我们可以这样定义状态表示:dp[i] ,表示当结点的数量为i个的时候,⼀共有多少颗二叉搜索树。

2、推导状态转移方程:dp[i],为了便于讨论,我们将给定的 i 个结点从 1 到 i 进行依次编号。接下来,选择一个特定的结点 j 作为头结点,以此为基础来分析构建 i 个结点时的所有可能二叉搜索树(BST)的数量。

当我们选择 j 号结点作为头结点时,根据二叉搜索树(BST)的定义:

左子树:j 号结点的左子树包含的结点编号应在 [1, j-1] 之间,一共有 j-1 个结点。因此,对于以 j 号结点作为头结点的情况,其左子树可能的种类数为 dp[j-1](根据 dp 数组的定义)。

右子树:j 号结点的右子树包含的结点编号应在 [j+1, i] 之间,一共有 i-j 个结点。同样地,对于 j 号结点作为头结点的情况,其右子树可能的种类数为 dp[i-j]

由此,以 j 作为根节点的左子树存在 dp[j-1]种,右子树存在 dp[i-j]种,我们可以任选一种左子树,与右子树进行组合,所形成的都是一颗以 j 作为根节点的二叉搜索树。

也就是说,根据排列组合的原理,当 j 号结点作为头结点时,能够形成的 BST 的种类数为 dp[j-1] * dp[i-j]

3、初始化: 根据状态转移方程,可以发现 j-1i-j 都是小于 i 的数值。这意味着,在求解 dp[i] 时,我们可能会依赖到前一个或更前面的状态值(特别是,当 i = 1 且 j = 1 时,我们需要用到 dp[0] 的数据)。

鉴于此,我们首先需要确保已经对 dp 数组的第一个元素进行了初始化:i = 0 时,它表示的是一颗空树。在二叉搜索树的定义中,空树同样被视为有效的二叉搜索树 。因此 dp[0] = 1,这代表了存在且仅存在一种空树的情况。

4、填表顺序:从左往右

5、返回值:根据状态表示,返回dp[n]的值。

2)、题解

cpp 复制代码
class Solution {
public:
    int numTrees(int n) {
        // 1、创建dp表并初始化
        vector<int> dp(n+1);
        dp[0] = 1;// 空树也是⼀颗⼆叉搜索树

        // 2、填表:从左到右
        for(int i = 1; i <= n; ++i)// 枚举结点的总数
        {
            for(int j = 1; j <= i; ++j) // 选择每⼀个根节点
            {
                dp[i] += dp[j-1]*dp[i-j];//  将满足要求的⼆叉树总量累加在⼀起
            }
        }

        // 3、返回
        return dp[n];
    }
};

Fin、共勉。

相关推荐
LabVIEW开发1 分钟前
什么样的LabVIEW控制算自动控制?
算法·labview
liuming199217 分钟前
Halcon中histo_2dim(Operator)算子原理及应用详解
图像处理·人工智能·深度学习·算法·机器学习·计算机视觉·视觉检测
sc写算法33 分钟前
Hash 映射
数据结构·算法·哈希算法
雅妮yyn1 小时前
头歌数据结构-排序的实现及其应用
数据结构·算法
云边有个稻草人1 小时前
【优选算法】—移动零(双指针算法)
算法·排序算法·双指针算法
小墨&晓末1 小时前
【PythonGui实战】自动摇号小程序
python·算法·小程序·系统安全
落魄君子2 小时前
SVM分类-支持向量机(Support Vector Machine)
神经网络·算法·支持向量机·分类
上理考研周导师3 小时前
【单片机原理】第1章 微机基础知识,运算器,控制器,寄存器,微机工作过程,数制转换
算法
IT猿手4 小时前
基于PWLCM混沌映射的麋鹿群优化算法(Elk herd optimizer,EHO)的多无人机协同路径规划,MATLAB代码
算法·elk·机器学习·matlab·无人机·聚类·强化学习
m0_675988239 小时前
Leetcode2545:根据第 K 场考试的分数排序
python·算法·leetcode