背包DP全讲

目录

前置知识

引入

0 - 1 背包问题

解释

解题思路

引例AC代码

本节思考

完全背包问题

解释与解析

0-1背包思考题AC代码

本节思考

多重背包问题

解析

二进制分组优化

解释

过程

代码实现

小试牛刀

单调队列优化

代码实现

小试牛刀

二维费用背包

分组背包

解析

小试牛刀

Leetcode 1155. 掷骰子等于目标和的方法数

2218. 从栈中取出 K 个硬币的最大面值和 - 力扣(LeetCode)

总结

有依赖背包


前置知识

前置知识请看另一篇博文 动态规划基础讲义

引入

在正是讲解各类背包问题时,我们先来看看一道题目。

P2871 [USACO 07 DEC] Charm Bracelet 

提议概要:

在上述例题中,由于每个物体只有两种可能的状态(取与不取),对应二进制中的 0 和 1 ,这类问题便被称为「0-1 背包问题」。

0 - 1 背包问题

解释

已知我们有 W 容量的背包, 以及 v_{i} 价值和 w_{i} 存储体积的 n 个物品。同时,每一件物品只能选取一次,试问如何选取物品可以使背包内物品价值最大

就如下图所示:

解题思路

例题中已知条件有第 i 个物品的重量 w_{i} ,价值 v_{i} ,以及背包的总容量 W 。

设 DP 状态 f(i, j) 为在只能放前 i 个物品的情况下,容量不超过 j 的背包所能达到的最大总价值。

考虑状态转移方程,假设我们当前处理到第 i 个物品,即前 i - 1 个物品已经处理完毕。根据 [0-1背包问题] 的阐述,我们对物品 i 只有两种处理(策略)方式----选取(1)/不选取(0)。所以,我们可以写出两种处理方式下对应的方程:选取 --> f(i, j) = f(i - 1, j - w_{i}) + v_{i}, 不选取 --> f(i, j) = f(i - 1, j)

此外,关注到 [0-1背包问题] 是一个最大化问题。所以,f(i, j) = max{选取情况, 不选取情况}

由此可以得出状态转移方程:

f(i, j) = max{f(i - 1, j - w_{i}) + v_{i}, f(i - 1, j)}

务必牢记并理解这个转移方程,因为大部分背包问题的转移方程都是在此基础上推导出来的。

因此,我们获取得到关键代码段

for (int i = 1; i < n; ++i) {//处理到第 i 个物品

        for (int l = w[i]; l < W; ++l) {//枚举重量

                f[i][l] = max(f[i - 1][l], f[i - 1][l - w[i]] + v[i]);//根据我们推导出的公式编写

        }

}

空间优化

我们知道外部循环固定了我们处理到第 i 个物品。所以我们可以将 f(i, j) --> f(j)。但是,此时就需要注意一些设计细节

状态转移示意图(原版)

j - w[i]...j
i - 1行1...1
i 行0...2

注:1表示计算时需要的状态, 2表示计算的状态。

而现在我们需要去除行的信息,那么上表变形为

状态转移示意图(优化)

j - w[i]...j
i - 1行/ i 行1...1/2

注:1表示计算时需要的状态, 2表示计算的状态,蓝色表示i - 1行,红表示 i 行。

我们发现为了保证能够正确的转移,所以次数数组的前部分是第 i - 1行的信息即蓝色,后部分是第 i 行的信息即红色

所以关键代码修改为

for (int i = 1; i < n; ++i) {//处理到第 i 个物品

        for (int l = W; l > w[i]; --l) {//枚举重量

                f[l] = max(f[l], f[l - w[i]] + v[i]);

        }

}

引例AC代码

#include <iostream>
#include <algorithm>
using namespace std;
const int maxn = 13010;
int n, W, w[maxn], v[maxn], f[maxn];

int main() {
  cin >> n >> W;
  for (int i = 1; i <= n; i++) cin >> w[i] >> v[i];  // 读入数据

  for (int i = 1; i <= n; i++) {
    for (int l = W; l >= w[i]; l--) {
      f[l] = std::max(f[l],f[l - w[i]] + v[i]);  // 状态方程
    }
  }

  cout << f[W];
  return 0;
}

