【动态规划算法】(背包问题经典模型与解题套路)


🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

在算法学习的过程中,动态规划一直是一个既重要又容易让人感到"头疼"的专题.它不像简单模拟或基础数据结构那样直观,很多时候需要我们从题目中抽象出状态,再通过状态之间的关系推导出转移方程.而在众多动态规划模型中,背包问题无疑是最经典、最具有代表性的一类问题.它不仅频繁出现在各类算法竞赛、笔试面试和刷题训练中,也常常被用来帮助初学者理解动态规划的核心思想.对于很多刚接触动态规划的同学来说,背包问题的难点并不只在于写出代码,而在于如何判断题目属于哪一种背包模型,如何定义状态,如何确定遍历顺序,以及如何从二维状态优化到一维滚动数组.尤其是在 0-1 背包和完全背包中,容量循环方向的不同常常会直接影响结果是否正确;而在多重背包、混合背包等进阶模型中,物品数量和选择方式的变化又会带来新的思考.因此,掌握背包问题不能只靠死记硬背模板,更需要理解每一种模型背后的逻辑和适用场景.本文将围绕"背包问题经典模型与解题套路"这一主题展开,系统梳理背包问题中的常见类型、核心状态转移思路以及实际解题时的分析方法.我们会从最基础的 0-1 背包开始,逐步延伸到完全背包、多重背包、分组背包等经典变形,并结合常见题型总结出一套可复用的解题套路.希望通过本文的讲解,读者不仅能够看懂背包问题的公式和代码,更能真正理解动态规划"从局部选择推导全局最优"的思想,在之后遇到类似问题时,能够快速识别模型、明确状态设计,并写出清晰可靠的解法.废话不多说,下面跟着小编的节奏🎵一起去疯狂的学习吧!

目录

1.背包问题背景介绍

背包问题最早来源于一个非常经典的现实场景:假设有一个容量有限的背包,以及若干个具有不同重量和价值的物品,我们希望在不超过背包容量的前提下,选择一些物品放入背包,使得背包中物品的总价值尽可能大.这个问题看似简单,却很好地抽象出了现实生活中常见的"资源有限、收益最大化"问题,因此逐渐成为算法领域中研究动态规划的重要模型之一.

从本质上看,背包问题是一类典型的组合优化问题.它关注的是如何在给定约束条件下,从多个选择中挑选出最优方案.这里的"背包容量"可以理解为有限资源,例如时间、空间、资金、成本、体力等;而"物品价值"则可以对应收益、利润、满意度、优先级等目标.因此,背包问题并不局限于真正的"背包装物品",它还可以对应许多实际场景,例如预算分配、任务选择、项目投资、资源调度、商品采购等.

在算法学习中,背包问题之所以重要,是因为它非常适合作为动态规划思想的入门和进阶模型.它具有清晰的状态划分和选择过程:对于每一个物品,我们都需要考虑"选"或者"不选";对于每一种容量状态,我们都需要判断当前选择是否能够带来更优结果.通过不断比较不同选择下的最优值,就可以逐步得到整个问题的最优解.这种由小问题推导大问题、由局部最优状态构建全局最优答案的过程,正是动态规划的核心思想.

随着问题条件的变化,背包问题也衍生出了多种经典模型.比如,每个物品只能选择一次时,就是 0-1 背包问题;每个物品可以选择无限次时,就是完全背包问题;每个物品有固定数量限制时,就是多重背包问题;物品被划分为若干组、每组最多只能选择一个时,就是分组背包问题.虽然这些模型在限制条件上有所不同,但它们的核心思路都是围绕状态定义、状态转移和最优选择展开的.

因此,学习背包问题不仅是为了掌握某一类题目的解法,更重要的是通过它理解动态规划的建模方法.只要能够看清题目中的"容量限制""选择对象"和"最优目标",很多看似复杂的问题都可以转化为背包模型来解决.这也是背包问题在算法竞赛、笔试面试和日常刷题中频繁出现的重要原因.

背包问题概述

背包问题(Knapsack problem)是一种组合优化的 NP 完全问题.

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

根据物品的个数,分为如下几类:

  • 01 背包问题:每个物品只有一个
  • 完全背包问题:每个物品有无限多个
  • 多重背包问题:每件物品最多有 si 个
  • 混合背包问题:每个物品会有上面三种情况......
  • 分组背包问题:物品有 n 组,每组物品里有若干个,每组里最多选一个物品

其中上述分类里面,根据背包是否装满,又分为两类:

  • 不一定装满背包
  • 背包一定装满

优化方案:

  • 空间优化 - 滚动数组
  • 单调队列优化
  • 贪心优化

根据限定条件的个数,又分为两类:

  • 限定条件只有一个:比如体积 -> 普通的背包问题
  • 限定条件有两个:比如体积 + 重量 -> 二维费用背包问题

根据不同的问法,又分为很多类:

  • 输出方案
  • 求方案总数
  • 最优方案
  • 方案可行性

其实还有很多分类,但是我们仅需了解即可.

因此,背包问题种类非常繁多,题型非常丰富,难度也是非常难以捉摸.但是,尽管种类非常多,都是从 01 背包问题演化过来的.所以,一定要把01背包问题学好.


2.01背包(OJ题)


算法思路:解法(动态规划):

我们先解决第一问:
1.状态表示:
dp[i][j] 表示:从前 i 个物品中挑选,总体积不超过j,所有的选法中,能挑选出来的最大价值.

2.状态转移方程:

线性 dp 状态转移方程分析方式,一般都是根据最后一步的状况,来分情况讨论:

i. 不选第 i 个物品:相当于就是去前 i - 1 个物品中挑选,并且总体积不超过 j.此时 dp[i][j] = dp[i - 1][j];

ii. 选择第 i 个物品:那么我就只能去前 i - 1 个物品中,挑选总体积不超过 j - v[i] 的物品.此时 dp[i][j] = dp[i - 1][j - v[i]] + w[i].但是这种状态不一定存在,因此需要特判一下.

综上,状态转移方程为:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]).

3.初始化:

我们多加一行,方便我们的初始化,此时仅需将第一行初始化为 0 即可.因为什么也不选,也能满足体积不小于 j 的情况,此时的价值为 0.

4.填表顺序:

根据状态转移方程,我们仅需从上往下填表即可.

5.返回值:

根据状态表示,返回 dp[n][V].

接下来解决第二问:

第二问仅需微调一下 dp 过程的五步即可.

因为有可能凑不齐 j 体积的物品,因此我们把不合法的状态设置为 -1.

1.状态表示:
dp[i][j] 表示:从前 i 个物品中挑选,总体积正好等于 j,所有的选法中,能挑选出来的最大价值.

2.状态转移方程:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]).

但是在使用 dp[i - 1][j - v[i]] 的时候,不仅要判断 j >= v[i],又要判断 dp[i - 1][j - v[i]] 表示的情况是否存在,也就是 dp[i - 1][j - v[i]] != -1.

3.初始化:

我们多加一行,方便我们的初始化:

i. 第一个格子为 0,因为正好能凑齐体积为 0 的背包;

ii. 但是第一行后面的格子都是 -1,因为没有物品,无法满足体积大于 0 的情况.

4.填表顺序:

根据状态转移方程,我们仅需从上往下填表即可.

5.返回值:

由于最后可能凑不成体积为 V 的情况,因此返回之前需要特判一下.

空间优化:

背包问题基本上都是利用滚动数组来做空间上的优化:

i. 利用滚动数组优化;

ii. 直接在原始代码上修改.

在01背包问题中,优化的结果为:

i. 删掉所有的横坐标;

ii. 修改一下 j 的遍历顺序.




核心代码

cpp 复制代码
#include <iostream>
#include <string.h>
using namespace std;

//定义数组最大容量,满足题目数据范围
const int N = 1010;

//全局变量:n=物品数量,V=背包最大容量,v[]=物品体积,w[]=物品价值
int n, V, v[N], w[N];
//dp二维数组:dp[i][j]表示前i个物品,背包容量为j时的最大价值
int dp[N][N];

