背包九讲,学习笔记、01背包,完全背包,多重背包,混合背包,二维背包,分组背包,依赖关系背包、统计方案数、统计具体方案

背包九讲学习笔记

这个博客是对动态规划的背包问题专题的学习笔记,记录了背包问题的九个经典类型。
参考视频:视频链接
题目来自视频中使用的oj中的例题。

话不多说,咱们开始

01背包

题目

分析问题

题目的意思很好理解:
有N个物品,体积为V的背包。每个物品有自己的价值和体积,由于是01背包问题,所以每件物品仅有可以选或者不选两种选项,问我们可以向背包中装下的价值的最大值。

题意很简单,接着就让我们展开思路想办法解决它。

两个可能会产生的思路

笔者个人补充,着急学习正解的同学可以跳过这里

思路一:枚举

考虑到每件物品仅有选或者不选两种状态,那么不难发现状态数是确定的,共有2^n种状态。于是就可以枚举每一种状态,在判断其体积合乎规则后,统计答案即可。

这样的思想当然可以,但是问题就在于时间复杂度上,上面说了状态数共有2n种,就算是在枚举的过程中顺带累积了体积和价值,那么复杂度也是O(2n),这个复杂度对于大于25的数据范围就有些吃力了。对于1000这种范围,显然是不可饶恕的,予以TLE警告。

然后25的范围也足以体现出这种算法的效率之低。所以这种想法,我们还是趁早打消的好。

思路二:贪心

最开始接触这个问题的我反应是这样的。想要通过计算物品的“性价比”作为标准排序进行选取。
但是转念一想后发现这样的想法是不可取的,一个单位性价比较高的物品放进背包中不一定会使答案最优。问题的原因就在于题目中的物品是不可分隔的,所以仅考虑“性价比”在当前体积还有足够的剩余时还可以保证其正确性,但在接近放满时就会出现问题,导致这种解法的不正确性就出现在这里了。
由于可能产生剩余的空间,不能再放下其他的物品,背包中就会产生空余。这些空余就会使已经放入背包中的“性价比”贬值,小于未加入背包的物品,从而导致选出的答案不是最优解。
换句话说我们不知道是否需要将已经放入背包中的那些物品拿出来换成其它的物品。这导致我们的这个思路无法解决问题。

值得注意的是,这种贪心的思想在该问题环境下破产的根本原因是物品选取是离散的,不可分割的。
所以如果物品可以分割,那么这种贪心的思想就可以保证其正确,这道题就是这样

所以两种思路都是不可取的,既然是正经的动态规划中的背包问题板子题,那这就让我们走回到正轨上来。

状态转移

设状态dp[i][j]表示在第i件物品,j的体积下的最大价值。

那么由于该件物品仅有选或不选两种情况,那么这个状态就可能会从两种状态转移而来

当不选时,前一状态为dp[i - 1][j],当前物品贡献为0;当选择时,前一状态为dp[i - 1][j - v[i]]当前物品贡献为w[i]。

我们从两者中取较大者作为当前状态的最优解。即为dp[i][j] = max(dp[i - 1][j],dp[i - 1][j - v[i]] + w[i]);
(一定不要忘记在选择物品的状态转移时加上当前物品的价值,当体积不足以放下当前物品时,直接照抄上一行的值。)
伪代码:

for(int i = 1;i <= n;i++)
		{
			v = scan.nextInt();
			w = scan.nextInt();
			for(int j = V;j >= 0;j--)
			{
				if(j < v)
				{
					dp[i][j] = dp[i - 1][j];
				}
				else
				{
					dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - v] + w);
				}
			}
		}

状态初始化

有了转移方程,我们就要确定边界,即确定初始状态和最终答案。

需要初始化的初始状态应该是一个物品都没有考虑的状态,这个(些)初始状态我们是已知的这里有两种初始化的方法,分别对应着对状态的不同理解:

