0
点赞
收藏
分享

微信扫一扫

夜深人静写算法(十四)- 0/1 背包

文章目录

一、前言

二、0/1 背包问题

  • 以上就是 0/1 背包问题的完整描述,之所以叫 0/1 背包,是因为每种物品只有一个,可以选择放入背包或者不放,而 0 代表不放,1 代表放。

1、状态设计

  • 第一步:设计状态;
  • 状态 ( i , j ) (i, j) (i,j) 表示前 i i i 个物品恰好放入容量为 j j j 的背包 ( i ∈ [ 0 , n ] , j ∈ [ 0 , m ] ) (i \in [0, n], j \in [0, m]) (i[0,n],j[0,m])
  • d p [ i ] [ j ] dp[i][j] dp[i][j] 表示状态 ( i , j ) (i, j) (i,j) 下该背包得到的最大价值,即前 i i i 个物品恰好放入容量为 j j j 的背包所得到的最大总价值;

2、状态转移方程

  • 第二步:列出状态转移方程; d p [ i ] [ j ] = m a x ( 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][jc[i]]+w[i])
  • 因为每个物品要么放,要么不放,所以只需要考虑第 i i i 个物品 放 或 不放 的情况:
  • 1)不放:如果 “第 i i i 个物品不放入容量为 j j j 的背包”,那么问题转化成求 “前 i − 1 i-1 i1 个物品放入容量为 j j j 的背包” 的问题;由于不放,所以最大价值就等于 “前 i − 1 i-1 i1 个物品放入容量为 j j j 的背包” 的最大价值,即 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j]
  • 2)放:如果 “第 i i i 个物品放入容量为 j j j 的背包”,那么问题转化成求 “前 i − 1 i-1 i1 个物品放入容量为 j − c [ i ] j-c[i] jc[i] 的背包” 的问题;那么此时最大价值就等于 “前 i − 1 i-1 i1 个物品放入容量为 j − c [ i ] j-c[i] jc[i] 的背包” 的最大价值 加上放入第 i i i 个物品的价值,即 d p [ i − 1 ] [ j − c [ i ] ] + w [ i ] dp[i-1][j - c[i]] + w[i] dp[i1][jc[i]]+w[i]
  • 将以上两种情况取大者,就是我们所求的 “前 i i i 个物品恰好放入容量为 j j j 的背包” 的最大价值了。

3、初始状态

  • 我们发现,当状态在进行转移的时候, ( i , j ) (i, j) (i,j) 不是来自 ( i − 1 , j ) (i-1, j) (i1,j),就是来自 ( i − 1 , j − c [ i ] ) (i-1, j - c[i]) (i1,jc[i]),所以必然有一个初始状态,而这个初始状态就是 ( 0 , 0 ) (0, 0) (0,0),含义是 “前 0 个物品放入一个背包容量为 0 的背包”,这个状态下的最大价值为 0,即 d p [ 0 ] [ 0 ] = 0 dp[0][0] = 0 dp[0][0]=0

4、非法状态

  • 那么我们再来考虑, ( 0 , 3 ) (0, 3) (0,3) 是什么意思呢?它代表的是 “前 0 个物品恰好放入一个背包容量为 3 的背包”,明显这种情况是不存在的,因为 0 个物品的价值肯定是 0。所以这种状态被称为非法状态,非法状态是无法进行状态转移的,于是我们可以通过初始状态和非法状态进所有状态进行初始化。