int main()
{
    //1.输入数据:读取物品总数n 和 背包最大容量V
    cin >> n >> V;
    //循环读取n个物品的体积v[i]和价值w[i](物品下标从1开始)
    for(int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];

    //第一问:经典01背包(不要求装满背包)
    //遍历每一个物品
    for(int i = 1; i <= n; i++)
        //遍历每一种背包容量
        for(int j = 0; j <= V; j++)
        {
            //状态1:不选第i个物品,价值等于前i-1个物品、容量j的最大价值
            dp[i][j] = dp[i - 1][j];
            //状态2:如果背包容量足够放下第i个物品,尝试选这个物品
            if(j >= v[i])
                //取「不选」和「选」两种情况的最大值
                dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
        }

    //输出结果:n个物品、背包满容量V时的最大价值(不要求装满)
    cout << dp[n][V] << endl;

    //第二问:01背包(要求必须装满背包)
    //重置dp数组为0,重新计算必须装满的场景
    memset(dp, 0, sizeof dp);
    //核心初始化:0个物品时,容量j>0无法装满,赋值为-1(表示非法状态)
    //dp[0][0]=0:0个物品、容量0,价值为0(合法,刚好装满)
    for(int j = 1; j <= V; j++) dp[0][j] = -1;

    //遍历物品和背包容量,逻辑同上
    for(int i = 1; i <= n; i++)
        for(int j = 0; j <= V; j++)
        {
            //不选第i个物品
            dp[i][j] = dp[i - 1][j];
            //必须满足:容量足够 + 前一个状态是合法的(能装满)
            if(j >= v[i] && dp[i - 1][j - v[i]] != -1)
                dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
        }

    //输出结果:如果dp[n][V]=-1说明无法装满,输出0;否则输出最大价值
    cout << (dp[n][V] == -1 ? 0 : dp[n][V]) << endl;

    return 0;
}

完整测试代码

cpp 复制代码
#include <iostream>
#include <string.h>
#include <sstream>
using namespace std;

// 定义数组最大容量,满足题目数据范围
const int N = 1010;

// 全局变量:n=物品数量,V=背包最大容量,v[]=物品体积,w[]=物品价值
int n, V, v[N], w[N];

// dp二维数组:dp[i][j]表示前i个物品,背包容量为j时的最大价值
int dp[N][N];

// 封装求解函数,方便测试多组数据
void solve(istream& in)
{
    // 1. 输入数据:读取物品总数n 和 背包最大容量V
    in >> n >> V;

    // 循环读取n个物品的体积v[i]和价值w[i]
    for(int i = 1; i <= n; i++)
        in >> v[i] >> w[i];

    // 第一问:经典01背包,不要求装满背包
    memset(dp, 0, sizeof dp);

    for(int i = 1; i <= n; i++)
        for(int j = 0; j <= V; j++)
        {
            // 不选第i个物品
            dp[i][j] = dp[i - 1][j];

            // 选第i个物品
            if(j >= v[i])
                dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
        }

    cout << "不要求装满背包的最大价值:" << dp[n][V] << endl;

    // 第二问:01背包,要求必须装满背包
    memset(dp, 0, sizeof dp);

    // 初始化非法状态
    // dp[0][0] = 0,表示0个物品装满容量0,合法
    // dp[0][j] = -1,表示0个物品无法装满容量j
    for(int j = 1; j <= V; j++)
        dp[0][j] = -1;

    for(int i = 1; i <= n; i++)
        for(int j = 0; j <= V; j++)
        {
            // 不选第i个物品
            dp[i][j] = dp[i - 1][j];

            // 选第i个物品
            // 要求前一个状态必须合法
            if(j >= v[i] && dp[i - 1][j - v[i]] != -1)
                dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
        }

    if(dp[n][V] == -1)
        cout << "必须装满背包的最大价值:0" << endl;
    else
        cout << "必须装满背包的最大价值:" << dp[n][V] << endl;
}

int main()
{
    // 测试用例1
    // 4个物品,背包容量5
    // 物品:
    // 体积 价值
    // 1    2
    // 2    4
    // 3    4
    // 4    5
    //
    // 不要求装满:可选体积2+3,价值4+4=8
    // 必须装满:同样可选体积2+3,价值8
    string test1 =
            "4 5\n"
            "1 2\n"
            "2 4\n"
            "3 4\n"
            "4 5\n";

    cout << "====== 测试用例1 ======" << endl;
    stringstream ss1(test1);
    solve(ss1);
    cout << endl;

    // 测试用例2
    // 3个物品,背包容量6
    // 物品体积分别为2、4、5
    // 可以选2+4刚好装满,最大价值为10
    string test2 =
            "3 6\n"
            "2 3\n"
            "4 7\n"
            "5 8\n";

    cout << "====== 测试用例2 ======" << endl;
    stringstream ss2(test2);
    solve(ss2);
    cout << endl;

    // 测试用例3
    // 3个物品,背包容量7
    // 物品体积为2、4、6
    // 不要求装满时,可以选6,价值8
    // 必须装满时,无法刚好凑出7,所以输出0
    string test3 =
            "3 7\n"
            "2 3\n"
            "4 5\n"
            "6 8\n";

    cout << "====== 测试用例3 ======" << endl;
    stringstream ss3(test3);
    solve(ss3);
    cout << endl;

    return 0;
}

核心代码

cpp 复制代码
//空间优化后
#include <iostream>
#include <string.h>
using namespace std;

//定义数组最大容量,适配题目数据范围
const int N = 1010;

//全局变量定义
//n:物品数量  V:背包最大容量
//v[]:存储每个物品的体积  w[]:存储每个物品的价值
int n, V, v[N], w[N];
//一维dp数组:滚动数组优化,dp[j]表示背包容量为j时的最大价值
int dp[N];

int main()
{
    //1.输入数据
    //读取物品总数n 和 背包最大容量V
    cin >> n >> V;
    //循环读取n个物品的体积和价值(物品下标从1开始)
    for(int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];

    //第一问:01背包(不要求装满背包)
    //遍历每个物品(外层循环:逐个选择物品)
    for(int i = 1; i <= n; i++)
        //逆序遍历背包容量!核心:防止同一个物品被重复选取(01背包关键)
        //从最大容量V往下遍历到当前物品体积v[i]
        for(int j = V; j >= v[i]; j--)
            //状态转移方程:
            //dp[j] = 不选第i个物品的价值(原值) vs 选第i个物品的价值(dp[j-v[i]]+w[i])
            dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
    //输出结果:背包容量为V时的最大价值(不要求装满)
    cout << dp[V] << endl;

    //第二问:01背包(必须装满背包)
    //重置dp数组为0,重新计算必须装满的场景
    memset(dp, 0, sizeof dp);
    //核心初始化:必须装满的特殊处理
    //dp[0]=0:容量0,价值0(合法,刚好装满)
    //dp[j>0]=-1:标记为非法状态(0个物品无法装满容量>0的背包)
    for(int j = 1; j <= V; j++) dp[j] = -1;

    //遍历每个物品
    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]);

    //输出结果:
    //如果dp[V]=-1 → 无法装满背包,输出0
    //否则输出装满背包时的最大价值
    cout << (dp[V] == -1 ? 0 : dp[V]) << endl;

    return 0;
}

完整测试代码

cpp 复制代码
#include <iostream>
#include <string.h>
#include <sstream>
using namespace std;

// 定义数组最大容量,适配题目数据范围
const int N = 1010;

// 全局变量定义
// n:物品数量  V:背包最大容量
// v[]:存储每个物品的体积  w[]:存储每个物品的价值
int n, V, v[N], w[N];

// 一维dp数组:滚动数组优化
// dp[j]表示背包容量为j时的最大价值
int dp[N];

// 封装求解函数,方便多组测试
void solve(istream& in)
{
    // 1. 输入数据
    in >> n >> V;

    for(int i = 1; i <= n; i++)
        in >> v[i] >> w[i];

    // 第一问:01背包,不要求装满背包
    memset(dp, 0, sizeof dp);

    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]);

    cout << "不要求装满背包的最大价值:" << dp[V] << endl;

    // 第二问:01背包,必须装满背包
    memset(dp, 0, sizeof dp);

    // 初始化:
    // dp[0] = 0,表示容量为0时可以刚好装满
    // dp[j] = -1,表示容量j暂时无法被刚好装满
    for(int j = 1; j <= V; j++)
        dp[j] = -1;

    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]);

    cout << "必须装满背包的最大价值:"
         << (dp[V] == -1 ? 0 : dp[V]) << endl;
}