1.将dp[i][j]看作是对于第i个物品,占用体积 恰好为j 时的最优解。初始状态为:没有放置任何物品的背包价值和占用体积都为0,即dp[0][0] = 0;其余体积都是不可能的,赋初值为负无穷

2.将dp[i][j]看作是对于第i个物品,占用体积 不大于j 时的最优解。初始状态为:没有放置任何物品时,总体积为零,不大于任何体积,所以所有体积的初值都为0.即dp[0][j] = 0

答案

对于该问题,最终的答案一定出现在在我们考虑了每一个物品后,即在数组中的最后一行dp[n]

不过具体在哪个位置,就和刚刚的初始化有关,关系如下:

初始化为负无穷,答案需要统计(某一总体积价值最大)
初始化为零,答案为最后一位(小于等于最大体积价值最大)

对于第一种初始化的方法,需要遍历最后一行,找出那个最大的价值方案,即ans=max{dp[n][i]}(1 ≤i≤V)
对于第二种初始化的方法,最优答案出现在体积最大的情况下,即ans=dp[n][V]

对01背包的优化

从上述状态转移方程中,不难发现其实第i个物品的所有状态都来自于第i-1个物品,所以为了求取最大的价值并没有必要保留所有的状态
当然,由于转移仅涉及上一行,所以可以采取滚动数组的优化方案来优化空间复杂度。但是我们仍有更优的优化方案,可以将数组直接从二维优化至一维。

二维转一维

重新分析状态和转移,当进行到第i个物品体积为j时,两个用来转移状态分别来自于上一物品占用体积较小的某个状态dp[i - 1][j - v[i]]和当前体积状态dp[i][j]。

所以可以将这个二选一的过程看作是先原封不动的复制上一行同一位置的答案,在将其与另一个状态选优。进而可以发现,这样的做法其实并不需要我们利用新的空间。也就是说可以直接将第j位同第j-v[i]位加上价值进行比较,产生的答案放置在第j位上。方程:dp[j]=max{dp[j],dp[j - v[i]] +w[i]}
那么这样一来,我们就不需要第二个维度了,进行新的物品的状态转移,仅需要在同一个数组上按照方程扫一遍即可。

不过,仅考虑到这里并没有结束,上述过程仍旧存在问题,需要我们对体积的枚举进行改造。

循环反向

刚刚的问题就在于,如果仍旧按照二维数组的正向循环的顺序去做,那么在利用前面元素进行转移时,就不能保证这个状态是来自上一个物品的了。
所以为了避免这个问题,在枚举体积时我们选择避开选取状态的方向,反向进行选取。
(这里可以参考拿着大拖把清洁狭长的走廊时,都是拉着拖把走,这样就可以避免把刚刚拖干净的地面踩脏)

代码:

import java.util.Scanner;

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

一点关于空间优化的想法:

笔者私货,可略过

在刚刚的分析和优化中背包问题出现了两种空间上的优化:

 先是将空间从n行优化为滚动数组的两行。
 再是将空间从两行优化为一行

这里的两次优化分别对应了背包问题转移时的两种性质,对于其他的动态规划题满足其中性质就可以做相应的优化。它们分别是:

1.用来转移的子状态全部来源于前一行
2.子状态全部集中分布在同一侧

下限行数优化

对于第一种性质,可以参考数列,就像是数列中的一类递推公式,每一个元素仅与前一个元素有关,例如ak = 5ak-1。这样的数列在根据递推式计算某一位时可以仅存储相邻的两个值。每一次根据公式计算出下一个值,覆盖掉两者中的靠前的那一位。这样的操作,在数组中,对应的就是滚动数组。

当然,数列可以看做是这种转移的特殊情况:
数组列数为1
转移时仅进行计算不涉及选择(相较于状态转移,这里更应该称之为递推)。

不过相信你已经发现了,那样的数列在计算时仅需要一个值不断更迭就好。其实正如我们将两行优化为一行一样,数列的递推满足了下面这条性质,所以在递推过程中可以对空间进行进一步的优化。

行数减一优化

