基础算法 --- 背包问题

本文深入探讨了背包问题的四种基本类型:01背包、完全背包、多重背包和分组背包,阐述了它们的状态转移方程和优化方法,如滚动数组和二进制优化。通过对背包问题的理解,有助于解决各种动态规划难题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


前言

背包问题作为动态规划的入门问题,值得我们好好掌握,理解其中原理,以便于应对各式各样的背包变种题目,以下题目所有来源于Acwing


一、01背包

01背包
01背包的核心为每个物品只能使用一次
我们建立二维DP变量f[i][j],该变量表示含义为从前i个物品中挑选,并且选取的物品体积总和小于等于j的最大价值。到这里我们就可以对状态f[i][j]基于是否选择第i个物品这个条件进行集合划分。
f[i][j]可以由两种状态转化而来:

  1. 不包含第i个物品,f[i-1][j]
  2. 包含第i个物品,f[i-1][j-v[i]] + w[i]

第一种情况比较容易理解,因为不包含第i个物品,就只能从前i-1个物品中选取,并且选取的所有物品体积总和小于等于j的最大价值。
第二种情况包含了第i个物品,因为选择了第i个物品的同时,体积不能大于j,所以状态的转移应当从f[i-1][j-v[i]这个状态,其中v[i]表示第i个物品的体积。选取了第i个物品,那么就需要加上第i个物品的价值,所以最终的状态应该是f[i-1][j-v[i]] + w[i],其中w[i]为第i个物品的价值。

提示:因为状态的转移出现了i-1,所以为了方便处理边界问题,循环的物品下标从1开始。

1. 朴素写法

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int n = in.nextInt(), v = in.nextInt();
        int[] vs = new int[n+1], ws = new int[n+1];
        for (int i = 1; i <= n; i ++) {
            vs[i] = in.nextInt();
            ws[i] = in.nextInt();
        }
        
        int[][] f = new int[n+1][v+1];
        for (int i = 1; i <= n; i ++) {
            for (int j = 0; j <= v; j ++) {
            	// 不包含第i个物品
                f[i][j] = f[i-1][j];
                if (j >= vs[i]) {
                	// 选择第i个物品
                    f[i][j] = Math.max(f[i][j], f[i-1][j-vs[i]] + ws[i]);
                }
            }
        }
        
        System.out.println(f[v]);
    }
}

2. 滚动数组写法

通过状态转移方程可以发现f[i][j]的状态只跟f[i-1][x]有关,所以只需要使用一维数组来存储上一次的状态结果即可。
这里需要注意的一点是,当使用一维数组存储状态的时候,体积的遍历需要从大到小。因为f[j]的状态由f[j-v[i]]状态转移而来,如果从小到大遍历,那么f[j-v[i]]的状态会提前被更新。也就是原本f[j-v[i]]应该是f[i-1]时的状态,但是因为被提前更新,所以f[j-v[i]]状态变为了f[i]时的状态,那么二维的状态转移方程就变成f[i][j] = f[i][j-v[i]] + w[i],显然跟我们上面推导的方程不同。因此在更新f[j]状态前,f[j-v[i]]的状态不能被提前更新,所以需要从大到小遍历背包体积j

如果这里看不明白的话,可以结合完全背包的状态转移方程与滚动优化,进行对比分析。

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int n = in.nextInt(), v = in.nextInt();
        int[] vs = new int[n+1], ws = new int[n+1];
        for (int i = 1; i <= n; i ++) {
            vs[i] = in.nextInt();
            ws[i] = in.nextInt();
        }
        
        int[] f = new int[v+1];
        for (int i = 1; i <= n; i ++) {
            for (int j = v; j >= vs[i]; j --) {
                f[j] = Math.max(f[j], f[j-vs[i]] + ws[i]);
            }
        }
        
        System.out.println(f[v]);
    }
}

二、完全背包

完全背包问题
完全背包问题与01背包唯一不同的地方是,每个物品可以选择无数次。
同样建立二维DP状态f[i][j],表示从前i个物品中选择,且总体积不大于j的最大价值。因为01背包中的物品只能选择一次,所以f[i][j]的状态转移方程只有选与不选两种情况。那么在完全背包中,每个物品可以被选择无数次,所以假设物品i可以被选择n次,那么状态转移情况如下:

  1. 不选择第i个物品,f[i-1][j]
  2. 选择1个第i个物品,f[i-1][j - v[i]] + w[i]
  3. 选择2个第i个物品,f[i-1][j - 2 * v[i]] + 2 * w[i]
  4. 选择n个第i个物品,f[i-1][ j - n * v[i]] + n * w[i]

当然这里的n是存在上限的,因为n * v[i]的体积不能大于j,所以按照上面的公式可以暴力求出每个f[i][j]的状态,但是这样的求法会超时,就不写了。那么正确的状态转移方程应该如下:

  1. 不选择第i个物品,f[i-1][j]
  2. 选择第i个物品,f[i][j - v[i]] + w[i]

注意,上面选择第i个物品的状态转移是f[i][j - v[i]] + w[i],而不是01背包的f[i-1][j - v[i]] + w[i]