int main()
{
    // 测试用例1:
    // 4个物品,背包容量5
    //
    // 物品:
    // 体积 价值
    // 1    2
    // 2    4
    // 3    4
    // 4    5
    //
    // 不要求装满:
    // 选择体积2和3的物品,总体积5,总价值8
    //
    // 必须装满:
    // 同样选择体积2和3的物品,刚好装满,总价值8
    string test1 =
            "4 5\n"
            "1 2\n"
            "2 4\n"
            "3 4\n"
            "4 5\n";

    cout << "====== 测试用例1:可以刚好装满 ======" << endl;
    stringstream ss1(test1);
    solve(ss1);
    cout << endl;

    // 测试用例2:
    // 3个物品,背包容量6
    //
    // 物品:
    // 体积 价值
    // 2    3
    // 4    7
    // 5    8
    //
    // 不要求装满:
    // 可以选择体积2和4的物品,总价值10
    //
    // 必须装满:
    // 体积2+4=6,刚好装满,总价值10
    string test2 =
            "3 6\n"
            "2 3\n"
            "4 7\n"
            "5 8\n";

    cout << "====== 测试用例2:选择多个物品刚好装满 ======" << endl;
    stringstream ss2(test2);
    solve(ss2);
    cout << endl;

    // 测试用例3:
    // 3个物品,背包容量7
    //
    // 物品:
    // 体积 价值
    // 2    3
    // 4    5
    // 6    8
    //
    // 不要求装满:
    // 最优选择是体积6的物品,价值8
    //
    // 必须装满:
    // 无法组合出总体积7,所以输出0
    string test3 =
            "3 7\n"
            "2 3\n"
            "4 5\n"
            "6 8\n";

    cout << "====== 测试用例3:无法刚好装满 ======" << endl;
    stringstream ss3(test3);
    solve(ss3);
    cout << endl;

    // 测试用例4:
    // 5个物品,背包容量10
    //
    // 物品:
    // 体积 价值
    // 2    6
    // 2    3
    // 6    5
    // 5    4
    // 4    6
    //
    // 不要求装满:
    // 可以选择体积2、2、6,总体积10,总价值14
    //
    // 必须装满:
    // 同样可以刚好装满,最大价值14
    string test4 =
            "5 10\n"
            "2 6\n"
            "2 3\n"
            "6 5\n"
            "5 4\n"
            "4 6\n";

    cout << "====== 测试用例4:多个组合比较最优值 ======" << endl;
    stringstream ss4(test4);
    solve(ss4);
    cout << endl;

    // 测试用例5:
    // 2个物品,背包容量1
    //
    // 物品体积都大于背包容量
    //
    // 不要求装满:
    // 什么都不选,最大价值为0
    //
    // 必须装满:
    // 无法装满容量1,输出0
    string test5 =
            "2 1\n"
            "2 10\n"
            "3 20\n";

    cout << "====== 测试用例5:所有物品都放不下 ======" << endl;
    stringstream ss5(test5);
    solve(ss5);
    cout << endl;

    return 0;
}

3.分割等和子集(OJ题)


算法思路:解法(动态规划):

先将问题转化成我们熟悉的题型.

如果数组能够被分成两个相同元素之和相同的子集,那么原数组必须有下面几个性质:

i. 所有元素之和应该是一个偶数;

ii. 数组中最大的元素应该小于所有元素总和的一半;

iii. 挑选一些数,这些数的总和应该等于数组总和的一半.

根据前两个性质,我们可以提前判断数组能够被划分.根据最后一个性质,我们发现问题就转化成了01背包的模型:

i. 数组中的元素只能选择一次;

ii. 每个元素面临被选择或者不被选择的处境;

iii. 选出来的元素总和要等于所有元素总和的一半.

其中,数组内的元素就是物品,总和就是背包.

那么我们就可以用背包模型的分析方式,来处理这道题.

请大家注意,不要背状态转移方程,因为题型变化之后,状态转移方程就会跟着变化.我们要记住的是分析问题的模式.用这种分析问题的模式来解决问题.

1.状态表示:
dp[i][j] 表示在前 i 个元素中选择,所有的选法中,能否凑成总和为 j 这个数.

2.状态转移方程:

老规矩,根据最后一个位置的元素,结合题目的要求,分情况讨论:

i. 不选择 nums[i]:那么我们是否能够凑成总和为 j,就要看在前 i - 1 个元素中选,能否凑成总和为 j.根据状态表示,此时 dp[i][j] = dp[i - 1][j];

ii. 选择 nums[i]:这种情况下是有前提条件的,此时的 nums[i] 应该是小于等于 j.因为如果这个元素都比要凑成的总和大,选择它就没有意义呀.那么我们是否能够凑成总和为 j,就要看在前 i - 1 个元素中选,能否凑成总和为 j - nums[i].根据状态表示,此时 dp[i][j] = dp[i - 1][j - nums[i]].

综上所述,两种情况下只要有一种能够凑成总和为 j,那么这个状态就是 true.因此,状态转移方程为:

复制代码
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.初始化:

由于需要用到上一行的数据,因此我们可以先把第一行初始化.

第一行表示不选择任何元素,要凑成目标和 j.只有当目标和为 0 的时候才能做到,因此第一行仅需初始化第一个元素 dp[0][0] = true

4.填表顺序:

根据状态转移方程,我们需要从上往下填写每一行,每一行的顺序是无所谓的.

5.返回值:

根据状态表示,返回 dp[n][aim] 的值.

其中 n 表示数组的大小,aim 表示要凑的目标和.

6.空间优化:

所有的背包问题,都可以进行空间上的优化.

对于01背包类型的,我们的优化策略是:

i. 删掉第一维;

ii. 修改第二层循环的遍历顺序即可.






核心代码

cpp 复制代码
//解法类:判断数组是否可以分割成两个子集,使得两个子集的元素和相等
class Solution
{
public:
    //核心函数:nums为输入的整数数组
    bool canPartition(vector<int>& nums)
    {
        //n:数组元素个数  sum:数组所有元素的总和
        int n = nums.size(), sum = 0;
        //遍历数组,计算所有元素的总和
        for(auto x : nums) sum += x;
        
        //关键判断:如果总和是奇数,一定无法平分,直接返回false
        if(sum % 2) return false; 

        //目标和:将数组平分,每个子集的和必须等于 sum/2
        //问题转化为:能否从数组中选出若干数,使得它们的和恰好等于 aim
        int aim = sum / 2; 

        //二维dp数组定义:
        //dp[i][j] 表示:从前 i 个元素中挑选,能否凑出和为 j(true=能,false=不能)
        //行数:n+1(前0~n个元素),列数:aim+1(和为0~aim)
        vector<vector<bool>> dp(n + 1, vector<bool>(aim + 1)); 

        //dp初始化:
        //前i个元素,凑和为0,一定可以实现(不选任何元素),所以全部赋值为true
        for(int i = 0; i <= n; i++) dp[i][0] = true; 
        
        //动态规划填表:从上到下,从左到右遍历
        for(int i = 1; i <= n; i++)      //遍历前i个元素
            for(int j = 1; j <= aim; j++)//遍历目标和j
            {
                //情况1:不选第i个元素
                //能否凑出和j,等于前i-1个元素能否凑出和j
                dp[i][j] = dp[i - 1][j];
                
                //情况2:选第i个元素(前提:当前目标和j >= 第i个元素的值)
                //注意:数组下标从0开始,第i个元素对应 nums[i-1]
                if(j >= nums[i - 1])
                    //两种情况满足其一即可,用 或运算(||)
                    dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i - 1]];
            }

        //返回结果:前n个元素,能否凑出和为aim(能则可以平分数组)
        return dp[n][aim];
    }
};

完整测试代码

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