5、状态初始化

  • d p [ 0 ] [ i ] = { 0 i = 0 i n f i > 0 dp[0][i] = \begin{cases} 0 & i = 0\\ inf & i > 0\end{cases} dp[0][i]={0infi=0i>0
  • 其中 i n f inf inf 在程序实现时,我们可以设定一个非常小的数,比如 − 1000000000 -1000000000 1000000000,只要保证无论如何状态转移它都不能成为最优解的候选状态。
  • 为了加深状态转移的概念,来看图二-5-1 的一个例子,每个格子代表一个状态, ( 0 , 0 ) (0,0) (0,0) 代表初始状态,蓝色的格子代表已经求得的状态,灰色的格子代表非法状态,红色的格子代表当前正在进行转移的状态,图中的第 i i i 行代表了前 i i i 个物品对应容量的最优值,第 4 个物品的容量为 2,价值为 8,则有状态转移如下:
  • d p [ 4 ] [ 4 ] = m a x ( d p [ 4 − 1 ] [ 4 ] , d p [ 4 − 1 ] [ 4 − 2 ] + 8 ) = m a x ( d p [ 3 ] [ 4 ] , d p [ 3 ] [ 2 ] + 8 ) \begin{aligned} dp[4][4] &= max( dp[4-1][4], dp[4-1][4 - 2] + 8) \\ &= max( dp[3][4], dp[3][2] + 8) \end{aligned} dp[4][4]=max(dp[41][4],dp[41][42]+8)=max(dp[3][4],dp[3][2]+8)
    在这里插入图片描述
    图二-5-1

三、0/1 背包问题的实现

1、背包物品结构设计

  • Knapsack来代表背包物品的数据结构,每个背包物品只需要两个值,一个值是容量,因为需要映射到下标进行状态转移,所以 99% 的情况都是整数(剩下 1% 留给读者 YY);另一个值是价值(或者说权值),价值可以是布尔类型、整型 或者 浮点数,所以我们用ValueType来代替,不同情况进行不用的定义,代码如下:
const int MAXN = 1010;
typedef int ValueType;

struct Knapsack {
    int capacity;
    ValueType weight;
public:
    Knapsack(){} 
    Knapsack(int c, ValueType w) : capacity(c), weight(w) {}
}Knap[MAXN];

2、状态数组

  • 状态数组存储的是价值的最优值(对于【例题1】中的问题,存储的是最大值,但是也有求最小值的问题,所以为了问题覆盖得更加全面,这里把它称为最优值);
  • 令背包物品为 n n n 个,背包最大容量为 m m m,那么总的状态数为 O ( n m ) O(nm) O(nm)
  • 则定义如下:
const int MAXN = 101;         // n
const int MAXC = 10001;       // m
typedef int ValueType;
ValueType dp[MAXN][MAXC];
  • MAXN代表物品的个数上限 n n nMAXC代表容量上限 m m m

3、状态转移

  • 状态转移采用的是两层循环,状态数为 O ( n m ) O(nm) O(nm),每次状态转移的消耗为 O ( 1 ) O(1) O(1),所以整个状态转移的过程时间复杂度是 O ( n m ) O(nm) O(nm)。用 opt(x,y)来求 x x x y y y 之间的最优值,以适应不同的问题。
ValueType opt(ValueType x, ValueType y) {
    return x > y ? x : y;
}

void zeroOneKnapsack(int knapsackSize, Knapsack *knap, int maxCapacity) {
    zeroOneKnapsackInit(maxCapacity);
    for(int i = 1; i <= knapsackSize; ++i) {
        for(int j = 0; j <= maxCapacity; ++j) {
            if( j >= knap[i].capacity )
                dp[i][j] = opt(dp[i-1][j], dp[i-1][j - knap[i].capacity] + knap[i].weight);
            else
                dp[i][j] = dp[i-1][j];
        }
    }
}
  • 从代码中可以看出,状态转移的过程就是不断利用前一行的数据,填充 n × m n \times m n×m 这个二维数组的过程。

4、状态初始化

const ValueType inf = -100000000;
const ValueType init = 0;
void zeroOneKnapsackInit(int maxCapacity) {
    for(int i = 0; i <= maxCapacity; ++i) {
        dp[0][i] = (i==0) ? init : inf;
    }
}

四、0/1 背包问题的扩展思考

1、最大值问题

  • 当所求问题的最优值是最大值的时候,就是上文提到的问题,非法状态inf需要取一个极小值,opt状态转移函数就是max(x,y)函数的功能;
typedef int ValueType;
const ValueType inf = -100000000;
const ValueType init = 0;
ValueType opt(ValueType x, ValueType y) {
    return x > y ? x : y;
}

2、最小值问题

  • 当所求问题的最优值是最小值的时候,定义非法状态inf的时候应该定义成极大值,并且状态转移函数opt应该是取两者中的小者,来看个例子;
  • 所谓正难则反,至少1个的概率问题我们可以转换成 1 减去 1个都不的概率,那么第 i i i 所学校无法获取 offer 的概率为 1 − p [ i ] 1 - p[i] 1p[i],问题转变成这样:
  • 每个学校选或不选,选定的学校的费用加起来不能超过 m m m,且满足选定的学校收不到 offer 的乘积最小,我们可以把概率取对数,这样就把乘法转换成加法了。利用的是:
  • l o g 2 ( a b ) = l o g 2 ( a ) + l o g 2 ( b ) log_{2}(ab) = log_{2}(a) + log_{2}(b) log2(ab)=log2(a)+log2(b)
  • 这样就转化成了一个价值是浮点数的,求解最小值的 0/1 背包问题了。
  • opt状态转移函数就是min(x,y)函数的功能,修改部分代码即可:
typedef double ValueType;
const ValueType inf = 100000000;
const ValueType init = 0;
ValueType opt(ValueType x, ValueType y) {
    return x < y ? x : y;
}

3、存在性问题

  • 存在性问题就是指:给定一些背包物品,问是否能够组合出恰好等于给定容量的值,同样通过一个例题来理解:
  • 因为每件设备只有两种选择,可以认为 选 和 不选 两种方案,所有选的设备的权值和为 X, 那么不选设备的权值和就应该等于 总权值和减去 X。需要求的就是 | 总权值和 - 2X | 尽量小。
  • d p [ i ] [ j ] dp[i][j] dp[i][j] 表示前 i i i 件设备的选择情况中,权值和为 j j j 的方案是否存在,则有状态转移方程:
  • d p [ i ] [ j ] = ( d p [ i − 1 ] [ j ]   o r   d p [ i − 1 ] [ j − v [ i ] ] ) dp[i][j] = (dp[i-1][j] \ or \ dp[i-1][j - v[i] ]) dp[i][j]=(dp[i1][j] or dp[i1][jv[i]])
  • 我们把这个问题中的权值理解成物品的容量,那么它就是一个物品价值为 0 的 0/1 背包问题。
  • 修改部分代码即可:
typedef bool ValueType;
const ValueType inf = false;
const ValueType init = true;
 ValueType opt(ValueType x, ValueType y) {
    return x || y;
}

4、方案数问题

  • 方案数问题就是指:给定一些背包物品,问组合出恰好等于给定容量值的方案数,同样通过一个例题来理解:
  • 因为砝码可以放入左边也可以放入右边,或者选择两边都不放,所以有三种情况,无法对应到 0/1 背包,所以这种情况我们需要升维,用一个三维的状态来表示: d p [ i ] [ j ] [ k ] dp[i][j][k] dp[i][j][k] 表示 “前 i i i 个砝码放入左右两边,并且左边的值为 j j j,右边的值为 k k k” 的方案数。
  • 那么可以列出状态转移方程: d p [ i ] [ j ] [ k ] = d p [ i − 1 ] [ j ] [ k ] + d p [ i − 1 ] [ j − v [ i ] ] [ k ] + d p [ i − 1 ] [ j ] [ k − v [ i ] ] dp[i][j][k] = dp[i-1][j][k] + dp[i-1][j - v[i]][k] + dp[i-1][j][k-v[i]] dp[i][j][k]=dp[i1][j][k]+dp[i1][jv[i]][k]+dp[i1][j][kv[i]]
  • d p [ i − 1 ] [ j ] [ k ] dp[i-1][j][k] dp[i1][j][k] 代表第 i i i 个砝码不放置的方案;
  • d p [ i − 1 ] [ j − v [ i ] ] [ k ] dp[i-1][j - v[i]][k] dp[i1][jv[i]][k] 代表第 i i i 个砝码放入左边天平的方案数;
  • d p [ i − 1 ] [ j ] [ k − v [ i ] ] dp[i-1][j][k-v[i]] dp[i1][j][kv[i]] 代表第 i i i 个砝码放入右边天平的方案数;
  • 初始状态 d p [ 0 ] [ 0 ] [ 0 ] = 1 dp[0][0][0] = 1 dp[0][0][0]=1
  • 假设天平左边有砝码个数为 a a a,右边砝码个数为 b b b,那么对于所有的 d p [ n ] [ j ] [ k ] dp[n][j][k] dp[n][j][k],累加所有满足: a + j = = b + k a + j == b + k a+j==b+k 的情况就是所求方案数了。

5、有顺序关联的问题

  • 假定有两个物品 a ( p a , q a ) a(p_a, q_a) a(pa,qa) b ( p b , q b ) b(p_b,q_b) b(pb,qb) 都想购买,那么有两种情况:
  • 1)先购买 a a a,再购买 b b b,则至少需要 p a + q b p_a + q_b pa+qb 元;
  • 2)先购买 b b b,再购买 a a a,则至少需要 p b + q a p_b + q_a pb+qa 元;
  • 我们希望购买相同的物品的花费尽量少,因为 a a a b b b 是无差别的,所以可以令 p a + q b < p b + q a p_a + q_b < p_b + q_a pa+qb<pb+qa 时,先购买 a a a,再购买 b b b,调整式子后得到: q a − p a > q b − p b q_a - p_a > q_b - p_b qapa>qbpb
  • 从而得到当 p p p q q q 差值越大时的物品先购买;
  • 但是,实际对物品进行排序的时候,是按照 q [ i ] − p [ i ] q[i] - p[i] q[i]p[i] 从小到大进行排序,因为根据动态规划的求解顺序,最后放入背包的其实是最先购买的;

