背包十论

背包十论

古有《美芹十论》,今有《背包十论》。

一、状态、状态转移与继承、策略与层

状态:就是在一种情况下,对应的最优解,代码中,这种状态通常用数组的下标表示,数组的每一个维度都代表了一个状态。

状态转移:从一个状态的最优解推导下一个状态的最优解,叫做状态转移。和递推不同的是,递推仅仅处理的是数据,而状态转移是一种决策,基本的决策手段有取最值(最优化问题)、求和(计数问题)等。

决策:定义了如何从一个状态的最优解推导下一个状态的最优解,也就是人们常说的动态规划方程

状态继承:通常这个状态不够条件进行决策,那么只能是上一个状态的最优解,这种转移方式被称为状态继承

层:层是一种状态,数组的一个维度(通常是第一个维度),层状态之间的转移是紧挨着的,即第 i i i层只能由第 i − 1 i-1 i1层推导过来,反之也只能决定 i + 1 i+1 i+1层的最优解。


二、滚动数组优化空间

我们看一下层状态 i i i,一般都只和上一层 i − 1 i-1 i1状态有关系,因此我们也可以像递推斐波那契序列一样,不再需要的值就可以直接覆盖,这种优化的策略叫滚动数组


三、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[i1][j],dp[i1][jci]+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 i1个物品。第二个策略是第 i i i个物品我们选他,花了 c i c_{i} ci的费用,赚了 w i w_{i} wi的价值,剩下的 j − w i j - w_{i} jwi费用全给前 i − 1 i-1 i1个物品。两个取最大值,就得到了我们的动态规划方程。

我们可以发现,在这个问题中的就是 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[i1][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[i1][j],dp[i][jci]+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 i1个物品,这和0/1背包是相同的。第二个策略,买一个 i i i个物品,花了 c i c_{i} ci,赚了 w i w_{i} wi的价值,把剩下的 j − c i j-c_{i} jci继续分给前 i i i个物品,因为第 i i i个物品可以买多次,当然 d p [ i ] [ j − c i ] dp[i][j-c_{i}] dp[i][jci]的值肯定能在 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][jci]的值有正确的含义。之后,同样的状态转移,两个策略取最大值。

在滚动数组中,因为是访问本层,因此我们要从前往后递推数组,这和0/1背包正好相反,保证获取到的值都是本层的。

代码

for (ll tol = c; tol <= capacity; t++)
{
    dp[tol] = max(dp[tol], dp[tol - c] + w);
}

除了for枚举的顺序不同之外,其余代码均与0/1背包相同。

现在我们考虑另外一个问题:

LeetCode 377

给你一个由 不同 整数组成的数组 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[i1][jk×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);
    }
}

P1776

多重背包模板题,随手写出代码:

#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背包、完全背包、多重背包或者混合背包的思路解决即可。

代码

P1855

#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]=kGimax(dp[i1][j],dp[i1][jck]+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[i1][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背包而已。注意,这里拆组为物品的概念非常重要,为我们引出泛化物品埋下了伏笔。

代码

P1757

#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;
}

以上背包问题我们称为线性背包问题,即物品的选择次序并不决定最大价值的选取,下面介绍非线性背包,例如依赖背包,树上的背包等等。


九、非线性背包——依赖背包和树上背包

模型

我们使用具体的问题来看看依赖背包怎么解决。

P2014 选课

对于节点 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

一些杂项

P2946

当涉及到倍数背包的时候,我们可以保存他的倍数,而不是实际数值。

#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;
}

总结

我们从动态规划的基本理念到背包问题的本质内涵,一一介绍给读者。计算机科学本身就是一种抽象科学,在不断抽象的过程中看透事物的本质,从特适走向普适的过程,也正是那句话所说的:

普适的代价是抽象。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值