// 解法类:判断数组是否可以分割成两个子集,使得两个子集的元素和相等
class Solution
{
public:
    // 核心函数:nums为输入的整数数组
    bool canPartition(vector<int>& nums)
    {
        // n:数组元素个数  sum:数组所有元素的总和
        int n = nums.size(), sum = 0;

        // 遍历数组,计算所有元素的总和
        for(auto x : nums) sum += x;

        // 关键判断:如果总和是奇数,一定无法平分,直接返回false
        if(sum % 2) return false;

        // 目标和:将数组平分,每个子集的和必须等于 sum / 2
        // 问题转化为:能否从数组中选出若干数,使得它们的和恰好等于 aim
        int aim = sum / 2;

        // 二维dp数组定义:
        // dp[i][j] 表示:从前 i 个元素中挑选,能否凑出和为 j
        vector<vector<bool>> dp(n + 1, vector<bool>(aim + 1));

        // dp初始化:
        // 前i个元素,凑和为0,一定可以实现,即不选任何元素
        for(int i = 0; i <= n; i++)
            dp[i][0] = true;

        // 动态规划填表
        for(int i = 1; i <= n; i++)
            for(int j = 1; j <= aim; j++)
            {
                // 情况1:不选第i个元素
                dp[i][j] = dp[i - 1][j];

                // 情况2:选第i个元素
                if(j >= nums[i - 1])
                    dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i - 1]];
            }

        // 返回结果:前n个元素,能否凑出和为aim
        return dp[n][aim];
    }
};

void printVector(const vector<int>& nums)
{
    cout << "[";
    for(int i = 0; i < nums.size(); i++)
    {
        cout << nums[i];
        if(i != nums.size() - 1)
            cout << ", ";
    }
    cout << "]";
}

void test(vector<int> nums, bool expected)
{
    Solution solution;

    bool result = solution.canPartition(nums);

    cout << "测试数组:";
    printVector(nums);
    cout << endl;

    cout << "程序结果:";
    cout << (result ? "true" : "false") << endl;

    cout << "预期结果:";
    cout << (expected ? "true" : "false") << endl;

    cout << "测试结论:";
    if(result == expected)
        cout << "通过";
    else
        cout << "未通过";

    cout << endl << endl;
}

int main()
{
    // 测试用例1:
    // nums = [1, 5, 11, 5]
    // 总和 = 22,目标和 = 11
    // 可以选择 [11] 或 [5, 5, 1] 凑出11
    // 所以可以分割成两个和相等的子集
    test({1, 5, 11, 5}, true);

    // 测试用例2:
    // nums = [1, 2, 3, 5]
    // 总和 = 11,是奇数
    // 奇数无法平分,所以返回false
    test({1, 2, 3, 5}, false);

    // 测试用例3:
    // nums = [1, 2, 3, 4]
    // 总和 = 10,目标和 = 5
    // 可以选择 [1, 4] 或 [2, 3] 凑出5
    // 所以可以平分
    test({1, 2, 3, 4}, true);

    // 测试用例4:
    // nums = [2, 2, 3, 5]
    // 总和 = 12,目标和 = 6
    // 无法从数组中凑出6
    // 所以不能平分
    test({2, 2, 3, 5}, false);

    // 测试用例5:
    // nums = [100, 100]
    // 总和 = 200,目标和 = 100
    // 可以分成 [100] 和 [100]
    test({100, 100}, true);

    // 测试用例6:
    // nums = [1, 1, 1, 1]
    // 总和 = 4,目标和 = 2
    // 可以分成 [1, 1] 和 [1, 1]
    test({1, 1, 1, 1}, true);

    // 测试用例7:
    // nums = [3, 3, 3, 4, 5]
    // 总和 = 18,目标和 = 9
    // 可以选择 [4, 5] 凑出9
    // 所以可以平分
    test({3, 3, 3, 4, 5}, true);

    return 0;
}


核心代码

cpp 复制代码
//空间优化后
//解题思路:将问题转化为 01 背包可行性问题
class Solution
{
public:
    //函数功能:判断能否将数组分成两个子集,使元素和相等
    bool canPartition(vector<int>& nums)
    {
        //n:数组元素个数  sum:数组所有元素的总和
        int n = nums.size(), sum = 0;
        //遍历计算数组总和
        for(auto x : nums) sum += x;
        
        //核心判断:总和为奇数,一定无法平分,直接返回 false
        if(sum % 2) return false; 

        //目标和:问题转化为 -> 能否选出若干元素,和恰好等于 sum/2
        int aim = sum / 2; 
        
        //一维滚动数组优化:
        //dp[j] 表示:能否凑出 元素和为 j 的子集(true=能,false=不能)
        vector<bool> dp(aim + 1); 
        
        //初始化:和为 0 一定可以凑出(不选任何元素),这是合法状态
        dp[0] = true; 
        
        //外层循环:遍历每一个元素(相当于01背包的「物品」)
        for(int i = 1; i <= n; i++) 
            //内层循环:逆序遍历目标和(01背包一维优化核心!)
            //逆序遍历:保证每个元素只被选一次,避免重复选取
            for(int j = aim; j >= nums[i - 1]; j--) 
                //状态转移方程:
                //dp[j] = 不选当前元素(保持原值) || 选当前元素(dp[j-当前元素值])
                dp[j] = dp[j] || dp[j - nums[i - 1]];

        //最终结果:能否凑出和为 aim 的子集,能则可以平分数组
        return dp[aim];
    }
};

完整测试代码

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

// 空间优化后
// 解题思路:将问题转化为 01 背包可行性问题
class Solution
{
public:
    // 函数功能:判断能否将数组分成两个子集,使元素和相等
    bool canPartition(vector<int>& nums)
    {
        // n:数组元素个数  sum:数组所有元素的总和
        int n = nums.size(), sum = 0;

        // 遍历计算数组总和
        for(auto x : nums) sum += x;

        // 核心判断:总和为奇数,一定无法平分,直接返回 false
        if(sum % 2) return false;

        // 目标和:问题转化为 -> 能否选出若干元素,和恰好等于 sum / 2
        int aim = sum / 2;

        // 一维滚动数组优化:
        // dp[j] 表示:能否凑出元素和为 j 的子集
        vector<bool> dp(aim + 1);

        // 初始化:和为 0 一定可以凑出,即不选任何元素
        dp[0] = true;

        // 外层循环:遍历每一个元素
        for(int i = 1; i <= n; i++)
            // 内层循环:逆序遍历目标和
            // 逆序遍历保证每个元素只被使用一次
            for(int j = aim; j >= nums[i - 1]; j--)
                dp[j] = dp[j] || dp[j - nums[i - 1]];

        // 最终结果:能否凑出和为 aim 的子集
        return dp[aim];
    }
};

void printVector(const vector<int>& nums)
{
    cout << "[";

    for(int i = 0; i < nums.size(); i++)
    {
        cout << nums[i];

        if(i != nums.size() - 1)
            cout << ", ";
    }

    cout << "]";
}

void test(vector<int> nums, bool expected)
{
    Solution solution;

    bool result = solution.canPartition(nums);

    cout << "测试数组:";
    printVector(nums);
    cout << endl;

    cout << "程序结果:";
    cout << (result ? "true" : "false") << endl;

    cout << "预期结果:";
    cout << (expected ? "true" : "false") << endl;

    cout << "测试结论:";

    if(result == expected)
        cout << "通过";
    else
        cout << "未通过";

    cout << endl << endl;
}

int main()
{
    // 测试用例1:
    // nums = [1, 5, 11, 5]
    // 总和 = 22,目标和 = 11
    // 可以选择 [11] 或 [1, 5, 5] 凑出 11
    // 所以可以分割成两个和相等的子集
    test({1, 5, 11, 5}, true);

    // 测试用例2:
    // nums = [1, 2, 3, 5]
    // 总和 = 11,是奇数
    // 奇数无法平分,所以返回 false
    test({1, 2, 3, 5}, false);

    // 测试用例3:
    // nums = [1, 2, 3, 4]
    // 总和 = 10,目标和 = 5
    // 可以选择 [1, 4] 或 [2, 3] 凑出 5
    test({1, 2, 3, 4}, true);

    // 测试用例4:
    // nums = [2, 2, 3, 5]
    // 总和 = 12,目标和 = 6
    // 无法从数组中选出若干元素凑出 6
    test({2, 2, 3, 5}, false);

    // 测试用例5:
    // nums = [100, 100]
    // 总和 = 200,目标和 = 100
    // 可以分成 [100] 和 [100]
    test({100, 100}, true);

    // 测试用例6:
    // nums = [1, 1, 1, 1]
    // 总和 = 4,目标和 = 2
    // 可以分成 [1, 1] 和 [1, 1]
    test({1, 1, 1, 1}, true);

    // 测试用例7:
    // nums = [3, 3, 3, 4, 5]
    // 总和 = 18,目标和 = 9
    // 可以选择 [4, 5] 凑出 9
    test({3, 3, 3, 4, 5}, true);

    // 测试用例8:
    // nums = [2, 4, 6, 8]
    // 总和 = 20,目标和 = 10
    // 可以选择 [2, 8] 或 [4, 6] 凑出 10
    test({2, 4, 6, 8}, true);

    // 测试用例9:
    // nums = [1, 2, 5]
    // 总和 = 8,目标和 = 4
    // 无法凑出 4
    test({1, 2, 5}, false);

    return 0;
}

