一看就懂的动态规划(上)

动态规划比较适合用来求解最值得问题,如求最大值和最小值等。它可以显著得降低时间复杂度,提高代码得执行效率。不过学习难度比较高,这一篇博客不讨论动态规划得理论知识,先主要看两个例子,一步一步走进动态规划。

利用动态规划解决0-1背包问题

‘对于背包问题,我们前面一篇博客讨论过用回溯算法解决不可分割得背包问题。

对于0-1背包问题,我们先重新回顾一下:对于一组不同重量,不可分割得物品,选择其中一些物品装入背包,在不超过背包可承载得最大重量得前提下,背包中可装载物品总重量得最大值是多少?对于这个问题,回溯算法是穷举所有可能得装法,然后找出满足条件得最大值。看看代码

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)
   //cw表示当前已经装进去得物品得重量和,i表示考察到哪个物品了
   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个物品
   }
}

缺点就是回溯算法得时间复杂度比较高,是指数级别的。那么有什么办法可以降低时间复杂度呢?我们通过一个具体的例子找一下规律。假设背包的最大承重重量是9,现在有5个不同的物品,每个物品的重量分别是2,2,4,6,3。把这个例子的回溯求解过程用递归树表示。

递归树的每一个节点表示一种状态,我们用(i,cw)来表示。其中,i表示将要决策第几个物品是否装入背包,cw表示当前背包中物品的总重量。比如(2,2)表示我们要决策第2个物品是否要装入背包,装入之前的总重量是2.

从递归树中我们可以看到有些子问题的求解过程是有重复的,图中标黄的部分,那么如何去解决这个问题呢?有一种叫”备忘录“的解决方式,记录已经计算好的f(i,cw),当再次计算到重复的f(i,cw)时,我们直接从备忘录中取出结果来用,不需要再用递归计算,这样就可以避免子问题被重复求解。

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){
   if(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 + weught[i] <= w){
      f(i+1,cw + weight[i]);//选择装第i个物品
   }
}

 实际上,在执行效率方面,基于备忘录去重的递归求解方法与动态规划基本上没差别。不过,动态规划更优雅,更具有普适性。

我们把整个求解的过程分为n个阶段,每个阶段会决策一个物品是否放到背包中。在对每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。需要注意的是,这里的节点状态与回溯算法的节点状态的定义不同,在回溯算法中,(i,cw)表示在考察第i个物品前,背包中的物品重量是cw,这里表示在第i个物品考察完之后,背包中的物品重量是cw,之所以有区别,主要是方便编程。

我们把每一层重复的状态合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。通过合并每一层重复的状态,就能保证每一层的状态个数不会超过w个(w表示背包可承载的最大重量),就是例子中的9.这样我们就可以避免回溯算法对应的递归树中每层状态个数的指数级增长。

我们用一个boolean类型的二位二维数组states[n][w+1]来记录每层可以达到的不同状态,二维数组的初始值为false。

第 0 个(下标从 0 开始编号)物品的重量是 2,要么装入背包,要么不装入背包,决策完 之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 2。我们用 states[0] [0]=true 和 states[0][2]=true 来表示这两种状态。
第 1 个物品的重量也是 2,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0+0),2(0+2 or 2+0),4(2+2)。我们用 states[1] [0]=true,states[1][2]=true,states[1][4]=true 来表示这三种状态。

 

 
以此类推,直到考察完所有的物品后,整个 states 状态数组就都计算好了。我把整个计算 的过程画了出来,你可以看看。图中 0 表示 false,1 表示 true。我们只需要在最后一层, 找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。

然后看看代码


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;
}

 

实际上,这就是一种用动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对
应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状
态集合,来推导下一个阶段的状态集合,动态地往前推进。这也是动态规划这个名字的由
来,你可以自己体会一下,是不是还挺形象的?
前面我们讲到,用回溯算法解决这个问题的时间复杂度 O(2^n),是指数级的。那动态规划
解决方案的时间复杂度是多少呢?我来分析一下。
这个代码的时间复杂度非常好分析,耗时最多的部分就是代码中的两层 for 循环,所以时间
复杂度是 O(n*w)。n 表示物品个数,w 表示背包可以承载的总重量。
从理论上讲,指数级的时间复杂度肯定要比 O(n*w) 高很多,但是为了让你有更加深刻的感
受,我来举一个例子给你比较一下。
我们假设有 10000 个物品,重量分布在 1 到 15000 之间,背包可以承载的总重量是 30000。如果我们用回溯算法解决,用具体的数值表示出时间复杂度,就是 2^10000,这 是一个相当大的一个数字。如果我们用动态规划解决,用具体的数值表示出时间复杂度,就 是 10000*30000。虽然看起来也很大,但是和 2^10000 比起来,要小太多了。
尽管动态规划的执行效率比较高,但是就刚刚的代码实现来说,我们需要额外申请一个 n 乘以 w+1 的二维数组,对空间的消耗比较多。
所以,有时候,我们会说,动态规划是一种
空间换时间的解决思路。你可能要问了,有什么办法可以降低空间消耗吗?
实际上,我们只需要一个大小为 w+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;
}
这里我特别强调一下代码中的第 6 行,j 需要从大到小来处理。如果我们按照 j 从小到大处
理的话,会出现 for 循环重复计算的问题。你可以自己想一想,这里我就不详细说了。

 

0-1 背包问题升级版

我们继续升级难度。我改造了一下刚刚的背包问题。你看这个问题又该如何用动态规划解
决?
我们刚刚讲的背包问题,只涉及背包重量和物品重量。我们现在引入物品价值这一变量。对
于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包
最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?
这个问题依旧可以用回溯算法来解决。这个问题并不复杂,所以具体的实现思路,我就不用
文字描述了,直接给你看代码。