本节思考

1.如果题目被改写为每个物品可以放置无数件呢?

也就是 P1616 疯狂的采药

2.如果我们增加一个新的费用维度呢?

[luogu] P1855 榨取kkksc03

3.如果我们给物品加上组别,每一组内只能选取一个物品,如何选取物品才能获取最大价值。 

 Leetcode 1155. 掷骰子等于目标和的方法数

4.如果我们选取某个物品的具有附加条件,即选择该物品前必须选择另外一个物品。如何获取最大值?

完全背包问题

由 [0-1背包问题] 最后的思考我们便引出了完全背包问题。

解释与解析

已知我们有 W 容量的背包, 以及 v_{i} 价值和 w_{i} 存储体积的 n 个物品。同时,每一件物品可以选取无数次,试问如何选取物品可以使背包内物品价值最大

OKOK,我当然知道上面的表述是有问题的!在

包有容量限制的条件下,一个物品可以选取无数次是毫无意义的!所以我本人更喜欢表述为每一件物品可以选取足够多次。什么意思呢?也就是我们的第 i 件物品至多只能选取 \left \lfloor W/w[i] \right \rfloor , 其中符号 \left \lfloor \right \rfloor 表示向下取整

所以我们不妨像 [0-1背包问题] 那样来假设处理。f(i, j) 表示处理到第 i 个物品,不超过重量 j 的最大价值。此时,我们假设我们已经处理到了第 i 个物品,即前 i - 1个物品处理完毕。面对完全背包问题,我们对物品 i 的处理(策略)方式只有两个----不选取/选取,选1到 \left \lfloor W/w[i] \right \rfloor。所以,我们可以给出对应的状态方程。

不选取:f(i, j) = f(i - 1, j)

选取:f(i, j) = f(i - 1, j - k * w[i]) + k * v[i], 其中 1\leqslant k \leqslant \left \lfloor j/w[i] \right \rfloor

根据上述表达式,我们发现我们需要对 k 进行枚举。但是,面对此类递推式子我们是可以优化的。为了表达 f(i, j), 我们目光来到表达式 f(i, j - w[i]) 身上。

根据上述表达式子,我们知道 f(i, j - w[i])如下

不选取:f(i, j - w[i]) = f(i - 1, j - w[i])

选取:f(i, j - w[i]) = f(i - 1, j - w[i] - k * w[i]) + k * v[i], 其中 1\leqslant k \leqslant \left \lfloor j/w[i]\right \rfloor - 1

因此 f(i, j) 在选取的情况下,被 f(i, j - w[i]) 完美包含。

由此我们得到递推表达式子:

f(i, j) = max{f(i, j - w_{i}) + v_{i}, f(i - 1, j)}

同样的,我们对完全背包进行空间优化 f(i, j) --> f(j)。

状态转移示意图(原版)

j - w[i]...j
i - 1行0...1
i 行1...2

注:1表示计算时需要的状态, 2表示计算的状态。

而现在我们需要去除行的信息,那么上表变形为

状态转移示意图(优化)

j - w[i]...j
i - 1行/ i 行1...1/2

注:1表示计算时需要的状态, 2表示计算的状态,蓝色表示i - 1行,红表示 i 行。

同 [0-1背包问题] 的分析,所以我们的循环方向应该是正向的。

for (int i = 1; i < n; ++i) {//处理到第 i 个物品

        for (int l = w[i]; l <= W ; ++l) {//枚举重量

                f[l] = max(f[l], f[l - w[i]] + v[i]);

        }

}

PS:这里能够优化的前提是 k_{i} 能取到恰当大

0-1背包思考题AC代码

#include <iostream>
using namespace std;
const int maxn = 1e4 + 5;
const int maxW = 1e7 + 5;
int n, W, w[maxn], v[maxn];
long long f[maxW];

int main() {
  cin >> W >> n;
  for (int i = 1; i <= n; i++) cin >> w[i] >> v[i];

  for (int i = 1; i <= n; i++) {
    for (int l = w[i]; l <= W; l++) {
      if (f[l - w[i]] + v[i] > f[l]) f[l] = f[l - w[i]] + v[i];  // 核心状态方程
    }
  }

  cout << f[W];
  return 0;
}

