文章目录
1. 什么是动态规划
在CH39讲回溯算法谈到回溯算法本质是枚举所有可能的路径,然后求取可行解或者最优解。这种暴力搜索方法,能够解决贪心算法那种出现局部最优解问题。
但是回溯算法的时间复杂度太高,有时候甚至没法在规定时间内计算出预期结果,其根本原因在于回溯算法会出现大量的重复计算。
动态规划本质上就是在递归回溯的基础上,减少重复计算,从而提高效率的方法。减少重复计算,在回溯算法种可以使用备忘录来存储历史计算记录,而动态规划是备忘录的一种延申,其使用状态记录数组或矩阵来记录历史状态,进而推导下一步状态。两者本质上都是通过以空间换时间的方式来提高算法效率的。
2. 再看0-1背包问题
对于一组不同重量、不可分割的物品,选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?
假设有5个物品,重量分别为2,2,4,6,3,背包最大重量为9,求解上述问题。
由于物品不可分割,显然不能通过贪心算法来解决,这时候首先考虑递归回溯。
2.1 递归回溯算法
直接上代码,代码注释解释很清楚,基本思想:
- 递归考察5个物品,放入或者不放入;
- 当物品重量达到限制或者物品考察完毕,则递归结束;
- 全局更新当前总重量,取最大值保存。
// 回溯算法实现。注意:我把输入的变量都定义成了成员变量。
private int maxW = Integer.MIN_VALUE; // 结果放到maxW中
private int[] weight = {2,2,4,6,3}; // 物品重量
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw) { // 调用f(0, 0)
if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
if (cw > maxW) maxW = cw;
return;
}
f(i+1, cw); // 选择不装第i个物品
if (cw + weight[i] <= w) {
f(i+1,cw + weight[i]); // 选择装第i个物品
}
}
2.2 递归回溯+备忘录
分析递归回溯代码,使用递归树画出状态图如下:
其中,状态(i, cw)表示考察到第几个物品以及当前的物品重量。
可以发现,结点(2,2),(3,4)都重复计算了,这就意味着此节点以下的所有结点都是重复计算,导致回溯算法计算量巨大。
怎么解决这个问题?这时候备忘录就被引入进来了,用一个5*10的二维数组记录结点状态,计算过的就置为True,这样就能够保证结点不重复计算。代码如下:
private int maxW = Integer.MIN_VALUE; // 结果放到maxW中
private int[] weight = {2,2,4,6,3}; // 物品重量
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
private boolean[][] mem = new boolean[5][10]; // 备忘录,默认值false
public void f(int i, int cw) { // 调用f(0, 0)
if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
if (cw > maxW) maxW = cw;
return;
}
if (mem[i][cw]) return; // 重复状态
mem[i][cw] = true; // 记录(i, cw)这个状态
f(i+1, cw); // 选择不装第i个物品
if (cw + weight[i] <= w) {
f(i+1,cw + weight[i]); // 选择装第i个物品
}
}
加上备忘录后,回溯算法的效率已经和动态规划不相上下了,因为本质上都是减少了重复计算,但是我们可以换个角度,用动态规划继续分析一下这棵递归树,看看能否找到其他的解决办法。
2.3 动态规划算法
动态规划思路如下:
- 根据考察物品个数,把问题求解过程划分n个阶段。
- 每个阶段都考察物品 n 是否放入(此时和回溯算法一致)。
- 统计当前的状态,对应的就是第n个物品放入或者不放入所能达到的物品重量的状态。(用一个二维数组 state 5*10 记录,5表示物品个数,10表示状态数,状态用True和False表示。)
- 合并重复状态,剔除无效状态 (这就起到了备忘录作用,同时)
- 继续考察下一个物品,走1,2,3步骤,直到物品考察结束。
- 输出结果。
状态分析图如下:
代码实现如下:
weight:物品重量,n:物品个数,w:背包可承载重量
public int knapsack(int[] weight, int n, int w) {
boolean[][] states = new boolean[n][w+1]; // 默认值false
states[0][0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
if (weight[0] <= w) {
states[0][weight[0]] = true;
}
for (int i = 1; i < n; ++i) { // 动态规划状态转移
for (int j = 0; j <= w; ++j) {// 不把第i个物品放入背包
if (states[i-1][j] == true) states[i][j] = states[i-1][j];
}
for (int j = 0; j <= w-weight[i]; ++j) {//把第i个物品放入背包
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;
}
3. 升级版 0-1背包问题
递归回溯+备忘录算法可以达到动态规划同样的效率,但是并不是所有问题都能用备忘录,这就是动态规划存在的必要性。
刚刚讲的背包问题,只涉及背包重量和物品重量。我们现在引入物品价值这一变量。对于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?
3.1 递归回溯算法
此时多加入一个状态(i,cw,cv),分别代表第i个物品,当前重量以及当前价值,代码实现如下:
private int maxV = Integer.MIN_VALUE; // 结果放到maxV中
private int[] weight= {2,2,4,6,3}; // 物品的重量
private int[] value = {3,4,8,9,6}; // 物品的价值
private int n = 5; // 物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw, int cv) { // 调用f(0, 0, 0)
if (cw == w || i == n) { // cw==w表示装满了,i==n表示物品都考察完了
if (cv > maxV) maxV = cv;
return;
}
f(i+1, cw, cv); // 选择不装第i个物品
if (cw + weight[i] <= w) {
f(i+1,cw+weight[i], cv+value[i]); // 选择装第i个物品
}
}
继续用递归树分析该问题,如下图所示:
其中同样存在大量重复结点,这里的重复结点不包括cv,因为同等重量的物品,本质只需要取cv最大的那个,所以光用true和false标记重复结点不可以。这就需要用到动态规划里状态统计步骤中的“合并重复状态”了,保留最大价值cv的状态即可。
3.2 动态规划求解
具体步骤,不再赘述,此时不同在于状态矩阵不再是True或者False而是对应的cv值,详细代码如下:
public static int knapsack3(int[] weight, int[] value, int n, int w) {
int[][] states = new int[n][w+1];
for (int i = 0; i < n; ++i) { // 初始化states
for (int j = 0; j < w+1; ++j) {
states[i][j] = -1;
}
}
states[0][0] = 0;
if (weight[0] <= w) {
states[0][weight[0]] = value[0];
}
for (int i = 1; i < n; ++i) { //动态规划,状态转移
for (int j = 0; j <= w; ++j) { // 不选择第i个物品,更新状态矩阵
if (states[i-1][j] >= 0) states[i][j] = states[i-1][j];
}
for (int j = 0; j <= w-weight[i]; ++j) { // 选择第i个物品,剔除无效状态,保证不超过重量,更新状态矩阵
if (states[i-1][j] >= 0) {
int v = states[i-1][j] + value[i];
if (v > states[i][j+weight[i]]) { //合并重复状态,判断选择最大的cv值保留
states[i][j+weight[i]] = v;
}
}
}
}
// 找出最大值
int maxvalue = -1;
for (int j = 0; j <= w; ++j) {
if (states[n-1][j] > maxvalue) maxvalue = states[n-1][j];
}
return maxvalue;
}
4. 动态规划空间复杂度再优化
通过上述代码可以计算得知时间复杂度
O
(
n
∗
w
)
O(n*w)
O(n∗w),空间复杂度
O
(
n
∗
w
)
O(n*w)
O(n∗w)。那么是否可以继续优化呢?答案是可以的,时间复杂度没法优化了,但是空间复杂度我们可以继续优化。
将二维状态矩阵替换为一维状态数组。
4.1 普通 0-1 背包问题优化
第二节中动态规划,换成一维状态数组的优化代码如下:
public static int knapsack2(int[] items, int n, int w) {
boolean[] states = new boolean[w+1]; // 默认值false
states[0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
if (items[0] <= w) {
states[items[0]] = true;
}
for (int i = 1; i < n; ++i) { // 动态规划
for (int j = w-items[i]; j >= 0; --j) {//把第i个物品放入背包
if (states[j]==true) states[j+items[i]] = true;
}
}
for (int i = w; i >= 0; --i) { // 输出结果
if (states[i] == true) return i;
}
return 0;
}
其中,把第i个物品放入背包,状态数组是从后往前遍历的。因为一旦从前往后遍历,状态数组后面的上一次状态会被本次决策影响,导致计算出错。(这块可以自己仔细想一想,我们必须保证本次状态是由上次状态推导而来,而不能影响上次状态)
4.2 升级版 0-1 背包问题优化
public static int knapsack3(int[] weight, int[] value, int n, int w) {
int[] states = new int[w+1];
for (int i = 0; i < w+1; ++i) { // 初始化states
states[i] = -1;
}
states[0] = 0;
if (weight[0] <= w) { //初始化,第一个物品不装和装入的状态,可以通过哨兵进行优化
states[weight[0]] = value[0];
}
for (int i = 1; i < n; ++i) { //动态规划,状态转移
for (int j = w-weight[i]; j >= 0; --j) {//把第i个物品放入背包
if (states[j] >= 0) {
int v = states[j] + value[i];
if (v > states[j + weight[i]]) { //判断只有cv值更大,才更新
states[j + weight[i]] = v;
}
}
}
}
// 找出最大值
int maxvalue = -1;
for (int j = 0; j <= w; ++j) {
if (states[j] > maxvalue) maxvalue = states[j];
}
return maxvalue;
}
4.3 哨兵优化
待更新