进行这种优化需要满足前两种性质,也就是说,这个转移或者递归的过程,每一行的状态或是值,仅有其前面确定数量的行递推得到。其次,就是子状态必须在一行中集中分布在当前状态的一侧(这里可以包含当前状态所处的位置)。

例如,对于背包问题,子状态在它的一行中就集中分布在当前状态的左侧。从二维数组的角度看,假设得到的新数组位于最下方,那么所有子状态都分布在当前状态的左上方。

这样一来,就可以将滚动数组再优化掉一行。数列的情况与之类似,上一个数字就在新的数字的正上方,满足这个性质,所以在滚动的过程中可以再减少一行。

这样看来,其实从二维优化至一维,是两行滚动数组再优化掉一行所致。本质上还是滚动的数组。
(在补充一点,集中分布在一侧,其实是为了在覆盖的时候不会影响后续,保证用到的子状态都是原数组的。所以,如果通过其他的选取子状态的方式保证使转移时不发生干扰,那么其实不必要求集中分布在一侧,也不必要求从后向前循环)

完全背包

题目

分析问题

大背景同01背包问题一样,本题作为01背包问题的一种扩展,不同于01背包仅有选或者选一个两种情况,完全背包的每件物品可以选取任意整数数量,可看做01…n背包。

状态转移

仿照01背包的思路,每个状态的转移需要枚举所有可能出现的选取方式,最少选0个,为dp[j];最多选k=j/v个,为dp[j - v[i] * k]+ k * w[i]

不过这样的转移方式,会导致时间复杂度直接多了一个k,k最大为V,则上限变成了O(NV2)。爆炸

所以,我们需要再进行优化。

循环方向(反转正)

其实这里的优化很简单,就是将刚刚的01背包中的循环反向即可。反向的操作使得我们可以利用刚刚考虑放过一定数量的物品的最优解继续计算是否需要继续放。这句话可能有一些绕,大致的意思就是,由于是正向循环,所以当体积枚举到j时,进行转移,就相当于在体积为j-vi的基础上判断是否再加上一个该物品或者不加。由于是加上一个,所以就相当于在体积允许的情况下选取若干个。
值得注意的是,01背包中我们反向循环正是为了避免这个问题(你品,你细品)
代码:

import java.util.Scanner;

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

多重背包

题目1
题目2
题目3

分析问题

有点像完全背包,可以选取多个该物品,不同点在于这次不是无限个了,所以在处理上还是需要作出一些改变。

暴力枚举

这个做法可以对比参考完全背包的暴力枚举。将上限改为可选取的上限即可。
代码:

import java.util.Scanner;

public class Main {
	public static void main(String[] args)
	{
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int V = scan.nextInt();
		int[] dp = new int[V + 1];
		int v,w,s;
		for(int i = 1;i <= n;i++)
		{
			v = scan.nextInt();
			w = scan.nextInt();
			s = scan.nextInt();
			for(int j = V;j >= 0;j--)
			{
				for(int k = 1;k <= s && k * v <= j;k++)
				{
					dp[j] = Math.max(dp[j], dp[j - k * v] + k * w);
				}
			}
		}
		System.out.print(dp[V]);
	}
}

那么这种写法的复杂度同完全背包的暴力枚举相同,为O(NV2),
复杂度很高,仅能通过题目1的数据,对于题目二的数据就会TLE了。

当然,除了暴力枚举,我们还可以将这个问题转化成为01背包问题解决。
将问题看作是将可以最多选取k个的物品,拆作k个选或者不选的物品,最终将问题转化为01背包。因此基于这样的可以通过下面的二进制优化来简化复杂度。

二进制优化

在上面的解法中,提到了将可多选的物品拆成多个相同的单选物品。
对于最多可选k个的物品,虽然拆成k个物品可以通过确定每个物品选或者不选来组合出0-k的所有组合。但是不难发现,除了0和k其余每个数字均会被表示成多种组合,这也就造成了冗余。

说起一些数通过选或者不选,不重复且不遗漏的表示一段区间中的所有值,那么自然的,可以联想到二进制每一位的值,即二的幂。