本节思考

如果 k_{i} 不能取到恰当大呢?也就是我们做出限制,没见物品至多选取 k_{i} 个。

多重背包问题

啊哈!没想到吧,由 [完全背包问题] 的思考,或者说对其做出的限制便引出了我们新一节的主角!

没错,在 [完全背包问题] 的基础上对 k_{i} 做出新限制,便成为了我们多重背包

所以,希望读者能够明白各个背包之间的关系与区别,从而可以合理运用、快速编码。基于目前为止的背包问题表述大体相同,而区别在于思考之处。所以在此我们就不给出解释了。

解析

还记得吗?我们曾在完全背包问题中便指出需要对 k_{i} 进行枚举(已在上节,红字标出)!所以有一个很自然的代码段落,也许这也是网上最常见到代码段落。

for (int i = 1; i < n; ++i) {                                             //处理到第 i 个物品

        int max_k = min(k[i], W / w[i]);

        for (int times = 0; times <= max_k; ++times) {

                for (int l = times * w[i]; l <= W ; ++l)  {        //枚举重量

                        f[i][l] = max(f[i][l], f[i - 1][l - times * w[i]] + times * v[i]);

                }

        }

}

同样的,我们先来思考能否进行空间优化。同 [0-1背包问题] 和 [完全背包问题] 一样,我们可以打表或者画图,来思考。这部分留个读者自己动手操作、思考。我们直接给出答案。

     for (int pos = 1; pos < n; ++pos) {
            int K = min(W / w[i], k[i]);         //最大选取次数
            int min_w = w[i];                    //最小选取额度
            for (int l = W; l >= min_w; --l) {
                for (int k = 1; k <= K; ++k) {
                    if (l - k * min_w < 0) break;//当前重量不支持选取k件物品

                    f[l] = max(f[l], f[l - k * min_w] + k * v[i]);
                }
            }
        }

现在我们给出时间复杂度 O(W\sum_{i = 1}^{n}k_{i})。

二进制分组优化

之所以将该优化单独拉出来,是因为该优化的底层思想比较重要。同时,在网络上众多教程所提及而不讲解的。

之所以提出这个优化方式,是基于一个想法--将 [多重背包问题] 转置为 [0-1背包问题],是基于一个思想--整体/局部思想,是基于一个底层计算机逻辑--二进制数

解释

显然的是,我们知道 O(N * W)的部分是不能够优化了。可以说该时间复杂度是背包问题的下限,因为所有背包问题在一定程度上,都可以转置为 [0-1背包问题] 而 [0-1背包问题] 的时间解决下限便是 O(N * W)。所以我们只能从 \sum k_{i} 下手。

为了表述方便,我们用 A(i,j) 代表第 i 种物品拆分出的第 j 个物品。

在朴素的做法中, 对于 \forall j \leqslant k_{i},A(i, j) 均表示相同物品。那么我们效率低的原因主要在于我们进行了大量重复性的工作。举例来说,我们考虑了「同时选 A(i, 1) 和 A(i, 2)」与「同时选 A(i, 2) 和 A(i, 3)」这两个完全等效的情况,因为这种情形都是选取了两个第 i 种物品。这样的重复性工作我们进行了许多次。那么优化拆分方式就成为了解决问题的突破口。

过程

我们可以通过「二进制分组」的方式使拆分方式更加优美。

具体地说就是令 A(i, j), j ∈ (0, \left \lfloor logk_{i} \right \rfloor - 1) 分别表示由 2^{j} 个单个物品「捆绑」而成的大物品(整体思想)。特殊地,若 k_{i} + 1 不是 2 的整数次幂,则需要在最后添加一个由 k_{i} - 2^{\left \lfloor logk_{i} + 1 \right \rfloor - 1} 个单个物品「捆绑」而成的大物品用于补足(也就是,剩余部分单独捆绑)。

举几个例子:

6 = 1 + 2 + 3;

8 = 1 + 2 + 4 + 1;

18 = 1 + 2 + 4 + 8 + 3;

31 = 1 + 2 + 4 + 8 + 16;

