DP学习笔记——背包专题

一、01背包

(dp[i][j]:=) 决策第(i)种物品、背包总容量为(j)时的最大价值

(dp[i][j])的取值有两种:

  • 不取第(i)种物品,直接继承(dp[i-1][j])
  • (dp[i-1][k](k≤j-cost[i]))的基础上,取了第(i)种物品,装入。装入之后容量为(j)的背包恰好装满或装不满。

[dp[i][j]= egin{cases} max(dp[i-1][j-cost[i]]+v[i],dp[i-1][j])& ext{j≥cost[i]}\ dp[i-1][j]& ext{其他} end{cases} ]

答案为(dp[N][T])(N)为物品种数,(T)为背包容量

空间优化(一维压缩)

如果将(dp[i][j])绘制成如下表格:

可以发现第(i)行的结果只与第(i-1)行有关,并且第(i)行第(j)列的格子只与它上方及左上方格子有关。

那么,当更新第(i)行第(j)列的格子之后,第(i-1)行第(j)列往右的格子都是无用的。可以想到,如果第(i)行的数据从右往左更新,那么第(i-1)行与第(i)行可以合并成一行表示,只要从右往左(即倒序)更新,用新的数据覆盖掉原来的数据即可。

这样,(dp)数组就可以压缩成一维:(dp[j])

[dp[j]= egin{cases} max(dp[j-cost[i]]+v[i],dp[j])& ext{j≥cost[i]}\ dp[j]& ext{其他} end{cases} ]

答案为(dp[T])(T)为背包容量。

变形——恰好背包

恰好背包与一维背包的不同,仅在于要求容量为(m)的背包恰好装满。

解决方法是,(dp)数组初始化为(-INF)即可,此外,容量为(0)的背包“恰好装满”时价值就是(0),因此(dp[0]=0)

这是因为,在恰好背包中,如果容量为(j)的背包在没有恰好装满的情况下取到了最优解,那么这个最优解其实是无效的,因为它不满足题意。

[dp[j]= egin{cases} max(dp[j-cost[i]]+v[i],dp[j])& ext{j≥cost[i]}\ dp[j]& ext{其他} end{cases} ]

观察状态转移方程,可以知道:

(1)如果(dp[j-cost[i]])(dp[j])都是有效状态,则更新后的(dp[j])也是有效状态。

(2)如果只有(dp[j-cost[i]])是有效状态,它一定比原来的(dp[j])更优——(dp[j])是无效状态,值为(-INF)

(3)如果只有(dp[j])是有效状态,同上。

二、完全背包/无限背包

与01背包的区别在于,完全背包/无限背包问题中的每种物品可以取无限个。

$dp[i][j]:= $ 决策第(i)种物品、背包总容量为(j)时的最大价值

(dp[i][j])的取值有两种:

  • 不取第(i)种物品,则与01背包相同,直接继承(dp[i-1][j])
  • 取第(i)种物品。因为可以无限取,所以状态可能由取了1个、取了2个...取了x个等状态转移过来。所以这里不应该从(dp[i-1][j-cost[i]])转移,而应该从(dp[i][k](k≤j-cost[i]))转移。遍历(j)的过程就是让背包容量逐渐增大的过程,状态转移时会不断试图在背包容量允许的情况下多拿一个又一个,并比较能得到的最大价值。所以当(j)遍历结束后,第(i)行的数据都是在不同背包容量下对第(i)种物品的最佳决策。

[dp[i][j]= egin{cases} max(dp[i][j-cost[i]]+v[i],dp[i-1][j])& ext{j≥cost[i]}\ dp[i-1][j]& ext{其他} end{cases} ]

for (int i = 1; i <= n; i++) {
	for (int j = 1; j <= m; j++) {
		dp[i][j] = dp[i - 1][j];
		if (j >= cost[i]) dp[i][j] = max(dp[i][j],dp[i - 1][j - cost[i]]);
	}
}

答案为(dp[N][T])(N)为物品种数,(T)为背包容量。

时间复杂度(O(nm)),空间复杂度(O(nm))

空间优化(一维压缩)

观察状态转移方程,容易发现它和01背包有些相似:第(i)行的结果与第(i-1)行有关,但与更上方的行无关。与01背包不同的是,完全背包的第(i)行第(j)列的结果并不只与第(i-1)行有关,还与第(i)行第(j)列左边的值有关。因为大背包的最优解应该由小背包的最优解推出,所以第(i)行第(j)列左边的数据应该比它更先更新。换句话说,对(j)的遍历应该从左往右进行。

由表格可以看出,要用到的数据其实就在同一行。

for (int i = 1; i <= n; i++) {
	for (int j = 1; j <= m; j++) {
		if (j >= cost[i]) dp[j] = max(dp[j], dp[j - cost[i]] + v[i]);
	}
}

显然当(j<cost[i])时不会进行任何操作,所以(j)也可以直接从(cost[i])开始枚举