4.目标和(OJ题)


算法思路:解法(动态规划):

本题可以直接用暴搜的方法解决.但是稍微用数学知识分析一下,就能转化成我们常见的背包模型的问题.

设我们最终选取的结果中,前面加 + 号的数字之和为 a,前面加 - 号的数字之和为 b,整个数组的总和为 sum,于是我们有:

  • a + b = sum
  • a - b = target

上面两个式子消去 b 之后,可以得到 a = (sum + target) / 2

也就是说,我们仅需在 nums 数组中选择一些数,将它们凑成和为 (sum + target) / 2 即可.

问题就变成了分割等和子集 这道题.

我们可以用相同的分析模式,来处理这道题.

1.状态表示:
dp[i][j] 表示:在前 i 个数中选,总和正好等于 j,一共有多少种选法.

2.状态转移方程:

老规矩,根据最后一个位置的元素,结合题目的要求,我们有选择最后一个元素或者不选择最后一个元素两种策略:

i. 不选 nums[i]:那么我们凑成总和 j 的总方案,就要看在前 i - 1 个元素中选,凑成总和为 j 的方案数.根据状态表示,此时 dp[i][j] = dp[i - 1][j];

ii. 选择 nums[i]:这种情况下是有前提条件的,此时的 nums[i] 应该是小于等于 j.因为如果这个元素都比要凑成的总和大,选择它就没有意义呀.那么我们能够凑成总和为 j 的方案数,就要看在前 i - 1 个元素中选,能否凑成总和为 j - nums[i].根据状态表示,此时 dp[i][j] = dp[i - 1][j - nums[i]].

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

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

3.初始化:

由于需要用到上一行的数据,因此我们可以先把第一行初始化.

第一行表示不选择任何元素,要凑成目标和 j.只有当目标和为 0 的时候才能做到,因此第一行仅需初始化第一个元素 dp[0][0] = 1

4.填表顺序:

根据状态转移方程,我们需要从上往下填写每一行,每一行的顺序是无所谓的.

5.返回值:

根据状态表示,返回 dp[n][aim] 的值.

其中 n 表示数组的大小,aim 表示要凑的目标和.

6.空间优化:

所有的背包问题,都可以进行空间上的优化.

对于01背包类型的,我们的优化策略是:

i. 删掉第一维;

ii. 修改第二层循环的遍历顺序即可.






核心代码

cpp 复制代码
//解题思路:将问题转化为 01背包求组合方案数 问题
class Solution
{
public:
    //函数功能:给数组元素添加 +/- 号,计算凑出target的方案总数
    int findTargetSumWays(vector<int>& nums, int target)
    {
        //计算数组所有元素的总和sum
        int sum = 0;
        for(auto x : nums) sum += x;
        
        //核心公式推导:
        //设正数和为A,负数和为B,则 A - B = target,A + B = sum
        //联立得:A = (sum + target) / 2
        //问题转化为:从数组中选若干数,和为A,求方案数
        int aim = (sum + target) / 2;

        //边界条件判断:
        //1.(sum+target)必须是偶数,否则无法整除,无方案
        //2.目标和aim不能为负数,否则无方案
        if(aim < 0 || (sum + target) % 2) return 0;
        
        //n:数组元素个数
        int n = nums.size();

        //二维dp数组定义:
        //dp[i][j] 表示:前i个元素中挑选,凑出和为j的 方案总数
        vector<vector<int>> dp(n + 1, vector<int>(aim + 1)); 
        
        //dp初始化:
        //0个元素,凑和为0,有1种方案(不选任何元素)
        dp[0][0] = 1; 
        
        //动态规划填表:从上到下遍历
        for(int i = 1; i <= n; i++)      //遍历前i个元素
            for(int j = 0; j <= aim; j++)//遍历目标和j
            {
                //情况1:不选第i个元素
                //方案数 = 前i-1个元素凑出j的方案数
                dp[i][j] = dp[i - 1][j];
                
                //情况2:选第i个元素(前提:j >= 当前元素值)
                //数组下标从0开始,第i个元素对应 nums[i-1]
                if(j >= nums[i - 1]) 
                    //方案数累加:选 + 不选 两种情况的总和
                    dp[i][j] += dp[i - 1][j - nums[i - 1]];
            }

        //返回结果:前n个元素,凑出和为aim的总方案数
        return dp[n][aim];
    }
};

完整测试代码

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

// 解题思路:将问题转化为 01背包求组合方案数 问题
class Solution
{
public:
    // 函数功能:给数组元素添加 +/- 号,计算凑出target的方案总数
    int findTargetSumWays(vector<int>& nums, int target)
    {
        // 计算数组所有元素的总和sum
        int sum = 0;
        for(auto x : nums) sum += x;

        // 边界条件判断:
        // 如果 |target| > sum,说明无论怎么加减都无法凑出 target
        if(abs(target) > sum) return 0;

        // 核心公式推导:
        // 设正数和为A,负数和为B
        // A - B = target
        // A + B = sum
        // 联立得:A = (sum + target) / 2
        //
        // 问题转化为:
        // 从数组中选若干数,使其和为 A,求方案数
        if((sum + target) % 2) return 0;

        int aim = (sum + target) / 2;

        // aim不能为负数
        if(aim < 0) return 0;

        // n:数组元素个数
        int n = nums.size();

        // 二维dp数组定义:
        // dp[i][j] 表示:前i个元素中挑选,凑出和为j的方案总数
        vector<vector<int>> dp(n + 1, vector<int>(aim + 1));

        // dp初始化:
        // 0个元素,凑和为0,有1种方案,即不选任何元素
        dp[0][0] = 1;

        // 动态规划填表
        for(int i = 1; i <= n; i++)
            for(int j = 0; j <= aim; j++)
            {
                // 情况1:不选第i个元素
                dp[i][j] = dp[i - 1][j];

                // 情况2:选第i个元素
                if(j >= nums[i - 1])
                    dp[i][j] += dp[i - 1][j - nums[i - 1]];
            }

        // 返回结果:前n个元素,凑出和为aim的总方案数
        return dp[n][aim];
    }
};

void printVector(const vector<int>& nums)
{
    cout << "[";

    for(int i = 0; i < nums.size(); i++)
    {
        cout << nums[i];

        if(i != nums.size() - 1)
            cout << ", ";
    }

    cout << "]";
}

void test(vector<int> nums, int target, int expected)
{
    Solution solution;

    int result = solution.findTargetSumWays(nums, target);

    cout << "测试数组:";
    printVector(nums);
    cout << endl;

    cout << "target:" << target << endl;

    cout << "程序结果:" << result << endl;
    cout << "预期结果:" << expected << endl;

    cout << "测试结论:";
    if(result == expected)
        cout << "通过";
    else
        cout << "未通过";

    cout << endl << endl;
}