显然,通过上述拆分方式,可以表示任意 j \leqslant k_{i} 个物品的等效选择方式。将每种物品按照上述方式拆分后,使用 0-1 背包的方法解决即可。

时间复杂度 O(W\sum_{i = 1}^{n} logk_{i})

代码实现

index = 0;
for (int i = 1; i <= n; i++) {
  int c = 1, ith_w, ith_v, k;
  cin >> ith_w >> ith_v >> k; //获取第 i 种物品重量、价值、最大选取个数
  
  //捆绑操作
  while (k > c) {
    k -= c;
    list[++index].w = c * ith_w;
    list[index].v = c * ith_v;
    c *= 2;
  }
  //剩余部分捆绑
  list[++index].w = ith_w * k;
  list[index].v = ith_v * k;
}

完整实现代码

int main() {
    //读入 N, W
    cin >> N >> W;
    int index = 0;
    for (int i = 1; i <= N; i++) {
        int c = 1, ith_w, ith_v, k;
        cin >> ith_v >> ith_w >> k; //获取第 i 种物品重量、价值、最大选取个数

        //捆绑操作
        while (k > c) {
            k -= c;
            list[++index].w = c * ith_w;
            list[index].v = c * ith_v;
            c *= 2;
        }
        //剩余部分捆绑
        list[++index].w = ith_w * k;
        list[index].v = ith_v * k;
    }

    N = index;
    //0-1背包代码
    for (int i = 0; i <= N; ++i) {
        for (int l = W; l >= list[i].w; --l) {
            f[l] = max(f[l], f[l - list[i].w] + list[i].v);
        }
    }

    cout << f[W];
    return 0;
}

小试牛刀

[luogu] P1776 宝物筛选

根据上述模板,可以获得代码

#include <iostream>
#include <algorithm>
using namespace std;
int N, W;

struct Product {
    int w, v;
};
Product list[1000001];
int f[400005];
int main() {
    //读入 N, W
    cin >> N >> W;
    int index = 0;
    for (int i = 1; i <= N; i++) {
        int c = 1, ith_w, ith_v, k;
        cin >> ith_v >> ith_w >> k; //获取第 i 种物品重量、价值、最大选取个数

        //捆绑操作
        while (k > c) {
            k -= c;
            list[++index].w = c * ith_w;
            list[index].v = c * ith_v;
            c *= 2;
        }
        //剩余部分捆绑
        list[++index].w = ith_w * k;
        list[index].v = ith_v * k;
    }

    N = index;
    //0-1背包代码
    for (int i = 0; i <= N; ++i) {
        for (int l = W; l >= list[i].w; --l) {
            f[l] = max(f[l], f[l - list[i].w] + list[i].v);
        }
    }

    cout << f[W];
    return 0;
}

单调队列优化

单调队列的优化关键在于对表达式子的变形。任然我们不能够对 O(NW) 的部分进行优化,所以目标任然是 \sum k_{i}

考虑优化 f(i, j) 的转移。为方便表述,设 g(x, y) = f(i, x * w[i] + y), g'(x, y) = f(i - 1, x * w[i-1] + y)则转移方程可以表示为:

 注意下面的操作, 我愿称为 “神来一手”。设 G(x, y) = g'(x, y) - x * v[i]

那么 g'(x - k, y) + v[i] * k = g'(x - k, y) - (x - k) *v[i] + k * v[i] + x * v[i] = G(x - k, y) + x * v[i]

这里使用了“凑”的变换技巧,因为造成我们麻烦的是k,所以要消去k,就要产生 + k * v[i] 的项,使用常值或者我们容易获取的值来代替 k。这是变换的初衷

状态转移方程又可以进一步表述为

做一些解释,这里 y 又可以称为剩余量,如果你看到其他blog。其实这里使用了整数的余数表示方式,即 整数 = 倍数 * 因子 + 余数为什么想到?很简单因为我们每次 - w[i],在数学的表达式上就是 j = x * w[i] + y

OK,扯回正题。根据上述表达式,我们可以反应过来可以使用单调栈/单调队列来维护

代码实现

版本1:

