动态规划适合求解最优问题,比如最大值最小值等。它可以显著的降低时间复杂度,提高代码的执行效率。
0-1 背包问题
在上篇总结中,用回溯算法解决了 0-1背包问题。但是,在求解的过程中,我们应该能想象的出,有些步骤是一直在重复执行。如果背包的总载重为 9 ,物品个数为 5 ,质量分别为 [2,2,4,6,3]。那么将这些数据带入回溯算法的代码中,执行阶段用递归树来表示:
在上图中递归树中每个节点的状态,用 (i,cw) 来表示。i 表示要决策第几个物品是否放入背包,cw 代表当前背包的总质量。例如 (2,2) 代表第二个物品将决定是否放入背包,决策之前背包的总质量是 2。
通过上面的图例加说明,我们可以看出 f(2,2) 和 f(3,4) 都被重复计算了两次。为了减少重复计算的次数,我们可以把计算过的情况记录在案,当下次遇到相同的计算的时候,直接取值用就好。代码如下:
1 | // 存储背包中物品总重量的最大值 |
上面的过程理解之后,接下来总结动态规划如何实现上面的过程。
求解的过程分解成 n 个阶段,n 代表物品的数量。每个阶段,决定一个物品是否放入,决策之后,背包的重量会发生变化,这个变化的状态对应到递归树中就是不同的节点。
现在,我们把递归树中每一层中相同状态的节点合并,只留下不同状态的集合,并且在本次状态集合下去推导下一次的状态集合。由于我们值保存不相同的状态,并且总重量不能大于 w (w 代表总重量),所以每层的状态集合最多有 w 个。(此处补充一下:前提是物品的质量都是整数,不可分割。)这样我们就避免了每一层的结果集成指数增长。
针对每一层的状态集,创建一个 states[n][w + 1] 二维数组来存储。n 代表层级,w + 1 代表结果集区间是从 0 到 w + 1。
对于放入第 0 个物品,质量为 2 ,对应数组中就是 states[0][0] = true,states[0][2] = true。states[0][0] 代表没有放入,states[0][2] 代表放入。以此类推,整个过程就是如下图所示:
代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34// weight: 物品重量,n: 物品个数,w: 背包可承载重量
public int knapsack(int[] weight, int n, int w) {
boolean[][] states = new boolean[n][w + 1]; // 默认值 false
// 第一个物品不放
states[0][0] = true;
// 第一个物品放
states[0][weight[0]] = true;
// 遍历 n 个物品等于执行 n 个阶段
for (int i = 1; i < n; ++i) {
// 以下两个 for 循环执行动态规划状态转移
// 不把第 i 个物品放入背包
for (int j = 0; j <= w; ++j) {
//如果上一层有状态,直接转移到本层
if (states[i - 1][j] == true){
states[i][j] = states[i - 1][j];
}
}
// 把第 i 个物品放入背包
// w - weight[i] 代表该物品放入之后,只有 j 以及 j 前面的不超重
for (int j = 0; j <= w - weight[i]; ++j) {
if (states[i - 1][j] == true){
//叠加上一层的状态存起来
states[i][j + weight[i]] = true;
}
}
}
for (int i = w; i >= 0; --i) {
// 输出最后一层结果
if (states[n - 1][i] == true)
return i;
}
return 0;
}
上述代码中有详细的注释,足以看懂整个过程。
上面所举的例子以及代码的实现,就是一个动态规划算法的实现。将问题分阶段解决,每个阶段都有不同的结果集,我们合并相同的结果集,然后用该结果集去推到下一阶段的结果集,这就是一个动态规划的过程。
当然,上述代码虽然比起回溯算法提高了效率,但是却需要额外开辟一个二维数组,增加可空间的消耗。下面用代码实现一种用一维数组完成的动态规划:
1 | public static int knapsack2(int[] items, int n, int w) { |
上述代码之所以可以用一维数组实现,主要是省略了每一层中物品不放入背包的逻辑。如果不省略,进行了不放入背包的判断,其实只是把上一次的状态搬移到本层。但是这次搬移其实是无用功的,因为上一层已经满足的条件,搬移到这一整层,依旧是满足条件的。所以,有意义的操作就是对本层物品放入的判断。另外一个原因,因为是动态规划,我们都是用上一层的结果集推导下一层的结果集。也就是说,当本层的结果集推导出来后,上一层的结果集已经没有用处了。所以,我们才可以采用本层额结果集覆盖上层的结果集,最终完成动态规划。
总结
本文创作灵感来源于 极客时间 王争老师的《数据结构与算法之美》课程,通过课后反思以及借鉴各位学友的发言总结,现整理出自己的知识架构,以便日后温故知新,查漏补缺。
初入算法学习,必是步履蹒跚,一路磕磕绊绊跌跌撞撞。看不懂别慌,也别忙着总结,先读五遍文章先,无他,唯手熟尔~
与诸君共勉