private int maxV = Integer.MIN_VALUE; // 结果放到maxV中
private int[] items = {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个物品
  }
}
针对上面的代码,我们还是照例画出递归树。在递归树中,每个节点表示一个状态。现在我
们需要 3 个变量(i, cw, cv)来表示一个状态。其中,i 表示即将要决策第 i 个物品是否装
入背包,cw 表示当前背包中物品的总重量,cv 表示当前背包中物品的总价值。

我们发现,在递归树中,有几个节点的 i 和 cw 是完全相同的,比如 f(2,2,4) 和 f(2,2,3)。 在背包中物品总重量一样的情况下,f(2,2,4) 这种状态对应的物品总价值更大,我们可以舍弃 f(2,2,3) 这种状态,只需要沿着 f(2,2,4) 这条决策路线继续往下决策就可以。
也就是说,对于 (i, cw) 相同的不同状态,那我们只需要保留 cv 值最大的那个,继续递归处 理,其他状态不予考虑。
思路说完了,但是代码如何实现呢?如果用回溯算法,这个问题就没法再用“备忘录”解决 了。所以,我们就需要换一种思路,看看动态规划是不是更容易解决这个问题?
我们还是把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个 阶段决策完之后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不 同的状态。
我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。不过这里数组存 储的值不再是 boolean 类型的了,而是当前状态对应的最大总价值。我们把每一层中 (i, cw) 重复的状态(节点)合并,只记录 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]]) {
          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;
}
关于这个问题的时间、空间复杂度的分析,跟上一个例子大同小异,所以我就不赘述了。我
直接给出答案,时间复杂度是 O(n*w),空间复杂度也是 O(n*w)。跟上一个例子类似,空
间复杂度也是可以优化的,你可以自己写一下。

双11购物时的凑单问题

淘宝的“双十一”购物节有各种促销活动,比如“满 200 元减 50 元”。假设你女朋友的 购物车中有 n 个(n>100)想买的商品,她希望从里面选几个,在凑够满减条件的前提 下,让选出来的商品价格总和最大程度地接近满减条件(200 元),这样就可以极大限度 地“薅羊毛”。作为程序员的你,能不能编个代码来帮她搞定呢?
对于这个问题,你当然可以利用回溯算法,穷举所有的排列组合,看大于等于 200 并且最 接近 200 的组合是哪一个?但是,这样效率太低了点,时间复杂度非常高,是指数级的。 当 n 很大的时候,可能“双十一”已经结束了,你的代码还没有运行出结果,这显然会让 你在女朋友心中的形象大大减分。
实际上,它跟第一个例子中讲的 0-1 背包问题很像,只不过是把“重量”换成了“价 格”而已。购物车中有 n 个商品。我们针对每个商品都决策是否购买。每次决策之后,对 应不同的状态集合。我们还是用一个二维数组 states[n][x],来记录每次决策之后所有可达 的状态。不过,这里的 x 值是多少呢?
0-1 背包问题中,我们找的是小于等于 w 的最大值,x 就是背包的最大承载重量 w+1。对 于这个问题来说,我们要找的是大于等于 200(满减条件)的值中最小的,所以就不能设 置为 200 加 1 了。就这个实际的问题而言,如果要购买的物品的总价格超过 200 太多,比 如 1000,那这个羊毛“薅”得就没有太大意义了。所以,我们可以限定 x 值为 1001。
不过,这个问题不仅要求大于等于 200 的总价格中的最小的,我们还要找出这个最小总价 格对应都要购买哪些商品。实际上,我们可以利用 states 数组,倒推出这个被选择的商品 序列。

// items商品价格,n商品个数, w表示满减条件,比如200
public static void double11advance(int[] items, int n, int w) {
  boolean[][] states = new boolean[n][3*w+1];//超过3倍就没有薅羊毛的价值了
  states[0][0] = true;  // 第一行的数据要特殊处理
  if (items[0] <= 3*w) {
    states[0][items[0]] = true;
  }
  for (int i = 1; i < n; ++i) { // 动态规划
    for (int j = 0; j <= 3*w; ++j) {// 不购买第i个商品
      if (states[i-1][j] == true) states[i][j] = states[i-1][j];
    }
    for (int j = 0; j <= 3*w-items[i]; ++j) {//购买第i个商品
      if (states[i-1][j]==true) states[i][j+items[i]] = true;
    }
  }

  int j;
  for (j = w; j < 3*w+1; ++j) { 
    if (states[n-1][j] == true) break; // 输出结果大于等于w的最小值
  }
  if (j == 3*w+1) return; // 没有可行解
  for (int i = n-1; i >= 1; --i) { // i表示二维数组中的行,j表示列
    if(j-items[i] >= 0 && states[i-1][j-items[i]] == true) {
      System.out.print(items[i] + " "); // 购买这个商品
      j = j - items[i];
    } // else 没有购买这个商品,j不变。
  }
  if (j != 0) System.out.print(items[0]);
}
代码的前半部分跟 0-1 背包问题没有什么不同,我们着重看后半部分,看它是如何打印出
选择购买哪些商品的。
状态 (i, j) 只有可能从 (i-1, j) 或者 (i-1, j-value[i]) 两个状态推导过来。所以,我们就检查这
两个状态是否是可达的,也就是 states[i-1][j] 或者 states[i-1][j-value[i]] 是否是 true。
如果 states[i-1][j] 可达,就说明我们没有选择购买第 i 个商品,如果 states[i-1][j-value[i]] 可达,那就说明我们选择了购买第 i 个商品。我们从中选择一个可达的状态(如果两个都可 达,就随意选择一个),然后,继续迭代地考察其他商品是否有选择购买。

 

真的好难啊!

 摘自《数据结构与算法之美》

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值