for (int i = 1; i <= n; i++) {
	for (int j = cost[i]; j <= m; j++) {
		 dp[j] = max(dp[j], dp[j - cost[i]] + v[i]);
	}
}

时间复杂度(O(nm)),空间复杂度(O(m))

再多一种枚举

上述思路中都是枚举每种物品以及背包容量,可以在此基础上再多一层枚举:枚举第(i)种物品的装入数量。

当第(i)种数量装入0个时,情况等价于不取第(i)种物品。

因为已经直接枚举了第(i)种物品取1个、取2个……的所有情况,所以更新都是基于第(i-1)行的值。

for(int i=1;i<=n;i++){
	for(int j=1;j<=m;j++){
		for(int k=0;k<=j/cost[i];k++){
			dp[i][j]=max(dp[i-1][j],dp[i-1][j-k*cost[i]]+k*v[i]);
		}
	}
}

可以看出状态转移方程与01背包是相同的,因此也可以用与01背包相同的方式进行一维压缩,也就是(j)需要倒序枚举:

(j<cost[i])时,第(i)种物品只能取0个,因此此时(dp[i][j])直接继承自(dp[i-1][j]),在一维压缩后就相当于对(dp[j])不作任何操作。因此(j)只从(m)枚举到(cost[i])即可。

[dp[j]= egin{cases} max(dp[j-k imes cost[i]]+k imes v[i],dp[j])& ext{j≥cost[i]}\ dp[j]& ext{其他} end{cases} ]

for(int i=1;i<=n;i++){
	for(int j=m;j>=cost[i];j--){
		for(int k=0;k<=j/cost[i];k++){
			dp[j]=max(dp[j],dp[j-k*cost[i]]+k*v[i]);
		}
	}
}

三、多重背包

这类背包的实质其实和"多重"这个词没什么联系,多重背包与完全背包的区别在于,多重背包中每种物品的数量是给定的有限个。当给定的数量都为1时,就是多重背包的特殊情形——01背包了。