int main()
{
    // 测试用例1:
    // nums = [1, 1, 1, 1, 1], target = 3
    //
    // 一共有5种方案:
    // -1 +1 +1 +1 +1 = 3
    // +1 -1 +1 +1 +1 = 3
    // +1 +1 -1 +1 +1 = 3
    // +1 +1 +1 -1 +1 = 3
    // +1 +1 +1 +1 -1 = 3
    test({1, 1, 1, 1, 1}, 3, 5);

    // 测试用例2:
    // nums = [1], target = 1
    // 只有一种方案:+1 = 1
    test({1}, 1, 1);

    // 测试用例3:
    // nums = [1], target = -1
    // 只有一种方案:-1 = -1
    test({1}, -1, 1);

    // 测试用例4:
    // nums = [1, 2, 3], target = 0
    // sum = 6
    // aim = (6 + 0) / 2 = 3
    // 能凑出3的组合有:[3]、[1,2]
    // 所以方案数为2
    test({1, 2, 3}, 0, 2);

    // 测试用例5:
    // nums = [2, 3, 5, 6], target = 4
    // sum = 16
    // aim = (16 + 4) / 2 = 10
    // 能凑出10的组合有:[2,3,5]
    // 所以方案数为1
    test({2, 3, 5, 6}, 4, 1);

    // 测试用例6:
    // nums = [1, 2, 7], target = 4
    // sum = 10
    // aim = (10 + 4) / 2 = 7
    // 能凑出7的组合有:[7]
    // 所以方案数为1
    test({1, 2, 7}, 4, 1);

    // 测试用例7:
    // nums = [1, 2, 7], target = 5
    // sum = 10
    // sum + target = 15,是奇数
    // 无法转化为整数目标和,所以方案数为0
    test({1, 2, 7}, 5, 0);

    // 测试用例8:
    // nums = [1, 2, 3], target = 10
    // target绝对值大于sum,无论如何都凑不出来
    test({1, 2, 3}, 10, 0);

    // 测试用例9:
    // nums = [0, 0, 1], target = 1
    //
    // 0可以加正号,也可以加负号:
    // +0 和 -0 数值一样,但属于不同符号方案
    //
    // 两个0分别有2种选择,共 2 * 2 = 4 种
    // 最后1取正号
    // 所以总方案数为4
    test({0, 0, 1}, 1, 4);

    return 0;
}


核心代码

cpp 复制代码
//空间优化后
//解题思路:转化为 01背包求组合方案数,用滚动数组优化空间
class Solution
{
public:
    //函数功能:给数组元素添加 +/- 符号,计算凑出目标值target的总方案数
    int findTargetSumWays(vector<int>& nums, int target)
    {
        //计算数组所有元素的总和
        int sum = 0;
        for(auto x : nums) sum += x;

        //核心公式推导:
        //设正数和为A,负数和为B → A - B = target,A + B = sum
        //联立方程得:A = (sum + target) / 2
        //问题转化:从数组中选若干数,和为A,求总方案数
        int aim = (sum + target) / 2;

        //边界条件判断,不满足则直接返回0种方案:
        //1.目标和aim为负数,无合法方案
        //2.sum+target是奇数,无法整除,无合法方案
        if(aim < 0 || (sum + target) % 2) return 0;

        //n:数组元素个数
        int n = nums.size();

        //一维滚动数组优化:
        //dp[j] 表示:凑出元素和为 j 的总方案数
        vector<int> dp(aim + 1); 

        //初始化:凑出和为0,只有1种方案(不选任何元素)
        dp[0] = 1; 

        //外层循环:遍历每一个元素(01背包的「物品」)
        for(int i = 1; i <= n; i++) 
            //内层循环:逆序遍历目标和(01背包一维优化核心!)
            //逆序遍历:保证每个元素只能被选一次,避免重复选取
            for(int j = aim; j >= nums[i - 1]; j--)
                //状态转移方程:
                //不选当前元素:保持dp[j]原值
                //选当前元素:方案数 += 凑出 j-当前元素值 的方案数
                dp[j] += dp[j - nums[i - 1]];

        //返回结果:凑出和为aim的总方案数
        return dp[aim];
    }
};

完整测试代码

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

// 空间优化后
// 解题思路:转化为 01背包求组合方案数,用滚动数组优化空间
class Solution
{
public:
    // 函数功能:给数组元素添加 +/- 符号,计算凑出目标值target的总方案数
    int findTargetSumWays(vector<int>& nums, int target)
    {
        // 计算数组所有元素的总和
        int sum = 0;
        for(auto x : nums) sum += x;

        // 边界判断1:
        // 如果 target 的绝对值大于 sum,说明无论怎么加减都不可能凑出 target
        if(abs(target) > sum) return 0;

        // 边界判断2:
        // sum + target 必须是偶数,否则无法得到整数 aim
        if((sum + target) % 2) return 0;

        // 核心公式推导:
        // 设正数和为A,负数和为B
        // A - B = target
        // A + B = sum
        // 联立得:A = (sum + target) / 2
        int aim = (sum + target) / 2;

        // aim 为负数,无合法方案
        if(aim < 0) return 0;

        // n:数组元素个数
        int n = nums.size();

        // 一维滚动数组优化:
        // dp[j] 表示:凑出元素和为 j 的总方案数
        vector<int> dp(aim + 1);

        // 初始化:凑出和为0,只有1种方案,即不选任何元素
        dp[0] = 1;

        // 外层循环:遍历每一个元素
        for(int i = 1; i <= n; i++)
            // 内层循环:逆序遍历目标和
            // 逆序遍历保证每个元素只能使用一次
            for(int j = aim; j >= nums[i - 1]; j--)
                // 状态转移:
                // 不选当前元素:dp[j]保持不变
                // 选当前元素:累加dp[j - nums[i - 1]]
                dp[j] += dp[j - nums[i - 1]];

        // 返回结果:凑出和为aim的总方案数
        return dp[aim];
    }
};

void printVector(const vector<int>& nums)
{
    cout << "[";

    for(int i = 0; i < nums.size(); i++)
    {
        cout << nums[i];

        if(i != nums.size() - 1)
            cout << ", ";
    }

    cout << "]";
}

void test(vector<int> nums, int target, int expected)
{
    Solution solution;

    int result = solution.findTargetSumWays(nums, target);

    cout << "测试数组:";
    printVector(nums);
    cout << endl;

    cout << "target:" << target << endl;

    cout << "程序结果:" << result << endl;
    cout << "预期结果:" << expected << endl;

    cout << "测试结论:";
    if(result == expected)
        cout << "通过";
    else
        cout << "未通过";

    cout << endl << endl;
}

int main()
{
    // 测试用例1:
    // nums = [1, 1, 1, 1, 1], target = 3
    //
    // 一共有5种方案:
    // -1 +1 +1 +1 +1 = 3
    // +1 -1 +1 +1 +1 = 3
    // +1 +1 -1 +1 +1 = 3
    // +1 +1 +1 -1 +1 = 3
    // +1 +1 +1 +1 -1 = 3
    test({1, 1, 1, 1, 1}, 3, 5);

    // 测试用例2:
    // nums = [1], target = 1
    // 只有一种方案:+1 = 1
    test({1}, 1, 1);

    // 测试用例3:
    // nums = [1], target = -1
    // 只有一种方案:-1 = -1
    test({1}, -1, 1);

    // 测试用例4:
    // nums = [1, 2, 3], target = 0
    //
    // sum = 6
    // aim = (6 + 0) / 2 = 3
    //
    // 能凑出3的组合有:
    // [3]
    // [1, 2]
    //
    // 所以方案数为2
    test({1, 2, 3}, 0, 2);

    // 测试用例5:
    // nums = [2, 3, 5, 6], target = 4
    //
    // sum = 16
    // aim = (16 + 4) / 2 = 10
    //
    // 能凑出10的组合有:
    // [2, 3, 5]
    //
    // 所以方案数为1
    test({2, 3, 5, 6}, 4, 1);

    // 测试用例6:
    // nums = [1, 2, 7], target = 4
    //
    // sum = 10
    // aim = (10 + 4) / 2 = 7
    //
    // 能凑出7的组合有:
    // [7]
    //
    // 所以方案数为1
    test({1, 2, 7}, 4, 1);

    // 测试用例7:
    // nums = [1, 2, 7], target = 5
    //
    // sum = 10
    // sum + target = 15,是奇数
    // 无法转化为整数目标和,所以无方案
    test({1, 2, 7}, 5, 0);

    // 测试用例8:
    // nums = [1, 2, 3], target = 10
    //
    // target 的绝对值大于 sum
    // 无论如何添加 + / - 都无法凑出 10
    test({1, 2, 3}, 10, 0);

    // 测试用例9:
    // nums = [0, 0, 1], target = 1
    //
    // 两个0都可以选择 +0 或 -0
    // 虽然数值不变,但符号方案不同
    //
    // 第一个0:2种选择
    // 第二个0:2种选择
    // 数字1:只能取 +1
    //
    // 总方案数 = 2 * 2 = 4
    test({0, 0, 1}, 1, 4);

    // 测试用例10:
    // nums = [0, 0, 0, 0, 1], target = 1
    //
    // 四个0分别都有 +0 / -0 两种选择
    // 总方案数 = 2^4 = 16
    test({0, 0, 0, 0, 1}, 1, 16);

    // 测试用例11:
    // nums = [1, 2, 1], target = 0
    //
    // sum = 4
    // aim = (4 + 0) / 2 = 2
    //
    // 能凑出2的组合有:
    // [2]
    // [1, 1]
    //
    // 所以方案数为2
    test({1, 2, 1}, 0, 2);

    return 0;
}