下面就证明下f[i][j - v[i]] + w[i]的状态转移是如何得到的。
通过暴力的状态遍历,我们可以得到f[i][j]的状态转移公式为:
f [ i ,    j ] = M a x ( f [ i − 1 ] [ j ] ,    f [ i − 1 ] [ j    −    v [ i ] ]    +    w [ i ] ,    . . . ,    f [ i − 1 ] [ j    −    n    ∗    v [ i ] ]    +    n    ∗    w [ i ] ) f\lbrack i,\;j\rbrack=Max(f\lbrack i-1\rbrack\lbrack j\rbrack,\;f\lbrack i-1\rbrack\lbrack j\;-\;v\lbrack i\rbrack\rbrack\;+\;w\lbrack i\rbrack,\;...,\;f\lbrack i-1\rbrack\lbrack j\;-\;n\;\ast\;v\lbrack i\rbrack\rbrack\;+\;n\;\ast\;w\lbrack i\rbrack) f[i,j]=Max(f[i1][j],f[i1][jv[i]]+w[i],...,f[i1][jnv[i]]+nw[i])
我们同样写下f[i][j - v[i]]的状态转移公式:
f [ i ,    j − v [ i ] ] = M a x ( f [ i − 1 ] [ j − v [ i ] ] ,      f [ i − 1 ] [ j    −    2    ∗    v [ i ] ]    +    w [ i ] ,    . . .    ,      f [ i − 1 ] [ j    −    n    ∗    v [ i ] ]    +    ( n − 1 )    ∗    w [ i ] ) f\lbrack i,\;j-v\lbrack i\rbrack\rbrack=Max(f\lbrack i-1\rbrack\lbrack j-v\lbrack i\rbrack\rbrack,\;\;f\lbrack i-1\rbrack\lbrack j\;-\;2\;\ast\;v\lbrack i\rbrack\rbrack\;+\;w\lbrack i\rbrack,\;...\;,\;\;f\lbrack i-1\rbrack\lbrack j\;-\;n\;\ast\;v\lbrack i\rbrack\rbrack\;+\;(n-1)\;\ast\;w\lbrack i\rbrack) f[i,jv[i]]=Max(f[i1][jv[i]],f[i1][j2v[i]]+w[i],...,f[i1][jnv[i]]+(n1)w[i])
经过对比我们发现f[i][j]的转移状态从第二个开始,都可以有f[i, j-v[i]] + w表示,所以最终f[i][j]的状态转移方程为:
f [ i ,    j ] = M a x ( f [ i − 1 ] [ j − v [ i ] ] ,      f [ i ] [ j    −    v [ i ] ]    +    w [ i ] ) f\lbrack i,\;j\rbrack=Max(f\lbrack i-1\rbrack\lbrack j-v\lbrack i\rbrack\rbrack,\;\;f\lbrack i\rbrack\lbrack j\;-\;v\lbrack i\rbrack\rbrack\;+\;w\lbrack i\rbrack) f[i,j]=Max(f[i1][jv[i]],f[i][jv[i]]+w[i])

1. 朴素写法

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int n = in.nextInt(), v = in.nextInt();
        int[] vs = new int[n+1], ws = new int[n+1];
        for (int i = 1; i <= n; i ++) {
            vs[i] = in.nextInt();
            ws[i] = in.nextInt();
        }
        
        int[][] f = new int[n+1][v+1];
        for (int i = 1; i <= n; i ++) {
            for (int j = 0; j <= v; j ++) {
                f[i][j] = f[i-1][j];
                if (j >= vs[i]) {
                    f[i][j] = Math.max(f[i][j], f[i][j-vs[i]] + ws[i]);
                }
            }
        }
        
        
        System.out.println(f[n][v]);
    }
}

2. 滚动数组写法

这里的滚动数组优化的时候,体积j可以从小到大遍历,因为f[i][j]的状态转移是由f[i][j-v[i]] + w[i]而来,并不是从f[i-1]状态转移。

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int n = in.nextInt(), v = in.nextInt();
        int[] vs = new int[n+1], ws = new int[n+1];
        for (int i = 1; i <= n; i ++) {
            vs[i] = in.nextInt();
            ws[i] = in.nextInt();
        }
        
        int[] f = new int[v+1];
        for (int i = 1; i <= n; i ++) {
            for (int j = vs[i]; j <= v; j ++) {
                f[j] = Math.max(f[j], f[j-vs[i]] + ws[i]);
            }
        }
        
        
        System.out.println(f[v]);
    }
}

三、多重背包

多重背包问题
多重背包问题与完全背包问题不同点在于,多重背包问题的物品数量为s个,并不是可以选取无限个,所以在求解的时候,如果s个数不大,可以选择直接暴力循环每一种状态。

1. 暴力写法