int solve(int N, int W, vector<int>& w, vector<int>& v, vector<int>* k) {
    vector<int> dp(W + 1, 0); // g
    vector<int> pre; // 辅助数列g',保存上一次dp(数组)值。见推理过程
    vector<int> que(W + 1); // 当前队列,

    for (int i = 0; i < N; ++i) {//循环初始条件按照实际情况来,i表示第i种物品
        pre = dp;
        
        for (int y = 0; y < w[i]; ++y) {//枚举剩余数-->正确、不漏地表示每一个数
            int head = 0; int tail = -1;//单调队列的首尾指针,初始--空队列

            for (int num = y; num <= W; num += w[i]) {
                // 剔除不满足关系的队头,对头是最大值
                // 那满的重量都不足 num
                while (head <= tail && num >= que[head] + w[i] * k[i]) ++head;

                // 使用G的等价表达式剔除尾部小于此时新G的部分
                while (head <= tail &&
                pre[num] - (num - y)/w[i] * v[i] >= pre[que[tail]] - (que[tail] - y)/w[i] *v[i]) --tail;
                
                que[++tail] = num;
                dp[num] = pre[que[head]] + (num - que[head])/w[i] * v[i];
            }
        }
    }
    
    return dp[W];
}

上述的编码方式更容易理解一点。当然有也以下版本。

版本2:

int solve(int N, int W, vector<int>& w, vector<int>& v, vector<int>* k) {
    vector<int> dp(W + 1, 0); // g
    vector<int> pre; // 辅助数列g',保存上一次dp(数组)值。见推理过程
    vector<int> que(W + 1); // 当前队列,

    for (int i = 0; i < N; ++i) {//循环初始条件按照实际情况来,i表示第i种物品
        pre = dp;
        
        for (int y = 0; y < w[i]; ++y) {//枚举剩余数-->正确、不漏地表示每一个数
            int head = 0; int tail = -1;//单调队列的首尾指针,初始--空队列

            for (int x = 0; x * w[i] + y <= W; ++x) {
                // 剔除不满足关系的队头,对头是最大值
                // 拿满都不足 x 个
                while (head <= tail && x >= que[head] + k[i]) ++head;

                // 使用G的等价表达式剔除尾部小于此时新G的部分
                while (head <= tail &&
                pre[y + x*w[i]] - x * v[i] >= pre[y + que[tail]*w[i]] - que[tail]*v[i]) --tail;
                
                que[++tail] = x;
                
                //使用g 和 g'的关系更新
                dp[y + x*w[i]] = pre[y + que[head]*w[i]] + (x - que[head]) * v[i];
            }
        }
    }
    
    return dp[W];
}

时间复杂度O(NW)。

小试牛刀

[Acwin] 多重背包问题III

 根据代码思路,我们得到AC代码

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
//多重背包问题: 限制每种物品可取次数 
//究极优化:单调队列
const int M = 20010, N = 1010;
int n, m;
int dp[M], g[M];
int que[M]; //队列只存储在同余的集合中是第几个,不存储对应值
int main() {
	cin >> n >> m;
	for(int i = 0; i < n; i ++){
		int v, w, s;
		cin >> v >> w >> s;
		
		//复制一份副本g,因为这里会是从小到大,不能像0-1背包那样从大到小,所以必须申请副本存i-1状态的,不然会被影响 
		memcpy(g, dp, sizeof dp);	
		for(int r = 0; r < v; r ++) {	//因为只有与v同余的状态 相互之间才会影响,余0,1,...,v-1 分为v组 
			int head = 0, tail = -1;
			for(int k = r; k <= m; k += v) { //每一组都进行处理,就相当于对所有状态都处理了
				if(head <= tail && k > que[head] + s*v) head++;
				
				while(head <= tail && g[k] - (k - r)/v * w >= g[que[tail]] - (que[tail] - r)/v * w) tail --;
				 
				que[++ tail] = k; 
				
				dp[k] = g[que[head]] + (k - que[head])/v * w; 
			}
		}
	}
	cout << dp[m] << endl; 
	return 0;
}

同时,我们给出 [luogu] P1776 宝物筛选 的此种解法代码

