1.问题-原始01背包问题
有n件物品(不可分割), 每件物品的价值为nCost[i],体积为nVol[i],要放入到总容积为kBag的背包中,输出背包中能够装载物品的最大价值。
例:有5个物品(忽略每个数组的第一个值)
n=5
nCost[] = {0 , 2 , 5 , 3 , 10 , 4}
nVol[] = {0 , 1 , 3 , 2 , 6 , 2}
kBag = 12
输出:
21
21是选择编号为1,3,4,5的物品所产生的最大值。
1.2求解
我们把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。
我们把每一层重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。我们可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量)。于是,我们就成功避免了每层状态个数的指数级增长。
用二维数组dp[i][j]
来储存当决策到第i件物品时,背包容积剩余j的情况下背包中物品价值的最大值。按照每件物品依次决策,容易得到当j < nVol[i]
时,背包剩余容积小于要决策的物品体积,只能放弃装入;而j >= nVol[i]
时,可以选择装入也可以选择不装入,按照前述规则,此处应该取两者最大值,得到核心逻辑:
if (j < nVol[i]) {
dp[i][j] = dp[i-1][j];//放弃装入
} else {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-nVol[i]] + nCost[i]);//表示装入物品,容积变为j-nVol[i],价值增加nCost[i].
}
代码如下:
public class ZeroOnePackage {
int clacDp(int[] nCost, int[] nVol, int kBag) {
int[][] dp = new int[7][13];
for (int i = 1; i < 6; i++) {
for (int j = 1; j <= kBag; j++) {
if (j < nVol[i]) {
//剩余容量小于物品容量 丢弃
dp[i][j] = dp[i-1][j];
} else {
//状态转移方程, 不装入情况和装入情况选最大值
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-nVol[i]] + nCost[i]);
}
System.out.println("dp["+i+"]["+j+"]:"+dp[i][j]);
}
}
return dp[5][12];
}
public static void main(String[] args) {
/**
* 物体价值(第一个为哨兵)
*/
int nCost[] = new int[]{0 , 2 , 5 , 3 , 10 , 4};
/**
* 物体体积(第一个为哨兵)
*/
int nVol[] = new int[]{0 , 1 , 3 , 2 , 6 , 2};
/**
* 背包容积
*/
int kBag = 12;
System.out.println(new ZeroOnePackage().clacDp(nCost, nVol, kBag));
}
}
1.3分析
令物品件数为n,背包容积为w。
空间复杂度:O(n*w)
,时间复杂度:O(n*w)
.
尽管比起回溯算法的O(2^n)
的时间复杂度小了很多,但是额外申请了大小为n*w
的数组,因此耗费了空间,属于空间换时间的操作。
能不能把空间优化一下呢?
开辟一维数组states[w]
来替代dp[i][w]
,dp[i][w]
是由dp[i-1][w]
和dp[i-1][w-nVol[i]
两个问题递推而来,我们需要保证递推顺序,保证我们的states[w]
不是从dp[i][w-nVol[i]]
递推而来。
当计算dp[i][j]的时候,由于dp[i][j+1…w]的状态已经被计算过了,所以dp[i-1][j…w]的值已经没有用了。所以dp[i][j]的值可以存放在dp[i-1][j]的位置。
由于dp[i][j] = max(dp[i-1][j], dp[i-1][j-nVol[i]] + nCost[i]),dp[i][j]改变的值只与dp[i-1][1…j]有关,并且dp[i-1][1…j]是上一次循环保存下来的值,我们逆序循环,上一次循环得到的缓存等于states[i],新的states[i]遵循原有的状态转移方程,就有states[i] = max(states[i], states[w-nVol[i]] + nCost[i]).
根据状态转移逻辑有
states[i] = max(states[i], states[w-nVol[i]] + nCost[i])
如果我们顺序遍历,则先会确定i较小时的states[i]的值,这样的话,如果遍历到i较大时的states[i],使用了states[w - nVol[i]]的值,就会出错,原因就是states[i]并非由dp[i-1][w-nVol[i]]递推而来,而是从dp[i][w-nVol[i]]递推而来。
假定我们已经确定states[2] = 7,相当于把i=1,2的物品放入了背包,但是我们用states[2]来确定states[4]的时候,由于i=1已经放入了背包,和我们预想的算法不同.
代码如下:
public class ZeroOnePackage {
.
int clacDp2(int[] nCost, int[] nVol, int kBag) {
int[] states = new int[13];
for (int i = 1; i < 6; i++) {
for (int j = kBag; j >= nVol[i]; j--) {
states[j] = Math.max(states[j], states[j-nVol[i]] + nCost[i]);
}
}
return states[12];
}
public static void main(String[] args) {
/**
* 物体价值(第一个为哨兵)
*/
int nCost[] = new int[]{0 , 2 , 5 , 3 , 10 , 4};
/**
* 物体体积(第一个为哨兵)
*/
int nVol[] = new int[]{0 , 1 , 3 , 2 , 6 , 2};
/**
* 背包容积
*/
int kBag = 12;
System.out.println(new ZeroOnePackage().clacDp2(nCost, nVol, kBag));
}
}
如果想要保存最优解选择的物品序号,再开辟一个数组,然后通过二维数组的方式回溯,暂未找到一维数组回溯的解决方案。
1.4思考题
假定一个类似杨辉三角形的结构,每个节点有一个值,每个节点只能到达下一层的左或右节点,求顶点到最底层点路径经过所有节点之和的最小值。
输入第一行节点层数n,2-n+1行为节点的值,输出节点的和的最小值。
例:
输入:
5
1
2,3
4,5,6
7,8,9,10
输出:
20
令dp[i][j]为第i层第j个节点的决策
状态转移方程:dp[i][j] = max(dp[i-1][j-1] +tri[i][j], dp[i-1][j]+tri[i][j]),考虑左右边际情况(最左侧节点只能到上一层最左侧节点,最右侧节点只能到上一层最右侧节点)
代码如下:
public class Triangle {
static int solution(int[][] tri, int n) {
int[][] dp = new int[n+1][n+1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= i; j++) {
if (j > 1 && j < i) {
dp[i][j] = Math.max(dp[i-1][j-1] + tri[i][j], dp[i-1][j] + tri[i][j]);
} else if (j < i) {
dp[i][j] = dp[i-1][j] + tri[i][j];
} else {
dp[i][j] = dp[i-1][j-1] + tri[i][j];
}
}
}
int max = 0;
for (int j = 1; j <= n; j++) {
max = Math.max(max, dp[n][j]);
}
return max;
}
public static void main(String[] args) {
int n = 4;
int[][] tri = new int[5][5];
for (int i = 1; i < 5; i++) {
for (int j = 1; j <= i; j++) {
tri[i][j] = (i-1)*i/2+j;
}
}
System.out.println(solution(tri, n));
}
}
2. 动态规划理论
2.1 什么样的问题适合动态规划
”一个模型三个特征”。
一个模型:适合动态规划的模型。
三个特征:最优子结构、无后效性、重复子问题
最优子结构:通过子问题的最优解推导出问题的最优解,即后面阶段的状态可以通过前面阶段的状态推导出来。
无后效性:推导一个前置状态,不需要知道前置状态是如何推导出来的;一个状态一旦被推导出,就不受后续状态的影响。满足动态规划特征的问题基本都满足无后效性。
重复子问题:不同的决策序列进行到某一步时可能会产生相同的状态。
2.2 动态规划的解题思路总结
2.2.1 状态转移表法
一般DP能够解决的问题,回溯法也能解决,通过回溯画出递归决策树,从中找出重复子问题,再查看是否能用DP解决。
用回溯+重复子问题缓存的方法,一般时间复杂度接近DP,或者采用状态转移表法。
适合最高二维的状态表,根据决策过程将对应位置的状态填充到表中,最后经过分析翻译成代码。三维以上的问题不适合采用状态表。
2.2.2 状态转移方程法
分析子问题的递归求解法,接下来分析递推公式,通过递归子问题+记忆化或者递推来解决问题。
实际上状态转移方程写出来基本问题就解决了。
2.2.3 思考题
假设我们有几种不同币值的硬币 v1,v2,……,vn(单位是元)。如果我们要支付 w 元,求最少需要多少个硬币。比如,我们有 3 种不同的硬币,1 元、3 元、5 元,我们要支付 9 元,最少需要 3 个硬币(3 个 3 元的硬币)。
输入:第一行硬币的面值,第二行要支付的金额
1,2,5,10,20,50,100
1203
输出:需要硬币最少的数量
14
选择12个100,1个1,1个2
代码如下:
public class CoinChange {
static int solution(int[] v, int w) {
if (w == 0) {
return 0;
}
int[] dp = new int[w+1];
for (int i = 1; i <= w; i++) {
dp[i] = i;
}
for (int i = 1; i <= w; i++) {
for (int j = 1; j < v.length; j++) {
if (i >= v[j] && dp[i-v[j]] + 1 < dp[i]) {
dp[i] = dp[i-v[j]] + 1;
}
}
}
return dp[w];
}
public static void main(String[] args) {
int[] v = new int[]{0,1,2,5,10,20,50,100};
int w = 1203;
System.out.println(solution(v,w));
}
}