跟完全背包的状态转移方程类似,不做介绍。

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int n = in.nextInt(), v = in.nextInt();
        int[] vs = new int[n+1], ws = new int[n+1], ss = new int[n+1];
        for (int i = 1; i <= n; i ++) {
            vs[i] = in.nextInt();
            ws[i] = in.nextInt();
            ss[i] = in.nextInt();
        }
        
        int[][] f = new int[n+1][v+1];
        for (int i = 1; i <= n; i ++) {
            for (int j = 0; j <= v; j ++) {
                f[i][j] = f[i-1][j];
                // 选择s个第i个物品
                for (int s = 1; s <= ss[i]; s ++) {
                    if (j - s * vs[i] >= 0) {
                        f[i][j] = Math.max(f[i][j], f[i-1][j-s*vs[i]] + s * ws[i]);
                    }
                }
            }
        }
        
        System.out.println(f[n][v]);
    }
}

2. 二进制优化写法

这里重点介绍,如何将多重背包问题转化为01背包问题进行求解。
我们知道01背包问题的物品是只能选择一次的,所以如何将多重背包中s个物品转化为只能选择1次的状态是关键。我们可以通过二进制数的方法来表示s个物品。
举个例子:假设我们有7个物品(这里的7表示某个物品有7个数量),那么我们将这7个物品分为1,2,4为一组,那么重新分组好的物品,可以表示0~7这个区间内的所有数,也就是是说可以通过选择与不选择这三个数来覆盖区间[0, 7],这样分组后,我们就可以将7个物品的多重背包问题转化为1, 2, 4个不同物品的01背包问题。
如果出现的物品个数并不是 2 n − 1 2^n-1 2n1的话,那么我们可以通过从小到大不断减去 2 n 2^n 2n,来求最后剩下的那个常数c
再举个例子,假设物品数量是10,那么我们分组结果为1, 2, 4, 3 => 2 0 , 2 1 , 2 2 , 3 2^0, 2^1, 2^2, 3 20,21,22,3。因为1,2,4可以覆盖范围是[0, 7],再加上3,覆盖区间就可以为[0, 10]。具体逻辑可以看代码,简单来说就是不断减去 2 n 2^n 2n,判断当前剩余的物品数量是否仍然大于 2 n 2^n 2n

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int n = in.nextInt(), m = in.nextInt();
        
        // 统计分组后的物品总个数cnt
        int cnt = 1;
        int[] vs = new int[13000], ws = new int[13000];
        
        for (int i = 0; i < n; i ++) {
            int v = in.nextInt();
            int w = in.nextInt();
            int s = in.nextInt();
            
            // 这里k表示2^n, 从1开始
            int k = 1;
            while (s >= k) {
                s -= k;
                // 以k个数量为一组的物品体积与价值
                vs[cnt] = v * k;
                ws[cnt] = w * k;
                k *= 2;
                cnt ++;
            }
            // 剩余的常数c的数量
            if (s != 0) {
                vs[cnt] = v * s;
                ws[cnt] = w * s;
                cnt ++;
            }
        }
        
        // 01背包模板
        int[] f = new int[m+1];
        for (int i = 1; i < cnt; i ++) {
            for (int j = m; j >= vs[i]; j --) {
                f[j] = Math.max(f[j], f[j - vs[i]] + ws[i]);
            }
        }
        System.out.println(f[m]);
    }
}

四、分组背包

分组背包问题
分组背包是指题目提供了不同组的物品,不同组间的物品可以同时选择,同一组内的物品互斥。
同样建立二维DP状态f[i][j],但是这里的状态表示为从前i组中选取的物品,体积不大于j的最大价值,因为分组的存在,所以i不能表示第i个物品。
状态转移方程如下:

  1. 不选择第i组的所有物品,f[i-1][j]
  2. 选择第i组的第k个物品,f[i-1][j - v[i][k]] + w[i][k]

这里与01背包不同的是多了分组下的第k个物品,所以在状态转移的时候,需要多加一层循环,表示第i组的k个物品。

1. 朴素写法

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        int n = in.nextInt(), m = in.nextInt();
        // s[i]表示第i个分组的物品类别数量
        int[] s = new int[n+1];
        int[][] vs = new int[105][105], ws = new int[105][105];
        for (int i = 1; i <= n; i ++) {
            s[i] = in.nextInt();
            for (int j = 0; j < s[i]; j ++) {
                vs[i][j] = in.nextInt();
                ws[i][j] = in.nextInt();
            }
        }
        
        int[][] f = new int[n+1][m+1];
        for (int i = 1; i <= n; i ++) {
            for (int j = 0; j <= m; j ++) {
                f[i][j] = f[i-1][j];
                for (int k = 0; k < s[i]; k ++) {
                    if (j >= vs[i][k])
                        f[i][j] = Math.max(f[i][j], f[i-1][j-vs[i][k]] + ws[i][k]);
                }
            }
        }
        
        System.out.println(f[n][m]);
    }
}

滚动数组的优化算法就留给你们自己去实现,注意状态转移是从f[i]还是从f[i-1]


总结

主要介绍了常见的四种背包问题及其变种问题,需要记住的是体积的遍历顺序,究竟是从大到小还是从小到大,状态转移方程的求解应当记牢,二进制状态的优化在其他DP类型的题目中会经常出现。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值