另外,对于第(i)种物品,如果它的总价值((cost[i] imes amount[i])(≥)题目所求的背包容量(m)时,对于这个背包而言,第(i)种物品可以视为无限个,可以直接应用完全背包的解法。

时间优化(二进制拆分)

在完全背包的代码中,枚举第(i)种物品的装入数量时,采用的是0、1、2...这样逐个枚举的方式。如果将每一次枚举视为取与不取两种操作的话,即可转化为01背包。但这样每一轮对k的遍历,平均需要(frac{m}{overline{cost[i]}})的时间。能否有别的枚举方式来替代逐个枚举,优化时间?

假设当前第(i)种物品最多能取(c)个,我们可以将十进制数(c)(2^k)的和式来表达,如(10=2^0+2^1+2^2+3),除最后一个数外,前面的数构成以(1)为首项,公比为(2)的等比数列。

这样,就可以将(10)分成(4)堆,且可以证明,这(4)堆的不同组合方式可以构成(1 sim 10)之间的所有正整数。

这样我们就把(1 sim 10)逐个枚举的过程,变成了枚举这(4)堆“取与不取”的过程。也就是说通过二进制拆分,我们将完全背包转化成了01背包。

for(int i=1;i<=n;i++){
    for(int k=1;k<=amount[i];k<<1){
    //先拆分,再01背包,所以先对k遍历再套j的遍历
        for(int j=m;j>=k*cost[i];j--){
            dp[j]=max(dp[j],dp[j-k*cost[i]]+k*v[i]);
        }
        amount -= k;
        //将分完的堆从当前物品总数中减出来
    }
  //对最后剩下的那一堆也要一次01背包
  //注意此处的amount已经进行过多次减法,不是最开始的amount了
    for(int j=m;j>=amount*cost[i];j--){
        dp[j]=max(dp[j],dp[j-amount*cost[i]]+amount*v[i]);
    }
}

四、混合背包(01背包+完全背包+多重背包)

给出(n)种物品,有些可以取无限个,有些只能取有限个,这样的情形就要结合以上三种背包算法。

我们注意到,这三种背包的代码有一些共同点:第一重循环都是对(i)的遍历、都可以将(dp)数组压缩成一维、在对(j)的遍历中仅用到(cost[i])与题目所求的背包容量(m)这两个参数。

那么,可以尝试将三种背包算法各自封装成函数:

//01背包
void ZeroOnePack(int cost,int value){
    for (int j = m; j >= cost; j--) {
        dp[j] = max(dp[j], dp[j - cost] + value);
    }
}
//完全背包
void CompletePack(int cost, int value) {
    for (int j = cost; j <= m; j++) {
        dp[j] = max(dp[j], dp[j - cost] + value);
    }
}
//多重背包
void MultiplePack(int cost, int value,int amount) {
    if (cost * amount >= m) {
        CompletePack(cost, value);
        return;
    }
    //如果这个物品amount个的总价值已经大于等于背包容量,则对于这个背包来说,这个物品可以视为有无限个
    for (int k = 1; k <= amount; k <<= 1) {
        ZeroOnePack(k * cost, k * value);
        amount -= k;
    }
    ZeroOnePack(amount * cost, amount * value);
}

那么我们的主函数可以简洁地写成这样:

int main(){
   输入数据
   for(int i=1;i<=n;i++){
    if(第i种物品数量为1) 
    	ZeroOnePack(cost[i],v[i]);
    else if(第i种物品数量无限) 
   	CompletePack(cost[i],v[i]);
    else if(第i种物品数量有限)
   	MultiplePack(cost[i],v[i],amount[i]);
   }
   cout<<dp[m];
   return 0;
}

五、多维背包

多维背包的特点是,一个物品有多种费用,当选择该物品时,必须同时付出这多种费用。同时也会给出背包对这多种费用的容量。

最简单的是二维背包:对于第(i)种物品,其费用为(cost1[i])(cost2[i]),价值为(value[i]),求容量为(m_1)(针对第一种费用)、(m_2)(针对第二种费用)的背包可得到的最大价值。

在一维背包中,只需要枚举一次背包容量for(int j=1;j<=m;j++),类比一下,二维背包只需要再加一层循环即可。当然(dp)数组也需要再加一维:(dp[i][j][k])

那么(dp[i][j][k])的取值有两种:

  • 不取第(i)种物品,直接继承(dp[i-1][j][k])
  • 取第(i)种物品(如果能),那么在(dp[i-1][j-cost1[i]][k-cost2[i]])的基础上装入第(i)种物品

状态转移方程:

[dp[i][j][k]= egin{cases} max(dp[i-1][j-cost1[i]][k-cost2[i]]+v[i],dp[i-1][j][k])& ext{j≥cost1[i]&&k≥cost2[i]}\ dp[i-1][j][k]& ext{其他} end{cases} ]

for (int i = 1; i <= n; i++) {
	for (int j = 1; j <= m1; j++) {
		for (int k = 1; k <= m2; k++) {
			if (j >= cost1[i] && k >= cost2[i])
				dp[i][j][k] = max(dp[i - 1][j][k], dp[i][j - cost1[i]][k - cost2[i]] + v[i]);
			else dp[i][j][k] = dp[i - 1][j][k];
		}
	}
}

答案是(dp[n][m1][m2])

空间优化

与前面几种背包类似,(dp)数组中的第1维([i])是可以省去的。且由状态转移方程可知,第(i)层的值只与第(i-1)层有关,这意味着我们可以类比一维背包的优化方式,通过对两种容量的倒序枚举进行优化。

for(int i=1;i<=n;i++){
  for (int j = m1; j >= cost1[i]; j--) {
    for (int k = m2; k >= cost2[i]; k--) {
        dp[j][k] = max(dp[j][k], dp[j - cost1[i]][k - cost2[i]] + v[i]);
    }
  }
}

答案是(dp[m1][m2])

六、分组背包

给出(n)种物品,每种都有它所属的组。若对每一组最多只能选择一个物品,问给定背包容量下能得到的最大价值。

(dp[i][j]:=) 决策第(i)组中的物品、背包总容量为j时的最大价值

(dp[i][j])的取值有两种:

  • 不取第(i)组中的物品,直接继承(dp[i-1][j])
  • (dp[i-1][k](k≤j-cost[p]))的基础上,取了第(i)组里的第(p)个物品,装入。

则$$dp[i][j]= egin{cases} dp[i-1][j-cost[p]]+v[p]& ext{j≥cost[p]} dp[i-1][j]& ext{其他} end{cases}$$

代码自然是三重循环:第一重枚举每一组,第二重枚举背包容量,第三重枚举当前组中的每种物品。

注意第二三重循环的顺序,必须先枚举背包容量而后才枚举组内物品。如果顺序调换,则可能导致装入了同一组的多个物品。

七、有依赖的背包

给出(n)种物品,且物品之间存在依赖关系:要选择物品(y),必须先选择物品(x)

称不依赖于其他物品的物品为主件,依赖于其他物品的为附件。

如果将不同主件及其附件集合视为不同的物品组,这就有点像分组背包问题。但区别在于,这里可以选择多个附件,导致问题规模的指数级增加。

将问题一般化,那么物品之间的依赖关系会构成一棵树。注意,主件与主件之间其实是相互独立的,但我们可以在此之上再增加一个费用为0的根节点,便于递归。

解决思路如下:对于一个父节点(i),先求子结点在背包容量为(m)时的最优结果(对于叶子节点来说,就是来一遍01背包),再在此基础上尝试装入父节点。如果完全装不了,则方案是不可能的,此时要将之前得到的结果清空。

(dp[i][j]:=) 以节点(i)为父节点、背包容量为(j)时的最大价值。

原文地址:https://www.cnblogs.com/streamazure/p/13663715.html