这样,我们将k拆作从1开始的二的幂以及剩下的不能再被拆的余数。
例如,可以将13拆为1,2,4和6,即将13个物品分为1个,2个,4个,6个,将每组的物品看成是一个大的物品,体积和价值对应翻倍。

为了便于添加元素,存放物品的数组改用vector。
代码:

import java.util.Scanner;
import java.util.Vector;

class Good{
	public Good(int v,int w)
	{
		this.w = w;
		this.v = v;
	}
	int w;
	int v;
}

public class Main {
	public static void main(String[] args)
	{
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int V = scan.nextInt();
		int[] dp = new int[V + 1];
		Vector<Good> good = new Vector<Good>();
		int v,w,s;
		for(int i = 1,k;i <= n;i++)
		{
			v = scan.nextInt();
			w = scan.nextInt();
			s = scan.nextInt();
			k = 1;
			while(s > k)
			{
				good.add(new Good(k * v, k * w));
				s -= k;
				k <<= 1;
			}
			if(s != 0)
			{
				good.add(new Good(s * v, s * w));
			}
		}
		
		for(Good g : good)
		{
			for(int j = V;j >= g.v;j--)
			{
				dp[j] = Math.max(dp[j], dp[j - g.v] + g.w);
			}
		}
		System.out.print(dp[V]);
	}
}

单调队列优化

还不会,鸽掉~

混合背包

题目

分析问题

这个背包问题,是前三个问题的混合,考虑到在转移时,枚举体积仅与物品有关。
所以分情况处理即可。

同质化处理

这里需要一个预处理,将多重背包通过二进制分解化为01背包。
找到完全背包的上限,二进制分解为01背包。

最后,做普通的01背包问题即可。
代码:

import java.util.Scanner;
import java.util.Vector;

class Good{
	public Good(int v,int w)
	{
		this.w = w;
		this.v = v;
	}
	int w;
	int v;
}


public class Main {
	public static void main(String[] args)
	{
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int V = scan.nextInt();
		int[] dp = new int[V + 1];
		Vector<Good> good = new Vector<Good>();
		int v,w,s;
		for(int i = 1, k;i <= n;i++)
		{
			v = scan.nextInt();
			w = scan.nextInt();
			s = scan.nextInt();
			if(s == -1)
			{
				good.add(new Good(v,w));
			}
			else
			{
				if(s == 0)
				{
					s = V / v + 1;
				}
				k = 1;
				while(s > k)
				{
					s -= k;
					good.add(new Good(v * k,w * k));
					k <<= 1;
				}
				if(s != 0)
				{
					good.add(new Good(v * k,w * k));
				}
			}
		}
		for(Good g : good)
		{
			for(int j = V;j >= g.v;j--)
			{
				dp[j] = Math.max(dp[j], dp[j - g.v] + g.w);
			}
		}
		System.out.print(dp[V]);
	}
}

补充

可以不分解完全背包,在dp的时候对完全背包正向循环即可。

二维背包

分析问题

仍旧是01背包,但是在限制上多了一个标准:重量。需要同时满足重量和体积不超过最大值。

状态及转移

相较于01背包,本质上没有改变,状态从一维扩展到二维即可。
变成dp[j][k],j和k分别表示重量和体积
在转移上,仍旧是两种状态,选或者不选,方程为:dp[j][k] = max{dp[j][k],dp[j - v[i]][k - g[i]] + w[i]}
两个维度都倒叙循环。
代码:

import java.util.Scanner;

public class Main {
	public static void main(String[] args)
	{
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int V = scan.nextInt();
		int M = scan.nextInt();
		int[][] dp = new int[M + 1][V + 1];
		int v,m,w;
		for(int i = 1;i <= n;i++)
		{
			v = scan.nextInt();
			m = scan.nextInt();
			w = scan.nextInt();
			for(int j = M;j >= m;j--)
			{
				for(int k = V;k >= v;k--)
				{
					dp[j][k] = Math.max(dp[j][k], dp[j - m][k - v] + w);
				}
			}
		}
		System.out.print(dp[M][V]);
	}
}