6、容量为负数的问题

  • 所谓负容量,就是背包的容量可以为负数,这样就无法进行下标映射了,我们可以采用加上一个偏移量的方法将容量转换成正数。
  • 购买物品问题 和 背包放置问题 正好是逆过程 ( 购买时的价格类比背包容量,它是越来越少的,而背包容量是越来越多的);
  • 所以这个问题中,令 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示 "购买前 i i i 个物品后余额为 j j j" 的方案是否存在,初始状态为 d p [ 0 ] [ m ] = 1 dp[0][m] = 1 dp[0][m]=1,那么有状态转移方程: d p [ i ] [ j ] = d p [ i − 1 ] [ j ]   o r   d p [ i − 1 ] [ j + p [ i ] ] dp[i][j] = dp[i-1][j] \ or \ dp[i-1][j + p[i]] dp[i][j]=dp[i1][j] or dp[i1][j+p[i]]
  • 这个问题中, d p [ i ] [ 4 ] dp[i][4] dp[i][4] 已经是终止状态,不能再进行状态转移了;当然, d p [ i ] [ 5 ] dp[i][5] dp[i][5] 是最小的非终止状态,每个菜的价格最大值为 50,所以最小的终止状态为 d p [ i ] [ − 45 ] dp[i][-45] dp[i][45]。由于出现负数,无法映射到下标,所以这里需要加上一个偏移量 45。
  • 状态转移的过程中需要保证 j + p [ i ] ≤ 5 j + p[i] \le 5 j+p[i]5;
  • 因为价格有限制,所以需要依照 【例题6】 的方式对价格进行排序,剩下就是普通的 0/1 背包问题了。

