背包十论
背包十论
古有《美芹十论》,今有《背包十论》。
一、状态、状态转移与继承、策略与层
状态:就是在一种情况下,对应的最优解,代码中,这种状态通常用数组的下标表示,数组的每一个维度都代表了一个状态。
状态转移:从一个状态的最优解推导下一个状态的最优解,叫做状态转移。和递推不同的是,递推仅仅处理的是数据,而状态转移是一种决策,基本的决策手段有取最值(最优化问题)、求和(计数问题)等。
决策:定义了如何从一个状态的最优解推导下一个状态的最优解,也就是人们常说的动态规划方程。
状态继承:通常这个状态不够条件进行决策,那么只能是上一个状态的最优解,这种转移方式被称为状态继承。
层:层是一种状态,数组的一个维度(通常是第一个维度),层状态之间的转移是紧挨着的,即第 i i i层只能由第 i − 1 i-1 i−1层推导过来,反之也只能决定 i + 1 i+1 i+1层的最优解。
二、滚动数组优化空间
我们看一下层状态 i i i,一般都只和上一层 i − 1 i-1 i−1状态有关系,因此我们也可以像递推斐波那契序列一样,不再需要的值就可以直接覆盖,这种优化的策略叫滚动数组。
三、0/1背包
模型
每个物品都有自己的价值和费用,问如何在固定的背包费用下,并且每个物品只能拿一次,装下的物品的价值最大?
动态规划
动态规划方程:
d p [ i ] [ j ] = max ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − c i ] + w i ) dp[i][j] = \max(dp[i-1][j],dp[i-1][j-c_{i}] +w_{i}) dp[i][j]=max(dp[i−1][j],dp[i−1][j−ci]+wi)
解释:
定义状态 d p [ i ] [ j ] dp[i][j] dp[i][j]为到第 i i i个物品为止,花费费用为 j j j(不一定全花完)能得到的物品最大的价值总和。如何和上一层建立关系呢?
求 d p [ i ] [ j ] dp[i][j] dp[i][j]的有两个策略,第一个是第 i i i个物品我们不选他,把费用 j j j全给前 i − 1 i-1 i−1个物品。第二个策略是第 i i i个物品我们选他,花了 c i c_{i} ci的费用,赚了 w i w_{i} wi的价值,剩下的 j − w i j - w_{i} j−wi费用全给前 i − 1 i-1 i−1个物品。两个取最大值,就得到了我们的动态规划方程。
我们可以发现,在这个问题中的层就是前 i i i个物品,策略有两个,状态就是 i i i和 j j j。状态转移就是从两个策略中选取最大值。
之后,我们把层 i i i状态进行压缩,用滚动数组的思想,因为是访问上一层,因此我们要从后往前递推数组,保证获取到的值都是上一层的。
等等,那枚举 j j j的时候, j j j小于 c i c_{i} ci怎么办?答案是状态继承,小于 c i c_{i} ci就是不能买第 i i i个物品,也就是 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] dp[i][j] = dp[i-1][j] dp[i][j]=dp[i−1][j],因为层状态 i i i被压缩掉了因此我们就不用管他了,直接让他继承上一层的状态值就好,因为没有做出决策,因此叫做状态继承。
代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
ll dp[10000005];
int main()
{
ll capacity;
int n;
cin >> capacity >> n;
while (n--)
{
ll c, w;
cin >> c >> w;
for (ll tol = capacity; tol >= c; t--)
{
dp[tol] = max(dp[tol], dp[tol - c] + w);
}
}
cout << dp[capacity ];
return 0;
}
四、完全背包
模型
每个物品都有自己的价值和体积,问如何在固定的背包费用下,并且每个物品可以拿多次,装下的物品的价值最大?
即与0/1背包不同的是,一个物品可以拿多次。
动态规划
动态规划方程:
d p [ i ] [ j ] = max ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − c i ] + w i ) dp[i][j] = \max(dp[i-1][j],dp[i][j-c_{i}] +w_{i}) dp[i][j]=max(dp[i−1][j],dp[i][j−ci]+wi)
我们发现,与0/1背包相同的是状态 d p [ i ] [ j ] dp[i][j] dp[i][j]仍表示花费 j j j去选前 i i i个物品的价值,层还是状态 i i i,状态是 i i i和 j j j。而对于策略,依旧有两个,只不过稍有不同。
第一个策略,第 i i i个物品一个也不选,就把 j j j全部分给前 i − 1 i-1 i−1个物品,这和0/1背包是相同的。第二个策略,买一个第 i i i个物品,花了 c i c_{i} ci,赚了 w i w_{i} wi的价值,把剩下的 j − c i j-c_{i} j−ci继续分给前 i i i个物品,因为第 i i i个物品可以买多次,当然 d p [ i ] [ j − c i ] dp[i][j-c_{i}] dp[i][j−ci]的值肯定能在 d p [ i ] [ j ] dp[i][j] dp[i][j]之前算出来,我们不关心第 i i i个物品买多少个,只关心 d p [ i ] [ j − c i ] dp[i][j-c_{i}] dp[i][j−ci]的值有正确的含义。之后,同样的状态转移,两个策略取最大值。
在滚动数组中,因为是访问本层,因此我们要从前往后递推数组,这和0/1背包正好相反,保证获取到的值都是本层的。
代码
for (ll tol = c; tol <= capacity; t++)
{
dp[tol] = max(dp[tol], dp[tol - c] + w);
}
除了for枚举的顺序不同之外,其余代码均与0/1背包相同。
现在我们考虑另外一个问题:
给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。
我们的思路是依次在每个位置放置元素,因此我们可以很容易写出动态规划:
class Solution
{
public:
int combinationSum4(vector<int> &nums, int target)
{
vector<int> dp(1005);
for (int i = 1; i <= target; i++)
{
// 依次放置数字
for (int num : nums)
{
if (i - num >= 0 && dp[i - num] < INT_MAX - dp[i])
dp[i] += dp[i - num];
}
}
return dp[target];
}
};
这和完全背包不同的是,价值和物品的枚举互换了位置,带来了问题本质上的变化,因此动态规划背包问题中,枚举的顺序十分重要。
五、多重背包
模型
完全背包的限制版本,每个物体不再是有任意个可以买,而是有一定的数量。即,第 i i i个物品的费用是 c i c_{i} ci,价值是 w i w_{i} wi,个数是 q i q_{i} qi个,问在一定的费用下,能买物品的最大价值是多少?
动态规划
和0/1背包问题相似,只不过策略是个变量,面对第 i i i个物品,我们可以买0次(不买),买1次,买2次,一直到 n i n_{i} ni次(花费足够)。因此我们的状态方程:
d p [ i ] [ j ] = max k = 0 n i ( d p [ i − 1 ] [ j − k × c i ] + k × w i ) dp[i][j] = \max_{k=0}^{n_{i}}(dp[i-1][j- k \times c_{i}] + k \times w_{i}) dp[i][j]=k=0maxni(dp[i−1][j−k×ci]+k×wi)
注意,当 k × c i k \times c_{i} k×ci超过 j j j时停止枚举 k k k。
代码
for (ll tol = capacity; tol >= 0; t--)
{
for(int k = 0;k * c <= tol;k++)
{
dp[tol] = max(dp[tol], dp[tol - k * c] + k * w);
}
}
多重背包模板题,随手写出代码:
#include <bits/stdc++.h>
using namespace std;
#define FR freopen("in.txt", "r", stdin)
int dp[40005];
int main()
{
int n, W;
scanf("%d %d", &n, &W);
int ans = 0;
while (n--)
{
int vi, wi, mi;
scanf("%d %d %d", &vi, &wi, &mi);
for (int i = W; wi <= i; i--)
{
for (int c = 1; c <= mi && wi * c <= i; c++)
{
dp[i] = max(dp[i], dp[i - wi * c] + c * vi);
ans = max(ans, dp[i]);
}
}
}
printf("%d", ans);
return 0;
}
一提交,好家伙,全部超时,这时候我们就需要单调队列优化和二进制优化了。
单调队列优化:
#include <bits/stdc++.h>
using namespace std;
#define FR freopen("in.txt", "r", stdin)
int dp[40005];
int main()
{
int n, W;
scanf("%d %d", &n, &W);
int ans = 0;
while (n--)
{
int vi, wi, mi; // vi价值 wi重量 mi数量
scanf("%d %d %d", &vi, &wi, &mi);
// 枚举余数偏移
for (int d = 0; d < wi; d++)
{
int up = min(mi, (W - d) / wi); // 最多能用多少个
deque<int> que, tque; // 单调队列
int tp = (W - d) / wi; // 点所对应的倍数最大值
for (int x = 0; x <= tp; x++) // 枚举倍数
{
// 考虑点dp[d + wi*x] 取f'[d + xwi] , f'[d + (x-1)wi] + 1 * vi ,..., f'[d + (x-up)wi] + up * vi的最大值
// f'[q] 为上一层的dp[q]
// 考虑统一下标dp[d + xwi] = max(f'[d+(x - k) * wi] - (x-k) * vi) + x * vi
// 先推入旧的项目f'[d+x* wi] - x * vi,此时k = 0
int val = dp[d + x * wi] - x * vi;
while (!que.empty() && val >= que.back())
{
que.pop_back();
tque.pop_back();
}
que.push_back(val);
tque.push_back(x);
// 如果这个值大于最大装载量,那么就抛弃
while (!tque.empty() && tque.front() < x - up)
{
que.pop_front();
tque.pop_front();
}
dp[d + x * wi] = que.front() + vi * x; // 更新值
ans = max(ans, dp[d + x * wi]); // 计算结果
}
}
}
printf("%d", ans);
return 0;
}
二进制优化:利用多重背包的最优性=拆分成0-1背包的最优性
#include <bits/stdc++.h>
using namespace std;
#define FR freopen("in.txt", "r", stdin)
#define TMAX 50005
int dp[40005];
int v[50005];
int w[50005];
int cnt = 0;
int main()
{
int n, W;
scanf("%d %d", &n, &W);
while (n--)
{
int vi, wi, mi; // vi价值 wi重量 mi数量
scanf("%d %d %d", &vi, &wi, &mi);
// 物品的合成与分解在背包问题中的最优不变性
// 按照二进制分解的效率是最好的
for (int j = 1; j <= mi; mi -= j, j <<= 1)
{
cnt++;
v[cnt] = j * vi;
w[cnt] = j * wi;
}
if (mi >= 0)
{
cnt++;
v[cnt] = mi * vi;
w[cnt] = mi * wi;
}
}
// 普通0-1背包
int ans = 0;
for (int i = 1; i <= cnt; i++)
{
for (int WI = W; WI >= w[i]; WI--)
{
dp[WI] = max(dp[WI], dp[WI - w[i]] + v[i]);
ans = max(ans, dp[WI]);
}
}
printf("%d", ans);
return 0;
}
六、混合背包
模型
就是将上面三个问题混合起来,有的物品只能取一次,有的可以取有限次,有的可以取无限次。根据取有限次和无限次选择合适的策略进行转移即可。
伪代码
枚举到第i个物品:
第i个物品是有限物品:
按照多重背包计算dp[i][j]的值
第i个物品是无限物品:
按照完全背包计算dp[i][j]的值
七、二维&多维费用背包
模型
上述问题中,费用只有一种,如果费用有两种,或多种如何解决呢?随着费用维数的增加,我们也可以增加 d p dp dp数组的维数。按照0/1背包、完全背包、多重背包或者混合背包的思路解决即可。
代码
#include <bits/stdc++.h>
using namespace std;
#define FR freopen("in.txt","r",stdin)
typedef long long ll;
int dp[205][205];
int main()
{
int n,M,T;
cin >> n >> M >> T;
while(n--)
{
int mi,ti;
cin >> mi >> ti;
for(int tm = M;tm >= mi;tm--)
for(int tt = T;tt >= ti;tt--)
{
dp[tm][tt] = max(dp[tm][tt],dp[tm - mi][tt - ti] + 1);
}
}
cout << dp[M][T];
return 0;
}
八、分组背包
模型
有 n n n个物品,都有自己的价值和费用。之后,将这些物品进行分组,一个组内只能选一个物品。问最多能选择的价值是多少?
动态规划
动态规划方程:
d p [ i ] [ j ] = max k ∈ G i ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − c k ] + w k ) dp[i][j] = \max_{k \in G_{i}}(dp[i-1][j],dp[i-1][j - c_{k}] + w_{k}) dp[i][j]=k∈Gimax(dp[i−1][j],dp[i−1][j−ck]+wk)
解释:
定义状态 d p [ i ] [ j ] dp[i][j] dp[i][j]为前 i i i个组内,花费 j j j的最大价值。转移策略有多个,第一,当前第 i i i组的物品一个也不买,剩下的 j j j都留给 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][j]。第二,买组 i i i内的第一个商品。第三,买组 i i i内的第二个商品。策略一直到买组 i i i内的最后一个商品。选择最大的进行转移。
这里的状态 i i i不再是物品,而是组,这就好比把组看成是“ n + 1 n+1 n+1个物品”(对应 n + 1 n+1 n+1种策略)进行0/1背包而已。注意,这里拆组为物品的概念非常重要,为我们引出泛化物品埋下了伏笔。
代码
#include <bits/stdc++.h>
#define FR freopen("in.txt", "r", stdin)
using namespace std;
typedef long long ll;
int dp[1005];
struct Good
{
int w, v, id;
} gs[1005];
int main()
{
int n, m;
int mxid = 0;
scanf("%d %d", &m, &n);
for (int i = 0; i < n; i++)
{
scanf("%d %d %d", &gs[i].w, &gs[i].v, &gs[i].id);
mxid = max(mxid, gs[i].id);
}
for (int i = 1; i <= mxid; i++)
{
for (int W = m; W >= 0; W--)
{
for (int j = 0; j < n; j++)
{
if (gs[j].id == i && gs[j].w <= W)
dp[W] = max(dp[W], dp[W - gs[j].w] + gs[j].v);
}
}
}
int ans = 0;
for (int w = 0; w <= m; w++)
{
ans = max(ans, dp[w]);
}
printf("%d", ans);
return 0;
}
以上背包问题我们称为线性背包问题,即物品的选择次序并不决定最大价值的选取,下面介绍非线性背包,例如依赖背包,树上的背包等等。
九、非线性背包——依赖背包和树上背包
模型
我们使用具体的问题来看看依赖背包怎么解决。
对于节点 r r r的子树集合 S S S,每一颗子树就相当于一个分组背包里的组,也就是每一颗子树对应不分配节点,分配一个节点,…,分配n个节点对应不同的价值,然后再按照分组背包求解即可。即先枚举组也就是子树,然后枚举组内的物品,也就是子树分配不同的策略。
#include <bits/stdc++.h>
using namespace std;
#define FR freopen("in.txt","r",stdin)
typedef long long ll;
int n,m;
struct Edge
{
int to;
int nxt;
} e[310];
int head[310];
int tot = 0;
inline void add(int u,int v)
{
tot++;
e[tot].to = v;
e[tot].nxt = head[u];
head[u] = tot;
}
int dp[310][310];
int wei[310];
void dfs(int u)
{
for(int j = 0; j<=m; j++)
{
if(j > 0) dp[u][j] = wei[u];
else dp[u][j] = 0;
}
for(int ne = head[u]; ne != 0; ne = e[ne].nxt)
{
int v =e[ne].to;
dfs(e[ne].to);
for(int j = m; j>=2; j--)
for(int gj = 0; gj<j; gj++)
{
int k = dp[e[ne].to][gj];
int c = dp[u][j - gj];
dp[u][j] = max(dp[u][j],dp[u][j - gj] + dp[e[ne].to][gj]);
}
}
}
int main()
{
cin >> n >> m;
m++;
for(int i = 1; i<=n; i++)
{
int p,w;
cin >> p >> w;
wei[i] = w;
add(p,i);
}
dfs(0);
cout << dp[0][m];
return 0;
}
十、泛化物品
背包问题的终极抽象——泛化物品。
定义一个泛化物品是一个关于分配费用 c c c的函数 f ( c ) = w f(c)=w f(c)=w,即分配不同的费用对应不同的价值。
例如:0/1背包中的泛化物品函数为 f ( c ) = w i f(c) = w_{i} f(c)=wi当且仅当 c = c i c=c_{i} c=ci,其他情况均为 0 0 0。完全背包中 f ( c ) = n w i f(c)=nw_{i} f(c)=nwi,当且仅当 n = c / c i n = c / c_{i} n=c/ci其他情况均为0。
再例如上述依赖背包中,每一颗子树就是一个泛化物品,分配不同的费用对应不同的价值。
通常求解这种泛化物品的解题方案是:
枚举泛化物品:
枚举总价值:
枚举分给当前泛化物品的价值(小于总价值):
状态转移
更进一步抽象,背包问题的本质就是针对一个函数族 f 1 ( c ) , f 2 ( c ) , … , f n ( c ) f_{1}(c),f_{2}(c),\ldots,f_{n}(c) f1(c),f2(c),…,fn(c),解决最优化问题 f 1 ( c 1 ) + f 2 ( c 2 ) + … + f n ( c n ) = W f_{1}(c_{1})+f_{2}(c_{2})+\ldots+f_{n}(c_{n}) = W f1(c1)+f2(c2)+…+fn(cn)=W,其中 c 1 + c 2 + … + c n = C c_{1}+c_{2}+\ldots+c_{n}=C c1+c2+…+cn=C, C C C为定值的,令 W W W最大的最优化问题。
还有一点需要注意的是,如果物品的价值或费用跟物品的访问顺序没有关系,那么物品的访问顺序是无关紧要的,如果有关系,那么必须先按照贪心的规则排序然后再进行顺序背包。如果排序也解决不了,那么这个问题就背包算法就无法解决,只能依靠其他最优化算法解决,如模拟退火。
排序背包的做法:P1417
一些杂项
当涉及到倍数背包的时候,我们可以保存他的倍数,而不是实际数值。
#include <bits/stdc++.h>
#define FR freopen("in.txt", "r", stdin)
using namespace std;
typedef long long ll;
ll dp[2005][1005];
int main()
{
int n, f;
scanf("%d %d", &n, &f);
dp[0][0] = 1;
for (int i = 1; i <= n; i++)
{
int val;
scanf("%d", &val);
for (int w = 0; w <= f - 1; w++)
{
dp[i][w] = (dp[i - 1][w] + dp[i - 1][(((w - val) % f) + f) % f]) % 100000000;
}
}
printf("%lld", (dp[n][0] - 1) % 100000000);
return 0;
}
总结
我们从动态规划的基本理念到背包问题的本质内涵,一一介绍给读者。计算机科学本身就是一种抽象科学,在不断抽象的过程中看透事物的本质,从特适走向普适的过程,也正是那句话所说的:
普适的代价是抽象。