分组背包

题目

分析问题

相较于之前的背包问题,这个版本将每个物品扩展成了一组物品,每组物品只能选择其中一个或者不选。需要在之前的思路上面做些微小的改动

分组枚举

其实题目中的一组物品,由于只能选择一个或者不选,可以看做是一个物品具有若干种不同的状态。所以模仿这01背包枚举选或者不选两种状态,在转移时枚举包含不选在内的所有状态即可。
代码:

import java.util.Scanner;

public class Main {
	public static void main(String[] args)
	{
		Scanner scan = new Scanner(System.in);
		int N = scan.nextInt();
		int V = scan.nextInt();
		int[] dp = new int[V + 1];
		int[] w, v;
		int n;
		for(int i = 1;i <= N;i++)
		{
			n = scan.nextInt();
			v = new int[n];
			w = new int[n];
			for(int k = 0;k < n;k++)
			{
				v[k] = scan.nextInt();
				w[k] = scan.nextInt();
			}
			for(int j = V;j >= 0;j--)
			{
				for(int k = 0;k < n;k++)
				{
					if(v[k] <= j)
					{
						dp[j] = Math.max(dp[j], dp[j - v[k]] + w[k]);
					}
				}
			}
		}
		System.out.print(dp[V]);
	}
}

分组背包和多重背包

将分组背包看作是一个物品可以拥有多种不同的状态,不难发现。多重背包其实是分组背包的一种特殊情况。

有依赖关系的背包问题

题目

分析问题

这道题在数据的组织形式上做了改变,从物品间相互独立变成了树形关系,并规定:选择子节点必须选择父节点
所以这道题首先是一个树形dp需要我们递归遍历整棵树进行dp
设状态dp[k][j]表示节点i及其子树和体积为j时的最大值
由于各个子树的各种状态存在子节点的dp数组中,每个子节点具有多种状态,所以子节点向父节点进行转移时使用分组背包的转移方式

树形dp + 分组背包

树形dp:需要利用递归遍历整棵树由于在转移时需要先处理出子节点的所有状态,先遍历子树再进行转移

分组背包:可以将子树看作一个整体,即一组物品,每个体积及其对应的子树最优解为一组中的一个物品,枚举即可。

依赖关系处理:枚举时不允许访问父节点数组小于父节点体积的状态;初始化:小于体积的位置初始化为负无穷表示不合法情况,初始情况仅有选择这一种。
看代码:
换c语言写了,表示树方便

#include<stdio.h>
struct node
{
	int k;
	int next;
}b[500];
int head[500];
int n, tot = 0,V;
int v[500];
int w[500];
int dp[200][200];
void link(int a,int c)
{
	tot++;
	b[tot].k = c;
	b[tot].next = head[a];
	head[a] = tot;
}

int max(int a,int b)
{
	return a > b ? a : b;
}

void dfs(int k)
{
	for(int i = 0;i <= V;i++)
	{
		dp[k][i] = i == v[k] ? w[k] : -1 << 29;
	}dp[k][0] = 0;
	for(int a = head[k],son;a;a = b[a].next)
	{
		son = b[a].k;
		dfs(son);
		for(int j = V;j >= v[k];j--)
		{
			for(int i = j - v[k];i >= 0;i--)
			{
				dp[k][j] = max(dp[k][j],dp[k][j - i] + dp[son][i]);
			}
		}
	}
}

int main()
{
	scanf("%d%d",&n,&V);
	int fa;
	int h;
	for(int i = 1;i <= n;i++)
	{
		scanf("%d%d%d",&v[i],&w[i],&fa);
		if(fa != -1)
		{
			link(fa,i);
		}
		else
		{
			h = i;
		}
	}
	dfs(h);
	int ans = 0;
	for(int i = 0;i <= V;i++)
	{
		ans = max(ans,dp[h][i]);
	}
	printf("%d",ans);
}