7、容量很大的问题

  • 这类问题由于背包容量很大,但是物品个数很少,所以并不是动态规划问题,需要采用 深度优先搜索 来求解;
  • 考虑每个物品选或者不选,最坏情况是 n = k = 40 n=k=40 n=k=40 的情况,为 2 40 2^{40} 240;然而实际情况下如果剪枝控制的好,状态会远远少于这个数;
  • 首先可以将所有物品按照价值递减排序,然后记录后缀和,进行深搜枚举所有情况,记录最优解 MAX,加入四种剪枝:
  • 1)可行性剪枝:如果枚举过程中当前组合出的价值 v a l u e > m value > m value>m,则返回;
  • 2)最优解剪枝:如果 v a l u e = = m value == m value==m,则找到全局最优解,终止搜索过程;
  • 3)最优性剪枝 1: v a l u e value value 加上剩余能够选取的几个最大价值的物品的总价值 ≤ \le MAX,则返回 ;
  • 4)最优性剪枝 2: v a l u e value value 加上剩余能够选取的几个最大价值的物品的总价值 ≤ m \le m m,更新 MAX, 返回;

五、0/1 背包问题的空间优化

  • 了解了几个典型的背包问题之后,我们发现背包问题的状态数是 O ( n m ) O(nm) O(nm) 的,状态转移已经是 O ( 1 ) O(1) O(1) 无法再优化,所以整个 0/1 背包 问题的求解过程的时间复杂度就是 O ( n m ) O(nm) O(nm) 的,但是空间复杂度还是可以优化的。