5.最后一块石头的重量||(OJ题)


算法思路:解法(动态规划):

先将问题转化成我们熟悉的题型.

  • 任意两块石头在一起粉碎,重量相同的部分会被丢掉,重量有差异的部分会被留下来.那就相当于在原始的数据的前面,加上加号或者减号,使最终的结果最小即可.也就是说把原始的石头分成两部分,两部分的和越接近越好.
  • 又因为当所有元素的和固定时,分成的两部分越接近数组总和的一半,两者的差越小.

因此问题就变成了:在数组中选择一些数,让这些数的和尽量接近 sum / 2,如果把数看成物品,每个数的值看成体积和价值,问题就变成了01背包问题.

1.状态表示:
dp[i][j] 表示在前 i 个元素中选择,总和不超过 j,此时所有元素的最大和.

2.状态转移方程:

老规矩,根据最后一个位置的元素,结合题目的要求,分情况讨论:

i. 不选 stones[i]:那么我们是否能够凑成总和为 j,就要看在前 i - 1 个元素中选,能否凑成总和为 j.根据状态表示,此时 dp[i][j] = dp[i - 1][j];

ii. 选择 stones[i]:这种情况下是有前提条件的,此时的 stones[i] 应该是小于等于 j.因为如果这个元素都比要凑成的总和大,选择它就没有意义呀.那么我们是否能够凑成总和为 j,就要看在前 i - 1 个元素中选,能否凑成总和为 j - stones[i].根据状态表示,此时 dp[i][j] = dp[i - 1][j - stones[i]] + stones[i].

综上所述,我们要的是最大价值.因此,状态转移方程为:

复制代码
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.初始化:

由于需要用到上一行的数据,因此我们可以先把第一行初始化.

第一行表示没有石子.因此想凑成目标和 j,最大和都是 0.

4.填表顺序:

根据状态转移方程,我们需要从上往下填写每一行,每一行的顺序是无所谓的.

5.返回值:

a. 根据状态表示,先找到最接近 sum / 2 的最大和 dp[n][sum / 2];

b. 因为我们要的是两堆石子的差,因此返回 sum - 2 * dp[n][sum / 2].

6.空间优化:

所有的背包问题,都可以进行空间上的优化.

对于01背包类型的,我们的优化策略是:

i. 删掉第一维;

ii. 修改第二层循环的遍历顺序即可.







核心代码

cpp 复制代码
//解题思路:将问题转化为 01背包问题 - 把石头分成重量最接近的两堆,求最小重量差
class Solution
{
public:
    //函数功能:根据石头粉碎规则,返回最后可能的最小石头重量
    int lastStoneWeightII(vector<int>& stones)
    {
        //1.准备工作:计算所有石头的总重量
        int sum = 0;
        for(auto x : stones) sum += x;
        
        //n:石头数量
        //m:背包容量 = 总重量的一半(目标:凑出不超过m的最大重量)
        int n = stones.size(), m = sum / 2;

        //2.动态规划核心
        //二维dp数组定义:
        //dp[i][j] 表示:从前i个石头中挑选,总重量不超过j时,能凑出的最大重量
        vector<vector<int>> dp(n + 1, vector<int>(m + 1));
        
        //遍历所有石头(外层循环:逐个处理物品)
        for(int i = 1; i <= n; i++)
            //遍历所有背包容量(内层循环:逐个处理容量)
            for(int j = 0; j <= m; j++)
            {
                //情况1:不选第i个石头
                //最大重量 = 前i-1个石头、容量j的最大重量
                dp[i][j] = dp[i - 1][j];
                
                //情况2:选第i个石头(前提:当前容量j >= 石头重量)
                if(j >= stones[i - 1])
                    //取「不选」和「选」两种情况的最大值
                    //选的话:剩余容量j-stones[i-1]的最大重量 + 当前石头重量
                    dp[i][j] = max(dp[i][j], dp[i - 1][j - stones[i - 1]] + stones[i - 1]);
            }

        //3.返回结果
        //总重量 - 2倍的最大凑出重量 = 两堆石头的最小重量差(即最后剩余石头的重量)
        return sum - 2 * dp[n][m];
    }
};

完整测试代码

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

// 解题思路:将问题转化为 01背包问题
// 把石头分成重量最接近的两堆,求最小重量差
class Solution
{
public:
    // 函数功能:根据石头粉碎规则,返回最后可能的最小石头重量
    int lastStoneWeightII(vector<int>& stones)
    {
        // 1. 准备工作:计算所有石头的总重量
        int sum = 0;
        for(auto x : stones) sum += x;

        // n:石头数量
        // m:背包容量 = 总重量的一半
        // 目标:凑出不超过 m 的最大重量
        int n = stones.size(), m = sum / 2;

        // 2. 动态规划核心
        // dp[i][j] 表示:
        // 从前 i 个石头中挑选,总重量不超过 j 时,能凑出的最大重量
        vector<vector<int>> dp(n + 1, vector<int>(m + 1));

        // 遍历所有石头
        for(int i = 1; i <= n; i++)
            // 遍历所有背包容量
            for(int j = 0; j <= m; j++)
            {
                // 情况1:不选第 i 个石头
                dp[i][j] = dp[i - 1][j];

                // 情况2:选第 i 个石头
                if(j >= stones[i - 1])
                    dp[i][j] = max(
                            dp[i][j],
                            dp[i - 1][j - stones[i - 1]] + stones[i - 1]
                    );
            }

        // 总重量 - 2倍的最大凑出重量
        // = 两堆石头的最小重量差
        return sum - 2 * dp[n][m];
    }
};

void printVector(const vector<int>& stones)
{
    cout << "[";

    for(int i = 0; i < stones.size(); i++)
    {
        cout << stones[i];

        if(i != stones.size() - 1)
            cout << ", ";
    }

    cout << "]";
}

void test(vector<int> stones, int expected)
{
    Solution solution;

    int result = solution.lastStoneWeightII(stones);

    cout << "测试石头重量:";
    printVector(stones);
    cout << endl;

    cout << "程序结果:" << result << endl;
    cout << "预期结果:" << expected << endl;

    cout << "测试结论:";
    if(result == expected)
        cout << "通过";
    else
        cout << "未通过";

    cout << endl << endl;
}

int main()
{
    // 测试用例1:
    // stones = [2, 7, 4, 1, 8, 1]
    //
    // 总重量 sum = 23
    // 目标容量 m = 23 / 2 = 11
    //
    // 可以凑出接近一半的重量 11:
    // 例如 [2, 4, 1, 1, 3] 不存在,这里更直观地看:
    // 可以选 [7, 4] = 11
    //
    // 两堆重量分别为 11 和 12
    // 最小差值 = 1
    test({2, 7, 4, 1, 8, 1}, 1);

    // 测试用例2:
    // stones = [31, 26, 33, 21, 40]
    //
    // 总重量 sum = 151
    // 目标容量 m = 75
    //
    // 能凑出的最接近一半的重量是 73
    // 另一堆重量是 78
    // 最小差值 = 5
    test({31, 26, 33, 21, 40}, 5);

    // 测试用例3:
    // stones = [1, 2]
    //
    // 两块石头互相粉碎后剩余重量:
    // 2 - 1 = 1
    test({1, 2}, 1);

    // 测试用例4:
    // stones = [1, 1]
    //
    // 两块一样重的石头可以完全抵消
    // 最后剩余重量为 0
    test({1, 1}, 0);

    // 测试用例5:
    // stones = [10]
    //
    // 只有一块石头,无法粉碎
    // 最后剩余重量就是 10
    test({10}, 10);

    // 测试用例6:
    // stones = [3, 3, 3, 3]
    //
    // 总重量 sum = 12
    // 可以分成 [3, 3] 和 [3, 3]
    // 两堆重量相等,差值为 0
    test({3, 3, 3, 3}, 0);

    // 测试用例7:
    // stones = [1, 3, 5, 7]
    //
    // 总重量 sum = 16
    // 可以分成 [1, 7] 和 [3, 5]
    // 两堆重量都是 8
    // 最小差值为 0
    test({1, 3, 5, 7}, 0);

    // 测试用例8:
    // stones = [2, 2, 2, 7]
    //
    // 总重量 sum = 13
    // 目标容量 m = 6
    // 最接近一半的重量可以凑出 6,即 [2, 2, 2]
    // 另一堆重量为 7
    // 最小差值为 1
    test({2, 2, 2, 7}, 1);

    return 0;
}


