动态规划的引入--数据结构与算法之美--CH40

1. 什么是动态规划

  在CH39讲回溯算法谈到回溯算法本质是枚举所有可能的路径,然后求取可行解或者最优解。这种暴力搜索方法,能够解决贪心算法那种出现局部最优解问题。
  但是回溯算法的时间复杂度太高,有时候甚至没法在规定时间内计算出预期结果,其根本原因在于回溯算法会出现大量的重复计算。
  动态规划本质上就是在递归回溯的基础上,减少重复计算,从而提高效率的方法。减少重复计算,在回溯算法种可以使用备忘录来存储历史计算记录,而动态规划是备忘录的一种延申,其使用状态记录数组或矩阵来记录历史状态,进而推导下一步状态。两者本质上都是通过以空间换时间的方式来提高算法效率的。

2. 再看0-1背包问题

  对于一组不同重量、不可分割的物品,选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?
  假设有5个物品,重量分别为2,2,4,6,3,背包最大重量为9,求解上述问题。
  由于物品不可分割,显然不能通过贪心算法来解决,这时候首先考虑递归回溯。

2.1 递归回溯算法

直接上代码,代码注释解释很清楚,基本思想:

  1. 递归考察5个物品,放入或者不放入;
  2. 当物品重量达到限制或者物品考察完毕,则递归结束;
  3. 全局更新当前总重量,取最大值保存。

// 回溯算法实现。注意:我把输入的变量都定义成了成员变量。
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 动态规划算法

动态规划思路如下:

  1. 根据考察物品个数,把问题求解过程划分n个阶段。
  2. 每个阶段都考察物品 n 是否放入(此时和回溯算法一致)。
  3. 统计当前的状态,对应的就是第n个物品放入或者不放入所能达到的物品重量的状态。(用一个二维数组 state 5*10 记录,5表示物品个数,10表示状态数,状态用True和False表示。)
  4. 合并重复状态,剔除无效状态 (这就起到了备忘录作用,同时)
  5. 继续考察下一个物品,走1,2,3步骤,直到物品考察结束。
  6. 输出结果。

状态分析图如下:

代码实现如下:

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(nw),空间复杂度 O ( n ∗ w ) O(n*w) O(nw)。那么是否可以继续优化呢?答案是可以的,时间复杂度没法优化了,但是空间复杂度我们可以继续优化
  将二维状态矩阵替换为一维状态数组

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 哨兵优化

待更新

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值