1、滚动数组

  • 对于 0/1 背包问题,考虑到当前行的状态值只依赖于上一行状态的两个值,和上上一行的状态无关,也就是说上上一行的状态对于计算当前行的状态完全没用,所以再计算完上一行以后,上上一行的数据就可以清理掉了。
    图五-1-1
  • 我们可以用 dp[2][MAXC]代替原先的dp[MAXN]MAXC],这样一来,空间复杂度不会随着物品个数增多而变大,而 dp[0][MAXC]代表了偶数行的状态,dp[1][MAXC]则代表了奇数行的状态;
  • 代码实现如下:
ValueType dp[2][MAXC];

void zeroOneKnapsackRollClear(int index, int maxCapacity) {
    for(int i = 0; i <= maxCapacity; ++i) {
        dp[index][i] = inf;
    }
}

int zeroOneKnapsack(int knapsackSize, Knapsack *knap) {
    zeroOneKnapsackInit();
    int maxCapacity = 0;
    int pre = 0;
	 
    for(int i = 0; i < knapsackSize; ++i) {
        maxCapacity += knap[i].capacity;
        zeroOneKnapsackRollClear(pre^1, maxCapacity);
        for(int j = 0; j <= maxCapacity; ++j) {
            if( j >= knap[i].capacity )
                dp[pre^1][j] = opt(dp[pre][j], dp[pre][j - knap[i].capacity] + knap[i].weight);
            else
                dp[pre^1][j] = dp[pre][j]; 
        }
        pre = (pre^1);
    }
    return pre;
}
  • 实现原理就是通过pre 行的状态推算 pre^1行的状态,然后令 pre = pre^1;实现状态的滚动;
  • 滚动数组解决了空间复杂度的问题,是一种全新的思维方式,当然利用这种方法编码较复杂一些,而且比较容易写错,如果不是很理解也没有关系,接下来将介绍降维的思想,无论是编码复杂度上还是理解上都会比滚动数组更加优秀!