核心代码

cpp 复制代码
//空间优化后
//解题思路:将石头分成重量尽可能接近的两堆,差值即为最小剩余重量
//本质转化为:01背包求不超过总重量一半的最大装载重量
class Solution
{
public:
    //函数功能:返回按规则粉碎后,最后剩余石头的最小可能重量
    int lastStoneWeightII(vector<int>& stones)
    {
        //1.准备工作
        //计算所有石头的总重量sum
        int sum = 0;
        for(auto x : stones) sum += x;
        //n:石头数量;m:背包容量(目标:不超过sum/2的最大重量)
        int n = stones.size(), m = sum / 2;

        //2.动态规划(一维滚动数组优化)
        //dp[j] 定义:背包容量为j时,能装下的石头最大重量
        vector<int> dp(m + 1);
        //外层循环:遍历每一块石头(01背包的「物品」)
        for(int i = 1; i <= n; i++)
            //内层循环:逆序遍历背包容量(01背包一维优化核心!)
            //逆序遍历:保证每块石头只能被选择一次,避免重复选取
            for(int j = m; j >= stones[i - 1]; j--)
                //状态转移方程:
                //不选当前石头:保持dp[j]原值
                //选当前石头:dp[j-当前石头重量] + 当前石头重量
                //取两种情况的最大值
                dp[j] = max(dp[j], dp[j - stones[i - 1]] + stones[i - 1]);

        //3.返回结果
        //总重量 - 2倍的最大装载重量 = 两堆石头的重量差(即最终剩余的最小重量)
        return sum - 2 * dp[m];
    }
};

完整测试代码

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

// 空间优化后
// 解题思路:将石头分成重量尽可能接近的两堆,差值即为最小剩余重量
// 本质转化为:01背包求不超过总重量一半的最大装载重量
class Solution
{
public:
    // 函数功能:返回按规则粉碎后,最后剩余石头的最小可能重量
    int lastStoneWeightII(vector<int>& stones)
    {
        // 1. 准备工作
        // 计算所有石头的总重量sum
        int sum = 0;
        for(auto x : stones) sum += x;

        // n:石头数量;m:背包容量
        // 目标:不超过sum/2的最大重量
        int n = stones.size(), m = sum / 2;

        // 2. 动态规划:一维滚动数组优化
        // dp[j] 定义:背包容量为j时,能装下的石头最大重量
        vector<int> dp(m + 1);

        // 外层循环:遍历每一块石头
        for(int i = 1; i <= n; i++)
            // 内层循环:逆序遍历背包容量
            // 逆序遍历保证每块石头只能被选择一次
            for(int j = m; j >= stones[i - 1]; j--)
                dp[j] = max(dp[j], dp[j - stones[i - 1]] + stones[i - 1]);

        // 总重量 - 2倍的最大装载重量
        // = 两堆石头的重量差
        return sum - 2 * dp[m];
    }
};

void printVector(const vector<int>& stones)
{
    cout << "[";

    for(int i = 0; i < stones.size(); i++)
    {
        cout << stones[i];

        if(i != stones.size() - 1)
            cout << ", ";
    }

    cout << "]";
}

void test(vector<int> stones, int expected)
{
    Solution solution;

    int result = solution.lastStoneWeightII(stones);

    cout << "测试石头重量:";
    printVector(stones);
    cout << endl;

    cout << "程序结果:" << result << endl;
    cout << "预期结果:" << expected << endl;

    cout << "测试结论:";
    if(result == expected)
        cout << "通过";
    else
        cout << "未通过";

    cout << endl << endl;
}

int main()
{
    // 测试用例1:
    // stones = [2, 7, 4, 1, 8, 1]
    //
    // 总重量 sum = 23
    // 背包容量 m = 23 / 2 = 11
    //
    // 可以凑出一堆重量 11,例如 [7, 4]
    // 另一堆重量为 12
    // 最小差值 = 12 - 11 = 1
    test({2, 7, 4, 1, 8, 1}, 1);

    // 测试用例2:
    // stones = [31, 26, 33, 21, 40]
    //
    // 总重量 sum = 151
    // 背包容量 m = 75
    //
    // 最优划分可以让两堆重量尽量接近
    // 最终最小差值为 5
    test({31, 26, 33, 21, 40}, 5);

    // 测试用例3:
    // stones = [1, 2]
    //
    // 两块石头互相粉碎后剩余重量为 1
    test({1, 2}, 1);

    // 测试用例4:
    // stones = [1, 1]
    //
    // 两块一样重的石头可以完全抵消
    // 最后剩余重量为 0
    test({1, 1}, 0);

    // 测试用例5:
    // stones = [10]
    //
    // 只有一块石头,无法粉碎
    // 最后剩余重量就是 10
    test({10}, 10);

    // 测试用例6:
    // stones = [3, 3, 3, 3]
    //
    // 总重量 sum = 12
    // 可以分成 [3, 3] 和 [3, 3]
    // 两堆重量相等
    // 最小差值为 0
    test({3, 3, 3, 3}, 0);

    // 测试用例7:
    // stones = [1, 3, 5, 7]
    //
    // 总重量 sum = 16
    // 可以分成 [1, 7] 和 [3, 5]
    // 两堆重量都是 8
    // 最小差值为 0
    test({1, 3, 5, 7}, 0);

    // 测试用例8:
    // stones = [2, 2, 2, 7]
    //
    // 总重量 sum = 13
    // 背包容量 m = 6
    // 可以凑出一堆重量 6,即 [2, 2, 2]
    // 另一堆重量为 7
    // 最小差值为 1
    test({2, 2, 2, 7}, 1);

    // 测试用例9:
    // stones = [5, 5, 5]
    //
    // 总重量 sum = 15
    // 背包容量 m = 7
    // 最多只能凑出 5
    // 另一堆重量为 10
    // 最小差值为 5
    test({5, 5, 5}, 5);

    // 测试用例10:
    // stones = [6, 7, 8, 9]
    //
    // 总重量 sum = 30
    // 背包容量 m = 15
    // 可以凑出 15,例如 [6, 9] 或 [7, 8]
    // 两堆重量都是 15
    // 最小差值为 0
    test({6, 7, 8, 9}, 0);

    return 0;
}


🚀真正的勇者不是流泪的人,而是含泪奔跑的人!


敬请期待下一篇文章内容【动态规划算法】(完全背包问题从状态定义到空间优化)


每日心灵鸡汤:"艰难困苦、玉汝于成."
很喜欢一段话:人生这条路很长,未来星辰大海般璀璨,不必踌躇于过去的半亩方塘,那些所谓的遗憾,可能是一种成长,那些曾受过的伤,终会化作照亮前路的光.总有一天你会明白真正能治愈你的,从来不是时间,而是你心里那段释怀和格局,只要你的内心不慌乱,连世界都难影响你.我们唯一应该厌倦的,是那些黯淡无光的日子,改变自己内心是第一步,把思想化作行动,拥有独立人格,提升自己,幸福会如阳光一样,出现在黎明破晓时刻.

相关推荐
weixin_421725261 小时前
2026年C/C++/C#全解析:底层语言的进化与场景抉择,选错直接掉队
c语言·c++·c·编程语言·技术选择
yyy(十一月限定版)2 小时前
数电1对应latex代码
算法
jieyucx2 小时前
Go语言切片:动态灵活的数据序列
算法·golang·指针·顺序表·数组·结构体·切片
我头发多我先学2 小时前
C++ 红黑树:从规则到实现,手把手带你写一棵红黑树
数据结构·c++·算法
遗憾随她而去.2 小时前
Java学习(一)
java·开发语言·学习
lzh200409192 小时前
深入学习Linux进程间通信:解析消息队列
linux·c++
nlpming2 小时前
opencode SQLite 数据库结构与查询手册
算法
水饺编程2 小时前
第5章,[标签 Win32] :设备的尺寸(三)
c语言·c++·windows·visual studio
Cando学算法2 小时前
中位数定理:到所有点的距离之和最小的点就是中位数
c++·算法·学习方法