引言(动态规划之装载问题)
1、问题描述
一艘船可装载重量为12,有6个集装箱,各自的重量为a[n]=[1,2,3,4,5,6],设计一个可以装载的方案,使得船装下集装箱重量最大
2、问题解决
比较有趣的一句话是:每个动态规划都从一个网格开始。 (所以学会网格的推导至关重要,而有些题解之所以写的不好,就是因为没有给出网格的推导过程,或者说,没有说清楚为什么要”这样“设计网格。)
装载问题的网格如下:
m[i][j] | j | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
i | ||||||||||||||
6 | 0 | 0 | 0 | 0 | 0 | 0 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | |
5 | 0 | 0 | 0 | 0 | 0 | 5 | 6 | 6 | 6 | 6 | 6 | 11 | 11 | |
4 | 0 | 0 | 0 | 0 | 4 | 5 | 6 | 6 | 6 | 9 | 9 | 11 | 11 | |
3 | 0 | 0 | 0 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 9 | 11 | 11 | |
2 | 0 | 0 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 11 | |
1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
为了省事,我一股脑的把所有网格都列出来了,下面将介绍网格是怎么一步步生成的
- 首先网格是空的,j表示船的最大装载量,i表示第i个箱子的重量,m[i][j]就表示船可以装下的最大重量。
- 这里的网格是从上到下逐渐生成的,先是只有6号箱子,也就代表目前你只能装6号箱子到船上,所以只能是j>=6的时候才能放进去,第一行的值就是这么定下来的。
- 现在考虑5号箱子,也就是现在可以在船上放6号和5号箱子。所以j=5的时候可以放5号箱子了,j=11和12的时候可以把5号和6号都放进去。
- 同理,一直推理到最后一行最后一列,就得出了最终的结果。
3、状态转移方程
现在我们来提取一下里面的规律,假设现在i+1到n号箱子都已经考虑完了,现在该考虑第i号箱子了,这个时候有两种情况
- 第i号箱子不放进去:那么 m [ i ] [ j ] = m [ i + 1 ] [ j ] m[i][j]=m[i+1][j] m[i][j]=m[i+1][j]
- 第i号箱子放进去: m [ i ] [ j ] = m [ i + 1 ] [ j − a [ i ] ] + a [ i ] m[i][j]=m[i+1][j-a[i]]+a[i] m[i][j]=m[i+1][j−a[i]]+a[i]
- 综上:
m
[
i
]
[
j
]
=
m
a
x
(
m
[
i
+
1
]
[
j
]
,
m
[
i
+
1
]
[
j
−
a
[
i
]
]
+
a
[
i
]
)
m[i][j]=max(m[i+1][j],m[i+1][j-a[i]]+a[i])
m[i][j]=max(m[i+1][j],m[i+1][j−a[i]]+a[i])
例如:求解m[3][9],它表示考虑3-6号箱子,可接受容量为9时的最大装载量
- 3号箱子不放,那么相当于表示考虑4-6号箱子,可接受容量为9时的最大装载量
m [ 3 ] [ 9 ] = m [ 4 ] [ 9 ] = 6 m[3][9]=m[4][9]=6 m[3][9]=m[4][9]=6 - 3号箱子放,那么3号箱子先放进去,然后考虑4-6号箱子,可接受容量为(9-3)时的最大装载量
m [ 3 ] [ 9 ] = m [ 4 ] [ 6 ] + a [ 3 ] = 9 m[3][9]=m[4][6]+a[3]=9 m[3][9]=m[4][6]+a[3]=9 - 所以 m [ 3 ] [ 9 ] = 9 m[3][9]=9 m[3][9]=9
经典01背包问题
1、问题描述
给定 3 件物品,物品的重量为 weight[]={1,3,1},对应的价值为 value[]={15,30,20}。现挑选物品放入背包中,假定背包能承受的最大重量 W 为 4,问应该如何选择装入背包中的物品,使得装入背包中物品的总价值最大?
2、问题解决
令 dp[i][w] 表示前 i 件物品放入容量为 w 的背包中可获得的最大价值。为了方便处理,我们约定下标从 1 开始。初始时,网格如下:
dp[i][w] | w | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|---|
i | ||||||
0 | 0 | 0 | 0 | 0 | 0 | |
1 | 0 | |||||
2 | 0 | |||||
3 | 0 |
根据状态转移方程 d p [ i ] [ k ] = m a x ( v a l u e [ i ] + d p [ i − 1 ] [ k − w e i g h t [ i ] ] , d p [ i − 1 ] [ k ] ) dp[i][k] = max(value[i] + dp[i-1][k-weight[i]], dp[i-1][k]) dp[i][k]=max(value[i]+dp[i−1][k−weight[i]],dp[i−1][k])
3、代码实现
public int maxValue(int[] weight, int[] value, int W) {
int n = weight.length;
if (n == 0) return 0;
int[][] dp = new int[n][W + 1];
// 先初始化第 0 行
for (int k = 1; k <= W; k++) {
if (k >= weight[0]) dp[0][k] = value[0];
}
for (int i = 1; i < n; i++) {
for (int k = 1; k <= W; k++) {
// 存放 i 号物品(前提是放得下这件物品)
int valueWith_i = (k-weight[i] >= 0) ? (value[i] + dp[i-1][k-weight[i]]) : 0;
// 不存放 i 号物品
int valueWithout_i = dp[i-1][k];
dp[i][k] = Math.max(valueWith_i, valueWithout_i);
}
}
return dp[n-1][W];
}
观察上面的代码,会发现,当**更新dp[i][…]时,只与dp[i-1][…]**有关,也就是说,我们没有必要使用O(n*W)的空间,而是只使用O(W)的空间即可。也就是说再更新新的一行的时候,直接将新的数值覆盖掉前面一行的内容即可
这里就会存在一个问题,就是我覆盖掉了前面一行的值会不会影响我后面的计算,因为后面的计算可能会用掉覆盖之前的旧值。
巧妙的是,在完全背包问题(物品可以重复取)中,正好可以利用这一点,正向遍历
观察状态转移方程就知道,我在计算dp[i][k]的时候我会用到上一行的dp[i-1][k]和dp[i-1][k-weight[i]],如果我是从左往右也就是k递增的顺序去覆盖的话,在计算到第k列的时候,我第k-weight[i]列的值已经覆盖掉了,所以这种顺序不行,只能从右往左也就是k递减的顺序去覆盖。
代码
public int maxValue(int[] weight, int[] value, int W) {
int n = weight.length;
if (n == 0) return 0;
int[] dp = new int[W + 1];
for (int i = 0; i < n; i++) {
//做了个优化:只要确保 k>=weight[i] 即可,而不是 k>=1,从而减少遍历的次数
for (int k = W; k >= weight[i]; k--) {
dp[k] = Math.max(dp[k - weight[i]] + value[i], dp[k]);
}
}
return dp[W];
}
其他案例
1、飞机座位分配概率(01背包问题)
题目
有 n 位乘客即将登机,飞机正好有 n 个座位。第一位乘客的票丢了,他随便选了一个座位坐下。
剩下的乘客将会:
- 如果他们自己的座位还空着,就坐到自己的座位上,
- 当他们自己的座位被占用时,随机选择其他座位
第 n 位乘客坐在自己的座位上的概率是多少?
示例 1:
输入:n = 1
输出:1.00000
解释:第一个人只会坐在自己的位置上。
示例 2:
输入: n = 2
输出: 0.50000
解释:在第一个人选好座位坐下后,第二个人坐在自己的座位上的概率是 0.5。
思路
我们定义原问题为 f(n)。对于第一个人来说,他有 n 中选择,就是分别选择 n 个座位中的一个。由于选择每个位置的概率是相同的,那么选择每个位置的概率应该都是 1 / n。
我们分三种情况来讨论:
- 如果第一个人选择了第一个人的位置(也就是选择了自己的位置),那么剩下的人按照票上的座位做就好了,这种情况第 n 个人一定能做到自己的位置,这时答案是1
- 如果第一个人选择了第 n 个人的位置,那么第 n 个人肯定坐不到自己的位置,这时答案是0
- 如果第一个人选择了第 i (1 < i < n)个人的位置,那么第 i 个人就相当于变成了“票丢的人”,此时问题转化为 f(n - i + 1)。
运用动态规划的思想,假设现在前n-1个人的概率都已经算出来了,现在来算第n个人的概率,状态转移方程为 d p [ n ] = ( 1 + d p [ 2 ] + d p [ 3 ] + . . . + d p [ n − 1 ] + 0 ) / n dp[n]=(1+dp[2]+dp[3]+...+dp[n-1]+0)/n dp[n]=(1+dp[2]+dp[3]+...+dp[n−1]+0)/n
代码
class Solution {
public double nthPersonGetsNthSeat(int n) {
if (n == 1) return 1.0;
double [] dp=new double[n+1];//从下标1开始使用
dp[2]=0.5;
double sum_dp=1.5;
for(int i=3;i<=n;i++){
dp[i]=(1.0/(double)i)*sum_dp;
sum_dp+=dp[i];
}
return dp[n];
}
}
2、零钱兑换(完全背包问题)
题目
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
思路
首先,我们定义:
- F ( S ) F(S) F(S):组成金额 SS 所需的最少硬币数量
- [ c 0 … c n − 1 ] [c_{0} \ldots c_{n-1}] [c0…cn−1] :可选的 n 枚硬币面额值
假设我们知道 F(S) ,即组成金额 S 最少的硬币数,最后一枚硬币的面值是 C。那么由于问题的最优子结构,转移方程应为:
F ( S ) = F ( S − C ) + 1 F(S) = F(S - C) + 1 F(S)=F(S−C)+1
但我们不知道最后一枚硬币的面值是多少,所以我们需要枚举每个硬币面额值 c 0 , c 1 … c n − 1 c_{0},c_{1} \ldots c_{n-1} c0,c1…cn−1,并选择其中的最小值。
假设在计算 F(i)之前,我们已经计算出 F(0)-F(i-1)的答案。 则 F(i)对应的转移方程应为
F
(
i
)
=
m
i
n
j
∈
[
0
,
n
]
F
(
i
−
c
j
)
+
1
F(i)=min_{j \in [0,n]} F(i-c_j)+1
F(i)=minj∈[0,n]F(i−cj)+1
代码
public class Solution {
public int coinChange(int[] coins, int amount) {
int max = amount + 1;
int[] dp = new int[amount + 1];
Arrays.fill(dp, max);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int j = 0; j < coins.length; j++) {
if (coins[j] <= i) {
dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
}