#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
//多重背包问题: 限制每种物品可取次数 
//究极优化:单调队列
const int M = 40005, N = 105;
int n, m;
int dp[M], g[M];
int que[M]; //队列只存储在同余的集合中是第几个,不存储对应值
int main() {
	cin >> n >> m;
	for (int i = 0; i < n; i++) {
		int v, w, s;
		cin >> w >> v >> s;

		//复制一份副本g,因为这里会是从小到大,不能像0-1背包那样从大到小,所以必须申请副本存i-1状态的,不然会被影响 
		memcpy(g, dp, sizeof dp);
		for (int r = 0; r < v; r++) {	//因为只有与v同余的状态 相互之间才会影响,余0,1,...,v-1 分为v组 
			int head = 0, tail = -1;
			for (int k = r; k <= m; k += v) { //每一组都进行处理,就相当于对所有状态都处理了
				if (head <= tail && k > que[head] + s * v) head++;

				while (head <= tail && g[k] - (k - r) / v * w >= g[que[tail]] - (que[tail] - r) / v * w) tail--;

				que[++tail] = k;

				dp[k] = g[que[head]] + (k - que[head]) / v * w;
			}
		}
	}
	cout << dp[m] << endl;
	return 0;
}

二维费用背包

还记得我们的在 [0-1背包问题] 中的第2个思考吗?第2个思考:如果增加一个新的费用维度。没错增加一个新的的费用维度,题目变成了二位费用背包。N维费用背包大家也可以自己理解了。稍微提一嘴,其实你可以认为在此之前的背包问题都视为一维费用背包问题

所以这个是很明显的 0-1 背包问题,可是不同的是选一个物品会消耗两种价值(以榨取为例子是经费、时间),只需在状态中增加一维存放第二种价值即可。

这时候就要注意,再开一维存放物品编号就不合适了,因为容易 MLE。

二维费用背包关键代码为

for (int k = 1; k <= n; k++)
  for (int i = m; i >= mi; i--)    // 对经费进行一层枚举
    for (int j = t; j >= ti; j--)  // 对时间进行一层枚举
      dp[i][j] = max(dp[i][j], dp[i - mi][j - ti] + 1);

[luogu] P1855 榨取kkksc03 的AC代码为

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
int n,M,T,dp[1010][1010];
int m,t;
int main()
{
    scanf("%d%d%d",&n,&M,&T);
    for(int i=1;i<=n;i++)
    {
        //仅仅只是多了一维而已 
        scanf("%d%d",&m,&t);
        for(int j=M;j>=m;j--)
            for(int k=T;k>=t;k--)
            {
                dp[j][k]=max(dp[j][k],dp[j-m][k-t]+1);
            }
    }
    printf("%d\n",dp[M][T]);
}

分组背包

OK,我们通过最基本的 [0-1背包问题] 与 [完全背包问题] 的思考加深,得到了许多新的背包问题以及他们的解决方案。现在我们需要面对 [0-1背包问题] 的第三个思考问题:如果给物品加上组别,组别内的物品是相互冲突的,即不能存放在一起。而组间的物品是可以存放在一起的。请问此时如何选取物品使得背包内价值最大

基于 [0-1背包问题] 提出的第三个思考问题,我们衍生出了分组背包问题。借此我们正是进入到分组背包的学习。

解析

通过我们之前的问题学习与锤炼,我们可以很快反应出来需要对 [0-1背包问题] 式子做出恰当变形。

这个问题变成了每组物品有若干种策略:是选择本组的某一件,还是一件都不选。也就是说设 F [k , v] F[k, v]F[k,v] 表示前 k 组物品花费费用 v 能取得的最大权值(收益),则有:

我们在此给出伪码

for (group k = 1; k < N; ++k) {

        for (tar = 0; tar <= target; ++tar) {

                for (item i = 1; i < size(k); ++i) {

                        //状态转移方程

                }

        }

}

小试牛刀

Leetcode 1155. 掷骰子等于目标和的方法数

AC代码