背包问题方案数

题目

分析问题

01背包的背景,换了个问题,改为统计最优方案数量。做法在普通01背包的基础上,需要再维护一个统计数组,表示某一体积的最优解的方案数,在进行最优解转移时,加上统计数组一并转移即可。

统计方案

对于一个体积,最优的方案可能是不选择,也有可能选择,统计方案数时,直接复制最优方案处的最大值,当选或不选价值相同时,方案数应该为两状态的方案数的加和。

初始化

初始化:统计数组将体积为零的方案数初始化为1,其余为0。dp数组采用第j位表示体积恰好为j的含义,体积0为0,其余位负无穷。

答案统计

先遍历一遍dp数组找出最优解的值,再遍历统计数组,将最优解的方案数统计在一起。

代码:

import java.util.Scanner;

public class Main {
	public static void main(String[] args)
	{
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int V = scan.nextInt();
		int[] dp = new int[V + 1];
		int[] cnt = new int[V + 1];
		int v,w;
		for(int i = 1;i <= V;i++)
		{
			dp[i] = -1 << 29;
		}
		cnt[0] = 1;
		for(int i = 1;i <= n;i++)
		{
			v = scan.nextInt();
			w = scan.nextInt();
			for(int j = V,c,s;j >= v;j--)
			{
				c = Math.max(dp[j], dp[j - v] + w);
				s = 0;
				if(c == dp[j]) s += cnt[j];
				if(c == dp[j - v] + w) s += cnt[j - v];
				cnt[j] = s % 1000000007;
				dp[j] = c;
			}
		}
		int ans = 0,ma = 0;
		for(int i = 0;i <= V;i++)
		{
			ma = Math.max(ma,dp[i]);
		}
		for(int i = 0;i <= V;i++)
		{
			ans += (dp[i] == ma ? cnt[i] : 0);
			ans %= 1000000007;
		}
		System.out.print(ans);
	}
}

背包问题求具体方案

题目

分析问题

这个问题同样是01背包的背景,这回不统计方案数了,改成求一个具体方案。
对于求动态规划方案的问题,通常可以根据状态来源回溯得到答案
所以这次我们要保留所有的状态,回到最原始的状态和转移
dp[i][j]表示第i个物品,体积不超过j的最优解。(注意区别二维背包)

状态转移

为了能在统计答案时计算出字典序最小的答案,在枚举物品时倒序枚举

答案统计

从最大体积开始(最优解的位置),正序枚举所有物品,若能从选择当前物品的状态中得出转移的答案,说明该元素是某个最优方案的选择,便将该元素输出,将当前体积减去当前物品的体积。优先考虑编号较小的物品,可以保证答案的字典序最小。
代码:

import java.util.Scanner;

public class Main {
	public static void main(String[] args)
	{
		Scanner scan = new Scanner(System.in);
		int n = scan.nextInt();
		int V = scan.nextInt();
		int[] w = new int[n + 1];
		int[] v = new int[n + 1];
		int[][] dp = new int[n + 2][V + 1];
		for(int i = 1;i <= n;i++)
		{
			v[i] = scan.nextInt();
			w[i] = scan.nextInt();
		}
		for(int i = n;i >= 1;i--)
		{
			for(int j = V;j >= 0;j--)
			{
				if(j >= v[i])
				{
					dp[i][j] = Math.max(dp[i + 1][j], dp[i + 1][j - v[i]] + w[i]);
				}
				else
				{
					dp[i][j] = dp[i + 1][j];
				}
			}
		}
		for(int i = 1;i <= n;i++)
		{
			if(V - v[i] >= 0 && dp[i][V] == dp[i + 1][V - v[i]] + w[i])
			{
				System.out.print(i + " ");
				V -= v[i];
			}
		}
	}
}
发布了17 篇原创文章 · 获赞 2 · 访问量 469

猜你喜欢

转载自blog.csdn.net/wayne_lee_lwc/article/details/104275852