2、降维思想

  • 上文提到了一个观点:当前行的状态值只依赖于上一行状态的两个值,和上上一行的状态无关;
  • 我们再来观察下状态转移方程:
  • d p [ i ] [ j ] = m a x ( 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][jc[i]]+w[i])
  • 那么对上文的观点再进行一个补充:对于第二维状态,状态转移的过程一定是从值小的到值大的方向进行转移;
  • 大胆假设将第一维去掉,再来看状态转移方程是否能够满足我们的需要:
  • d p [ j ] = m a x ( d p [ j ] , d p [ j − c [ i ] ] + w [ i ] ) dp[j] = max(dp[j], dp[j - c[i]] + w[i]) dp[j]=max(dp[j],dp[jc[i]]+w[i])
  • 这种情况下,如果还是按照原有代码进行状态转移,会发现一个问题:每个物品会被用多次。举个例子:容量为4的背包,放入容量为2,价值为 8 的物品,那么 d p [ 4 ] dp[4] dp[4] 的状态一定来自 d p [ 2 ] dp[2] dp[2],而 d p [ 2 ] dp[2] dp[2] 的状态来自 d p [ 0 ] dp[0] dp[0],这时候我们发现这个容量为 2 的物品被用了两次: d p [ 4 ] = d p [ 2 ] + 8 = ( d p [ 0 ] + 8 ) + 8 dp[4] = dp[2] + 8 = (dp[0] + 8) +8 dp[4]=dp[2]+8=(dp[0]+8)+8
  • 事实上,我们只需要将状态转移的过程从大到小逆序求解即可,原因就是在求解 d p [ j ] dp[j] dp[j] 时,如果逆序求解,由于 j − c [ i ] j - c[i] jc[i] j j j 小,所以 d p [ j − c [ i ] ] dp[j - c[i]] dp[jc[i]] 相当于还是上一行的状态,等价于降维之前的 d p [ i − 1 ] [ j − c [ i ] ] dp[i-1][j-c[i]] dp[i1][jc[i]];而反观顺序求解, d p [ j − c [ i ] ] dp[j - c[i]] dp[jc[i]] 状态计算会在 d p [ i ] dp[i] dp[i] 之前,则变成了当前行的状态,和 0/1 背包状态转移不符。
  • 最后给出降维后,状态转移部分的代码实现:
void zeroOneKnapsack(int knapsackSize, Knapsack *knap, int maxCapacity) {
    zeroOneKnapsackInit(maxCapacity);
    for(int i = 0; i < knapsackSize; ++i) {
        for(int j = maxCapacity; j >= knap[i].capacity; --j) {
            dp[j] = opt(dp[j], dp[j - knap[i].capacity] + knap[i].weight);
        }
    }
}

  • 关于 0/1 背包 的内容到这里就结束了。
  • 如果还有不懂的问题,可以 想方设法 找到作者的微信进行在线咨询。

  • 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHeroFrom/Code_Templates

在这里插入图片描述


六、0/1 背包问题相关题集整理

题目链接难度解析
HDU 2602 Bone Collector★☆☆☆☆【例题1】最大值问题
HDU 1203 I NEED A OFFER!★☆☆☆☆【例题2】最小值问题
HDU 1171 Big Event in HDU★☆☆☆☆【例题3】存在性问题
洛谷 P2925 Hay For Sale S★☆☆☆☆存在性问题
PKU 3624 Charm Bracelet★☆☆☆☆最大值问题
HDU 1864 最大报销额★☆☆☆☆存在性问题
PKU 1948 Triangular Pastures★☆☆☆☆二维 0/1 背包 的 存在性问题
HDU 2126 Buy the souvenirs★☆☆☆☆最大值问题 + 方案数
HDU 2955 Robberies★☆☆☆☆最大值问题
PKU 2184 Cow Exhibition★★☆☆☆负容量问题
P1507 NASA的食物计划★★☆☆☆二维容量 最大值问题
HDU 4502 吉哥系列故事——临时工计划★★☆☆☆区间、非恰好的情况
HDU 3448 Bag Problem★★☆☆☆【例题7】搜索 + 剪枝 求解 0/1 背包
HDU 2660 Accepted Necklace★★☆☆☆增加一维状态
PKU 1157 LITTLE SHOP OF FLOWERS★★☆☆☆最大值问题
HDU 3466 Proud Merchants★★★☆☆【例题5】有顺序的 0/1 背包
HDU 2546 饭卡★★★☆☆【例题6】负容量问题
HDU 2639 Bone Collector II★★★☆☆增加一维状态
HDU 3535 AreYouBusy★★★☆☆分组 0/1 背包
HDU 6376 度度熊剪纸条★★★★☆0/1 背包 +贪心
HDU 5543 Pick The Sticks★★★★☆0/1 背包 +贪心
举报

相关推荐

0 条评论