class Solution {
public:
    int numRollsToTarget(int n, int k, int target) {
        vector<vector<int>> dp = vector<vector<int>>(n + 1, vector<int>(target + 1, 0));
        dp[0][0] = 1;
        int mod = 1000000007;
        
        for(int i = 1; i <= n; ++i){
            for(int tar = 1; tar <= target; ++tar){
                for(int j = min(k, tar); j > 0; --j){
                    dp[i][tar] = (dp[i-1][tar - j] + dp[i][tar]) % mod; 
                }
            }
        }

        return dp[n][target];
    }
};

2218. 从栈中取出 K 个硬币的最大面值和 - 力扣(LeetCode)

提示:

此题需要做一些变换,因为一个栈中的元素是非互斥的。因此,不能将 “栈” 视作一个分组。因此,需要做出变换。我们发现最大情况是固定的、可知的,即某一个 “栈” 取出几个硬币是固定的。而某一个栈取多少硬币是互斥的

PS:例如最大价值的情况是第一个 “栈” 取 2 枚硬币,那么最大情况不可能是第一个 “栈” 取其他次数的硬币。所以说某一个栈取多少硬币是互斥的。

AC代码

class Solution {
public:
    int maxValueOfCoins(vector<vector<int>>& piles, int k) {
        int n = piles.size();//n个栈;
        vector<int> dp = vector<int>(k + 1, 0);

        for(int i = 0; i < n; ++i){
            for(int j = 1; j < piles[i].size(); ++j){
                piles[i][j] += piles[i][j - 1];
            }
        }//一个栈分成n组,使用前缀和维护。
        
        for(int i = 0; i < n; ++i){
            for(int times = k; times >= 0; --times){
                for(int j = 1; j <= piles[i].size(); ++j){
                    if(times >= j) dp[times] = std::max(dp[times], dp[times - j] + piles[i][j - 1]);
                }
            }
        }

        return dp[k];
    }
};

总结

我相信大家发现了,在对于第一道题和第二题,我们的DP数组维度是不同的。首先,这里有一些细节。例如,“掷骰子” 这道题是每轮都掷骰子的,也就是每组都要选取一个元素;而 “硬币和” 则是没有每组都要取一个元素的情况。所以,“掷骰子” 没有去掉 组别的维度

有依赖背包

 (长叹一口气...)

终于,来到最后一个背包问题了。这个背包问题对应就是 [0-1背包问题] 中的思考4。而这个问题已经半只脚踏入了 树形DP问题。这是有难度,请耐心沉淀。

一般我们研究的都是简化的问题版本,也就是有依赖背包问题还有一个前提条件:一个物品能被多个物品依赖,但是不能依赖多个物品

那么我们可以使用简单的数据结构--树来反应上述关系,如下图。

我们拥有一个 “主件(host)” 和 k 个 “附件(Appendixes)”。现在,摆在我们面前的问题是每一个附件都有选与不选两种状态。所以,如果枚举所有选择状态那么我们的时间复杂度在 O(2^{k})。这所付出的代价是高昂的。

可是,我们的策略并不是死路一条。因为我们需要获取的是最大价值。所以从另外一个角度出发,我们即便是 相同费用 的 n 个方案,我们也是调取价值最大的方案

而记录 相同费用方案中的最大值恰巧就是 [0-1背包问题]。当然这里的费用是一维的(此时费用为重量)。也就是说,我先对 “附件” 进行一次 [0-1背包]。

但是上面的图只是我们的简单情况,可能附件还有附件。所以要对附件的附件进行一次 [0-1背包],而反应到上层 k个附件已然变成了 k 个分组。所以我们对每一个 “主件” 进行一次 [分组背包]

实现代码

//考虑从 主件(根) u 处理
void dfs(int u) {
    for (int appendix: appendixes) {
        dfs(appendix)
        
        //进行一次分组背包,注意前提选附件必要选主件 W - w[u]
        for (int m = W - w[u]; m >= w[appendix]; --m) {
            for (int k = 0; k <= m; ++k) {
                 f[u][m] = max(f[u][m], f[u][m - k] + f[appendix][k]);
            }
        }
    }

    //加入 主件u 的值
    for (int m = W; m >= w[u]; --m) f[u][m] = f[u][m - w[i]] + v[u];//能选状态
    for (int m = 0; m < w[u]; ++m) f[u][m] = 0;//不能选状态
    return ;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值